refactor(frontend): rename project to s2s-frontend

This commit is contained in:
2026-10-06 17:00:57 -03:00
parent 99413fc06d
commit 6c4ee3a5e3
54 changed files with 26 additions and 26 deletions
+149
View File
@@ -0,0 +1,149 @@
import type { NoiseGate } from "../lib/contracts";
import playbackUrl from "./audio-playback.worklet.ts?worker&url";
import { decodePcm } from "./codec";
import captureUrl from "./mic-capture.worklet.ts?worker&url";
const BANDS = [2, 5, 9, 16, 28, 52];
export const MIC_CONSTRAINTS: MediaStreamConstraints = {
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
video: false,
};
/** Construct inside the click handler, before awaiting microphone permission. */
export function createAudioContext() {
const context = new AudioContext({ latencyHint: "interactive" });
void context.resume().catch(() => undefined);
return context;
}
export class AudioEngine {
private readonly context: AudioContext;
private stream: MediaStream | null = null;
private capture: AudioWorkletNode | null = null;
private playback: AudioWorkletNode | null = null;
private nodes: AudioNode[] = [];
private frame = 0;
private closed = false;
private gate: NoiseGate;
private muted = false;
constructor(context: AudioContext, gate: NoiseGate) {
this.context = context;
this.gate = gate;
}
async start(onChunk: (chunk: ArrayBuffer) => void, onLevel: (rms: number) => void, speaking: () => boolean) {
const stream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS);
if (this.closed) {
stream.getTracks().forEach((track) => {
track.stop();
});
return;
}
this.stream = stream;
await Promise.all([
this.context.audioWorklet.addModule(captureUrl),
this.context.audioWorklet.addModule(playbackUrl),
]);
if (this.closed) {
return;
}
const capture = new AudioWorkletNode(this.context, "mic-capture", { numberOfInputs: 1, numberOfOutputs: 0 });
const playback = new AudioWorkletNode(this.context, "audio-playback", {
numberOfInputs: 0,
numberOfOutputs: 1,
outputChannelCount: [1],
});
// The checked-in speech pipeline emits native 16 kHz PCM, despite older 24 kHz comments.
playback.port.postMessage({ kind: "config", inputRate: 16000 });
this.capture = capture;
this.playback = playback;
this.setGate(this.gate);
this.setMuted(this.muted);
capture.port.onmessage = (event: MessageEvent<ArrayBuffer | { kind: "level"; rms: number }>) => {
if (this.closed) {
return;
}
if (event.data instanceof ArrayBuffer) {
onChunk(event.data);
} else {
document.documentElement.style.setProperty("--audio-level", String(Math.min(1, event.data.rms * 5)));
onLevel(event.data.rms);
}
};
const input = this.context.createMediaStreamSource(stream);
const micAnalyser = this.context.createAnalyser();
const outAnalyser = this.context.createAnalyser();
micAnalyser.fftSize = outAnalyser.fftSize = 256;
micAnalyser.smoothingTimeConstant = 0;
outAnalyser.smoothingTimeConstant = 0.3;
input.connect(capture);
input.connect(micAnalyser);
playback.connect(outAnalyser);
outAnalyser.connect(this.context.destination);
this.nodes = [input, capture, playback, micAnalyser, outAnalyser];
const buffer = new Uint8Array(micAnalyser.frequencyBinCount);
const levels = new Float32Array(5);
const tick = () => {
const source = speaking() ? outAnalyser : micAnalyser;
source.getByteFrequencyData(buffer);
for (let band = 0; band < 5; band++) {
const lo = BANDS[band] ?? 0;
const hi = BANDS[band + 1] ?? 52;
let sum = 0;
for (let index = lo; index < hi; index++) {
sum += buffer[index] ?? 0;
}
const target = sum / ((hi - lo) * 255);
const previous = levels[band] ?? 0;
levels[band] = previous + (target - previous) * (target > previous ? 0.6 : 0.18);
document.documentElement.style.setProperty(`--bar${band}`, String(levels[band]));
}
outAnalyser.getByteFrequencyData(buffer);
document.documentElement.style.setProperty("--ai-audio-level", String(Math.max(...buffer.subarray(0, 52)) / 255));
this.frame = requestAnimationFrame(tick);
};
this.frame = requestAnimationFrame(tick);
}
play(base64: string) {
if (!base64 || this.closed) {
return;
}
const samples = decodePcm(base64);
this.playback?.port.postMessage({ kind: "audio", samples }, [samples.buffer]);
}
clear() {
this.playback?.port.postMessage({ kind: "clear" });
}
setGate(gate: NoiseGate) {
this.gate = gate;
this.capture?.port.postMessage({ kind: "gate", ...gate });
}
setMuted(muted: boolean) {
this.muted = muted;
this.stream?.getAudioTracks().forEach((track) => {
track.enabled = !muted;
});
this.capture?.port.postMessage({ kind: "enable", value: !muted });
}
async close() {
this.closed = true;
cancelAnimationFrame(this.frame);
this.stream?.getTracks().forEach((track) => {
track.stop();
});
this.capture?.port.close();
this.playback?.port.close();
this.nodes.forEach((node) => {
node.disconnect();
});
if (this.context.state !== "closed") {
await this.context.close();
}
for (let index = 0; index < 5; index++) {
document.documentElement.style.removeProperty(`--bar${index}`);
}
document.documentElement.style.removeProperty("--ai-audio-level");
document.documentElement.style.removeProperty("--audio-level");
}
}