パソコンのブラウザーでは再生できる音声が、iPhone では再生できない。再生はできても、シークバーを動かすと最初に戻ってしまう。こうした問題は、JavaScript やブラウザーではなく、音声を配信するサーバーの側に原因があることがよくあります。特に、PHP などのプログラムを通して音声ファイルを返している場合に起きがちです。
音声は「一部分ずつ」要求される
ブラウザーは、音声や動画を最初から最後まで一度に受け取るとは限りません。シークした位置の近くだけを読みたいときや、長さを知るために先頭だけを読みたいときは、「この範囲のバイトだけください」という要求を送ります。これを Range リクエスト (範囲付きリクエスト) と呼びます。
GET /audio/sample.mp3 HTTP/1.1
Host: example.com
Range: bytes=0-1
範囲付きの要求に対応したサーバーは、200 OK ではなく 206 Partial Content で、求められた部分だけを返します。
HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Range: bytes 0-1/123456
Content-Length: 2
Content-Type: audio/mpeg
Accept-Ranges: bytesは、バイト単位の範囲指定に対応していることを示しますContent-Rangeは、返した範囲と、ファイル全体の大きさ (ここでは 123456 バイト) を示しますContent-Lengthは、この応答で実際に返すバイト数です
iPhone などの Safari は、最初にごく小さな範囲を要求して、サーバーが範囲指定に対応しているかを確かめることがあります。ここで範囲を無視して 200 OK でファイル全体を返したり、エラーを返したりすると、音声を再生できないことがあります。ほかのブラウザーでも、範囲指定に対応していない配信では、シークができなかったり、毎回最初から読み直したりします。
昔の iOS では、こうした配信側の不備が、そのまま「iPhone だけ音が鳴らない」という形で表に出ていました。範囲指定への対応が求められる点は、今の Safari でも変わりません。
プログラムを通すと、何が抜けやすいか
Apache などの Web サーバーは、静的なファイルを返すときに、範囲付きの要求へ自動で対応します。ところが、PHP で readfile() を呼んでファイルを返すだけのプログラムでは、次のような点が抜けがちです。
Rangeヘッダーを読まず、いつもファイル全体を200 OKで返しているContent-Typeを指定していない。PHP は何もしなければtext/htmlを返すので、音声として扱われないことがある (MP3 ならaudio/mpeg、AAC を入れた M4A ならaudio/mp4)Content-Lengthを返していない。全体の長さが分からず、再生時間の表示やシークがおかしくなる- 出力の圧縮 (gzip) がかかり、バイト数が変わってしまう
また、PHP のセッションを使っていると、同じ利用者からの要求は、セッションが閉じられるまで 1 つずつ順番に処理されます。音声の配信中にほかの要求が待たされることがあるので、セッションを使う場合は、必要な処理が済んだら session_write_close() で早めに閉じておきます。
いちばん無難なのは、静的なファイルとして配信すること
誰でも聞いてよい音声なら、プログラムを通さず、Web サーバーが直接返す場所に置くのが確実です。範囲指定にも、ファイルの更新日時を使ったキャッシュにも、Web サーバーが対応してくれます。
ログインした人だけに聞かせたいなど、どうしてもプログラムから返す必要があるときは、範囲付きの要求を自分で扱います。次は、範囲を 1 つだけ指定した要求に対応する例です。
<?php
// 返すファイルは、利用者の入力からではなく、プログラムの側で決める
$file = __DIR__ . '/../private/audio/sample.mp3';
$size = filesize($file);
$start = 0;
$end = $size - 1;
header('Content-Type: audio/mpeg');
header('Accept-Ranges: bytes');
// 「bytes=開始-終了」の形の、範囲 1 つだけの指定を扱う。ほかの形は無視して全体を返す
if (isset($_SERVER['HTTP_RANGE'])
&& preg_match('/^bytes=(\d*)-(\d*)$/', $_SERVER['HTTP_RANGE'], $m)
&& ($m[1] !== '' || $m[2] !== '')) {
if ($m[1] === '') {
// 「bytes=-500」は、最後の 500 バイトという意味
$start = max(0, $size - (int)$m[2]);
} else {
$start = (int)$m[1];
if ($m[2] !== '') {
$end = min((int)$m[2], $size - 1);
}
}
if ($start > $end) {
// 満たせない範囲には 416 を返す
http_response_code(416);
header("Content-Range: bytes */$size");
exit;
}
http_response_code(206);
header("Content-Range: bytes $start-$end/$size");
}
$length = $end - $start + 1;
header("Content-Length: $length");
$fp = fopen($file, 'rb');
fseek($fp, $start);
$remaining = $length;
while ($remaining > 0 && !feof($fp)) {
$chunk = fread($fp, min(8192, $remaining));
if ($chunk === false || $chunk === '') {
break;
}
echo $chunk;
$remaining -= strlen($chunk);
}
fclose($fp);
返すファイルのパスを、URL のパラメーターなどからそのまま組み立てるのは危険です。../ を含む値で、公開するつもりのないファイルまで読み出されるおそれがあります。ファイルを選ばせる場合は、決めておいた一覧の中から選ぶ形にします。
curl で確かめる
配信が正しいかどうかは、curl で応答のヘッダーを見ると分かります。
# ヘッダーだけを見る (HEAD リクエスト)
curl -I https://example.com/audio/sample.mp3
# 先頭の 2 バイトだけを要求し、応答のヘッダーを表示する
curl -s -D - -o /dev/null -r 0-1 https://example.com/audio/sample.mp3
-r 0-1 は範囲の指定で、Range: bytes=0-1 を送ります。応答が 206 で、Content-Range と Content-Length: 2 が返っていれば、範囲指定に対応できています。200 が返る、Content-Type が text/html になっている、といった場合は、配信の側を直します。なお、HTTP/2 で接続した場合は、1 行目が HTTP/2 206 のように表示されます。
まとめ
- 音声が再生できない・シークできない原因は、配信する側にあることがある
- ブラウザーは
Rangeヘッダーで一部分ずつ要求する。サーバーは206 Partial ContentとContent-Range、Content-Lengthで答える必要がある Content-Typeは音声の形式に合わせる (MP3 ならaudio/mpeg)- できるだけ静的なファイルとして Web サーバーから配信する。プログラムから返すなら、範囲付きの要求を自分で扱う
curl -Iやcurl -r 0-1で、応答のヘッダーを確かめる