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:
+15
-4
@@ -268,13 +268,20 @@ try {
|
||||
assert.equal(await picker.getByLabel('Sound').count(), 0, 'no extra sound with a camera');
|
||||
await startBtn.click();
|
||||
await alice.page.locator('.sidebar .row.user.self').getByTitle('You are sharing your camera').waitFor();
|
||||
// Cameras play in the tiles. Bob joins the camera round with one click and sees Alice's
|
||||
// Cameras play in the tiles and come on by themselves, like in a video call
|
||||
const cam = bob.page.locator('.sidebar').getByRole('button', { name: `Watch the camera of ${aliceName}`, exact: true });
|
||||
await cam.waitFor();
|
||||
const liveCams = (p: Page) => p.evaluate(() => [...document.querySelectorAll('.stage .tile video')].filter(v => (v as HTMLVideoElement).videoWidth > 0).length);
|
||||
const camsShown = (p: Page, n: number) => p.waitForFunction(
|
||||
want => [...document.querySelectorAll('.stage .tile video')].filter(v => (v as HTMLVideoElement).videoWidth > 0).length === want, n, { timeout: 30000 });
|
||||
await bob.page.locator('.stage').getByRole('button', { name: 'Watch all cameras' }).click();
|
||||
await camsShown(bob.page, 1);
|
||||
// One person's camera can be hidden, and shown again
|
||||
await bob.page.locator('.stage .tile', { hasText: aliceName }).click({ button: 'right' });
|
||||
await bob.page.getByRole('menuitem', { name: 'Hide camera' }).click();
|
||||
await camsShown(bob.page, 0);
|
||||
await alice.page.locator('.stage .status', { hasText: '0 watching' }).waitFor();
|
||||
await bob.page.locator('.stage .tile', { hasText: aliceName }).click({ button: 'right' });
|
||||
await bob.page.getByRole('menuitem', { name: 'Show camera' }).click();
|
||||
await camsShown(bob.page, 1);
|
||||
assert.equal(await bob.page.getByRole('region', { name: 'Screen share' }).count(), 0, 'a camera does not take over the stage by itself');
|
||||
|
||||
@@ -295,13 +302,17 @@ try {
|
||||
// A click enlarges one camera; the statistics show it came through the relay
|
||||
await bob.page.locator('.stage .tile', { hasText: aliceName }).click();
|
||||
await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
|
||||
assert.equal(await bob.page.locator('.stage .tile', { hasText: aliceName }).locator('video').count(), 0, 'the enlarged camera is not also in its tile');
|
||||
assert.equal(await bob.page.locator('.stage .tile.self video').count(), 1, 'the other tiles keep theirs');
|
||||
const camStats = await nerd(bob.page, [/^relayed \(TURN\), reached over TCP/, /^0 direct, 0 through STUN, [1-9]\d* relayed/]);
|
||||
await bob.page.getByRole('region', { name: 'Screen share' }).getByRole('button', { name: 'Back to tiles' }).click();
|
||||
|
||||
// Leaving the round keeps Bob's own camera on and drops the others
|
||||
await bob.page.locator('.stage').getByRole('button', { name: 'Stop watching the cameras' }).click();
|
||||
// Everyone's cameras off at once keeps Bob's own camera on and drops the others; on again brings them back
|
||||
await bob.page.locator('.stage').getByRole('button', { name: 'Hide all cameras' }).click();
|
||||
await camsShown(bob.page, 1);
|
||||
await alice.page.locator('.stage .status', { hasText: '0 watching' }).waitFor();
|
||||
await bob.page.locator('.stage').getByRole('button', { name: 'Show all cameras' }).click();
|
||||
await camsShown(bob.page, 2);
|
||||
await bob.page.locator('.me').getByRole('button', { name: 'Stop sharing your camera' }).click();
|
||||
await camsShown(alice.page, 1);
|
||||
await alice.page.locator('.me').getByRole('button', { name: 'Stop sharing your camera' }).click();
|
||||
|
||||
+35
-2
@@ -9,7 +9,9 @@ import { mkdtempSync, existsSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createCodec } from '../../src/core/proto.ts';
|
||||
import { decodeVoice, type VoicePacket } from '../../src/core/voice-packet.ts';
|
||||
import { decodeVoice, encodeVoice, type VoicePacket } from '../../src/core/voice-packet.ts';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { instantiateOpus, WasmDecoder } from '../../src/lib/audio/opus-wasm.ts';
|
||||
import { MumbleClient } from '../../src/core/client.ts';
|
||||
import type { Transport } from '../../src/core/transport.ts';
|
||||
import { openTls } from '../../electron/tls-transport.ts';
|
||||
@@ -171,7 +173,10 @@ try {
|
||||
page2.on('console', m => { if (m.type() === 'error') console.log('[page2]', m.text()); });
|
||||
await page2.setViewportSize({ width: 1280, height: 800 });
|
||||
await page2.getByRole('button', { name: /Create a new identity/ }).click();
|
||||
await page2.getByLabel('Name', { exact: true }).fill(`webcarol${Date.now() % 100000}`);
|
||||
const carol = `webcarol${Date.now() % 100000}`;
|
||||
// This browser is made to use the built-in Opus codec, as one without WebCodecs audio would
|
||||
await page2.evaluate(() => localStorage.setItem('mumh5.forceWasmOpus', '1'));
|
||||
await page2.getByLabel('Name', { exact: true }).fill(carol);
|
||||
await page2.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await page2.getByRole('button', { name: 'Skip for now' }).click();
|
||||
await page2.getByRole('button', { name: 'Done' }).click();
|
||||
@@ -179,6 +184,34 @@ try {
|
||||
await page2.getByRole('button', { name: 'Save and connect' }).click();
|
||||
await page2.getByText(/Connected/).first().waitFor();
|
||||
|
||||
// Talking with the built-in codec: its packets are real Opus, checked by decoding them here
|
||||
const carolSession = () => [...bob.users.values()].find(u => u.name === carol)!.session;
|
||||
const carolVoice = new Promise<VoicePacket[]>(res => {
|
||||
const packets: VoicePacket[] = [];
|
||||
const off = bob.on('voice', raw => {
|
||||
const p = decodeVoice(raw);
|
||||
if (!p || p.session !== carolSession() || !p.opus.length) return;
|
||||
packets.push(p);
|
||||
if (packets.length >= 20) { off(); res(packets); }
|
||||
});
|
||||
});
|
||||
await page2.getByTitle('Settings').click();
|
||||
await page2.getByRole('radio', { name: /Always on/ }).click();
|
||||
await page2.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
const fromCarol = await within(carolVoice, 'voice from the built-in codec', 20000);
|
||||
const opus = await instantiateOpus(readFileSync(path.join(root, 'node_modules/@evan/opus/wasm/opus.wasm')));
|
||||
const check = new WasmDecoder(opus);
|
||||
const decoded = fromCarol.map(p => check.decode(p.opus));
|
||||
assert.ok(decoded.every(pcm => pcm && pcm.length === 960), 'every packet is 20 ms of decodable Opus');
|
||||
assert.ok(decoded.some(pcm => pcm!.some(v => Math.abs(v) > 0.01)), 'and carries the test tone, not silence');
|
||||
// Hearing: her own packets sent back as bob light him up, and no codec error shows
|
||||
const back = fromCarol.map((p, i) => encodeVoice({ target: 0, frame: 9000 + i * 2, opus: p.opus, last: i === fromCarol.length - 1 }, bob.protobufVoice));
|
||||
const pump = setInterval(() => { const pk = back.shift(); if (pk) bob.sendVoiceTunnel(pk); }, 20);
|
||||
await page2.locator('.sidebar .row.user', { hasText: 'bob' }).locator('.label.talking').waitFor({ timeout: 5000 });
|
||||
clearInterval(pump);
|
||||
assert.equal(await page2.getByText('Voice is not working').count(), 0);
|
||||
console.log(`ok: voice with the built-in Opus codec (${fromCarol[0].opus.length} bytes per packet)`);
|
||||
|
||||
await page.locator('.me').getByRole('button', { name: 'Share your screen' }).click();
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Share your screen' });
|
||||
|
||||
@@ -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