Files
GMW/services/frontend/src/lib/audio/pcm-player.ts
T
asepharyana a6e2c1fa4f voice: deep stability audit — FFmpeg crash recovery, activity timeout, reconnect refresh
Gateway (transmitter.ts):
- Auto-stop on FFmpeg crash: non-zero exit triggers stop() to prevent
  silent audio loss and resource leaks
- Voice activity timeout (10s): auto-stops transmitter when no PCM
  received, preventing dead-air CPU waste on backgrounded tabs
- Stderr cap (4KB): prevents unbounded memory growth in long sessions

Gateway (voice.handler.ts):
- Double-check voiceController.getStatus().connected before starting
  transmitter — detects stale player state after gateway disconnect

Frontend (context.tsx):
- Force-refetch voice status on WS reconnect — UI converges in <1s
  instead of waiting up to 4s for SWR poll interval

All: tsc clean, biome clean
2026-08-26 23:48:36 +07:00

154 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Receive Discord voice PCM over the WebSocket and play it through the
* browser audio stack.
*
* Binary frame format (from the gateway, via backend WS):
* Byte 0–3: FNV-1a 32-bit userId hash (UInt32LE)
* Byte 4+: PCM audio (24kHz mono Int16LE)
*
* The player keeps one ring buffer per user (2s @ 24kHz), upsamples
* 24k → 48k with linear interpolation inside the audio callback, and mixes
* every active user into a single mono output. A ScriptProcessorNode is used
* because it runs on the main thread — ring buffers need no SharedArrayBuffer
* and work without COOP/COEP headers. `start()` must be called from a user
* gesture (audio autoplay policy).
*/
const INPUT_RATE = 24000;
const OUTPUT_RATE = 48000;
const RING_SECONDS = 2;
const RING_LEN = INPUT_RATE * RING_SECONDS;
interface UserRing {
data: Float32Array;
/** absolute write position (monotonic) */
write: number;
/** absolute read position as float — advances at 0.5× per output sample */
readPos: number;
/** last tick where this user produced audio (for stale-ring cleanup) */
lastActive: number;
}
export class PcmPlayer {
private ctx: AudioContext | null = null;
private processor: ScriptProcessorNode | null = null;
private master: GainNode | null = null;
private rings = new Map<number, UserRing>();
private levels = new Map<number, number>();
private volume = 0.75;
private started = false;
get isStarted(): boolean {
return this.started;
}
/** Create the AudioContext + processor. MUST be called from a user gesture. */
start(): void {
if (this.started) return;
const Ctor =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
if (!Ctor) return;
this.ctx = new Ctor({ sampleRate: OUTPUT_RATE });
this.master = this.ctx.createGain();
this.master.gain.value = this.volume;
this.master.connect(this.ctx.destination);
this.processor = this.ctx.createScriptProcessor(4096, 0, 1);
this.processor.onaudioprocess = (e) => this.tick(e);
this.processor.connect(this.master);
this.started = true;
}
/** Push one PCM chunk (s16 mono @24kHz) for a user. */
push(userIdHash: number, samples: Int16Array): void {
if (!this.started || samples.length === 0) return;
let ring = this.rings.get(userIdHash);
if (!ring) {
ring = {
data: new Float32Array(RING_LEN),
write: 0,
readPos: 0,
lastActive: Date.now(),
};
this.rings.set(userIdHash, ring);
}
ring.lastActive = Date.now();
for (let i = 0; i < samples.length; i++) {
ring.data[ring.write % RING_LEN] = samples[i] / 32768;
ring.write++;
}
// Overflow guard: never let the ring lag more than RING_LEN behind.
// This handles both normal drift and extreme backpressure scenarios.
const lag = ring.write - ring.readPos;
if (lag > RING_LEN - 4096) {
ring.readPos = ring.write - RING_LEN + 4096;
}
}
setVolume(v: number): void {
this.volume = v;
if (this.master) this.master.gain.value = v;
}
/** Peak |sample| per user since the last call (drives the UI waveform). */
getLevels(): Map<number, number> {
return new Map(this.levels);
}
stop(): void {
if (this.processor) {
this.processor.disconnect();
this.processor.onaudioprocess = null;
}
if (this.ctx) {
void this.ctx.close().catch(() => {});
}
this.processor = null;
this.ctx = null;
this.master = null;
this.rings.clear();
this.levels.clear();
this.started = false;
}
private tick(e: AudioProcessingEvent): void {
const out = e.outputBuffer.getChannelData(0);
out.fill(0);
const n = out.length;
const step = INPUT_RATE / OUTPUT_RATE; // 0.5
const nextLevels = new Map<number, number>();
for (const [hash, ring] of this.rings) {
let level = 0;
for (let i = 0; i < n; i++) {
const pos = ring.readPos + i * step;
if (pos + 1 >= ring.write) break;
const i0 = Math.floor(pos);
const frac = pos - i0;
const a = ring.data[i0 % RING_LEN];
const b = ring.data[(i0 + 1) % RING_LEN];
const v = a + (b - a) * frac;
out[i] += v;
const av = Math.abs(v);
if (av > level) level = av;
}
ring.readPos += n * step;
if (ring.readPos > ring.write) ring.readPos = ring.write;
if (level > 0.001) {
ring.lastActive = Date.now();
nextLevels.set(hash, level);
}
}
// Drop users silent for >5s so the ring map doesn't grow unbounded.
const now = Date.now();
for (const [hash, ring] of this.rings) {
if (now - ring.lastActive > 5000) this.rings.delete(hash);
}
this.levels = nextLevels;
}
}