Audio plays in Chrome but not Safari

2019-08-24 22:24发布

问题:

I've got an angular 5 application where I've set the click handler of a button to download an audio file and play it. I'm using this code to do so:

onPreviewPressed(media: Media): void {
    const url = ".....";

    this.httpClient.get(url, {responseType: 'blob'}).subscribe(x => {
        const fileReader = new FileReader();

        fileReader.onloadend = () => {
            const context = new ((<any>window).AudioContext || (<any>window).webkitAudioContext)();
            const source = context.createBufferSource();

            context.decodeAudioData(fileReader.result, buffer => {
                source.buffer = buffer;
                source.connect(context.destination);
                source.start(0);
            }, y => {
                console.info("Error: " + y);
            });
        };

        fileReader.readAsArrayBuffer(x);
    });
}

If I go to the page in Chrome and press the button the audio starts right up. If I do it in Safari nothing happens. I know Safari locked things down but this is in response to a button click, it's not an auto-play.

The audio is sent back from the server via a PHP script, and it's sending headers like this, in case it matters:

header("Content-Type: audio/mpeg");
header('Content-Transfer-Encoding: binary');
header('Content-Length: ' . filesize($_GET['file']));
header('Cache-Control: no-cache');

回答1:

No, it is not "in response to a button click".
In response to this click event, you are starting an asynchronous task. By the time you call source.start(0), your event is long dead (or at least not anymore an "trusted user gesture". So they will indeed block this call.

To circumvent this, you could simply mark your context as allowed with silence. Then, when the data will be available, you'll be able to start it with no restriction:

function markContextAsAllowed(context) {
  const gain = context.createGain();
  gain.gain.value = 0; // silence
  const osc = context.createOscillator();
  osc.connect(gain);
  gain.connect(context.destination);
  osc.onended = e => gain.disconnect();
  osc.start(0);
  osc.stop(0.01);
}


onPreviewPressed(media: Media): void {
  const url = ".....";
  // declare in the event handler
  const context = new(window.AudioContext || window.webkitAudioContext)();
  const source = context.createBufferSource();
  // allow context synchronously
  markContextAsAllowed(context);


  this.httpClient.get(url, {
    responseType: 'blob'
  }).subscribe(x => {
    const fileReader = new FileReader();

    fileReader.onloadend = () => {
      context.decodeAudioData(fileReader.result, buffer => {
        source.buffer = buffer;
        source.connect(context.destination);
        source.start(0);
      }, y => {
        console.info("Error: " + y);
      });
    };

    fileReader.readAsArrayBuffer(x);
  });
}

As a fiddle since Safari doesn't like over-protected StackSnippets®

Also, my angular knowledge is very limited, but if httpClient.get does support {responseType: 'arraybuffer'} option, you could get rid of this FileReader and avoid populating twice the memory with the same data.

Finally, if you are going to play this audio more than once, consider prefetching and pre-decoding it, you'll then be able to avoid the whole asynchronous mess.