angularaudiocontextwebkitaudiocontext

Audio plays in Chrome but not Safari


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');

Solution

  • 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.