1

For Windows Chrome (and probably many other browsers), this code works for serving an mp3 in an audio element:

/**
 * 
 * @param string $filename
 * @return \Illuminate\Http\Response|\Illuminate\Contracts\Routing\ResponseFactory
 */
public function getMp3($filename) {
    $fileContents = Storage::disk(\App\Helpers\CoachingCallsHelper::DISK)->get($filename);
    $fileSize = Storage::disk(\App\Helpers\CoachingCallsHelper::DISK)->size($filename);
    $shortlen = $fileSize - 1;
    $headers = [
        'Accept-Ranges' => 'bytes',
        'Content-Range' => 'bytes 0-' . $shortlen . '/' . $fileSize,
        'Content-Type' => "audio/mpeg"
    ];
    Log::debug('$headers=' . json_encode($headers));
    $response = response($fileContents, 200, $headers);
    return $response;
}

But when I use an iPhone to browse to the same page, the mp3 file does not show the total duration, and when I play it, it says "Live broadcast".

I've tried to follow suggestions from various answers of this question (HTML5 <audio> Safari live broadcast vs not) and other articles I've read, but none seem to have an effect.

No matter how I change the headers, the mp3 seems to function as desired on Windows and does not work on iOS.

How can I debug what I'm doing wrong?

Here is HTML:

<audio controls preload="auto">
    <source src="{{$coachingCall->getMp3Url()}}" type="audio/mpeg"/>
    <p>Your browser doesnt support embedded HTML5 audio. Here is a <a href="{{$coachingCall->getMp3Url()}}">link to the audio</a> instead.</p>
</audio>
Ryan
  • 22,332
  • 31
  • 176
  • 357

3 Answers3

1

MP3 files don't have timestamps, and therefore no inherent length that can be known ahead of time. Chrome is just guessing, based on the bitrate at the beginning of the file and the byte size of the file. It doesn't really know.

Some players don't bother guessing.

Also, all browsers on iOS are Safari under the hood, thanks to some incredibly restrictive policies by Apple. Therefore, Chrome on iOS is really just a wrapper for a Safari web view.

Brad
  • 159,648
  • 54
  • 349
  • 530
  • Right, I knew that about Apple being super restrictive and all of its browsers really being Safari. And the iOS browsers are the only ones that have this problem. I'm trying to figure out how to serve an mp3 file across all browsers (including iOS). What do you recommend? Thanks. – Ryan Feb 18 '19 at 00:38
  • 2
    @Ryan Don't use MP3 files. Use AAC wrapped in MP4. You'll have much higher quality audio for a given bitrate, as well as correct timestamps. – Brad Feb 18 '19 at 00:42
  • Interesting. I originally had `.m4a` files (and I think m4a is a container of AAC) that I converted to mp3s. Although I wish I could get mp3s to work since I've done so much work later (cutting sections out of the audio clips), I can try using m4a and see if it works across all browsers. Thanks. – Ryan Feb 18 '19 at 00:48
  • M4A is MP4... exactly same container (essentially ISO Base Media File Format), just a different name out of convention to indicate that the file contains audio only. You should never convert from a lossy codec to another lossy codec. You're losing quality every step, with no benefit. There's definitely no reason top convert from your M4A files to MP3. AAC and MP4 are well supported in browsers. You also shouldn't attempt to cut lossy audio files... your cuts will either be imprecise or will require yet another re-encode and significant quality loss. Edit from the originals. – Brad Feb 18 '19 at 00:52
  • The service I was using only provided M4A, so there were no lossless "originals" for me to edit. And I did need to convert to mp3 because the site I was using to distribute them didn't support M4A. But now I'm building my own site. However, in the past 15 minutes, I tried changing my code to use M4A instead of mp3, and the problem still exists: Windows browsers work fine, but iOS does not. – Ryan Feb 18 '19 at 01:08
  • 1
    @Ryan Post a packet capture or HAR file or something we can take a look at, between your server and the actual device/browser in question. – Brad Feb 18 '19 at 02:01
  • Thank you for your willingness to help. I finally figured it out and posted the answer. – Ryan Feb 20 '19 at 22:22
  • @Ryan So, your range headers *weren't* working before? Anyway, glad you were able to figure it out. – Brad Feb 20 '19 at 22:50
  • I think one problem was that Safari expected a 206 instead of 200 response and also needed it to be a `\Symfony\Component\HttpFoundation\StreamedResponse` instead of the typical `\Illuminate\Http\Response|\Illuminate\Contracts\Routing\ResponseFactory`. – Ryan Feb 20 '19 at 23:09
