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