- 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>
55 lines
2.6 KiB
TypeScript
55 lines
2.6 KiB
TypeScript
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);
|
|
});
|