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:
2026-10-02 00:36:43 +02:00
co-authored by Claude Opus 5.5
parent 6368e63b0e
commit 5d43b8e4ae
17 changed files with 353 additions and 43 deletions
+2
View File
@@ -6,6 +6,8 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`.
### Added
- Voice in browsers without an audio codec of their own (Firefox on Android): a built-in Opus codec takes over there.
- Cameras of people in your channel show by themselves, like in a video call; each one can be hidden from its right-click menu, and all of them switched off with one button.
- Camera sharing, as a second source in the screen sharing dialog, and camera rounds: several people's cameras at once in the voice tiles.
- A relay for streams in the web proxy, used automatically by the browser version and, with its address entered in Settings, by the desktop app, for networks and browsers that allow no direct connection.
- Screen sharing between mumh5 users in a channel: a dialog to choose the screen or window (with preview), the sound and the quality; sound from one program or the whole system on Linux through PipeWire. Viewers watch in the voice tiles, enlarge a stream like in a meeting and set its volume. Streams go directly between clients; a STUN server can be set in Settings, Voice.
+2 -2
View File
@@ -46,7 +46,7 @@ A reused test server keeps registrations and channels from earlier runs; tests m
- `server/` (Node): the web proxy. `proxy.ts` serves `dist-web`, bridges WebSocket connections to Mumble over TLS (reusing `electron/tls-transport.ts`) and has stateless identity endpoints. It stores nothing; the browser keeps identities in localStorage and sends one with each connect. `turn.ts` is its STUN and TURN server for screen sharing between browser users (UDP and TCP on one port, credentials from `GET /api/relay`, which any origin may read so the desktop app can use a proxy as its relay; secret made up at start). `localStorage mumh5.iceDebug = relay` or `relay-tcp` limits a client to relayed routes, which is how the E2E exercises the relay.
- `src/core/` (browser-safe TypeScript, also runs in Node for tests): framing and codec (`proto.ts`), the Mumble client state machine (`client.ts`), voice packet formats (`voice-packet.ts`). No DOM, no Electron, no Node imports here.
- `src/lib/native.ts`: `desktop` is the Electron preload API or null; `native` is what both platforms provide (identities, certificates), backed by `web.svelte.ts` in the browser build (`isWeb`, vite `--mode web`). Desktop-only features check `desktop`.
- `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets). `html.ts` sanitizes incoming HTML and serializes outgoing rich text.
- `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets); where a browser has no WebCodecs audio it uses libopus in WebAssembly (`audio/opus-wasm.ts`, `localStorage mumh5.forceWasmOpus = 1` forces it). `html.ts` sanitizes incoming HTML and serializes outgoing rich text.
- Screen sharing: `src/core/share-signal.ts` (signals as Mumble plugin data, id `mumh5.share`, compressed and chunked) and `src/lib/share.svelte.ts` (one WebRTC connection per viewer and stream, candidates inside the description, no trickle; a viewer holds one screen and any number of cameras in `share.watches`). The start dialog is `ShareDialog.svelte`, the tiles and the large view are `VoiceStage.svelte`. Desktop source listing is `share:sources` / `share:pick` in `electron/main.ts`; Linux sound is `electron/pipewire.ts` (a virtual microphone fed with pw-link, never with this app's own playback).
- `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store).
@@ -55,7 +55,7 @@ Voice runs on one server at a time (where you last joined a channel); background
## Conventions
- Svelte 5 runes only. Comment density: short comments that explain why, not what.
- The protobuf codec is generated static code (no eval) because the CSP forbids `unsafe-eval`. Field names keep snake_case from the .proto.
- The protobuf codec is generated static code (no eval) because the CSP forbids `unsafe-eval`. `wasm-unsafe-eval` is allowed, for the built-in Opus codec only. Field names keep snake_case from the .proto.
- TypeScript files imported by Node tests use `.ts` extensions and erasable syntax only (no parameter properties, no enums).
- New features get an E2E step in `test/e2e/app.e2e.ts`; protocol logic gets unit or server tests.
- Privacy rule: opening a chat must never contact hosts the user did not choose. Remote media only from the upload host and user-listed hosts; YouTube is click-to-play (`youtube-nocookie.com`).
+1 -1
View File
@@ -248,7 +248,7 @@ What is different from the desktop app:
- **Voice goes over TCP.** Browsers have no UDP sockets, so voice uses Mumble's TCP tunnel through the proxy. It works, with more delay on lossy connections.
- **Clearing the browser's data removes your identity.** Download a backup; the same `.p12` file works in the desktop app and in Mumble.
- **Not available:** the public server browser, link previews fetched by your own computer, the tray icon, importing the desktop Mumble certificate automatically.
- Voice needs a browser with WebCodecs audio. It is tested in Chromium only.
- Voice uses the browser's own audio codec where there is one, and a built-in one otherwise (Firefox on Android). Both are tested in Chromium only.
## File sharing with f0ckm
+8
View File
@@ -41,3 +41,11 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Runtime and build dependencies (Electron, Svelte, protobufjs, node-forge, DOMPurify and
others) are distributed under their own licenses, found in each package under `node_modules/`.
## @evan/opus and libopus
The Opus codec compiled to WebAssembly (`opus.wasm`), used for voice in browsers that have no
audio codec of their own. The package is MIT licensed, Copyright (c) 2021-present evanwashere.
libopus is under the three-clause BSD license, Copyright 2001-2011 Xiph.Org, Skype Limited,
Octasic, Jean-Marc Valin, Timothy B. Terriberry, CSIRO, Gregory Maxwell, Mark Borgerding,
Erik de Castro Lopo.
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob: https: http:; connect-src 'self' https: http: ws: wss:; object-src 'none'; base-uri 'none'; frame-src https://www.youtube-nocookie.com" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob: https: http:; connect-src 'self' https: http: ws: wss:; object-src 'none'; base-uri 'none'; frame-src https://www.youtube-nocookie.com" />
<title>mumh5</title>
</head>
<body>
+8
View File
@@ -14,6 +14,7 @@
"protobufjs": "^8.8.0"
},
"devDependencies": {
"@evan/opus": "^1.0.3",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tsconfig/svelte": "^5.0.8",
"@types/node": "^26.6.3",
@@ -827,6 +828,13 @@
"node": ">=18"
}
},
"node_modules/@evan/opus": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@evan/opus/-/opus-1.0.3.tgz",
"integrity": "sha512-ADfwIad83W1LuiZDNMjDMDNQRsPz8rj5xnDLExhVWTnA5wGJCLntOn12Ir5rxGBqdfo10QhnNVdd2+gXiZ6xCg==",
"dev": true,
"license": "MIT"
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
+1
View File
@@ -90,6 +90,7 @@
}
},
"devDependencies": {
"@evan/opus": "^1.0.3",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tsconfig/svelte": "^5.0.8",
"@types/node": "^26.6.3",
+6 -1
View File
@@ -69,7 +69,12 @@ export function userMenu(u: User): MenuItem[] {
);
if (u.userId == null) items.push({ label: 'Register on this server', action: () => session.register(u.session) });
} else {
if (session.sharing[u.session]) items.push({ label: `Watch ${session.sharing[u.session]}`, action: () => share.watch(sessions.active, u.session) });
const sharing = session.sharing[u.session];
if (sharing === 'camera') {
// Cameras show by themselves; this is the per-person switch
const hidden = share.isHidden(sessions.active, u.session) || !share.watchOf(u.session);
items.push({ label: hidden ? 'Show camera' : 'Hide camera', action: () => share.hideCamera(sessions.active, u.session, !hidden) });
} else if (sharing) items.push({ label: `Watch ${sharing}`, action: () => share.watch(sessions.active, u.session) });
items.push(
{ label: 'Send message', action: () => session.selectView(`dm:${u.session}`) },
{ label: 'Mute for me', checked: !!voice.settings.localMutes[userKey(u)], action: () => voice.setLocalMute(u, !voice.settings.localMutes[userKey(u)]) },
+8
View File
@@ -0,0 +1,8 @@
import wasmUrl from '@evan/opus/wasm/opus.wasm?url';
import { instantiateOpus, type Opus } from './opus-wasm.ts';
// Fetched once, and only in browsers that need it
let loading: Promise<Opus> | null = null;
export function loadOpus(): Promise<Opus> {
return loading ??= fetch(wasmUrl).then(r => r.arrayBuffer()).then(instantiateOpus).catch(e => { loading = null; throw e; });
}
+126
View File
@@ -0,0 +1,126 @@
// Opus in WebAssembly (libopus, from the @evan/opus package), for browsers without WebCodecs
// audio, such as Firefox on Android. Only loaded there; everywhere else the browser's own
// codec is used. The two classes offer the small part of the WebCodecs interface the voice
// engine needs. No DOM or bundler imports here, so the tests can run it in Node.
interface Exports {
memory: WebAssembly.Memory;
malloc(size: number): number;
free(ptr: number): void;
opus_encoder_get_size(channels: number): number;
opus_encoder_init(ptr: number, rate: number, channels: number, application: number): number;
opus_encoder_ctl_set(ptr: number, request: number, value: number): number;
opus_encode(ptr: number, pcm: number, frameSize: number, out: number, max: number): number;
opus_decoder_get_size(channels: number): number;
opus_decoder_init(ptr: number, rate: number, channels: number): number;
opus_decode(ptr: number, data: number, length: number, pcm: number, frameSize: number, fec: number): number;
}
export interface Opus { wasm: Exports }
const RATE = 48000;
const MAX_PACKET = 4000;
// The longest Opus frame is 120 ms
const MAX_SAMPLES = 5760;
const APPLICATION_VOIP = 2048, SET_BITRATE = 4002, SET_COMPLEXITY = 4010, SET_INBAND_FEC = 4012, SET_PACKET_LOSS = 4014, SET_SIGNAL = 4024, SIGNAL_VOICE = 3001;
// bytes: the contents of opus.wasm (fetched by opus-load.ts in the app, read from disk in tests)
export async function instantiateOpus(bytes: BufferSource): Promise<Opus> {
const imports = {
wasi_snapshot_preview1: { fd_seek() { return 0; }, fd_write() { return 0; }, fd_close() { return 0; }, proc_exit() {} },
// Memory is read through fresh views every call, so growth needs no bookkeeping
env: { emscripten_notify_memory_growth() {} }
};
const { instance } = await WebAssembly.instantiate(bytes, imports);
return { wasm: instance.exports as unknown as Exports };
}
// Takes the engine's 10 ms frames and gives out one packet per frame duration
export class WasmEncoder {
state: 'configured' | 'closed' = 'configured';
private wasm: Exports;
private ptr: number;
private pcm: number;
private out: number;
private samples: number;
private filled = 0;
private output: (packet: Uint8Array) => void;
constructor(opus: Opus, bitrate: number, frameMs: number, output: (packet: Uint8Array) => void) {
const w = this.wasm = opus.wasm;
this.output = output;
this.samples = RATE * frameMs / 1000;
this.ptr = w.malloc(w.opus_encoder_get_size(1));
this.pcm = w.malloc(this.samples * 2);
this.out = w.malloc(MAX_PACKET);
if (w.opus_encoder_init(this.ptr, RATE, 1, APPLICATION_VOIP) < 0) { this.close(); throw new Error('Could not start the Opus encoder'); }
w.opus_encoder_ctl_set(this.ptr, SET_BITRATE, Math.round(bitrate));
// A notch below the maximum: phones are the ones without a codec of their own
w.opus_encoder_ctl_set(this.ptr, SET_COMPLEXITY, 8);
w.opus_encoder_ctl_set(this.ptr, SET_INBAND_FEC, 1);
w.opus_encoder_ctl_set(this.ptr, SET_PACKET_LOSS, 10);
w.opus_encoder_ctl_set(this.ptr, SET_SIGNAL, SIGNAL_VOICE);
}
push(frame: Float32Array): void {
if (this.state !== 'configured') return;
let view = new Int16Array(this.wasm.memory.buffer, this.pcm, this.samples);
for (let i = 0; i < frame.length; i++) {
const v = Math.max(-1, Math.min(1, frame[i]));
view[this.filled++] = v < 0 ? v * 0x8000 : v * 0x7fff;
if (this.filled < this.samples) continue;
this.filled = 0;
const length = this.wasm.opus_encode(this.ptr, this.pcm, this.samples, this.out, MAX_PACKET);
if (length > 0) this.output(new Uint8Array(this.wasm.memory.buffer, this.out, length).slice());
// Encoding may have grown the memory, which detaches the view
view = new Int16Array(this.wasm.memory.buffer, this.pcm, this.samples);
}
}
// An unfinished frame at the end of a transmission is dropped
flush(): Promise<void> {
this.filled = 0;
return Promise.resolve();
}
close(): void {
if (this.state === 'closed') return;
this.state = 'closed';
for (const p of [this.ptr, this.pcm, this.out]) if (p) this.wasm.free(p);
}
}
export class WasmDecoder {
private wasm: Exports;
private ptr: number;
private data: number;
private pcm: number;
private closed = false;
constructor(opus: Opus) {
const w = this.wasm = opus.wasm;
this.ptr = w.malloc(w.opus_decoder_get_size(1));
this.data = w.malloc(MAX_PACKET);
this.pcm = w.malloc(MAX_SAMPLES * 2);
if (w.opus_decoder_init(this.ptr, RATE, 1) < 0) { this.close(); throw new Error('Could not start the Opus decoder'); }
}
// Mono samples for the playback worklet, or null for a packet that does not decode
decode(packet: Uint8Array): Float32Array<ArrayBuffer> | null {
// An empty packet would make libopus invent audio (loss concealment)
if (this.closed || !packet.length || packet.length > MAX_PACKET) return null;
new Uint8Array(this.wasm.memory.buffer, this.data, packet.length).set(packet);
const n = this.wasm.opus_decode(this.ptr, this.data, packet.length, this.pcm, MAX_SAMPLES, 0);
if (n <= 0) return null;
const samples = new Int16Array(this.wasm.memory.buffer, this.pcm, n);
const out = new Float32Array(n);
for (let i = 0; i < n; i++) out[i] = samples[i] / 0x8000;
return out;
}
close(): void {
if (this.closed) return;
this.closed = true;
for (const p of [this.ptr, this.data, this.pcm]) if (p) this.wasm.free(p);
}
}
+48 -13
View File
@@ -1,3 +1,5 @@
import { WasmEncoder, WasmDecoder, type Opus } from './opus-wasm.ts';
import { loadOpus } from './opus-load.ts';
import captureUrl from './capture.worklet.ts?worker&url';
import playbackUrl from './playback.worklet.ts?worker&url';
import { encodeVoice, decodeVoice } from '../../core/voice-packet.ts';
@@ -77,8 +79,10 @@ class VoiceEngine {
private outputGainNode: GainNode | null = null;
private capture: AudioWorkletNode | null = null;
private playback: AudioWorkletNode | null = null;
private encoder: AudioEncoder | null = null;
private decoders = new Map<number, { decoder: AudioDecoder; lastFrame: number; timer: ReturnType<typeof setTimeout> | null }>();
// The browser's Opus codec (WebCodecs), or our own in WebAssembly where the browser has none
private opus: Opus | null = null;
private encoder: AudioEncoder | WasmEncoder | null = null;
private decoders = new Map<number, { decoder: AudioDecoder | WasmDecoder; lastFrame: number; timer: ReturnType<typeof setTimeout> | null }>();
private client: MumbleClient | null = null;
private sessionGains = new Map<number, number>();
private unsubscribe: (() => void)[] = [];
@@ -139,8 +143,18 @@ class VoiceEngine {
if (this.ctx) return;
this.error = '';
try {
if (typeof AudioEncoder === 'undefined' || !(await AudioEncoder.isConfigSupported(this.encoderConfig())).supported) {
throw new Error('This browser cannot encode Opus audio (WebCodecs is missing), so you cannot talk or hear others here. Chromium-based browsers and the desktop app can.');
const builtIn = typeof AudioEncoder !== 'undefined' && typeof AudioDecoder !== 'undefined' &&
(await AudioEncoder.isConfigSupported(this.encoderConfig()).catch(() => ({ supported: false }))).supported;
// mumh5.forceWasmOpus in localStorage: use the built-in codec regardless, for testing it
let forced = false;
try { forced = localStorage.getItem('mumh5.forceWasmOpus') === '1'; } catch { /* storage unavailable */ }
if (!builtIn || forced) {
// Firefox on Android, for one, has no audio codec for pages; bring our own
try {
this.opus = await loadOpus();
} catch (e) {
throw new Error(`This browser has no Opus audio codec, and the built-in one could not be loaded (${(e as Error).message})`);
}
}
const ctx = new AudioContext({ sampleRate: SAMPLE_RATE, latencyHint: 'interactive' });
this.ctx = ctx;
@@ -281,11 +295,16 @@ class VoiceEngine {
this.effectiveBitrate = this.allowedBitrate();
if (!this.ctx) return;
try { this.encoder?.close(); } catch { /* closed */ }
this.encoder = new AudioEncoder({
if (this.opus) {
this.encoder = new WasmEncoder(this.opus, this.effectiveBitrate, this.settings.frameMs, packet => this.onPacket(packet));
return;
}
const encoder = new AudioEncoder({
output: chunk => this.onEncoded(chunk),
error: e => { this.error = `Encoder: ${e.message}`; }
});
this.encoder.configure(this.encoderConfig());
encoder.configure(this.encoderConfig());
this.encoder = encoder;
}
private canTalk(): boolean {
@@ -320,18 +339,24 @@ class VoiceEngine {
if (!wanted && this.open) { this.endTransmission(); return; }
if (!this.open) return;
const encoder = this.encoder!;
if (encoder instanceof WasmEncoder) return encoder.push(frame);
const data = new AudioData({
format: 'f32-planar', sampleRate: SAMPLE_RATE, numberOfFrames: FRAME, numberOfChannels: 1,
timestamp: this.sampleTime, data: frame
});
this.sampleTime += 10000;
this.encoder!.encode(data);
encoder.encode(data);
data.close();
}
private onEncoded(chunk: EncodedAudioChunk): void {
const opus = new Uint8Array(chunk.byteLength);
chunk.copyTo(opus);
this.onPacket(opus);
}
private onPacket(opus: Uint8Array): void {
if (this.stopping) { this.held.push(opus); return; }
this.sendPacket(opus, false);
}
@@ -389,11 +414,15 @@ class VoiceEngine {
let d = this.decoders.get(p.session);
if (!d) {
const session = p.session;
const decoder = new AudioDecoder({
output: data => this.onDecoded(session, data),
error: () => this.dropSession(session)
});
decoder.configure({ codec: 'opus', sampleRate: SAMPLE_RATE, numberOfChannels: 1 });
let decoder: AudioDecoder | WasmDecoder;
if (this.opus) decoder = new WasmDecoder(this.opus);
else {
decoder = new AudioDecoder({
output: data => this.onDecoded(session, data),
error: () => this.dropSession(session)
});
decoder.configure({ codec: 'opus', sampleRate: SAMPLE_RATE, numberOfChannels: 1 });
}
d = { decoder, lastFrame: -1, timer: null };
this.decoders.set(session, d);
if (user) this.setSessionGain(session, this.settings.userVolumes[userKey(user)] ?? 1);
@@ -402,7 +431,13 @@ class VoiceEngine {
d.lastFrame = p.frame;
if (p.opus.length) {
d.decoder.decode(new EncodedAudioChunk({ type: 'key', timestamp: p.frame * 10000, data: p.opus }));
if (d.decoder instanceof WasmDecoder) {
const pcm = d.decoder.decode(p.opus);
if (pcm) {
this.stats.decoded++;
this.playback?.port.postMessage({ session: p.session, pcm }, [pcm.buffer]);
}
} else d.decoder.decode(new EncodedAudioChunk({ type: 'key', timestamp: p.frame * 10000, data: p.opus }));
}
if (!this.talking[p.session]) this.talking[p.session] = true;
if (d.timer) clearTimeout(d.timer);
+2
View File
@@ -25,6 +25,7 @@ export interface Settings {
publicListUrl: string; // where the public server list comes from
arrangement: Arrangement;
shareQuality: 'low' | 'medium' | 'high'; // bitrate and frame rate of our own stream
autoCameras: boolean; // show other people's cameras as they come on
shareVolume: number; // volume of streams we watch, 0 to 1
relayServer: string; // a mumh5 web proxy whose STUN and relay to use for screen sharing
stunServer: string; // helps screen sharing connect through routers; empty contacts nobody
@@ -58,6 +59,7 @@ const defaults: Settings = {
publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0',
arrangement: 'side',
shareQuality: 'medium',
autoCameras: true,
shareVolume: 1,
relayServer: '',
stunServer: '',
+32 -16
View File
@@ -46,9 +46,8 @@ class ScreenShare {
viewers = $state(0);
// Streams we receive: one screen at a time, and any number of cameras next to it
watches = $state.raw<Watching[]>([]);
// In the camera round: everyone's camera in the channel is watched as it comes on
cams = $state(false);
private camHost: Session | null = null;
// People whose camera we chose not to see, by certificate (or name); until the app restarts
hidden = $state<Record<string, boolean>>({});
error = $state('');
// The server the start dialog is open for
dialog = $state.raw<Session | null>(null);
@@ -148,8 +147,6 @@ class ScreenShare {
this.send(s, this.others(s), SHARE.on, kind);
s.setSharing(s.client.session!, kind);
sounds.play('shareStarted');
// Turning the camera on joins the round
if (kind === 'camera') this.setCams(s, true);
}
private release(): void {
@@ -193,6 +190,28 @@ class ScreenShare {
return this.watches.find(w => w.session === session);
}
// Cameras come on by themselves, like in a video call, once the note about IP addresses was
// confirmed; each person's can be hidden, and the whole thing switched off
get cams(): boolean {
return store.settings.autoCameras;
}
private who(s: Session, session: number): string {
const u = s.client?.users.get(session);
return u ? u.hash || `name:${u.name}` : '';
}
isHidden(s: Session, session: number): boolean {
return !!this.hidden[this.who(s, session)];
}
hideCamera(s: Session, session: number, hide: boolean): void {
const key = this.who(s, session);
if (!key) return;
if (hide) { this.hidden[key] = true; this.unwatch(session); }
else { delete this.hidden[key]; this.watch(s, session); }
}
private autoWatch(s: Session, session: number): void {
if (this.cams && store.settings.shareNoticeSeen && s.sharing[session] === 'camera' && !this.isHidden(s, session)) this.watch(s, session);
}
async watch(s: Session, session: number): Promise<void> {
const kind = s.sharing[session];
if (!this.supported(s) || !kind || this.watchOf(session) || !(await this.notice())) return;
@@ -200,6 +219,8 @@ class ScreenShare {
for (const w of this.watches) if (w.host !== s || (kind === 'screen' && w.kind === 'screen')) this.unwatch(w.session);
if (kind === 'camera' && this.watches.filter(w => w.kind === 'camera').length >= MAX_CAMERAS) return;
if (!s.sharing[session] || this.watchOf(session)) return;
// Asking for it shows it again
delete this.hidden[this.who(s, session)];
this.watches = [...this.watches, { host: s, session, kind, stream: null, state: 'connecting' }];
this.send(s, [session], SHARE.watch);
this.watchTimers.set(session, setTimeout(() => this.setWatchState(session, 'failed'), 20000));
@@ -228,20 +249,16 @@ class ScreenShare {
this.watches = this.watches.map(w => w.session === session ? { ...w, state, stream: stream ?? w.stream } : w);
}
// The camera round: watch every camera in the channel, now and as they come on
// All cameras on or off, remembered
async setCams(s: Session, on: boolean): Promise<void> {
if (on && (!this.supported(s) || !(await this.notice()))) return;
store.settings.autoCameras = on;
store.saveSettings();
if (!on) {
this.cams = false;
this.camHost = null;
for (const w of this.watches) if (w.kind === 'camera') this.unwatch(w.session);
return;
}
if (!this.supported(s) || !(await this.notice())) return;
this.cams = true;
this.camHost = s;
for (const [session, kind] of Object.entries(s.sharing)) {
if (kind === 'camera' && Number(session) !== s.client?.session) this.watch(s, Number(session));
}
for (const session of Object.keys(s.sharing)) if (Number(session) !== s.client?.session) this.autoWatch(s, Number(session));
}
// Candidates travel inside the description, so wait for them instead of sending each one
@@ -338,7 +355,7 @@ class ScreenShare {
case SHARE.on:
if (!s.sharing[sender] && Date.now() - (this.enteredAt.get(s) ?? 0) > 3000) sounds.play('shareStarted');
s.setSharing(sender, msg.text === 'camera' ? 'camera' : 'screen');
if (msg.text === 'camera' && this.cams && this.camHost === s) this.watch(s, sender);
this.autoWatch(s, sender);
break;
case SHARE.off:
if (s.sharing[sender]) sounds.play('shareStopped');
@@ -388,7 +405,6 @@ class ScreenShare {
if (this.host === s) this.release();
if (this.dialog === s) this.dialog = null;
for (const w of this.watches) if (w.host === s) this.endWatch(w.session);
if (this.camHost === s) { this.cams = false; this.camHost = null; }
}
}
+4 -3
View File
@@ -288,7 +288,8 @@
{@const stream = streamOf(u.session)}
{@const sharing = session.sharing[u.session]}
{@const isSelf = u.session === self?.session}
{@const cam = camOf(u.session)}
<!-- While a camera is shown large, its tile goes back to the avatar: one picture, not two -->
{@const cam = focus === u.session ? null : camOf(u.session)}
{@const w = watchOf(u.session)}
<button class="tile" class:talking={talking(u.session)} class:self={isSelf} class:focused={focus === u.session}
onclick={() => tileClick(u.session)}
@@ -359,8 +360,8 @@
{/if}
</button>
{#if anyCamera || share.cams}
<button class="icon-btn" class:live={share.cams} aria-pressed={share.cams} title={share.cams ? 'Stop watching the cameras' : 'Watch everyone\'s camera'}
aria-label={share.cams ? 'Stop watching the cameras' : 'Watch all cameras'} onclick={() => share.setCams(sessions.active, !share.cams)}>
<button class="icon-btn" class:live={share.cams} aria-pressed={share.cams} title={share.cams ? 'Hide everyone\'s camera' : 'Show everyone\'s camera'}
aria-label={share.cams ? 'Hide all cameras' : 'Show all cameras'} onclick={() => share.setCams(sessions.active, !share.cams)}>
<Icon name="camera" size={18} />
</button>
{/if}
+15 -4
View File
@@ -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
View File
@@ -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' });
+54
View File
@@ -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);
});