癒し系のアプリを作っていて最初に裏切られるのは、「プレビューでは完璧に鳴っていた音が、ホームに戻った瞬間や画面ロックで止まる」場面です。私自身、個人開発で長く運営している Relaxing Healing という睡眠・瞑想向けの音アプリを Rork で作り直してみたとき、生成直後のコードはフォアグラウンドでしか鳴りませんでした。就寝前に使うアプリで画面を消した瞬間に無音になるのは、用途として致命的です。
Rork が出力するのは React Native(Expo)のコードなので、この問題は Rork 固有ではなく Expo の音声まわりの設定で解けます。ただし公式の最小サンプルをそのまま貼っても、ロック画面の再生ボタンは出てきませんし、Android では数分で勝手に止まります。ここでは expo-audio(Expo SDK 54 系)を使って、バックグラウンド再生とロック画面操作の両方を成立させるまでの実装を、つまずいた順に並べます。
なぜ生成直後のコードでは背景で止まるのか
audio を「ただ再生する」のと「アプリが背景にいても鳴らし続ける」のは、OS から見ると別の許可が要る行為です。前者は音声セッションの初期状態でも動きますが、後者には次の3つが揃って初めて成立します。
OSごとに要る「3つの許可」
まず iOS では、バックグラウンド実行モードに audio を申告していないと、背景に回った瞬間に音声セッションが停止されます。次に、音声セッション自体を「背景でも鳴らす」モードに切り替える必要があります。そして Android では、ロック画面の通知(メディア通知)を能動的に出さないと、OS が約3分でプロセスの音声を止めます。生成直後のコードはこの3つのうち、たいてい1つも満たしていません。
1. expo-audio を入れて、背景モードを申告する
Rork からエクスポートしたプロジェクトに expo-audio を追加し、app.json(または app.config.js)のプラグイン設定で iOS のバックグラウンド音声を有効にします。プラグイン経由で書くと、ネイティブの Info.plist に手を入れずに UIBackgroundModes が設定されます。
{
"expo" : {
"plugins" : [
[
"expo-audio" ,
{
"microphonePermission" : false
}
]
],
"ios" : {
"infoPlist" : {
"UIBackgroundModes" : [ "audio" ]
}
},
"android" : {
"permissions" : [ "android.permission.FOREGROUND_SERVICE" , "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" ]
}
}
}
ここを入れずに開発ビルドを作ると、iOS シミュレータでは一見動くのに実機で背景に回した途端に止まる、という再現性の低いバグに変わります。私はこの差で半日溶かしたので、最初に申告しておくことを強く推奨します。設定変更後は npx expo prebuild と再ビルドが必要です(Expo Go では UIBackgroundModes は反映されません)。
2. 音声セッションを「背景でも鳴らす」モードにする
アプリ起動時(再生する前)に一度だけ setAudioModeAsync を呼び、セッションの性格を決めます。ここで重要なのは shouldPlayInBackground: true と、ロック画面操作を効かせるための interruptionMode: 'doNotMix' です。
// audioSession.ts — アプリ起動時に一度だけ呼ぶ
import { setAudioModeAsync } from 'expo-audio' ;
export async function configureAudioSession () {
await setAudioModeAsync ({
// マナーモード(消音スイッチ)でも鳴らす。ヒーリング・音楽用途では必須
playsInSilentMode: true ,
// 背景・ロック画面でも再生を継続する
shouldPlayInBackground: true ,
// 他アプリの音と混ぜない。これがないとロック画面操作がプレイヤーに紐づかない
interruptionMode: 'doNotMix' ,
// Android: 他アプリが鳴り出したら一時停止し、戻ったら再開する余地を残す
interruptionModeAndroid: 'doNotMix' ,
});
}
interruptionMode を既定値('mixWithOthers' 相当)のままにすると、音は鳴るのにロック画面の再生コントロールがどのプレイヤーにも結びつかず、ボタンが反応しません。「音は背景でも鳴るのにロック画面のボタンだけ効かない」という症状の大半は、この一行が原因です。
3. プレイヤーを作り、再生状態を購読する
expo-audio では useAudioPlayer でプレイヤーを生成し、useAudioPlayerStatus で再生状態を購読します。旧 expo-av の Audio.Sound と違い、フックがアンマウント時の解放まで面倒を見てくれるので、メモリリークの温床だった手動 unloadAsync が要りません。
// SoundScreen.tsx
import { useEffect } from 'react' ;
import { View, Text, Pressable } from 'react-native' ;
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio' ;
import { configureAudioSession } from './audioSession' ;
const SOURCE = require ( '../assets/audio/rain-loop.mp3' );
export default function SoundScreen () {
const player = useAudioPlayer ( SOURCE );
const status = useAudioPlayerStatus (player);
useEffect (() => {
configureAudioSession ();
// ループ再生(環境音・ヒーリング用途で多い)
player.loop = true ;
}, [player]);
const toggle = () => {
if (status.playing) {
player. pause ();
} else {
player. play ();
}
};
return (
< View style = { { flex: 1 , justifyContent: 'center' , alignItems: 'center' } } >
< Text > { status.isLoaded ? '準備完了' : '読み込み中…' } </ Text >
< Pressable onPress = { toggle } >
< Text > { status.playing ? '一時停止' : '再生' } </ Text >
</ Pressable >
</ View >
);
}
status.isLoaded を見てからボタンを有効化するのが地味に効きます。音源の読み込みが終わる前に play() を叩くと、最初のタップが無反応になり「ボタンが効かない」という低評価レビューにつながりやすいからです。
4. ロック画面・コントロールセンターに情報を出す
ここが本題です。背景で鳴るようになっても、ロック画面に曲名や再生ボタンが出ていなければ、ユーザーは画面を点けてアプリを開き直すしかありません。setActiveForLockScreen にメタデータを渡して、ロック画面のメディアコントロールを点灯させます。
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio' ;
async function startWithLockScreen ( player ) {
player. play ();
// ⚠️ 再生開始の直後に少し遅らせて呼ぶ。
// 再生前に呼ぶと、まだセッションがアクティブでないため
// ロック画面コントロールが表示されないことがある
setTimeout (() => {
player. setActiveForLockScreen ( true , {
title: '雨音 - 8時間ループ' ,
artist: 'Relaxing Healing' ,
albumTitle: '睡眠サウンド' ,
artworkUrl: 'https://example.com/artwork/rain.jpg' ,
// シーク(早送り・巻き戻し)を出すか。環境音ループでは false が自然
showSeekForward: false ,
showSeekBackward: false ,
});
}, 250 );
}
アートワークとシークボタンの出し分け
artworkUrl はリモートURLか、ローカルアセットを Asset.fromModule().localUri で解決したパスを渡します。ここを空にするとロック画面のサムネイルが既定アイコンになり、せっかくの世界観が崩れます。環境音のループ再生では、シークボタンは出さず再生/一時停止だけに絞ったほうが操作の迷いが減ります。
そして Android では、この setActiveForLockScreen を呼んでメディア通知を出していないと、約3分でOSがバックグラウンドの音声を止めます。iOS は shouldPlayInBackground: true だけで鳴り続けますが、Android はロック画面通知の表示と背景再生の継続が結びついている、という非対称をここで吸収します。
iOSとAndroidの挙動差を一枚で押さえる
実装の判断に直結する差分だけ、表にまとめます。
項目 iOS Android
背景再生の継続条件 UIBackgroundModes に audio + shouldPlayInBackground 左記+ロック画面通知(setActiveForLockScreen)が必須
通知を出さない場合 鳴り続ける 約3分で停止
消音スイッチ中の再生 playsInSilentMode: true で鳴る スイッチ自体がないため影響小
ロック画面操作の紐づけ interruptionMode: 'doNotMix' が前提 FOREGROUND_SERVICE 権限が前提
Expo Go での確認 UIBackgroundModes が効かず不可 同様に不可。開発ビルドが必要
割り込み(電話・他アプリの音)からの復帰
睡眠アプリで一晩流しっぱなしにすると、必ず途中で電話やアラーム、別アプリの音が割り込みます。割り込み中は自動で一時停止し、終わったら自分の再生方針に従って再開するかを決めます。ヒーリング用途では「割り込みが終わったら自動再開」がユーザーの期待に近いことが多いです。
import { useEffect, useRef } from 'react' ;
import { AppState } from 'react-native' ;
function useResumeAfterInterruption ( player , status ) {
// ユーザーが意図的に止めたかどうかを記録するフラグ
const shouldAutoResume = useRef ( true );
useEffect (() => {
const sub = AppState. addEventListener ( 'change' , ( next ) => {
// フォアグラウンド復帰時、ユーザーが止めていないなら再開
if (next === 'active' && ! status.playing && shouldAutoResume.current) {
player. play ();
}
});
return () => sub. remove ();
}, [player, status.playing]);
return shouldAutoResume;
}
ここで気をつけたいのは、「ユーザーが意図的に止めた一時停止」と「割り込みによる一時停止」を取り違えないことです。私はこの区別をフラグで持たずに自動再開を実装してしまい、ユーザーが止めたのに復帰のたびに鳴り出す、という残念な挙動を作ったことがあります。shouldAutoResume のような明示的なフラグで、再生を止めた理由を必ず記録しておくのが安全です。
リリース前に踏みがちな落とし穴
最後に、ここまでで触れた以外で実機リリース時に詰まりやすい点を挙げます。
開発ビルドでしか確認できない設定
開発ビルドを作らずに Expo Go で「背景で止まる」と判断してしまうのが、いちばん多い時間の溶かし方です。UIBackgroundModes も Android のフォアグラウンドサービスも、Expo Go には反映されません。必ず eas build の開発ビルドか本番ビルドで確認してください。
もう一つは、setActiveForLockScreen をプレイヤーが解放された後にも呼んでしまい、画面遷移のたびにロック画面コントロールが残骸として残るケースです。画面を離れるときに setActiveForLockScreen(false) を呼んで明示的に下ろすと、複数音源を切り替えるアプリでも表示が混線しません。
バックグラウンド再生は「鳴らす」よりも「OSと正しく約束する」作業です。背景モードの申告・セッションの性格づけ・ロック画面通知の3点が揃って初めて、ユーザーが画面を消したまま安心して眠れるアプリになります。まずは手持ちの音源1つで、画面ロック後も鳴り続けるところまでを今日のうちに通してみてください。