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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user