睡眠向けの環境音アプリの、操作音まわりのコードを見直していた朝のことです。ボタンを押すたびに 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');
}
// ...以下は同じ
};時間切れの件数が日を追って増えていくなら、どこかでプレイヤーが残っているのだと判断できます。数字として見えれば、手を入れる優先度も決めやすくなります。
自分のアプリを確かめる三つの順番
最後に、すでに出しているアプリを点検する順番を残します。
- プロジェクトを
createAudioPlayerで検索し、呼ばれている場所を数える。ボタンや画面遷移のたびに通る経路は、最優先で見る - それぞれの経路で、
release()が呼ばれているかを確認する。remove()しか書かれていなければ、要修正 didJustFinishを待って次へ進む処理に、時間切れの出口があるかを確認する
同じ症状でも、画面をロックしたあとに音が消えるケースは原因が別です。そちらは 画面ロックから3分後に止まる件 で三層に分けて整理しています。環境音のループ再生まわりの落とし穴は、癒しアプリのループ音源の設計メモ にまとめました。
借りたものは、返す場所を一か所に決めておく。 プレイヤーの作り方を改めたあとも、この線引きだけは守るようにしています。まずは createAudioPlayer の検索結果を一覧にして、release() が書かれていない行に印を付けるところから始めていただければと思います。