feat: complete WZP Phase 2 (T2/T3/T4) — adaptive quality, AudioWorklet, sessions

WZP-P2-T2: Adaptive quality switching
- QualityAdapter with sliding window of QualityReports
- Hysteresis: 3 consecutive reports before switching profiles
- Thresholds: loss>15%/rtt>200ms→CATASTROPHIC, loss>5%/rtt>100ms→DEGRADED
- CallConfig::from_profile() constructor
- 5 unit tests: good/degraded/catastrophic conditions, hysteresis, recovery

WZP-P2-T3: AudioWorklet migration (web bridge)
- audio-processor.js: WZPCaptureProcessor + WZPPlaybackProcessor
- Capture: buffers 128-sample AudioWorklet blocks → 960-sample frames
- Playback: ring buffer, Int16→Float32 conversion in worklet
- ScriptProcessorNode fallback if AudioWorklet unavailable
- Existing UI preserved (connect, room, PTT)

WZP-P2-T4: Concurrent session management (relay)
- SessionManager tracks active sessions with HashMap
- Enforces max_sessions limit from RelayConfig
- create_session/remove_session lifecycle
- Wired into relay main: session created after auth+handshake,
  cleaned up after run_participant returns
- 7 unit tests: create/remove, max enforced, room tracking, info, expiry

207 tests passing across all crates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-28 10:20:51 +04:00
parent bf56d84ef0
commit 524d1145bb
7 changed files with 633 additions and 110 deletions

View File

