Text channels #18
@@ -24,11 +24,17 @@ export interface VoiceSettings {
|
|||||||
frameMs: 10 | 20 | 40 | 60;
|
frameMs: 10 | 20 | 40 | 60;
|
||||||
jitterMs: number;
|
jitterMs: number;
|
||||||
forceTcp: boolean; // send voice through the TCP connection even when UDP works
|
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)
|
// Per-user local volume and mute, keyed by certificate hash (or name without one)
|
||||||
userVolumes: Record<string, number>;
|
userVolumes: Record<string, number>;
|
||||||
localMutes: Record<string, boolean>;
|
localMutes: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 = {
|
const defaults: VoiceSettings = {
|
||||||
inputDevice: 'default',
|
inputDevice: 'default',
|
||||||
outputDevice: 'default',
|
outputDevice: 'default',
|
||||||
@@ -45,6 +51,7 @@ const defaults: VoiceSettings = {
|
|||||||
frameMs: 20,
|
frameMs: 20,
|
||||||
jitterMs: 60,
|
jitterMs: 60,
|
||||||
forceTcp: false,
|
forceTcp: false,
|
||||||
|
releaseMicMuted: handheld,
|
||||||
userVolumes: {},
|
userVolumes: {},
|
||||||
localMutes: {}
|
localMutes: {}
|
||||||
};
|
};
|
||||||
@@ -63,6 +70,7 @@ class VoiceEngine {
|
|||||||
inputDevices = $state<DeviceInfo[]>([]);
|
inputDevices = $state<DeviceInfo[]>([]);
|
||||||
outputDevices = $state<DeviceInfo[]>([]);
|
outputDevices = $state<DeviceInfo[]>([]);
|
||||||
running = $state(false);
|
running = $state(false);
|
||||||
|
micOpen = $state(false); // the microphone is held right now
|
||||||
error = $state('');
|
error = $state('');
|
||||||
level = $state(-100); // current input level, dBFS
|
level = $state(-100); // current input level, dBFS
|
||||||
transmitting = $state(false);
|
transmitting = $state(false);
|
||||||
@@ -122,7 +130,8 @@ class VoiceEngine {
|
|||||||
if ('jitterMs' in patch) this.playback?.port.postMessage({ jitterMs: this.settings.jitterMs });
|
if ('jitterMs' in patch) this.playback?.port.postMessage({ jitterMs: this.settings.jitterMs });
|
||||||
if ('outputDevice' in patch) this.applyOutputDevice();
|
if ('outputDevice' in patch) this.applyOutputDevice();
|
||||||
if ('bitrate' in patch || 'frameMs' in patch) this.configureEncoder();
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Devices ────────────────────────────────────────────────────────────────
|
||||||
@@ -174,7 +183,7 @@ class VoiceEngine {
|
|||||||
this.inputGainNode.connect(this.capture);
|
this.inputGainNode.connect(this.capture);
|
||||||
|
|
||||||
await this.applyOutputDevice();
|
await this.applyOutputDevice();
|
||||||
await this.restartInput();
|
await this.syncInput();
|
||||||
this.configureEncoder();
|
this.configureEncoder();
|
||||||
await ctx.resume();
|
await ctx.resume();
|
||||||
this.levelTimer = setInterval(() => { this.level = this.levelRaw; }, 50);
|
this.levelTimer = setInterval(() => { this.level = this.levelRaw; }, 50);
|
||||||
@@ -192,6 +201,7 @@ class VoiceEngine {
|
|||||||
this.levelTimer = null;
|
this.levelTimer = null;
|
||||||
this.stream?.getTracks().forEach(t => t.stop());
|
this.stream?.getTracks().forEach(t => t.stop());
|
||||||
this.stream = null;
|
this.stream = null;
|
||||||
|
this.micOpen = false;
|
||||||
for (const d of this.decoders.values()) { try { d.decoder.close(); } catch { /* closed */ } }
|
for (const d of this.decoders.values()) { try { d.decoder.close(); } catch { /* closed */ } }
|
||||||
this.decoders.clear();
|
this.decoders.clear();
|
||||||
try { this.encoder?.close(); } catch { /* closed */ }
|
try { this.encoder?.close(); } catch { /* closed */ }
|
||||||
@@ -205,13 +215,39 @@ class VoiceEngine {
|
|||||||
this.level = -100;
|
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<void> {
|
||||||
|
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<void> {
|
private async restartInput(): Promise<void> {
|
||||||
if (!this.ctx || !this.inputGainNode) return;
|
if (!this.ctx || !this.inputGainNode) return;
|
||||||
this.source?.disconnect();
|
this.source?.disconnect();
|
||||||
this.stream?.getTracks().forEach(t => t.stop());
|
this.stream?.getTracks().forEach(t => t.stop());
|
||||||
|
this.stream = null;
|
||||||
|
this.micOpen = false;
|
||||||
const s = this.settings;
|
const s = this.settings;
|
||||||
|
let stream: MediaStream;
|
||||||
try {
|
try {
|
||||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: {
|
||||||
deviceId: s.inputDevice && s.inputDevice !== 'default' ? { exact: s.inputDevice } : undefined,
|
deviceId: s.inputDevice && s.inputDevice !== 'default' ? { exact: s.inputDevice } : undefined,
|
||||||
echoCancellation: s.echoCancellation,
|
echoCancellation: s.echoCancellation,
|
||||||
@@ -225,6 +261,10 @@ class VoiceEngine {
|
|||||||
this.error = `Microphone unavailable: ${(e as Error).message}`;
|
this.error = `Microphone unavailable: ${(e as Error).message}`;
|
||||||
return;
|
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.error = '';
|
||||||
this.source = this.ctx.createMediaStreamSource(this.stream);
|
this.source = this.ctx.createMediaStreamSource(this.stream);
|
||||||
this.source.connect(this.inputGainNode);
|
this.source.connect(this.inputGainNode);
|
||||||
@@ -249,7 +289,8 @@ class VoiceEngine {
|
|||||||
this.stats = { sent: 0, received: 0, decoded: 0, lost: 0 };
|
this.stats = { sent: 0, received: 0, decoded: 0, lost: 0 };
|
||||||
this.unsubscribe = [
|
this.unsubscribe = [
|
||||||
client.on('voice', raw => { if (this.client === client) this.receive(raw); }),
|
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.limitBitrate();
|
||||||
this.start();
|
this.start();
|
||||||
@@ -490,6 +531,7 @@ class VoiceEngine {
|
|||||||
|
|
||||||
setTesting(on: boolean): void {
|
setTesting(on: boolean): void {
|
||||||
this.testing = on;
|
this.testing = on;
|
||||||
|
this.syncInput();
|
||||||
if (on) this.start();
|
if (on) this.start();
|
||||||
else {
|
else {
|
||||||
this.playback?.port.postMessage({ drop: -1 });
|
this.playback?.port.postMessage({ drop: -1 });
|
||||||
|
|||||||
@@ -114,6 +114,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Processing</h3>
|
<h3>Processing</h3>
|
||||||
|
<label class="check"><input type="checkbox" checked={s.releaseMicMuted} onchange={e => voice.update({ releaseMicMuted: e.currentTarget.checked })} /> Give the microphone back while muted</label>
|
||||||
|
<p class="help">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} <strong>{voice.micOpen ? 'The microphone is open now.' : 'The microphone is given back now.'}</strong>{/if}</p>
|
||||||
<label class="check"><input type="checkbox" checked={s.noiseSuppression} onchange={e => voice.update({ noiseSuppression: e.currentTarget.checked })} /> Noise suppression</label>
|
<label class="check"><input type="checkbox" checked={s.noiseSuppression} onchange={e => voice.update({ noiseSuppression: e.currentTarget.checked })} /> Noise suppression</label>
|
||||||
<label class="check"><input type="checkbox" checked={s.echoCancellation} onchange={e => voice.update({ echoCancellation: e.currentTarget.checked })} /> Echo cancellation (for speakers instead of headphones)</label>
|
<label class="check"><input type="checkbox" checked={s.echoCancellation} onchange={e => voice.update({ echoCancellation: e.currentTarget.checked })} /> Echo cancellation (for speakers instead of headphones)</label>
|
||||||
<label class="check"><input type="checkbox" checked={s.autoGainControl} onchange={e => voice.update({ autoGainControl: e.currentTarget.checked })} /> Automatic gain control</label>
|
<label class="check"><input type="checkbox" checked={s.autoGainControl} onchange={e => voice.update({ autoGainControl: e.currentTarget.checked })} /> Automatic gain control</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user