Search code examples
pythonhtmlflaskpyaudio

Flask send pyaudio to browser


I'm sending my servers microphone's audio to the browser (mostly like this post but with some modified options).

All works fine, until you head over to a mobile or safari, where it doesn't work at all. I've tried using something like howler to take care of the frontend but with not success (still works in chrome and on the computer but not on the phones Safari/Chrome/etc). <audio> ... </audio> works fine in chrome but only on the computer.

function play_audio() {
  var sound = new Howl({
    src: ['audio_feed'],
    format: ['wav'],
    html5: true,
    autoplay: true
  });
  sound.play();
}

How does one send a wav-generated audio feed which is 'live' that works in any browser?

EDIT 230203:

I have narrowed the error down to headers (at least what I think is causing the errors).

What headers should one use to make the sound available in all browsers?

Take this simple app.py for example:

from flask import Flask, Response, render_template
import pyaudio
import time

app = Flask(__name__)

@app.route('/')
def index():
    return render_template('index.html', headers={'Content-Type': 'text/html'})

def generate_wav_header(sampleRate, bitsPerSample, channels):
    datasize = 2000*10**6
    o = bytes("RIFF",'ascii')
    o += (datasize + 36).to_bytes(4,'little')
    o += bytes("WAVE",'ascii')
    o += bytes("fmt ",'ascii')
    o += (16).to_bytes(4,'little')
    o += (1).to_bytes(2,'little')
    o += (channels).to_bytes(2,'little')
    o += (sampleRate).to_bytes(4,'little')
    o += (sampleRate * channels * bitsPerSample // 8).to_bytes(4,'little')
    o += (channels * bitsPerSample // 8).to_bytes(2,'little')
    o += (bitsPerSample).to_bytes(2,'little')
    o += bytes("data",'ascii')
    o += (datasize).to_bytes(4,'little')
    return o

def get_sound(InputAudio):

    FORMAT = pyaudio.paInt16
    CHANNELS = 2
    CHUNK = 1024
    SAMPLE_RATE = 44100
    BITS_PER_SAMPLE = 16

    wav_header = generate_wav_header(SAMPLE_RATE, BITS_PER_SAMPLE, CHANNELS)

    stream = InputAudio.open(
        format=FORMAT,
        channels=CHANNELS,
        rate=SAMPLE_RATE,
        input=True,
        input_device_index=1,
        frames_per_buffer=CHUNK
    )

    first_run = True
    while True:
       if first_run:
           data = wav_header + stream.read(CHUNK)
           first_run = False
       else:
           data = stream.read(CHUNK)
       yield(data)


@app.route('/audio_feed')
def audio_feed():

    return Response(
        get_sound(pyaudio.PyAudio()),
        content_type = 'audio/wav',
    )

if __name__ == '__main__':
    app.run(debug=True)

With a index.html looking like this:

<html>
  <head>
    <title>Test audio</title>
  </head>
  <body>
    <button onclick="play_audio()">
      Play audio
    </button>
    <div id="audio-feed"></div>
  </body>
<script>

  function play_audio() {
    var audio_div = document.getElementById('audio-feed');
    const audio_url = "{{ url_for('audio_feed') }}"
    audio_div.innerHTML = "<audio controls><source src="+audio_url+" type='audio/x-wav;codec=pcm'></audio>";
  }

</script>
</html>

Fire upp the flask development server python app.py and test with chrome, if you have a microphone you will hear the input sound (headphones preferably, otherwise you'll get a sound loop). Firefox works fine too.

But If you try the same app with any browser on an iPhone you'll get no sound, and the same goes for safari on MacOS.

There's no errors and you can see that the byte stream of the audio is getting downloaded in safari, but still no sound.

What is causing this? I think I should use some kind of headers in the audio_feed response but with hours of debugging I cannot seem to find anything for this.

EDIT 230309:

@Markus is pointing out to follow RFC7233 HTTP Range Request. And that's probably it. While firefox, chrome and probably more browsers on desktop send byte=0- as header request, safari and browsers used on iOS send byte=0-1 as header request.


Solution

  • EDITED 2023-03-12

    It turns out that it is sufficient to convert the audio live stream to mp3. For this you can use ffmpeg. The executable has to be available in the execution path of the server process. Here is a working draft tested with windows laptop as server and Safari on iPad as client:

    from subprocess import Popen, PIPE
    from threading import Thread
    from flask import Flask, Response, render_template
    import pyaudio
    
    FORMAT = pyaudio.paFloat32
    CHANNELS = 1
    CHUNK_SIZE = 4096
    SAMPLE_RATE = 44100
    BITS_PER_SAMPLE = 16
    
    app = Flask(__name__)
    
    
    @app.route('/')
    def index():
        return render_template('index.html', headers={'Content-Type': 'text/html'})
    
    
    def read_audio(inp, audio):
        while True:
            inp.write(audio.read(num_frames=CHUNK_SIZE))
    
    
    def response():
        a = pyaudio.PyAudio().open(
            format=FORMAT,
            channels=CHANNELS,
            rate=SAMPLE_RATE,
            input=True,
            input_device_index=1,
            frames_per_buffer=CHUNK_SIZE
        )
    
        c = f'ffmpeg -f f32le -acodec pcm_f32le -ar {SAMPLE_RATE} -ac {CHANNELS} -i pipe: -f mp3 pipe:'
        p = Popen(c.split(), stdin=PIPE, stdout=PIPE)
        Thread(target=read_audio, args=(p.stdin, a), daemon=True).start()
    
        while True:
            yield p.stdout.readline()
    
    
    @app.route('/audio_feed', methods=['GET'])
    def audio_feed():
        return Response(
            response(),
            headers={
                # NOTE: Ensure stream is not cached.
                'Cache-Control': 'no-cache, no-store, must-revalidate',
                'Pragma': 'no-cache',
                'Expires': '0',
            },
            mimetype='audio/mpeg')
    
    
    if __name__ == "__main__":
        app.run(host='0.0.0.0')
    

    In index.html change the type to audio/mp3:

    <!DOCTYPE html>
    <html>
      <head>
        <title>Test audio</title>
      </head>
      <body>
        <button onclick="play_audio()">
          Play audio
        </button>
        <div id="audio-feed"></div>
      </body>
    <script>
      function play_audio() {
        var audio_div = document.getElementById('audio-feed');
        const audio_url = "{{ url_for('audio_feed') }}"
        audio_div.innerHTML = "<audio preload='all' controls><source src=" + audio_url + " type='audio/mp3'></audio>";
      }
    </script>
    </html>
    

    Disclaimer: This is just a basic demo. It opens an audio-ffmpeg subprocess for each call to the audio_feed handler. It doesn't cache data for multiple requests, it doesn't remove unused threads and it doesn't delete data that isn't consumed.

    Credits: how to convert wav to mp3 in live using python?