/* * capture-worklet.js — pulls audio off the graph in fixed frames. * * Runs on the audio thread, so it does the least possible work: pick the * channels that carry a microphone, average them, fill a frame, hand it over. * All judgement about what is speech happens on the main thread where it * cannot cause a dropout. */ /* An empty pick means "mix everything", which is right for a two-channel console feed. A specific set is right for Dante or SoundGrid, where the device presents 64 channels and the voice is on one or two of them — mixing those would bury the speaker under 62 silent channels and make a perfectly good feed look like silence. */ function normalize(list) { if (!Array.isArray(list)) return null; const out = []; for (const v of list) { const n = Number(v); if (Number.isInteger(n) && n >= 0 && out.indexOf(n) === -1) out.push(n); } return out.length ? out.sort((a, b) => a - b) : null; } /* Per-channel levels, posted about ten times a second. They are what makes a 64-channel device usable at all: without them, choosing the right channel is guesswork against a list of identical names, and the only feedback is whether the whole app appears broken. With them you watch for the one that moves when someone talks. */ const LEVEL_MS = 100; class CaptureProcessor extends AudioWorkletProcessor { constructor(options) { super(); const opts = (options && options.processorOptions) || {}; this._buf = new Float32Array(opts.frameSize || 1024); this._n = 0; this._pick = normalize(opts.channels); this._reported = -1; this._sumSq = null; // per-channel accumulator for the level meters this._sumN = 0; this._lastLevel = 0; /* Changing channel used to mean tearing the capture graph down and building it again, which is not something to do mid-service. A message is, so the picker now works while translation is running. */ this.port.onmessage = (e) => { const d = e.data; if (d && d.t === 'channels') this._pick = normalize(d.channels); }; } process(inputs) { const input = inputs[0]; if (!input || input.length === 0) return true; const chans = input.length; const len = input[0].length; /* Tell the main thread how many channels actually arrived, once. */ if (this._reported !== chans) { this._reported = chans; this._sumSq = new Float32Array(chans); this._sumN = 0; this.port.postMessage({ channels: chans }); } /* * Resolve the pick ONCE per render quantum, not once per sample. A channel * index that no longer exists (the device changed underneath us) falls back * to mixing rather than reading past the end of the array. */ let picks = null; if (this._pick) { picks = []; for (let k = 0; k < this._pick.length; k++) { if (this._pick[k] < chans) picks.push(this._pick[k]); } if (!picks.length) picks = null; } const np = picks ? picks.length : chans; for (let i = 0; i < len; i++) { let sum = 0; if (picks) for (let k = 0; k < np; k++) sum += input[picks[k]][i]; else for (let c = 0; c < chans; c++) sum += input[c][i]; this._buf[this._n++] = sum / np; if (this._n === this._buf.length) { this.port.postMessage(this._buf.slice(0)); this._n = 0; } } /* Levels for EVERY channel, including the ones not selected — the whole point is to find the one that is moving. */ if (this._sumSq && this._sumSq.length === chans) { for (let c = 0; c < chans; c++) { const ch = input[c]; let s = 0; for (let i = 0; i < len; i++) s += ch[i] * ch[i]; this._sumSq[c] += s; } this._sumN += len; const now = currentTime * 1000; if (now - this._lastLevel >= LEVEL_MS && this._sumN > 0) { this._lastLevel = now; const rms = new Float32Array(chans); for (let c = 0; c < chans; c++) rms[c] = Math.sqrt(this._sumSq[c] / this._sumN); this.port.postMessage({ levels: rms }); this._sumSq.fill(0); this._sumN = 0; } } return true; } } registerProcessor('capture', CaptureProcessor);