Files
wz-phone/crates/wzp-web/static/audio-processor.js
Siavash Sameni 524d1145bb 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>
2026-03-28 10:20:51 +04:00

143 lines
4.6 KiB
JavaScript

// WarzonePhone AudioWorklet processors.
// Both capture and playback handle 960-sample frames (20ms @ 48kHz).
// AudioWorklet calls process() with 128-sample blocks, so we buffer internally.
const FRAME_SIZE = 960;
class WZPCaptureProcessor extends AudioWorkletProcessor {
constructor() {
super();
// Pre-allocate ring buffer large enough for several frames
this._ring = new Float32Array(FRAME_SIZE * 4);
this._writePos = 0;
}
process(inputs, _outputs, _parameters) {
const input = inputs[0];
if (!input || !input[0]) return true;
const samples = input[0]; // Float32Array, 128 samples typically
const len = samples.length;
// 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;
// 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]);
}
return true;
}
}
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);