javascriptgoogle-chromeweb-audio-apiaudio-worklet

Chromium Audio Worklet dropouts. Some initially, then occasionally in 30s intervals


I'm noticing frequent and repeatable dropouts when using the Chromium Audio worklet with a MediaDevices.getUserMedia media stream (microphone). This is not 100% reproducible, but they do tend to follow pattern when they do occur:

(Time ranges vary slightly per attempt)

  1. 0:00 -> 0:00.2 : No samples are received. (Able to reproduce 100% of the time, but this feels like a separate issue that I wasn't necessarily tracking down at the moment)
  2. 0:00.2 -> 0:00.5 : Samples are received
  3. 0:00.5 -> 0:00.6 : Dropout occurs, no samples received (Able to reproduce ~20% of the time).
  4. 0:00.6 -> 0:30.0 : Samples received
  5. Every 30s from here on out, occasionally dropouts will occur. Tends to happen the most often at the first 30s mark. (First 30s mark I can reproduce around 20% of the time as well).

Here is a codepen that illustrates the behavior: https://codepen.io/GJStevenson/pen/GRErPbm

const startRecordingButton = document.getElementById('startRecordingButton');
let mediaStreamSourceNode;
let isRecording = false;
let timer;

const workletString = `
const formatTimeString = s => {
   const m = (s / 60).toFixed(2);
   const h = (m / 60).toFixed(2);
   const ms = Math.trunc(s * 1000) % 1000;
   const ss = Math.trunc(s) % 60;
   const mm = Math.trunc(m) % 60;
   const hh = Math.trunc(h);
   return hh + ":" + mm + ":" + ss + "." + ms;
};

class RecorderWorklet extends AudioWorkletProcessor {
    constructor(options) {
        super(options);
        this.sampleRate = 0;
        this.sampleCount = 0;

        this.port.onmessage = event => {
            if (event.data.message === 'init') {
                this.sampleRate = event.data.sampleRate;
            }
        }
    }

    process(inputs) {
        if (inputs.length > 0 && inputs[0].length > 0) {
            this.sampleCount += inputs[0][0].length; 
            //console.debug(formatTimeString(this.sampleCount/this.sampleRate), ' : ', inputs[0][0]);

            if (inputs[0][0].includes(0)) {
                console.log('Dropped Samples at: ', formatTimeString(this.sampleCount/this.sampleRate), ' : ', ...inputs[0][0])
            }
        }
        return true;
    }
}

registerProcessor('recorder-worklet', RecorderWorklet);
`;

async function listAudioInputs() {
    const devices = await navigator.mediaDevices.enumerateDevices();
    return devices.filter((device) => device.kind === 'audioinput');
}

async function getDefaultInput(fallbackToFirstInput = true) {
    const audioInputs = await listAudioInputs();
    const defaultDevice = audioInputs.find((device) => device.deviceId === 'default');
    if (defaultDevice) {
        return defaultDevice;
    }
    return fallbackToFirstInput && audioInputs.length > 0 ? audioInputs[0] : undefined;
}

async function getAudioStream(device) {
    const constraints = {
        audio: {
            deviceId: device.deviceId,
        },
    };
    return navigator.mediaDevices.getUserMedia(constraints);
}

async function createRecordingPipeline(device) {
    const stream = await getAudioStream(device);
    const audioTracks = stream.getAudioTracks();

    const sampleRate = audioTracks[0].getSettings().sampleRate;
    console.log('Sample Rate: ', sampleRate);
    const context = new AudioContext({ sampleRate, latencyHint: 'interactive' });

    const blob = new Blob([workletString], { type: 'text/javascript' });
    const workletUrl = URL.createObjectURL(blob);

    await context.audioWorklet.addModule(workletUrl);
    const workletNode = new AudioWorkletNode(context, 'recorder-worklet');

    workletNode.port.postMessage({
        message: 'init',
        sampleRate: sampleRate
    });

    mediaStreamSourceNode = context.createMediaStreamSource(stream);
    mediaStreamSourceNode.connect(workletNode)
                         .connect(context.destination);
}

function formatTimeString(s) {
   const m = (s / 60).toFixed(2);
   const h = (m / 60).toFixed(2);
   const ms = Math.trunc(s * 1000) % 1000;
   const ss = Math.trunc(s) % 60;
   const mm = Math.trunc(m) % 60;
   const hh = Math.trunc(h);
   return hh + ":" + mm + ":" + ss + "." + ms;
};

async function startRecording() {
    const device = await getDefaultInput();
    await createRecordingPipeline(device);

    let timeElapsed = 0;
    timer = setInterval(() => {
        timeElapsed++;
        console.log('Time: ', formatTimeString(timeElapsed));
    }, 1000);
  
    startRecordingButton.innerText = "Stop Recording";
}

async function stopRecording() {
    if (mediaStreamSourceNode) {
        mediaStreamSourceNode.mediaStream.getAudioTracks().forEach(track => {
           track.stop();
        });
        mediaStreamSourceNode.disconnect();
    }
    mediaStreamSourceNode = null;
    clearInterval(timer);
    
    startRecordingButton.innerText = "Start Recording";
}

async function toggleRecording() {
    if (!isRecording) {
        await startRecording();
    } else {
        await stopRecording();
    }
    isRecording = !isRecording;
}
<button onclick="toggleRecording()" id="startRecordingButton">Start Recording</button>

The dropped samples will look something like this in the console:

dropped samples in console

Any thoughts as to what the problem may be?

Edit: Ran chrome://tracing to capture trace of the dropped sample. https://www.dropbox.com/s/veg1vgsg9nn03ty/trace_dropped-sample-trace.json.gz?dl=0. Dropped samples happened from ~.53s -> .61s

picture of dropped traces from .53s to .61s


Solution

  • Got some answers after opening an issue with Chromium. Summarizing some of the responses from https://bugs.chromium.org/p/chromium/issues/detail?id=1248169:

    0:00 -> 0:00.2 : No samples are received. (Able to reproduce 100% of the time, but this feels like a separate issue that I wasn't necessarily tracking down at the moment)

    These first few zero samples are the initial primed values from the underlying buffers and are expected.

    0:00.5 -> 0:00.6 : Dropout occurs, no samples received (Able to reproduce ~20% of the time).

    This should not happen if the AudioWorklet thread runs on a RT priority. This was difficult to reproduce, so shelving it for now.

    Every 30s from here on out, occasionally dropouts will occur. Tends to happen the most often at the first 30s mark. (First 30s mark I can reproduce around 20% of the time as well).

    This dropout was a result of having configured a destination source, but not utilizing it. After 30 seconds of silence, the AudioWorklet thread switches to a low priority one, causing the dropout.

    So changing

    mediaStreamSourceNode.connect(workletNode)
                         .connect(context.destination);
    

    to

    mediaStreamSourceNode.connect(workletNode);
    

    fixed the problem.