作業用BGMアプリ「Felody」を個人開発しています — iOSの音で詰まった3つのこと
- #個人開発
- #Next.js
- #iOS
- #Web Audio
- #Howler.js
Felody という作業用BGMプレーヤーを個人で作っています。2025年4月から書きはじめて、2026年9月に正式リリースしました。
この記事は、そのなかでいちばん時間を取られた「iOSで音を鳴らす」まわりの制作記録です。先に何を作ったのかを短く書いて、後半は詰まった話に使います。
Felodyとは
気分でジャンルを選んで、時間を決めて流すだけのBGMプレーヤーです。
- 歌詞のないオリジナル楽曲を12ジャンル。 「落ち着きたい」「切り替えたい」「気分を上げたい」の3つの気分で分けてあります
- タイマーで自動停止。 30分だけ集中したい、寝落ちしても止まってほしい、という使い方ができます
- 聴いた時間がそのまま記録になります。 再生前に「勉強」「筋トレ」などを選んでおくと、鳴っていた時間が積み上がります。記録のための操作はありません
- ポモドーロタイマー。 休憩中だけ別のジャンルを流せるので、音が変わること自体が切り替えの合図になります
登録なしで試せます。無料プランは1日30分まで、プレミアムは月額380円で無制限です。広告はどちらのプランでも出しません。
いまはこれに加えて、モバイルアプリ版を開発中です。理由は後半に書きます。
構成は Next.js 15 (App Router) / React 19 / TypeScript、音は Howler.js、認証とDBは Supabase、決済は RevenueCat、ホスティングは Cloudflare Workers。音源は Supabase Storage と R2 に置いて、署名付きURLで取り出しています。
作ろうと思った理由は単純で、作業中に流すBGMを動画サイトで探すのが面倒だったからです。広告が挟まるし、選んでいるうちに時間が溶ける。「気分を選ぶ」と「時間を決める」の2択だけで鳴りはじめるものが欲しかった。
ここからが本題です。
1. iOSでは、Web Audio を使った時点でバックグラウンド再生が死ぬ
最初に踏んだのがこれでした。iPhoneで再生を始めて画面をロックすると、数秒で無音になる。
原因は、iOSがアプリのバックグラウンド移行・画面ロックのときに AudioContext ごと中断することです。Web Audio API では回避できません。バックグラウンドで鳴り続けられるのは HTMLAudioElement だけなので、iOSのときは Howler を html5: true で動かす——というところまでは、よくある話です。
ところが html5: true だけでは直りませんでした。
Howler のコンストラクタを読むと、html5 オプションを読むより前に、無条件で setupAudioContext() を呼んでいます。つまり html5: true を渡しても AudioContext の生成そのものは防げない。しかも autoUnlock が既定で有効なので、初回タップの時点で無音バッファを ctx.destination に流して ctx.resume() まで走ります。
結果、ページは「running な Web Audio グラフ」を持つことになります。実際に音を出しているのは HTMLAudioElement なのに、オーディオセッションのほうは Web Audio 側に持っていかれている。だから画面をロックすると中断される。
直し方は、iOSでは Howler の Web Audio 経路そのものを封じることでした。Howler.autoUnlock = false を含めて、AudioContext が作られる余地を残さない。音を鳴らす可能性のある画面は必ずこのモジュールを先に読み込む、という前提込みの構成にしています。
教訓としては、「HTML5 Audio で鳴らしているつもり」と「ページが Web Audio を持っていない」は別物、ということでした。ライブラリの設定値を信じずにコンストラクタを読みに行ったのが、結局いちばんの近道だった。
2. 背面に回すと、タイマーが半分の速度で進む
次はタイマーです。「30分にしたのに、画面を消していたら45分経っても止まらない」。
実機で測ったら理由ははっきりしていて、アプリを背面に回すと setInterval(1000) が約2秒に1回まで間引かれていました。 止まるわけではないので気づきにくい。
まずいのは数え方のほうでした。発火のたびに残り時間から1秒引く実装だと、2秒に1回しか呼ばれない状況では実時間の半分の速度で進みます。5分17秒ぶん経過したところで155秒ぶん数え落としていました。利用者から見ると「タイマーが止まっている」です。
直し方は2つです。
残り時間を、毎回「終了時刻」から引き算して出す。 Date.now() 基準の終了時刻を持っておけば、間引かれようが何回発火しようが、出てくる値は常に正しい。
終了は setInterval に頼らず、終了時刻ちょうどの単発タイマーで起こす。 同じ計測で、背面でも 30秒 / 60秒 / 120秒 の setTimeout が誤差数ミリ秒で発火することを確認できました。間引かれるのは繰り返しのほうだけで、単発は正確に来る。これで画面を消したままでも時間どおりに音を止められます。
この修正のあと、今度は「残り時間の表示がたまに1秒飛ぶ(25:00 の次が 24:58)」という報告が来ました。値自体は終了時刻から出しているので間違っていません。犯人は表示の更新間隔のほうで、タイマーの発火は毎回わずかに遅れるため、遅れが積もって1秒を超えた回は秒の境目を2つまたいでしまう。数分に1回起きます。
こちらは、1秒ごとに起きるのをやめて、次に表示が変わる時刻ちょうどを毎回計算して起きるようにしました。遅れが積もらないので飛ばなくなります。
3. 通知音を、音源ファイルを持たずに鳴らす
ポモドーロの区切りでベルを鳴らしたくなりました。作業→休憩、休憩→作業、全部終了の3種類です。
素直にやるなら音声ファイルを置くところですが、数百ミリ秒のベルのために音声を配信するのは重い。高さや長さを試しながら決めたかったので、コードだけで直せるようにもしたかった。
そこで、実行時に正弦波を合成して WAV をその場で組み立てることにしました。作った Blob URL はモジュール内でキャッシュしています。音程は G5〜D5 のような完全5度と、終了だけ長三和音の3音。高すぎると耳に刺さり、低すぎるとBGMに埋もれるので、範囲を決めて詰めました。下降なら「ひと区切り」、上昇なら「戻る合図」と、向きだけで意味が伝わるのが気に入っています。
ここで1章の話と合流します。合成した音を OscillatorNode で鳴らしてはいけない。 鳴らした瞬間に AudioContext が running になり、裏で鳴っているBGMがバックグラウンドで止まります。通知音のためにBGMを壊しては本末転倒なので、BGMと同じ HTMLAudioElement 経路に通しました。
もうひとつ、鳴るのは25分後です。そのときにはユーザー操作の文脈がとっくに切れているので、再生許可は最初のタップのうちに取っておく必要があります。 既存の解除処理に相乗りさせました。ただし Howler のプール(iOSでは1個)を借りてはいけない。借りると、鳴っているBGMを通知音で上書きしてしまいます。専用の Audio 要素を別に持たせています。
直さないと決めたもの
ホーム画面に追加した状態(standalone PWA)で背面に回すと、曲が終わっても次の曲に進まない、という症状があります。Safariのタブで開いているときは起きません。
実機で計測したところ、音が止まると十数秒でアプリごと凍結される挙動でした。1回目は43秒間、2回目は189秒間、setTimeout も setInterval も動かない。前面に戻った瞬間にまとめて実行されます。「戻ったら次の曲が鳴りだす」のはこれです。
これは Felody の不具合ではなく WebKit の既知のバグで、2023年9月に報告されてから未解決のままです。
回避策の候補は2つまで絞れていて、どちらも実機で「通る」ことまでは確認しました。音声要素を1つ持ち続けて src を差し替える案と、次の曲を音量0で先に走らせておく案です。それでも入れないことにしました。
理由は、片方は Howler の前提(読み込み時に曲の長さを記録して終了検知に使う)と噛み合わず、iOS経路を自前で組み直すことになること。もう片方は Howl を2つ同時に抱えるため、手動スキップ・一時停止・タイマー終了・画面離脱・読み込み失敗・利用上限のすべての経路で先読みぶんを始末する必要があり、1つ漏らすと「見えないところで音だけ鳴り続ける」という最悪の不具合になること。そしてOS側が2023年から直っておらず、回避策を入れても次の更新で別の形で壊れうること。決め手になったのは最後の理由で、この症状は後述のモバイルアプリ版で素直に消える見込みがあります。OS側の穴を塞ぐために危ない実装を抱えるより、塞がる場所で塞いだほうがいい、と判断しました。
計測した事実と判断の理由はリポジトリ内のメモに残してあります。同じ報告が来たときに、調べ直すのではなくそこから再開できるようにしておく。個人開発だと数ヶ月後の自分が他人なので、「直さないと決めた」ことこそ書き残す価値があると思っています。
いま、モバイルアプリ版を作っています
ここまで書いた3つの話には、共通点があります。どれも Felody 側のロジックの問題ではなく、「ブラウザで音を鳴らす」という前提から来ているということです。
- Web Audio を使うとバックグラウンドで中断される → ブラウザにはオーディオセッションを主張する手段がない
- 背面で
setIntervalが間引かれる → ページは前面にいないと全力では走らせてもらえない - PWA で曲送りが止まる → 音が途切れた瞬間にアプリごと凍結される
いずれも、ネイティブアプリならそもそも発生しないか、OSが用意した方法で正面から解けます。バックグラウンド再生は申告して使う機能ですし、タイマーも間引かれません。曲送りが止まる WebKit のバグは、そもそも WebKit の外に出ます。ロック画面に曲名を出す、といったブラウザでは手の届かないこともできるようになります。
そういうわけで、いまモバイルアプリ版を開発中です。上の「直さないと決めた」判断も、これがあってのものでした。
設計の面では、そのための地ならしを先にやってあります。課金を自前で持たずに RevenueCat に寄せたのは、まさにこのためでした。Web で契約した人がアプリでもプレミアムとして扱われる、という状態を後から作るのは骨が折れます。課金状態の正をサービスの外に置いて、users.plan_id はその写像でしかない(書き込み口は同期処理の1箇所だけ)という形にしてあるので、購入経路が増えても、参照する側の作りは変えずに済みます。
配信時期はまだ言える段階ではないので、進んだらまた書きます。それまでは Web 版が本体です。ブラウザで動くぶん、リンクを開けばその場で鳴るという良さは変わりません。
おまけ:音は鳴るのに、ビジュアライザーだけ動かない
最後に、いちばん気づきにくかったやつを。
プレビュー環境で「音は普通に鳴るのに、ビジュアライザーのバーが平らなまま」という状態になりました。原因は音源を置いているバケットのCORS設定で、プレビューはホスト名が変わるため許可オリジンから外れていた。
厄介なのは、Howler が黙ってフォールバックすることです。Web Audio 用の音源取得(XHR)がCORSで落ちても loaderror は飛ばず、HTML5 Audio に切り替わって普通に再生されます。音が出ているので誰も気づかない。周波数データが取れないビジュアライザーだけが、静かに死んでいる。
「エラーが出ない不具合」は本当に見つけづらい、という話でした。
書ききれなかったこと
同じくらい詰まったけれど長くなるので分けるもの。そのうち書きます。
- 公開ページを認証状態でゲートすると、JSを実行しないクローラーに中身が届かない話
- shadcn/ui の
dark:に相乗りしてダークテーマを作ろうとして失敗した話 - 再生1回を1行のログにするとき、「終了通知が届かなかった行」をどう締めるか
Felody は felody.com で公開しています。登録なしでそのまま試せるので、作業のおともにどうぞ。