◉RORK LABEN
●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SONNET 4.5 — Claude API での提供終了は 11/30、残り36日。API 連携を足したアプリは移行先の確認を●SDK 58 ISSUE — Android ウィジェットが定期更新されない、Stripe が静的リンクになるなど初期報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SONNET 4.5 — Claude API での提供終了は 11/30、残り36日。API 連携を足したアプリは移行先の確認を●SDK 58 ISSUE — Android ウィジェットが定期更新されない、Stripe が静的リンクになるなど初期報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…
記事一覧/開発ツール
⬡ 開発ツール/2026-10-25中級

効果音が40回目で鳴らなくなる——expo-audio の createAudioPlayer は remove() だけでは片づきません

expo-audio で再生のたびに createAudioPlayer を作ると、Android では約40回で音が止まり、didJustFinish も届かなくなります。remove() と release() の役割の違い、作り方を変える三つの選択肢、止まったときの見張り方を整理します。

expo-audio5createAudioPlayerReact Native242Expo227Android61トラブルシューティング95

睡眠向けの環境音アプリの、操作音まわりのコードを見直していた朝のことです。ボタンを押すたびに createAudioPlayer で短い音を作って鳴らし、終わったら片づける——自分でも何度も書いてきた形でした。念のため Expo の Issue を検索すると、その形で音が止まるという報告が見つかりました(expo/expo の #41852)。

再現手順は、1〜2秒の mp3 を作っては鳴らし、終わったら片づける往復を繰り返すだけです。Android 実機では、40回目あたりで音が止まり、以後は didJustFinish も届かなくなったと書かれていました。エラーは出ません。

最初にお伝えしたいのは、止まる原因は「片づけたつもり」の側にあるということです。ここでは Issue のやりとりと公式ドキュメントを読み合わせて、何が起きているのか、どう作り変えるのかを書き残します。私自身の端末で40回を測ったわけではなく、報告された挙動と公式の記述を土台にした整理であることは、先にお断りしておきます。

止まるのは「失敗」ではなく、完了の合図が来なくなるかたちです

Issue の再現コードは、次の流れでした。

// Issue #41852 の再現を縮めたもの(概念を示すための要約)
import { createAudioPlayer, AudioStatus } from 'expo-audio';
 
async function playOnce(source: number) {
  const player = createAudioPlayer(source);
  await new Promise<void>((resolve) => {
    const sub = player.addListener('playbackStatusUpdate', (s: AudioStatus) => {
      if (s.didJustFinish) {
        sub.remove();
        player.remove(); // ← 片づけたつもり
        resolve();
      }
    });
    player.play();
  });
}

didJustFinish が来てから resolve() する作りなので、完了の合図が届かなくなった時点で、await が永遠に終わらなくなります。連続再生のキューなら、そこで列全体が止まります。

厄介なのは、例外が出ないことです。音は出ず、完了も届かず、エラーもありません。三拍子そろうと、コードのどこを疑えばよいのか分からなくなります。

報告の範囲も確認しておきます。

  • Expo Go でも、EAS ビルドの開発ビルドでも再現(Android の複数機種)
  • 同じコメント欄に、iOS でも数分後に似た症状という報告
  • 7月末の時点で、expo@57.0.8 / expo-audio@57.0.3 でも再現するというコメント
  • 私が確認した10月25日の時点では、Issue は open のまま

remove() と release() は、別のものを片づけています

コメント欄で最も手がかりになったのは、「remove() だけでは足りず、release() も呼ぶ」という報告でした。関連する別の Issue(#42773)では、公式の担当者が次のように整理しています。

  • release() は、ネイティブ側のオブジェクトを解放する。createAudioPlayer で作ったなら、使い終わった時点で呼ぶ必要がある
  • remove() は、プレイヤーを Audio モジュールから外す。外されたプレイヤーは止まる

公式ドキュメントにも、createAudioPlayer については「自動では解放されない」「release() を呼ぶのは自分の責任」「扱いを誤るとメモリリークにつながる」という趣旨の記述があります。一方 useAudioPlayer は、コンポーネントがアンマウントされるときに自動で解放するフックです。

つまり、createAudioPlayer を使う側は、解放を自分で引き受ける約束で借りていることになります。remove() は名前から「片づけ」を連想しやすいのですが、約束を果たしたのは release() の側だったのです。

もう一点、コメント欄でコードを読んだ方の推測も紹介しておきます。iOS では release() が内部で remove() 相当の後始末まで行うのに対し、Android ではそうならないため、両方を呼ばないとプレイヤーが残るのだそうです。また useAudioPlayer は Android では release() しか呼ばないので、同じ残り方をするかもしれない、という読みです。これは公式の確定した説明ではありません。私も裏取りができていないため、「そういう見立てがある」という位置づけで留めておきます。

作り方を変える三つの選択肢

原因の見立てが定まったので、手を入れる場所を考えます。大きくは三つあり、短い効果音なら、いちばん上の選択肢が堅実です。

選択肢向いている場面注意点
A. プレイヤーを一つ持ち、巻き戻して再利用する同じ効果音を何度も鳴らす同時に重ねて鳴らしたいときは、音の数だけ持つ
B. useAudioPlayer で画面に結びつける画面の中だけで鳴る音画面を離れると解放される。Android での挙動は上の見立てに注意
C. createAudioPlayer のまま、remove() と release() を両方呼ぶ再生のたびに別の音源を作る必要がある呼び忘れの経路を作らない。完了が来ない場合の逃げ道も用意する

A. 一つを持って、巻き戻して鳴らす

import { useAudioPlayer } from 'expo-audio';
import { Pressable, Text } from 'react-native';
 
const tap = require('../assets/sounds/tap.mp3');
 
export function TimerButton() {
  // 画面が存在するあいだ、プレイヤーは一つだけ
  const player = useAudioPlayer(tap);
 
  const onPress = () => {
    // 前回の再生位置が残っていると、2回目以降が無音に聞こえるため先頭へ戻す
    player.seekTo(0);
    player.play();
  };
 
  return (
    <Pressable onPress={onPress}>
      <Text>開始</Text>
    </Pressable>
  );
}

押すたびにプレイヤーを作らないので、「40回」という数え方そのものが成り立たなくなります。短い効果音には、この形がいちばん素直だと感じています。

C. 作り続けるなら、片づけを一か所に閉じ込める

音源をその都度変える必要がある場合は、解放の呼び出しを一つの関数にまとめます。呼び出し側が個別に remove() や release() を書くと、いずれ片方が抜けるからです。

import { createAudioPlayer, type AudioPlayer, type AudioSource } from 'expo-audio';
 
function disposePlayer(player: AudioPlayer) {
  // remove と release の両方を、順番を決めて呼ぶ。片方が失敗しても、もう片方は試す
  try { player.remove(); } catch {}
  try { player.release(); } catch {}
}
 
export function playClip(source: AudioSource, maxMs = 8000): Promise<void> {
  return new Promise((resolve) => {
    const player = createAudioPlayer(source);
    let done = false;
 
    const finish = () => {
      if (done) return;       // 完了の合図と時間切れが二重に来ても、片づけは一度だけ
      done = true;
      clearTimeout(timer);
      sub.remove();
      disposePlayer(player);
      resolve();
    };
 
    const sub = player.addListener('playbackStatusUpdate', (s) => {
      if (s.didJustFinish) finish();
    });
 
    // 合図が来なかった場合の逃げ道。待ち続ける設計は作らない
    const timer = setTimeout(finish, maxMs);
 
    player.play();
  });
}

ここでの要は二つです。片づけを disposePlayer に閉じ込めたことと、didJustFinish を唯一の出口にしなかったことです。完了の合図は、この Issue の症状では来なくなる側のものです。そこに依存した await は、止まったときに列ごと道連れにします。

maxMs は、音源の長さに合わせて決めてください。上のコードは概念を示す叩き台で、お手元の端末と音源で挙動を確かめてからお使いいただければ幸いです。

止まったかどうかを、後から気づける形にしておきます

この手の不具合でいちばん厄介なのは、気づくのが遅れることだと感じています。音が出なくなっても、クラッシュレポートには何も残りません。レビューに「たまに音が鳴らなくなる」と書かれて、初めて知ることになります。

そこで、時間切れの経路が使われたときだけ、一行のログを残すことを勧めます。

const finish = (reason: 'finished' | 'timeout') => {
  if (done) return;
  done = true;
  if (reason === 'timeout') {
    // 合図が来なかった回数を数える。増え続けるなら、プレイヤーが残っている兆候
    console.warn('[audio] didJustFinish not received, released by timeout');
  }
  // ...以下は同じ
};

時間切れの件数が日を追って増えていくなら、どこかでプレイヤーが残っているのだと判断できます。数字として見えれば、手を入れる優先度も決めやすくなります。

自分のアプリを確かめる三つの順番

最後に、すでに出しているアプリを点検する順番を残します。

  1. プロジェクトを createAudioPlayer で検索し、呼ばれている場所を数える。ボタンや画面遷移のたびに通る経路は、最優先で見る
  2. それぞれの経路で、release() が呼ばれているかを確認する。remove() しか書かれていなければ、要修正
  3. didJustFinish を待って次へ進む処理に、時間切れの出口があるかを確認する

同じ症状でも、画面をロックしたあとに音が消えるケースは原因が別です。そちらは 画面ロックから3分後に止まる件 で三層に分けて整理しています。環境音のループ再生まわりの落とし穴は、癒しアプリのループ音源の設計メモ にまとめました。

借りたものは、返す場所を一か所に決めておく。 プレイヤーの作り方を改めたあとも、この線引きだけは守るようにしています。まずは createAudioPlayer の検索結果を一覧にして、release() が書かれていない行に印を付けるところから始めていただければと思います。

シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

⬡ 開発ツール2026-08-31
画面ロックから3分後、expo-audio の環境音は静かに止まっていました
expo-audio の音が画面ロックで止まる原因を、ビルド設定・オーディオセッション・ロック画面連携の三層に分けて切り分けます。Android で約3分後に止まるのは、ドキュメントに書かれた止まり方です。
⬡ 開発ツール2026-10-22
「容量不足」と出た動画の保存。空き容量を疑う前に、ファイルが 2GB を超えていないかを見ます
Android で 2.2GB の動画だけがギャラリーに保存できず、「Not enough space」と出る。空き容量は十分なのに、です。Expo の公開 Issue を読み、同じ境目をこの手元の Linux で動かして確かめました。保存前に大きさを見る数行と、分けてコピーする考え方をまとめています。
⬡ 開発ツール2026-10-07
日本語の見出しだけ太字にならない — Android で fontWeight が効かないときの切り分けと直し方
Rork で作ったアプリで、iPhone では太字なのに Android では日本語の見出しが細いままになる症状の切り分け記事です。原因の見分け方、fontFamily を重みごとに分ける直し方、確認用コードを載せています。