1

Whoa, that was a very difficult problem to solve. (It took me days.)

And I learned that it wasn't just iOS that was having problems: Safari on Mac hadn't been working either.

Now I think everything works on every browser I've tested.

I'm really glad I found this example to follow.

Here is my answer:

/**
 * 
 * @param string $disk
 * @param string $filename
 * @return \Illuminate\Http\Response|\Illuminate\Contracts\Routing\ResponseFactory|\Symfony\Component\HttpFoundation\StreamedResponse
 */
public static function getMediaFile($disk, $filename) {
    $rangeHeader = request()->header('Range');
    $fileContents = Storage::disk($disk)->get($filename);
    $fullFilePath = Storage::disk($disk)->path($filename); //https://stackoverflow.com/a/49532280/470749
    $headers = ['Content-Type' => Storage::disk($disk)->mimeType($fullFilePath)];
    if ($rangeHeader) {
        return self::getResponseStream($disk, $fullFilePath, $fileContents, $rangeHeader, $headers);
    } else {
        $httpStatusCode = 200;
        return response($fileContents, $httpStatusCode, $headers);
    }
}

/**
 * 
 * @param string $disk
 * @param string $fullFilePath
 * @param string $fileContents
 * @param string $rangeRequestHeader
 * @param array  $responseHeaders
 * @return \Symfony\Component\HttpFoundation\StreamedResponse
 */
public static function getResponseStream($disk, $fullFilePath, $fileContents, $rangeRequestHeader, $responseHeaders) {
    $stream = Storage::disk($disk)->readStream($fullFilePath);
    $fileSize = strlen($fileContents);
    $fileSizeMinusOneByte = $fileSize - 1; //because it is 0-indexed. https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.16
    list($param, $rangeHeader) = explode('=', $rangeRequestHeader);
    if (strtolower(trim($param)) !== 'bytes') {
        abort(400, "Invalid byte range request"); //Note, this is not how https://stackoverflow.com/a/29997555/470749 did it
    }
    list($from, $to) = explode('-', $rangeHeader);
    if ($from === '') {
        $end = $fileSizeMinusOneByte;
        $start = $end - intval($from);
    } elseif ($to === '') {
        $start = intval($from);
        $end = $fileSizeMinusOneByte;
    } else {
        $start = intval($from);
        $end = intval($to);
    }
    $length = $end - $start + 1;
    $httpStatusCode = 206;
    $responseHeaders['Content-Range'] = sprintf('bytes %d-%d/%d', $start, $end, $fileSize);
    $responseStream = response()->stream(function() use ($stream, $start, $length) {
        fseek($stream, $start, SEEK_SET);
        echo fread($stream, $length);
        fclose($stream);
    }, $httpStatusCode, $responseHeaders);
    return $responseStream;
}
Ryan
  • 22,332
  • 31
  • 176
  • 357
1

I can't comment since I just made my account, so... complementing RYAN's

Just found out that you can save some loading time removing the

$fileContents = Storage::disk($disk)->get($filename);

And replacing it with

$fileSize = Storage::disk($disk)->size($filename);

Passing the size directly to the getResponseStream function, instead of downloading the whole content into a variable and then measuring the length.

Thank you Ryan, saved me a lot of precious time with the stinky safari.

El David
  • 11
  • 1