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 側を選ぶ。