Built-in Opus codec for browsers without one; cameras on by default with per-person hide
- libopus in WebAssembly takes over voice where WebCodecs audio is missing (Firefox on Android); tested for tone, timing and bitrate, and in a browser forced onto it - Cameras in the channel show by themselves once the IP note was confirmed; a person's camera can be hidden from the right-click menu, all of them with one button; an enlarged camera leaves its tile - CSP allows wasm-unsafe-eval for the codec Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { instantiateOpus, WasmEncoder, WasmDecoder } from '../src/lib/audio/opus-wasm.ts';
|
||||
|
||||
const opus = await instantiateOpus(readFileSync(new URL('../node_modules/@evan/opus/wasm/opus.wasm', import.meta.url)));
|
||||
|
||||
// One second of a 440 Hz tone in the engine's 10 ms frames
|
||||
const tone = () => Array.from({ length: 100 }, (_, f) => Float32Array.from({ length: 480 }, (_, i) => 0.5 * Math.sin(2 * Math.PI * 440 * (f * 480 + i) / 48000)));
|
||||
|
||||
for (const frameMs of [10, 20, 40, 60]) {
|
||||
test(`a tone survives encoding and decoding with ${frameMs} ms packets`, () => {
|
||||
const packets: Uint8Array[] = [];
|
||||
const encoder = new WasmEncoder(opus, 40000, frameMs, p => packets.push(p));
|
||||
for (const frame of tone()) encoder.push(frame);
|
||||
assert.equal(packets.length, Math.floor(1000 / frameMs), 'one packet per frame duration');
|
||||
// Around the asked bitrate, not raw audio
|
||||
const bytes = packets.reduce((n, p) => n + p.length, 0);
|
||||
assert.ok(bytes > 2000 && bytes < 8000, `about 40 kbit/s: ${bytes} bytes for one second`);
|
||||
|
||||
const decoder = new WasmDecoder(opus);
|
||||
const out = packets.map(p => decoder.decode(p)!);
|
||||
assert.ok(out.every(pcm => pcm.length === 48 * frameMs), 'each packet decodes to its duration');
|
||||
// The tone is still there: count the zero crossings in the second half (the codec needs a moment to settle)
|
||||
const all = Float32Array.from(out.slice(out.length / 2).flatMap(pcm => [...pcm]));
|
||||
let crossings = 0;
|
||||
for (let i = 1; i < all.length; i++) if ((all[i - 1] < 0) !== (all[i] < 0)) crossings++;
|
||||
const hz = crossings / 2 / (all.length / 48000);
|
||||
assert.ok(Math.abs(hz - 440) < 15, `decoded tone at ${hz.toFixed(1)} Hz`);
|
||||
const peak = all.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
|
||||
assert.ok(peak > 0.3 && peak < 0.8, `level kept: ${peak.toFixed(2)}`);
|
||||
encoder.close();
|
||||
decoder.close();
|
||||
});
|
||||
}
|
||||
|
||||
test('an unfinished frame is dropped at the end, and garbage does not decode', () => {
|
||||
const packets: Uint8Array[] = [];
|
||||
const encoder = new WasmEncoder(opus, 40000, 20, p => packets.push(p));
|
||||
encoder.push(tone()[0]);
|
||||
assert.equal(packets.length, 0);
|
||||
encoder.flush();
|
||||
encoder.push(tone()[0]);
|
||||
assert.equal(packets.length, 0, 'the half frame before the flush is gone');
|
||||
encoder.push(tone()[1]);
|
||||
assert.equal(packets.length, 1);
|
||||
const decoder = new WasmDecoder(opus);
|
||||
assert.equal(decoder.decode(new Uint8Array(5000)), null);
|
||||
assert.equal(decoder.decode(Uint8Array.of()), null);
|
||||
encoder.close();
|
||||
encoder.push(tone()[0]);
|
||||
decoder.close();
|
||||
assert.equal(decoder.decode(packets[0]), null);
|
||||
});
|
||||
Reference in New Issue
Block a user