@@ -1,34 +1,51 @@
// AudioWorklet processor for capturing microphone audio.
// Accumulates samples and posts 960-sample (20ms @ 48kHz) frames to the main thread.
// WarzonePhone AudioWorklet processors.
// Both capture and playback handle 960-sample frames (20ms @ 48kHz).
// AudioWorklet calls process() with 128-sample blocks, so we buffer internally.
class CaptureProcessor extends AudioWorkletProcessor {
const FRAME_SIZE = 960;
class WZPCaptureProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.buffer = new Float32Array(0);
// Pre-allocate ring buffer large enough for several frames
this._ring = new Float32Array(FRAME_SIZE * 4);
this._writePos = 0;
}
process(inputs, outputs, parameters) {
process(inputs, _outputs, _parameters) {
const input = inputs[0];
if (!input || !input[0]) return true;
const samples = input[0]; // Float32Array, typically 128 samples
const samples = input[0]; // Float32Array, 128 samples typically
const len = samples.length;
// Accumulate
const newBuf = new Float32Array(this.buffer.length + samples.length);
newBuf.set(this.buffer);
newBuf.set(samples, this.buffer.length);
this.buffer = newBuf;
// Write into ring buffer
if (this._writePos + len > this._ring.length) {
// Should not happen with FRAME_SIZE * 4 capacity and timely draining,
// but handle gracefully by resizing
const bigger = new Float32Array(this._ring.length * 2);
bigger.set(this._ring.subarray(0, this._writePos));
this._ring = bigger;
}
this._ring.set(samples, this._writePos);
this._writePos += len;
// Send complete 960-sample frames
while (this.buffer.length >= 960) {
const frame = this.buffer.slice(0, 960);
this.buffer = this.buffer.slice(960);
// Convert to Int16
const pcm = new Int16Array(960);
for (let i = 0; i < 960; i++) {
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
// Drain complete 960-sample frames
while (this._writePos >= FRAME_SIZE) {
// Convert Float32 -> Int16 PCM
const pcm = new Int16Array(FRAME_SIZE);
for (let i = 0; i < FRAME_SIZE; i++) {
const s = this._ring[i];
pcm[i] = s < -1 ? -32768 : s > 1 ? 32767 : (s * 32767) | 0;
}
// Shift remaining data forward
this._writePos -= FRAME_SIZE;
if (this._writePos > 0) {
this._ring.copyWithin(0, FRAME_SIZE, FRAME_SIZE + this._writePos);
}
// Send the Int16 PCM buffer (1920 bytes) to the main thread
this.port.postMessage(pcm.buffer, [pcm.buffer]);
}
@@ -36,4 +53,90 @@ class CaptureProcessor extends AudioWorkletProcessor {
}
}
registerProcessor('capture-processor', CaptureProcessor);
class WZPPlaybackProcessor extends AudioWorkletProcessor {
constructor() {
super();
// Ring buffer for decoded Float32 samples ready for output
this._ring = new Float32Array(FRAME_SIZE * 8);
this._readPos = 0;
this._writePos = 0;
this._maxBuffered = FRAME_SIZE * 6; // ~120ms max to prevent drift
this.port.onmessage = (e) => {
// Receive Int16 PCM from main thread, convert to Float32
const pcm = new Int16Array(e.data);
const len = pcm.length;
// Check capacity
let available = this._writePos - this._readPos;
if (available < 0) available += this._ring.length;
if (available + len > this._maxBuffered) {
// Too much buffered; drop oldest samples to prevent drift
this._readPos = this._writePos;
}
// Ensure ring buffer is big enough
if (this._ring.length < len + available + 128) {
const bigger = new Float32Array(this._ring.length * 2);
// Copy existing data contiguously
if (this._readPos <= this._writePos) {
bigger.set(this._ring.subarray(this._readPos, this._writePos));
} else {
const firstPart = this._ring.subarray(this._readPos);
const secondPart = this._ring.subarray(0, this._writePos);
bigger.set(firstPart);
bigger.set(secondPart, firstPart.length);
}
this._ring = bigger;
const count = available;
this._readPos = 0;
this._writePos = count;
}
// Write converted samples into ring buffer linearly (simpler: use linear buffer)
for (let i = 0; i < len; i++) {
this._ring[this._writePos] = pcm[i] / 32768.0;
this._writePos++;
if (this._writePos >= this._ring.length) this._writePos = 0;
}
};
}
process(_inputs, outputs, _parameters) {
const output = outputs[0];
if (!output || !output[0]) return true;
const out = output[0]; // 128 samples typically
const needed = out.length;
let available;
if (this._writePos >= this._readPos) {
available = this._writePos - this._readPos;
} else {
available = this._ring.length - this._readPos + this._writePos;
}
if (available >= needed) {
for (let i = 0; i < needed; i++) {
out[i] = this._ring[this._readPos];
this._readPos++;
if (this._readPos >= this._ring.length) this._readPos = 0;
}
} else {
// Output what we have, zero-fill the rest (underrun)
for (let i = 0; i < available; i++) {
out[i] = this._ring[this._readPos];
this._readPos++;
if (this._readPos >= this._ring.length) this._readPos = 0;
}
for (let i = available; i < needed; i++) {
out[i] = 0;
}
}
return true;
}
}
registerProcessor('wzp-capture-processor', WZPCaptureProcessor);
registerProcessor('wzp-playback-processor', WZPPlaybackProcessor);

View File

@@ -165,16 +165,34 @@ function stopCall() {
function cleanupAudio() {
if (captureNode) { captureNode.disconnect(); captureNode = null; }
if (playbackNode) { playbackNode.disconnect(); playbackNode = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; workletLoaded = false; }
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; }
}
let workletLoaded = false;
async function loadWorkletModule() {
if (workletLoaded) return true;
if (typeof AudioWorkletNode === 'undefined' || !audioCtx.audioWorklet) {
console.warn('AudioWorklet API not supported in this browser — using ScriptProcessorNode fallback');
return false;
}
try {
await audioCtx.audioWorklet.addModule('audio-processor.js');
workletLoaded = true;
return true;
} catch(e) {
console.warn('AudioWorklet module failed to load — using ScriptProcessorNode fallback:', e);
return false;
}
}
async function startAudioCapture() {
const source = audioCtx.createMediaStreamSource(mediaStream);
const hasWorklet = await loadWorkletModule();
try {
await audioCtx.audioWorklet.addModule('audio-processor.js');
captureNode = new AudioWorkletNode(audioCtx, 'capture-processor');
if (hasWorklet) {
captureNode = new AudioWorkletNode(audioCtx, 'wzp-capture-processor');
captureNode.port.onmessage = (e) => {
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
ws.send(e.data);
@@ -188,10 +206,10 @@ async function startAudioCapture() {
};
source.connect(captureNode);
captureNode.connect(audioCtx.destination); // needed to keep worklet alive
} catch(e) {
// Fallback to ScriptProcessor if AudioWorklet not supported
console.warn('AudioWorklet not available, using ScriptProcessor fallback:', e);
captureNode = audioCtx.createScriptProcessor(1024, 1, 1);
} else {
// Fallback to ScriptProcessorNode (deprecated but widely supported)
console.warn('Capture: using ScriptProcessorNode fallback');
captureNode = audioCtx.createScriptProcessor(4096, 1, 1);
let acc = new Float32Array(0);
captureNode.onaudioprocess = (ev) => {
if (!active || !ws || ws.readyState !== WebSocket.OPEN || !transmitting) return;
@@ -215,13 +233,14 @@ async function startAudioCapture() {
}
async function startAudioPlayback() {
try {
await audioCtx.audioWorklet.addModule('playback-processor.js');
playbackNode = new AudioWorkletNode(audioCtx, 'playback-processor');
const hasWorklet = await loadWorkletModule();
if (hasWorklet) {
playbackNode = new AudioWorkletNode(audioCtx, 'wzp-playback-processor');
playbackNode.connect(audioCtx.destination);
} catch(e) {
console.warn('AudioWorklet playback not available, using scheduled fallback');
playbackNode = null; // will use createBufferSource fallback
} else {
console.warn('Playback: using scheduled BufferSource fallback');
playbackNode = null; // will use createBufferSource fallback in playAudio()
}
}
@@ -230,16 +249,15 @@ let nextPlayTime = 0;
function playAudio(pcmInt16) {
if (!audioCtx) return;
const floatData = new Float32Array(pcmInt16.length);
for (let i = 0; i < pcmInt16.length; i++) {
floatData[i] = pcmInt16[i] / 32768.0;
}
if (playbackNode && playbackNode.port) {
// AudioWorklet path — send float samples to the worklet
playbackNode.port.postMessage(floatData.buffer, [floatData.buffer]);
// AudioWorklet path — send Int16 PCM directly to the worklet for conversion
playbackNode.port.postMessage(pcmInt16.buffer, [pcmInt16.buffer]);
} else {
// Fallback: scheduled BufferSource
// Fallback: scheduled BufferSource (convert Int16 -> Float32 on main thread)
const floatData = new Float32Array(pcmInt16.length);
for (let i = 0; i < pcmInt16.length; i++) {
floatData[i] = pcmInt16[i] / 32768.0;
}
const buffer = audioCtx.createBuffer(1, floatData.length, SAMPLE_RATE);
buffer.getChannelData(0).set(floatData);
const source = audioCtx.createBufferSource();

View File

@@ -1,45 +0,0 @@
// AudioWorklet processor for playing received audio.
// Receives PCM samples from the main thread and outputs them.
class PlaybackProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.buffer = new Float32Array(0);
this.maxBuffered = 48000 / 5; // 200ms max
this.port.onmessage = (e) => {
const incoming = new Float32Array(e.data);
// Append
const newBuf = new Float32Array(this.buffer.length + incoming.length);
newBuf.set(this.buffer);
newBuf.set(incoming, this.buffer.length);
this.buffer = newBuf;
// Cap buffer to prevent drift
if (this.buffer.length > this.maxBuffered) {
this.buffer = this.buffer.slice(this.buffer.length - this.maxBuffered);
}
};
}
process(inputs, outputs, parameters) {
const output = outputs[0];
if (!output || !output[0]) return true;
const out = output[0]; // 128 samples typically
if (this.buffer.length >= out.length) {
out.set(this.buffer.subarray(0, out.length));
this.buffer = this.buffer.slice(out.length);
} else if (this.buffer.length > 0) {
out.set(this.buffer);
for (let i = this.buffer.length; i < out.length; i++) out[i] = 0;
this.buffer = new Float32Array(0);
} else {
for (let i = 0; i < out.length; i++) out[i] = 0;
}
return true;
}
}
registerProcessor('playback-processor', PlaybackProcessor);