fix even better audio
This commit is contained in:
1 parent
ce3d40aba8
commit
87f181504c
3 files changed
+169
-21
No files matched your search
+85
-12
@@ -1,6 +1,6 @@
|
|||||||
import * as matrixcs from 'matrix-js-sdk';
|
import * as matrixcs from 'matrix-js-sdk';
|
||||||
import cytoscape from 'cytoscape';
|
import cytoscape from 'cytoscape';
|
||||||
import { AudioManager } from './audio-manager.js';
|
import { AudioManager, MAX_MIC_GAIN, MAX_MIC_GAIN_HIFI } from './audio-manager.js';
|
||||||
|
|
||||||
function captureRumIssue(message, severity = 'ERROR', extra = {}) {
|
function captureRumIssue(message, severity = 'ERROR', extra = {}) {
|
||||||
const rum = globalThis.OtelRUM?.last;
|
const rum = globalThis.OtelRUM?.last;
|
||||||
@@ -33,6 +33,9 @@ window.addEventListener('unhandledrejection', (event) => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const ROOM_UNITS = 100; // spatial plane is 0..100 x 0..100
|
const ROOM_UNITS = 100; // spatial plane is 0..100 x 0..100
|
||||||
const SPATIAL_SCALE = 0.12; // room units -> audio world units
|
const SPATIAL_SCALE = 0.12; // room units -> audio world units
|
||||||
|
// Per-user playback boost ceiling: 400% (+12 dB) over the spatial mix, matching
|
||||||
|
// the headroom the playback limiter in attachSpatial() is designed for.
|
||||||
|
const MAX_USER_VOLUME = 4;
|
||||||
// Hi-Fi rooms: stereo Opus at 256 kbps with echo cancellation disabled —
|
// Hi-Fi rooms: stereo Opus at 256 kbps with echo cancellation disabled —
|
||||||
// for music/listening channels where fidelity beats echo-proofing. Marked
|
// for music/listening channels where fidelity beats echo-proofing. Marked
|
||||||
// with a HI-FI badge in the channel list.
|
// with a HI-FI badge in the channel list.
|
||||||
@@ -43,6 +46,7 @@ const LS_NAME = 'voicepoc.nick';
|
|||||||
const LS_ICE = 'voicepoc.iceOverride';
|
const LS_ICE = 'voicepoc.iceOverride';
|
||||||
const LS_AUDIO_INPUT = 'voicepoc.audioInput';
|
const LS_AUDIO_INPUT = 'voicepoc.audioInput';
|
||||||
const LS_SCREEN_HINT = 'voicepoc.screenHint';
|
const LS_SCREEN_HINT = 'voicepoc.screenHint';
|
||||||
|
const LS_USER_VOLUME = 'voicepoc.userVolume';
|
||||||
|
|
||||||
const S = {
|
const S = {
|
||||||
ws: null,
|
ws: null,
|
||||||
@@ -372,6 +376,10 @@ async function joinVoice(room) {
|
|||||||
// Hi-Fi profile must be resolved before capture: S.room still names the
|
// Hi-Fi profile must be resolved before capture: S.room still names the
|
||||||
// previous channel here, so the target room is passed explicitly.
|
// previous channel here, so the target room is passed explicitly.
|
||||||
const hifi = HIFI_ROOMS.has(room);
|
const hifi = HIFI_ROOMS.has(room);
|
||||||
|
// Select the gain profile before capture: Hi-Fi rooms run raw (no AGC/echo
|
||||||
|
// cancellation) and get a higher ceiling, normal rooms keep their own value.
|
||||||
|
S.audioManager.setProfile(hifi);
|
||||||
|
updateMicGainUi();
|
||||||
|
|
||||||
// Mic permission & AudioContext: request immediately on the user-click gesture
|
// Mic permission & AudioContext: request immediately on the user-click gesture
|
||||||
// so browsers don't block the microphone permission prompt or audio autoplay policy.
|
// so browsers don't block the microphone permission prompt or audio autoplay policy.
|
||||||
@@ -445,6 +453,10 @@ function leaveVoice(reason) {
|
|||||||
S.peers.clear();
|
S.peers.clear();
|
||||||
if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; }
|
if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; }
|
||||||
if (S.ws) { try { S.ws.close(); } catch {} S.ws = null; }
|
if (S.ws) { try { S.ws.close(); } catch {} S.ws = null; }
|
||||||
|
// Back to the normal-room gain profile. leaveVoice also runs while switching
|
||||||
|
// channels; joinVoice re-selects the target profile straight after, so the
|
||||||
|
// intermediate hop never touches the microphone.
|
||||||
|
S.audioManager.setProfile(false);
|
||||||
S.audioManager.stop();
|
S.audioManager.stop();
|
||||||
S.rawMicStream = null;
|
S.rawMicStream = null;
|
||||||
S.micStream = null;
|
S.micStream = null;
|
||||||
@@ -681,7 +693,8 @@ function makePeerRecord(p) {
|
|||||||
state: p.state || { muted: false, deafened: false, sharing: false },
|
state: p.state || { muted: false, deafened: false, sharing: false },
|
||||||
pc: null, polite: false, makingOffer: false, ignoreOffer: false,
|
pc: null, polite: false, makingOffer: false, ignoreOffer: false,
|
||||||
pendingCandidates: [], micStream: null, screenStreams: new Map(),
|
pendingCandidates: [], micStream: null, screenStreams: new Map(),
|
||||||
gain: null, panner: null, analyser: null, vol: 1, speaking: false,
|
gain: null, panner: null, analyser: null, limiter: null,
|
||||||
|
vol: savedUserVolume(p.name), speaking: false,
|
||||||
resetAttempts: 0,
|
resetAttempts: 0,
|
||||||
lastIceNotice: null,
|
lastIceNotice: null,
|
||||||
icePath: null,
|
icePath: null,
|
||||||
@@ -691,6 +704,26 @@ function makePeerRecord(p) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-user playback volume is remembered by nickname: peer ids are per-socket
|
||||||
|
// and change on every reconnect, so keying on the id would lose the boost on
|
||||||
|
// every rejoin/reload.
|
||||||
|
function savedUserVolumes() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(LS_USER_VOLUME)) || {}; } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function savedUserVolume(name) {
|
||||||
|
const n = Number(savedUserVolumes()[name]);
|
||||||
|
if (!Number.isFinite(n)) return 1;
|
||||||
|
return Math.max(0, Math.min(MAX_USER_VOLUME, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberUserVolume(name, vol) {
|
||||||
|
const all = savedUserVolumes();
|
||||||
|
if (Math.abs(vol - 1) < 0.001) delete all[name]; // 100% is the default — don't store noise
|
||||||
|
else all[name] = vol;
|
||||||
|
try { localStorage.setItem(LS_USER_VOLUME, JSON.stringify(all)); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
function createOrphanPeer(id) {
|
function createOrphanPeer(id) {
|
||||||
const p = makePeerRecord({ id, name: 'peer-' + id.slice(0, 4), x: 50, y: 50 });
|
const p = makePeerRecord({ id, name: 'peer-' + id.slice(0, 4), x: 50, y: 50 });
|
||||||
S.peers.set(id, p);
|
S.peers.set(id, p);
|
||||||
@@ -974,15 +1007,32 @@ function attachSpatial(peer, stream) {
|
|||||||
gain.gain.value = S.deafened ? 0 : peer.vol;
|
gain.gain.value = S.deafened ? 0 : peer.vol;
|
||||||
const analyser = ctx.createAnalyser();
|
const analyser = ctx.createAnalyser();
|
||||||
analyser.fftSize = 256;
|
analyser.fftSize = 256;
|
||||||
src.connect(panner).connect(gain).connect(analyser).connect(ctx.destination);
|
const limiter = makePlaybackLimiter(ctx);
|
||||||
Object.assign(peer, { panner, gain, analyser });
|
// The analyser taps the signal before the per-user gain so the "speaking"
|
||||||
|
// highlight follows the peer's real level: at 400% it would otherwise be on
|
||||||
|
// permanently, and at 0% permanently dark.
|
||||||
|
src.connect(panner).connect(analyser).connect(gain).connect(limiter).connect(ctx.destination);
|
||||||
|
Object.assign(peer, { panner, gain, analyser, limiter });
|
||||||
updatePanner(peer);
|
updatePanner(peer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Playback headroom: the destination hard-clips anything above 0 dBFS, so a
|
||||||
|
// 400% per-user boost needs a limiter to stay loud instead of crunching.
|
||||||
|
function makePlaybackLimiter(ctx) {
|
||||||
|
const limiter = ctx.createDynamicsCompressor();
|
||||||
|
limiter.threshold.value = -1.5;
|
||||||
|
limiter.knee.value = 0;
|
||||||
|
limiter.ratio.value = 20;
|
||||||
|
limiter.attack.value = 0.002;
|
||||||
|
limiter.release.value = 0.15;
|
||||||
|
return limiter;
|
||||||
|
}
|
||||||
|
|
||||||
function detachSpatial(peer) {
|
function detachSpatial(peer) {
|
||||||
if (peer.gain) { try { peer.gain.disconnect(); } catch {} }
|
if (peer.gain) { try { peer.gain.disconnect(); } catch {} }
|
||||||
|
if (peer.limiter) { try { peer.limiter.disconnect(); } catch {} }
|
||||||
if (peer.panner) { try { peer.panner.disconnect(); } catch {} }
|
if (peer.panner) { try { peer.panner.disconnect(); } catch {} }
|
||||||
peer.panner = peer.gain = peer.analyser = null;
|
peer.panner = peer.gain = peer.analyser = peer.limiter = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePanner(peer) {
|
function updatePanner(peer) {
|
||||||
@@ -1313,19 +1363,30 @@ function renderTiles() {
|
|||||||
volPop.className = 'volume-popup';
|
volPop.className = 'volume-popup';
|
||||||
const volLabel = document.createElement('div');
|
const volLabel = document.createElement('div');
|
||||||
volLabel.className = 'vol-title';
|
volLabel.className = 'vol-title';
|
||||||
volLabel.textContent = `User Volume: ${Math.round(peer.vol * 100)}%`;
|
const applyVol = (next) => {
|
||||||
|
peer.vol = next;
|
||||||
|
volLabel.textContent = `User Volume: ${Math.round(peer.vol * 100)}%`;
|
||||||
|
// setTargetAtTime ramps smoothly instead of zipper-clicking on drag
|
||||||
|
if (peer.gain && S.audioCtx && !S.deafened) peer.gain.gain.setTargetAtTime(peer.vol, S.audioCtx.currentTime, 0.02);
|
||||||
|
rememberUserVolume(peer.name, peer.vol);
|
||||||
|
};
|
||||||
|
applyVol(peer.vol);
|
||||||
const volInput = document.createElement('input');
|
const volInput = document.createElement('input');
|
||||||
volInput.type = 'range';
|
volInput.type = 'range';
|
||||||
volInput.min = 0;
|
volInput.min = 0;
|
||||||
volInput.max = 2;
|
volInput.max = MAX_USER_VOLUME;
|
||||||
volInput.step = 0.05;
|
volInput.step = 0.05;
|
||||||
volInput.value = peer.vol;
|
volInput.value = peer.vol;
|
||||||
volInput.oninput = (e) => {
|
volInput.oninput = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
peer.vol = Number(volInput.value);
|
applyVol(Number(volInput.value));
|
||||||
volLabel.textContent = `User Volume: ${Math.round(peer.vol * 100)}%`;
|
};
|
||||||
// setTargetAtTime ramps smoothly instead of zipper-clicking on drag
|
// Double-click snaps back to 100%: on a 400% range it is easy to lose
|
||||||
if (peer.gain && S.audioCtx && !S.deafened) peer.gain.gain.setTargetAtTime(peer.vol, S.audioCtx.currentTime, 0.02);
|
// the neutral point while dragging.
|
||||||
|
volInput.ondblclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
volInput.value = String(1);
|
||||||
|
applyVol(1);
|
||||||
};
|
};
|
||||||
volPop.append(volLabel, volInput);
|
volPop.append(volLabel, volInput);
|
||||||
|
|
||||||
@@ -2362,10 +2423,22 @@ function updateMicGainUi() {
|
|||||||
const gain = S.audioManager.gain;
|
const gain = S.audioManager.gain;
|
||||||
const input = $('#mic-gain');
|
const input = $('#mic-gain');
|
||||||
const value = $('#mic-gain-value');
|
const value = $('#mic-gain-value');
|
||||||
|
// The ceiling follows the active room profile (400% normal, 800% Hi-Fi).
|
||||||
|
if (input) input.max = String(S.audioManager.maxGain);
|
||||||
if (input) input.value = gain;
|
if (input) input.value = gain;
|
||||||
if (value) value.textContent = `${Math.round(gain * 100)}%`;
|
if (value) value.textContent = `${Math.round(gain * 100)}%`;
|
||||||
|
const hint = $('#mic-gain-hint');
|
||||||
|
if (hint) {
|
||||||
|
hint.textContent = S.audioManager.hifi
|
||||||
|
? `Hi-Fi room: raw capture, no AGC — up to ${Math.round(S.audioManager.maxGain * 100)}% here.`
|
||||||
|
: `This room allows up to ${Math.round(MAX_MIC_GAIN * 100)}%. Hi-Fi rooms (Code, Gaming) capture raw with no AGC and allow up to ${Math.round(MAX_MIC_GAIN_HIFI * 100)}%.`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$('#mic-gain').oninput = (ev) => {
|
const micGainInput = $('#mic-gain');
|
||||||
|
// Ceiling comes from the AudioManager profile, so the slider can never offer a
|
||||||
|
// gain the engine would silently reject.
|
||||||
|
micGainInput.max = String(S.audioManager.maxGain);
|
||||||
|
micGainInput.oninput = (ev) => {
|
||||||
S.audioManager.setGain(ev.target.value);
|
S.audioManager.setGain(ev.target.value);
|
||||||
updateMicGainUi();
|
updateMicGainUi();
|
||||||
};
|
};
|
||||||
|
|||||||
+81
-7
@@ -1,3 +1,35 @@
|
|||||||
|
// Microphone gain ceilings. Normal rooms capture with echo cancellation enabled,
|
||||||
|
// which already lifts quiet microphones. Hi-Fi rooms capture raw (no AGC, no echo
|
||||||
|
// cancellation) for fidelity, so quiet mics arrive much lower and need more
|
||||||
|
// make-up gain before they reach the Opus encoder.
|
||||||
|
export const MAX_MIC_GAIN = 4; // 400% (+12 dB) — normal rooms
|
||||||
|
export const MAX_MIC_GAIN_HIFI = 8; // 800% (+18 dB) — Hi-Fi rooms, raw capture
|
||||||
|
|
||||||
|
// Gain is stored per profile: dropping back from a Hi-Fi room to a normal one must
|
||||||
|
// not silently truncate a +18 dB setting the user dialled in.
|
||||||
|
const GAIN_KEY = { normal: 'voicepoc.micGain', hifi: 'voicepoc.micGainHifi' };
|
||||||
|
|
||||||
|
// Peaks above 0 dBFS are hard-clipped when the float graph is converted for the
|
||||||
|
// Opus encoder, which turns a big boost into crunch. The limiter only engages
|
||||||
|
// near full scale, so normal-level speech passes through untouched.
|
||||||
|
const LIMITER = { threshold: -1.5, knee: 0, ratio: 20, attack: 0.002, release: 0.15 };
|
||||||
|
|
||||||
|
function makeLimiter(context) {
|
||||||
|
const limiter = context.createDynamicsCompressor();
|
||||||
|
limiter.threshold.value = LIMITER.threshold;
|
||||||
|
limiter.knee.value = LIMITER.knee;
|
||||||
|
limiter.ratio.value = LIMITER.ratio;
|
||||||
|
limiter.attack.value = LIMITER.attack;
|
||||||
|
limiter.release.value = LIMITER.release;
|
||||||
|
return limiter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampGain(value, max) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return 1;
|
||||||
|
return Math.max(0, Math.min(max, n));
|
||||||
|
}
|
||||||
|
|
||||||
export class AudioManager {
|
export class AudioManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.context = null;
|
this.context = null;
|
||||||
@@ -5,10 +37,49 @@ export class AudioManager {
|
|||||||
this.outputStream = null;
|
this.outputStream = null;
|
||||||
this.source = null;
|
this.source = null;
|
||||||
this.gainNode = null;
|
this.gainNode = null;
|
||||||
|
this.limiter = null;
|
||||||
this.analyser = null;
|
this.analyser = null;
|
||||||
this.meterSink = null;
|
this.meterSink = null;
|
||||||
this.meterData = new Float32Array(1024);
|
this.meterData = new Float32Array(1024);
|
||||||
this.gain = Number(localStorage.getItem('voicepoc.micGain') || 1);
|
// The room is unknown until joinVoice() resolves it, so the stricter normal-room
|
||||||
|
// profile is the starting point; joinVoice() calls setProfile() before capture.
|
||||||
|
this.hifi = false;
|
||||||
|
this.maxGain = MAX_MIC_GAIN;
|
||||||
|
// Clamped on load too: an out-of-range localStorage value (older build, hand
|
||||||
|
// edit) must not bypass the ceiling of the profile it belongs to.
|
||||||
|
this.gain = this.readGain();
|
||||||
|
}
|
||||||
|
|
||||||
|
gainKey() {
|
||||||
|
return this.hifi ? GAIN_KEY.hifi : GAIN_KEY.normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
readGain() {
|
||||||
|
const stored = localStorage.getItem(this.gainKey());
|
||||||
|
// No stored value yet: 100%. Users in Hi-Fi rooms raise it themselves — the
|
||||||
|
// ceiling change must not make anyone unexpectedly loud on first join.
|
||||||
|
return stored === null ? 1 : clampGain(stored, this.maxGain);
|
||||||
|
}
|
||||||
|
|
||||||
|
storeGain() {
|
||||||
|
localStorage.setItem(this.gainKey(), String(this.gain));
|
||||||
|
}
|
||||||
|
|
||||||
|
applyGain() {
|
||||||
|
if (this.gainNode) this.gainNode.gain.setTargetAtTime(this.gain, this.context.currentTime, 0.015);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch between the normal and Hi-Fi gain profiles. Safe to call on every join:
|
||||||
|
// it is a no-op when the profile is unchanged.
|
||||||
|
setProfile(hifi) {
|
||||||
|
const next = Boolean(hifi);
|
||||||
|
if (next === this.hifi) return this.gain;
|
||||||
|
this.storeGain(); // keep what the profile we are leaving was set to
|
||||||
|
this.hifi = next;
|
||||||
|
this.maxGain = next ? MAX_MIC_GAIN_HIFI : MAX_MIC_GAIN;
|
||||||
|
this.gain = this.readGain();
|
||||||
|
this.applyGain(); // live mic, if one is attached
|
||||||
|
return this.gain;
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureContext() {
|
async ensureContext() {
|
||||||
@@ -24,6 +95,7 @@ export class AudioManager {
|
|||||||
this.source = this.context.createMediaStreamSource(stream);
|
this.source = this.context.createMediaStreamSource(stream);
|
||||||
this.gainNode = this.context.createGain();
|
this.gainNode = this.context.createGain();
|
||||||
this.gainNode.gain.value = this.gain;
|
this.gainNode.gain.value = this.gain;
|
||||||
|
this.limiter = makeLimiter(this.context);
|
||||||
this.analyser = this.context.createAnalyser();
|
this.analyser = this.context.createAnalyser();
|
||||||
this.analyser.fftSize = 1024;
|
this.analyser.fftSize = 1024;
|
||||||
this.meterSink = this.context.createGain();
|
this.meterSink = this.context.createGain();
|
||||||
@@ -32,25 +104,27 @@ export class AudioManager {
|
|||||||
this.source.connect(this.gainNode).connect(this.analyser);
|
this.source.connect(this.gainNode).connect(this.analyser);
|
||||||
// Keep the analyser alive without sending the microphone to the speakers.
|
// Keep the analyser alive without sending the microphone to the speakers.
|
||||||
this.analyser.connect(this.meterSink).connect(this.context.destination);
|
this.analyser.connect(this.meterSink).connect(this.context.destination);
|
||||||
this.gainNode.connect(destination);
|
// The analyser taps the signal post-gain but pre-limiter, so the clipping
|
||||||
|
// hint reports how hard the gain stage is pushed, not the limited peak.
|
||||||
|
this.gainNode.connect(this.limiter).connect(destination);
|
||||||
this.outputStream = destination.stream;
|
this.outputStream = destination.stream;
|
||||||
return this.outputStream;
|
return this.outputStream;
|
||||||
}
|
}
|
||||||
|
|
||||||
detachInput() {
|
detachInput() {
|
||||||
for (const node of [this.source, this.gainNode, this.analyser, this.meterSink]) {
|
for (const node of [this.source, this.gainNode, this.analyser, this.limiter, this.meterSink]) {
|
||||||
try { node?.disconnect(); } catch {}
|
try { node?.disconnect(); } catch {}
|
||||||
}
|
}
|
||||||
this.outputStream?.getTracks().forEach((track) => track.stop());
|
this.outputStream?.getTracks().forEach((track) => track.stop());
|
||||||
this.rawStream?.getTracks().forEach((track) => track.stop());
|
this.rawStream?.getTracks().forEach((track) => track.stop());
|
||||||
this.source = this.gainNode = this.analyser = this.meterSink = null;
|
this.source = this.gainNode = this.analyser = this.limiter = this.meterSink = null;
|
||||||
this.outputStream = this.rawStream = null;
|
this.outputStream = this.rawStream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setGain(value) {
|
setGain(value) {
|
||||||
this.gain = Math.max(0, Math.min(2, Number(value) || 0));
|
this.gain = clampGain(value, this.maxGain);
|
||||||
localStorage.setItem('voicepoc.micGain', String(this.gain));
|
this.storeGain();
|
||||||
if (this.gainNode) this.gainNode.gain.setTargetAtTime(this.gain, this.context.currentTime, 0.015);
|
this.applyGain();
|
||||||
}
|
}
|
||||||
|
|
||||||
getLevel() {
|
getLevel() {
|
||||||
|
|||||||
+3
-2
@@ -142,7 +142,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<label class="field-label" for="mic-gain">Microphone gain</label>
|
<label class="field-label" for="mic-gain">Microphone gain</label>
|
||||||
<div class="gain-control">
|
<div class="gain-control">
|
||||||
<input id="mic-gain" type="range" min="0" max="2" step="0.05" value="1" />
|
<input id="mic-gain" type="range" min="0" max="4" step="0.05" value="1" />
|
||||||
<span id="mic-gain-value">100%</span>
|
<span id="mic-gain-value">100%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="signal-preview" aria-live="polite">
|
<div class="signal-preview" aria-live="polite">
|
||||||
@@ -150,7 +150,8 @@
|
|||||||
<meter id="mic-level" min="0" max="1" low="0.35" high="0.8" optimum="0.55" value="0"></meter>
|
<meter id="mic-level" min="0" max="1" low="0.35" high="0.8" optimum="0.55" value="0"></meter>
|
||||||
<span id="mic-level-value">-∞ dB</span>
|
<span id="mic-level-value">-∞ dB</span>
|
||||||
</div>
|
</div>
|
||||||
<p id="mic-clipping" class="hint hidden">Clipping detected — reduce microphone gain.</p>
|
<p id="mic-clipping" class="hint hidden">Peaks are hitting the limiter — lower the gain if it sounds distorted.</p>
|
||||||
|
<p id="mic-gain-hint" class="hint">This room allows up to 400%. Hi-Fi rooms (Code, Gaming) capture raw with no AGC and allow up to 800%.</p>
|
||||||
<p class="hint">Changes apply immediately when connected, or the next time you join.</p>
|
<p class="hint">Changes apply immediately when connected, or the next time you join.</p>
|
||||||
<h3>Screen sharing</h3>
|
<h3>Screen sharing</h3>
|
||||||
<label class="field-label" for="screen-hint">Optimization</label>
|
<label class="field-label" for="screen-hint">Optimization</label>
|
||||||
|
|||||||
Reference in new issue
Block a user