ログオフ
開発2026-08-30

expo-audio の効果音:2回目が音割れ、プールすると無音、毎回作ると遅い

プレイヤーの持ち方を変えるたびに症状が別のものへ化ける。再利用→プール→ワンショットと試して落ち着いた形と、そのあとに出た読み上げとの競合まで。

expo-audio で効果音を鳴らすと、プレイヤーの持ち方を変えるたびに症状が別のものへ化ける。音割れ、無音、遅延と3周して、最後に落ち着いた形がある。

1. プレイヤーを再利用すると、2回目以降が音割れする

事象

1つの AudioPlayer を使い回して、seekTo(0) してから play() で鳴らし直す。iOS で2回目以降が音割れするか、無音になる。

切り分けの近道は、単発音を連打してみること。最初の1音だけ綺麗なら再利用が犯人だ。

原因

seekTo()Promise<void> を返す非同期で、play() は同期で void を返す。seek が終わる前に play が走ると、終端の位置から再生される。

player.seekTo(0);   // ❌ Promise を待っていない
player.play();

ただし await を付けても歪みは残った。expo-audio には seekTo まわりの不具合報告が複数ある(#39915 繰り返し seekTo するとエラーを返さないまま音が止まる、#38550 SDK 53 の iOS で seekTo(0) がクラッシュ)。await の有無だけで片付く話ではない。

解決策

使い回すのをやめて、毎回作って捨てる。

const player = createAudioPlayer(src);
player.play();
setTimeout(() => player.remove(), 3000);

createAudioPlayer() は同期で戻り、remove() はプレイヤーをメモリから解放する。新品プレイヤーの初回再生だけは、いつ鳴らしても綺麗に出る。

2. だったらプールすればいい、とはならない

事象

毎回生成すると再生のレイテンシがばらつき、カウントダウン音のリズムがよれる。ならばプレイヤーを数個プリロードして順に使い回そう、と考えた。

結果、5-4-3-2-1 のカウントダウンで 5・3・1 しか鳴らない。 寝かせたプレイヤーが1つ飛ばしで無音になる。

原因

計装したところ、play() は5回とも発火していて、JS 側のスケジュールも 1000ms ちょうどだった。JS より下の層で落ちている。

ここで効いた計装のコツが2つある。

  • 「発火したか」と「鳴ったか」を分けて計る。 各音にタイムスタンプ付きのログを出し、機能を ON/OFF する一時フラグをコードに挿して二分探索する
  • 新しめの Expo は console.log を JS デバッガ側へ送り、Metro の標準出力に出さないことがある。console.warn にすると出る

解決策

プールを捨てて、ワンショット都度生成に戻す。expo-audio は「プレイヤーを寝かせて後で鳴らす」形が総じて弱いと割り切るのが早い。

3. ワンショットに戻すと、最初の1〜2発だけ遅い

事象

アプリ起動後、最初の1〜2発だけ再生が遅れる。3発目からは安定する。

原因

初回だけに乗るコストがある、というところまでしか特定していない。アセットの読み込み、デコーダの初期化、オーディオセッションの有効化のどれか、あるいは全部だと思われるが、切り分けはしていない。

解決策

セッション開始時に、音量 0 のプレイヤーを1回鳴らして即捨てる。

const warm = createAudioPlayer(src);
warm.volume = 0;   // 0.0 〜 1.0
warm.play();
setTimeout(() => warm.remove(), 500);

保持も再利用もしないので、2 の無音バグは踏まない。温まるのは共有のオーディオまわりとデコード済みアセットのほうで、本番の再生は毎回新規生成のまま変えなくていい。原因を特定できていなくても、この形なら副作用が無いので採れる。

4. 読み上げが、効果音の頭を欠けさせる

事象

効果音を鳴らした数ms後に Speech.speak() を呼ぶと、再生中の効果音の頭が一瞬途切れる。毎回ではなく、たまに出る。

出はじめたタイミングははっきりしている。効果音を 160ms から 500ms に伸ばした瞬間だった。

原因

効果音が短いうちは鳴り終わってから喋り始めるので、競合しない。長くすると重なり、音声合成エンジンの起動がオーディオセッションを掴みにいく。長さを変えたときに初めて表に出る種類の不具合になる。

解決策

読み上げを、効果音の長さぶん後ろへずらす。Speech.speak() は同期で void を返すので、遅らせるのは呼び出し側の仕事になる。

const t = setTimeout(() => Speech.speak(text), 500);

// 画面を離れる・操作で中断されたとき
clearTimeout(t);
Speech.stop();

キャンセルできる形にしておかないと、離れたあとに喋り出す。音の重なりが消えて聞き取りが良くなる副次効果もあった。

補足:拍の正確さ自体が要件なら、ライブラリを変える

ここまでは「効果音が鳴ればいい」場合の話。等間隔で鳴り続けることそのものが要件なら、上の工夫では頭打ちになる。

expo-audio には発音時刻を指定する API が無い。JS から play() を呼ぶしか手が無いので、JS スレッドの遅延がそのまま音のズレになる。600ms 間隔の設計に対して実測 482〜801ms、和音の3音が 35〜87ms ばらついた。発音直前の await を排するなどの工夫で 558〜638ms までは詰まるが、そこで止まる。

Web Audio API の React Native 実装(react-native-audio-api)に移すと start(when) で発音時刻を予約でき、スケジュールをオーディオスレッドが持つ。同じ実機の生演奏で 586〜610ms に収まり、和音のずれも消えた。同じ when で複数の source を start するだけなので、原理的に同時に鳴る。

移行そのものは別の話になるので、選択の分かれ目だけ書いておく。効果音なら expo-audio のワンショットで足りる。リズムが要るなら、最初から Web Audio API 側を選ぶ。

関連記事

開発 一覧へ