はじめに
PCやAndroidでは問題なく再生できるMP3ファイルが、iPhone / iPadだけ途中で止まるという問題に遭遇しました。
最初は、MP3ファイルの破損、<audio>タグの書き方、iOS Safari固有の挙動、キャッシュなどを疑っていました。
ただ、最終的には音声ファイル自体やHTMLの問題ではなく、サーバーがHTTP Range Requestに対して正しく 206 Partial Content を返せていないことが原因でした。
特に、PHP / CakePHP経由でMP3を配信している場合、PCやAndroidでは再生できても、iOS系だけ不安定になることがあります。
MDNでも、Rangeリクエストに成功した場合は 206 Partial Content が返ると説明されています。また、AppleのSafari向けドキュメントでも、iOS向けにメディアファイルを配信するHTTPサーバーはbyte-range requestに対応している必要があり、curl --range で確認する方法が紹介されています。
発生した現象
発生していた現象は以下のようなものでした。
- PCでは再生できる
- Androidでも再生できる
- iPhone / iPadだけ途中で止まる
- 特に1回目の再生で止まりやすい
- 2回目以降は最後まで再生できることがある
- iPhone上では「ライブブロードキャスト」のような表示になることがある
この時点では、原因候補がかなり広く見えます。
- MP3ファイルが壊れている?
-
<audio>タグの書き方が悪い? - iOS Safariの仕様?
- キャッシュ?
- CakePHP側のレスポンス?
- nginx / FastCGI / Xserver側の問題?
厄介だったのは、PCとAndroidでは普通に再生できていたことです。
そのため、最初は「ファイル自体は問題ないのでは」「iPhone側のキャッシュや端末差では」と考えていました。
最初に疑ったこと
MP3ファイル自体の問題
まず疑ったのは、MP3ファイル自体の破損です。
ただし、PCやAndroidでは問題なく再生できていたため、ファイル破損だけが原因とは考えにくい状況でした。
<audio>タグの問題
次に、HTML側の指定を疑いました。
preload、controls、type指定なども確認しましたが、静的に配置したMP3ファイルでは問題が出ませんでした。
そのため、HTMLだけの問題ではなさそうだと判断しました。
iPhone固有のキャッシュや端末差
iPhone 11 / iPadでは止まる一方で、別のiPhoneでは止まらないケースもありました。
この挙動だけを見ると、端末差・OS差・キャッシュ差のようにも見えます。
ただ、最終的には端末差に見えていたものの、根本原因はサーバー側のレスポンス差でした。
Range Requestとは
Range Requestは、クライアントがファイル全体ではなく、一部のバイト範囲だけを要求するためのHTTPの仕組みです。
通常のGETは、ざっくり言うとこうです。
ブラウザ「このMP3を全部ください」
サーバー「200 OKで全部返します」
一方、Range Requestではこうなります。
ブラウザ「このMP3の0〜1バイト目だけください」
サーバー「206 Partial Contentでその範囲だけ返します」
たとえば、以下のように Range ヘッダーを付けて確認できます。
curl -I -H "Range: bytes=0-1" "https://example.com/path/to/audio.mp3"
期待するレスポンスは以下のようなものです。
HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Range: bytes 0-1/xxxxx
Content-Length: 2
Content-Type: audio/mpeg
逆に、以下のようなレスポンスになっている場合は注意が必要です。
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: audio/mpeg
Range: bytes=0-1 を送っているにもかかわらず 200 OK で全体配信になっていたり、Content-Length が出ずに Transfer-Encoding: chunked になっている場合、iOSのメディア再生では問題になる可能性があります。
実際の切り分け
通常の録音URLを確認する
まず、実際にアプリが使っている録音URLに対してRangeリクエストを投げました。
curl -I -H "Range: bytes=0-1" "https://example.com/member/recording/xxxx.mp3"
期待していたのは、以下のようなレスポンスです。
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1/xxxxx
Content-Length: 2
Content-Type: audio/mpeg
しかし、実際には 206 Partial Content ではなく、200 OK や Transfer-Encoding: chunked のようなレスポンスになっていました。
この時点で、iPhone側の問題というより、サーバーがRange Requestに正しく応答できていない可能性が高いと判断しました。
PHP側にRangeヘッダーが届いているか確認する
次に、診断用PHPを設置して、PHP側で $_SERVER['HTTP_RANGE'] を確認しました。
<?php
header('Content-Type: text/plain');
echo 'HTTP_RANGE=' . ($_SERVER['HTTP_RANGE'] ?? '(none)') . PHP_EOL;
echo 'REQUEST_METHOD=' . ($_SERVER['REQUEST_METHOD'] ?? '(none)') . PHP_EOL;
確認コマンドは以下です。
curl -H "Range: bytes=0-1" "https://example.com/range_probe.php"
期待値はこうです。
HTTP_RANGE=bytes=0-1
REQUEST_METHOD=GET
しかし、実際には以下のように、PHP側でRangeヘッダーが見えていない状態でした。
HTTP_RANGE=(none)
REQUEST_METHOD=GET
つまり、curlからは Range ヘッダーを送っているにもかかわらず、PHP / CakePHP側では HTTP_RANGE が取得できていませんでした。
ここで、単純なCakePHPのレスポンス実装だけではなく、サーバー側のヘッダー転送やFastCGIまわりも疑う必要が出てきました。
CakePHPを通さない静的MP3で確認する
次に、同じサーバー上に静的MP3を配置し、CakePHPを通さずにアクセスしました。
curl -I -H "Range: bytes=0-1" "https://example.com/member/static_test.mp3"
この場合は、以下のように正しく 206 Partial Content が返りました。
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1/10000
Content-Length: 2
Content-Type: audio/mpeg
ここで、切り分けとしては以下のように整理できました。
静的MP3配信 → 206 Partial Content が返る
CakePHP経由の配信 → 206 Partial Content が返らない
PHP診断スクリプト → HTTP_RANGE が見えていない
つまり、nginxの静的ファイル配信ではRange Requestに対応できている一方で、PHP / CakePHP経由の動的配信ではRange Requestに正しく応答できていない状態でした。
ハマったポイント
PCとAndroidでは普通に再生できる
一番厄介だったのは、PCとAndroidでは普通に再生できることです。
PCとAndroidで再生できるため、最初は「ファイルもURLも問題ない」と判断しがちです。
しかし、PCで再生できることと、HTTP的に正しくメディア配信できていることは別問題です。
特にiOS系でだけ音声・動画再生が不安定な場合は、<audio>タグやファイル破損を疑う前に、Range Requestに対して 206 Partial Content が返っているか確認した方が近道でした。
1回目だけ止まり、2回目以降は再生できる
今回の問題では、毎回必ず止まるわけではありませんでした。
- 1回目は途中で止まる
- 2回目は最後まで再生できる
- 別端末だと止まらないことがある
このような挙動だったため、キャッシュや端末依存に見えてしまいました。
ただし、メディア再生はブラウザ側のバッファ、キャッシュ、先読みの影響を受けます。
そのため、再現が不安定でも、まずはサーバーの最終レスポンスを確認する必要があります。
PHPでContent-Lengthを出しても解決しない
PHP側で以下のようにヘッダーを出しても、必ずしもクライアントから見たレスポンスが期待通りになるとは限りません。
header('Content-Type: audio/mpeg');
header('Content-Length: ' . filesize($file));
readfile($file);
PHPでは Content-Length を出したつもりでも、最終的なレスポンスがnginx / FastCGI / サーバー設定の影響で変わることがあります。
実際に確認すべきなのは、PHPコード上のヘッダーではなく、curlで外から見た最終レスポンスです。
curl -v -H "Range: bytes=0-1" "https://example.com/audio.mp3" -o /dev/null
アプリケーションコードだけを見るのではなく、ブラウザやcurlから見たHTTPレスポンスを確認することが重要でした。
Xserverにも問い合わせた
静的MP3では 206 Partial Content が返る一方で、CakePHP経由のURLではRange Requestに正しく応答できていませんでした。
また、診断用PHPで $_SERVER['HTTP_RANGE'] を確認しても、PHP側に Range ヘッダーが届いていないように見える状態でした。
この時点で、アプリケーション側の実装だけでなく、Xserver側のnginx / FastCGI / PHP実行環境の影響も疑いました。
そのため、Xserverにも問い合わせを行いました。
ただし、共有サーバー環境ではnginxやFastCGIまわりの個別設定を利用者側で変更することは難しく、サーバー設定で根本的に調整する方針は取りづらい状況でした。
つまり、原因らしきものは見えてきたものの、サーバー設定を変更して解決する権限がない状態でした。
そのため、PHP / CakePHP側でMP3配信処理を頑張って実装するよりも、実際のMP3配信はXserverの静的ファイル配信に任せる方針にしました。
暫定対応として静的配信に逃がした
最終的な暫定対応は、PHP / CakePHP経由でMP3を直接返すのをやめることでした。
具体的には、以下の流れに変更しました。
- 録音ファイルを一時的な静的MP3として参照できる場所へ配置する
- CakePHPの録音再生URLへアクセスされたら、その静的MP3のURLへ
302リダイレクトする - 実際のMP3配信はnginxの静的ファイル配信に任せる
変更前は以下のような流れでした。
iPhone
↓
CakePHPの録音再生URL
↓
PHPがMP3を返す
↓
Rangeに正しく応答できず再生が不安定
変更後は以下のようになります。
iPhone
↓
CakePHPの録音再生URL
↓
一時MP3の静的URLへ302リダイレクト
↓
nginxが静的MP3として配信
↓
206 Partial Contentが返る
↓
iPhoneでも再生できる
この対応により、最終的なMP3配信は静的ファイル配信となり、Range Requestに対して 206 Partial Content が返るようになりました。
結果として、iPhone / iPadでも音声再生が途中で止まらなくなりました。
確認に使ったコマンド
今回のような問題では、まず以下のコマンドで確認するのが良いと思います。
curl -I -H "Range: bytes=0-1" "https://example.com/audio.mp3"
詳細を見る場合は、以下のようにします。
curl -v -H "Range: bytes=0-1" "https://example.com/audio.mp3" -o /dev/null
期待するレスポンスは以下です。
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1/xxxxx
Content-Length: 2
Accept-Ranges: bytes
Content-Type: audio/mpeg
問題がありそうな例は以下です。
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: audio/mpeg
PHP側にRangeヘッダーが届いているか確認する場合は、以下のような診断用PHPを使いました。
<?php
header('Content-Type: text/plain');
echo 'HTTP_RANGE=' . ($_SERVER['HTTP_RANGE'] ?? '(none)') . PHP_EOL;
echo 'REQUEST_METHOD=' . ($_SERVER['REQUEST_METHOD'] ?? '(none)') . PHP_EOL;
確認コマンドは以下です。
curl -H "Range: bytes=0-1" "https://example.com/range_probe.php"
この結果が以下のようになれば、PHP側にRangeヘッダーが届いています。
HTTP_RANGE=bytes=0-1
REQUEST_METHOD=GET
一方、以下のようになっている場合は、PHP側でRangeヘッダーを見えていない可能性があります。
HTTP_RANGE=(none)
REQUEST_METHOD=GET
恒久対応として考えられること
今回は暫定対応として、静的MP3へ 302 リダイレクトする形にしました。
ただ、恒久対応としては、以下のような選択肢が考えられます。
Range対応した配信処理を実装する
PHP / CakePHP側で Range ヘッダーを解釈し、指定されたバイト範囲だけを返す実装にする方法です。
その場合、少なくとも以下のような対応が必要になります。
-
HTTP_RANGEの取得 - 要求された開始位置・終了位置の解析
-
206 Partial Contentの返却 -
Content-Rangeの返却 -
Content-Lengthの返却 - ファイルの該当範囲のみ出力
- 不正なRange指定への対応
ただし、共有サーバー環境では、PHP側で正しく実装しても最終レスポンスがサーバー側で変わる可能性があります。
そのため、外から見たHTTPレスポンスの確認は必須です。
S3などのオブジェクトストレージに逃がす
録音ファイルやメディアファイルをS3などのオブジェクトストレージに置き、アプリケーション側では署名付きURLを発行する方法です。
この方法であれば、PHPでファイル本体を返す必要がなくなります。
アクセス制御が必要な場合も、期限付きURLを使えば対応しやすくなります。
nginx / Apache側で正しく配信する
サーバー設定を変更できる環境であれば、PHPを通さず、Webサーバー側でメディアファイルを配信する構成にするのが自然です。
ただし、Xserverのような共有サーバーでは、nginx / FastCGIまわりの個別設定を触れないことがあります。
この場合は、サーバー設定で解決するのではなく、アプリケーション側で静的配信に逃がすなどの回避策を検討する必要があります。
まとめ
今回の学びは、以下です。
- iPhone / iPadだけ音声再生が途中で止まる場合は、まずRange Requestを確認する
- PCやAndroidで再生できても、HTTP的に正しくメディア配信できているとは限らない
-
curl -H "Range: bytes=0-1"で206 Partial Contentが返るか確認する - PHP / CakePHP経由の配信と、静的ファイル配信は分けて確認する
- PHPコード上のヘッダーではなく、curlで外から見た最終レスポンスを見る
- 共有サーバーではnginx / FastCGI設定を触れないことがある
- サーバー設定を変更できない場合は、静的ファイル配信やオブジェクトストレージへ逃がす判断も必要
今回のケースでは、CakePHP経由でMP3を直接返していたことが問題の入口でした。
静的MP3では 206 Partial Content が返っていたため、最終的には一時的な静的MP3を生成し、そこへ 302 リダイレクトする形で回避しました。
iPhoneだけ音声・動画再生が不安定なときは、フロントの <audio> タグやファイル破損を疑う前に、まずHTTP Range Requestに対して 206 Partial Content が返っているか確認するのが近道でした。