パソコンのブラウザーでは再生できる音声が、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 で、応答のヘッダーを確かめる

参考