diff --git a/src/lib/audio/voice.svelte.ts b/src/lib/audio/voice.svelte.ts index a010e94..77016a1 100644 --- a/src/lib/audio/voice.svelte.ts +++ b/src/lib/audio/voice.svelte.ts @@ -24,11 +24,17 @@ export interface VoiceSettings { frameMs: 10 | 20 | 40 | 60; jitterMs: number; forceTcp: boolean; // send voice through the TCP connection even when UDP works + // Give the microphone back while muted. An open microphone puts a Bluetooth headset into its + // telephone mode, which also makes what you hear sound like a phone call. + releaseMicMuted: boolean; // Per-user local volume and mute, keyed by certificate hash (or name without one) userVolumes: Record; localMutes: Record; } +// Phones and tablets, where Bluetooth headsets are the usual case +const handheld = typeof navigator !== 'undefined' && /Android|iPhone|iPad|iPod/.test(navigator.userAgent); + const defaults: VoiceSettings = { inputDevice: 'default', outputDevice: 'default', @@ -45,6 +51,7 @@ const defaults: VoiceSettings = { frameMs: 20, jitterMs: 60, forceTcp: false, + releaseMicMuted: handheld, userVolumes: {}, localMutes: {} }; @@ -63,6 +70,7 @@ class VoiceEngine { inputDevices = $state([]); outputDevices = $state([]); running = $state(false); + micOpen = $state(false); // the microphone is held right now error = $state(''); level = $state(-100); // current input level, dBFS transmitting = $state(false); @@ -122,7 +130,8 @@ class VoiceEngine { if ('jitterMs' in patch) this.playback?.port.postMessage({ jitterMs: this.settings.jitterMs }); if ('outputDevice' in patch) this.applyOutputDevice(); if ('bitrate' in patch || 'frameMs' in patch) this.configureEncoder(); - if (['inputDevice', 'echoCancellation', 'noiseSuppression', 'autoGainControl'].some(k => k in patch)) this.restartInput(); + if (['inputDevice', 'echoCancellation', 'noiseSuppression', 'autoGainControl'].some(k => k in patch)) this.syncInput(true); + else if ('releaseMicMuted' in patch) this.syncInput(); } // ─── Devices ──────────────────────────────────────────────────────────────── @@ -174,7 +183,7 @@ class VoiceEngine { this.inputGainNode.connect(this.capture); await this.applyOutputDevice(); - await this.restartInput(); + await this.syncInput(); this.configureEncoder(); await ctx.resume(); this.levelTimer = setInterval(() => { this.level = this.levelRaw; }, 50); @@ -192,6 +201,7 @@ class VoiceEngine { this.levelTimer = null; this.stream?.getTracks().forEach(t => t.stop()); this.stream = null; + this.micOpen = false; for (const d of this.decoders.values()) { try { d.decoder.close(); } catch { /* closed */ } } this.decoders.clear(); try { this.encoder?.close(); } catch { /* closed */ } @@ -205,13 +215,39 @@ class VoiceEngine { this.level = -100; } + // The microphone is not needed while we cannot talk anyway (muted or deafened, by us or the server) + private micWanted(): boolean { + if (!this.settings.releaseMicMuted || this.testing) return true; + const self = this.client?.self; + return !self || !(self.selfMute || self.mute || self.suppress || self.selfDeaf || self.deaf); + } + + // Opens or gives back the microphone as the mute state asks. restart: open it anew (settings changed). + private async syncInput(restart = false): Promise { + if (!this.ctx) return; + const wanted = this.micWanted(); + if (wanted && (restart || !this.stream)) return this.restartInput(); + if (!wanted && this.stream) { + this.endTransmission(); + this.source?.disconnect(); + this.source = null; + this.stream.getTracks().forEach(t => t.stop()); + this.stream = null; + this.micOpen = false; + this.levelRaw = -100; + } + } + private async restartInput(): Promise { if (!this.ctx || !this.inputGainNode) return; this.source?.disconnect(); this.stream?.getTracks().forEach(t => t.stop()); + this.stream = null; + this.micOpen = false; const s = this.settings; + let stream: MediaStream; try { - this.stream = await navigator.mediaDevices.getUserMedia({ + stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: s.inputDevice && s.inputDevice !== 'default' ? { exact: s.inputDevice } : undefined, echoCancellation: s.echoCancellation, @@ -225,6 +261,10 @@ class VoiceEngine { this.error = `Microphone unavailable: ${(e as Error).message}`; return; } + // Muted again, or opened a second time, while the browser was getting the microphone + if (!this.ctx || this.stream || !this.micWanted()) return stream.getTracks().forEach(t => t.stop()); + this.stream = stream; + this.micOpen = true; this.error = ''; this.source = this.ctx.createMediaStreamSource(this.stream); this.source.connect(this.inputGainNode); @@ -249,7 +289,8 @@ class VoiceEngine { this.stats = { sent: 0, received: 0, decoded: 0, lost: 0 }; this.unsubscribe = [ client.on('voice', raw => { if (this.client === client) this.receive(raw); }), - client.on('userRemove', u => this.dropSession(u.session)) + client.on('userRemove', u => this.dropSession(u.session)), + client.on('user', u => { if (this.client === client && u.session === client.session) this.syncInput(); }) ]; this.limitBitrate(); this.start(); @@ -490,6 +531,7 @@ class VoiceEngine { setTesting(on: boolean): void { this.testing = on; + this.syncInput(); if (on) this.start(); else { this.playback?.port.postMessage({ drop: -1 }); diff --git a/src/ui/VoiceSettings.svelte b/src/ui/VoiceSettings.svelte index aae0f77..e9b2a29 100644 --- a/src/ui/VoiceSettings.svelte +++ b/src/ui/VoiceSettings.svelte @@ -114,6 +114,8 @@

Processing

+ +

For Bluetooth headsets: with the microphone open, a headset switches to its telephone mode and everything sounds like a phone call. Muted, you then hear in full quality. To talk in full quality too, choose the phone's own microphone above instead of the headset's. No reminder when you speak while muted.{#if s.releaseMicMuted && voice.running} {voice.micOpen ? 'The microphone is open now.' : 'The microphone is given back now.'}{/if}