Files
gods-eye-view/src/voice/local/localAudio.js
T

229 lines
6.4 KiB
JavaScript

const CAPTURE_WORKLET = `
class GevCapture extends AudioWorkletProcessor {
process(inputs) {
const channel = inputs[0] && inputs[0][0];
if (channel) this.port.postMessage(channel.slice(0));
return true;
}
}
registerProcessor('gev-capture', GevCapture);
`;
// Audio kept from just before a press so the first word is not clipped.
const PRE_ROLL_SECONDS = 0.5;
export const CAPTURE_SAMPLE_RATE = 16000;
/**
* Microphone capture resampled to 16 kHz. start()/stop() bracket one
* utterance; stop() resolves with the samples captured in between, plus a
* short pre-roll from before start().
*/
export async function createMicrophoneCapture({
getUserMedia = (constraints) =>
navigator.mediaDevices.getUserMedia(constraints),
} = {}) {
const stream = await getUserMedia({
audio: {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
// Capture at the device rate and resample: a 16 kHz context can deliver
// silence from some inputs, and not every browser supports one.
const context = new AudioContext();
let source;
let node;
let sink;
try {
const moduleUrl = URL.createObjectURL(
new Blob([CAPTURE_WORKLET], { type: 'application/javascript' }),
);
try {
await context.audioWorklet.addModule(moduleUrl);
} finally {
URL.revokeObjectURL(moduleUrl);
}
source = context.createMediaStreamSource(stream);
node = new AudioWorkletNode(context, 'gev-capture');
sink = context.createGain();
} catch (error) {
stream.getTracks().forEach((track) => track.stop());
context.close().catch(() => {});
throw error;
}
sink.gain.value = 0;
source.connect(node);
node.connect(sink);
sink.connect(context.destination);
let chunks = [];
let recording = false;
let generation = 0;
const preRoll = [];
let preRollSamples = 0;
const preRollLimit = PRE_ROLL_SECONDS * context.sampleRate;
node.port.onmessage = ({ data }) => {
if (recording) {
chunks.push(data);
return;
}
preRoll.push(data);
preRollSamples += data.length;
while (preRollSamples - preRoll[0].length > preRollLimit)
preRollSamples -= preRoll.shift().length;
};
return {
stream,
get recording() {
return recording;
},
start() {
generation++;
chunks = preRoll.splice(0);
preRollSamples = 0;
recording = true;
context.resume().catch(() => {});
},
async stop() {
if (!recording) return new Float32Array(0);
const utterance = generation;
const captured = chunks;
// Let the last render quantum arrive before closing the utterance.
await new Promise((resolve) => setTimeout(resolve, 60));
// A new utterance started meanwhile: it owns the recording now.
if (utterance === generation) {
recording = false;
chunks = [];
}
const length = captured.reduce((sum, chunk) => sum + chunk.length, 0);
const audio = new Float32Array(length);
let offset = 0;
for (const chunk of captured) {
audio.set(chunk, offset);
offset += chunk.length;
}
return resampleTo16k(audio, context.sampleRate);
},
close() {
generation++;
recording = false;
try {
source.disconnect();
node.disconnect();
sink.disconnect();
} catch {
/* already disconnected */
}
stream.getTracks().forEach((track) => track.stop());
context.close().catch(() => {});
},
};
}
/**
* Box-filter resampler to 16 kHz; adequate for speech recognition input.
* @param {Float32Array} samples
* @param {number} rate
*/
export function resampleTo16k(samples, rate) {
if (!rate || rate === CAPTURE_SAMPLE_RATE) return samples;
const ratio = rate / CAPTURE_SAMPLE_RATE;
const out = new Float32Array(Math.floor(samples.length / ratio));
for (let index = 0; index < out.length; index++) {
const start = Math.floor(index * ratio);
const end = Math.min(samples.length, Math.floor((index + 1) * ratio));
let sum = 0;
for (let cursor = start; cursor < end; cursor++) sum += samples[cursor];
out[index] = sum / Math.max(1, end - start);
}
return out;
}
/** Gapless queue for synthesized speech with an interruptible output. */
export function createSpeechPlayer({ onSpeaking, onIdle } = {}) {
let context = null;
let gain = null;
let destination = null;
let nextStart = 0;
let playing = 0;
let generation = 0;
let idleWaiters = [];
function settleIdle() {
onIdle?.();
const waiters = idleWaiters;
idleWaiters = [];
for (const resolve of waiters) resolve();
}
function ensure() {
if (context) return;
context = new AudioContext();
destination = context.createMediaStreamDestination();
connectGain();
}
function connectGain() {
gain = context.createGain();
gain.connect(context.destination);
gain.connect(destination);
}
return {
get stream() {
ensure();
return destination.stream;
},
get speaking() {
return playing > 0;
},
/** Schedules samples; resolves with the delay until they are audible. */
enqueue(samples, sampleRate) {
ensure();
context.resume().catch(() => {});
const buffer = context.createBuffer(1, samples.length, sampleRate);
buffer.copyToChannel(samples, 0);
const source = context.createBufferSource();
source.buffer = buffer;
source.connect(gain);
const at = Math.max(context.currentTime + 0.02, nextStart);
source.start(at);
nextStart = at + buffer.duration;
const epoch = generation;
if (playing++ === 0) onSpeaking?.();
source.onended = () => {
if (epoch !== generation) return;
if (--playing === 0) settleIdle();
};
return (at - context.currentTime) * 1000;
},
stop() {
if (!context) return;
generation++;
const wasPlaying = playing > 0;
playing = 0;
nextStart = 0;
try {
gain.disconnect();
} catch {
/* already disconnected */
}
connectGain();
if (wasPlaying) settleIdle();
},
/** Settles once everything scheduled has finished playing. */
idle() {
return playing
? new Promise((resolve) => idleWaiters.push(resolve))
: Promise.resolve();
},
close() {
this.stop();
context?.close().catch(() => {});
context = null;
},
};
}