feat(call): per-participant volume up to 200% via WebAudio gain
HTMLMediaElement.volume caps at 1.0, so boosting a quiet peer past 100% needs an explicit GainNode in the output chain. New remoteAudioPipelines module owns one AudioContext + GainNode per remote audio track; attachTrack / detachTrack now create and tear down the pipeline alongside the LiveKit element. Once a track is on the WebAudio path its direct output is diverted (createMediaElementSource semantics), so audio.muted / volume can't drive output anymore. Deafen, watch-state, manual screen-share mute and per-user volume are collapsed into one effective-gain formula that gets recomputed on every state flip — the effect subscribes to both participantVolumes and screenShareVolumes for live slider drags. Slider ranges updated to 0–200% across: - ParticipantVolumeMenu (per-user right-click menu) - ParticipantsPopover (in-call participant list) - ScreenShareContextMenu (per-share right-click) Values above 100% render the percentage in amber as a soft hint that clipping is possible. Clamp in both volume stores extended to [0, 2] so persisted values survive. setAudioOutputDevice now additionally routes via AudioContext.setSinkId (Chrome 115+) for the WebAudio graph; the HTMLAudioElement.setSinkId fallback stays for older runtimes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,9 +30,14 @@ function load(): VolumeMap {
|
||||
}
|
||||
}
|
||||
|
||||
// Matches Discord's slider range — up to 200% via a WebAudio GainNode in
|
||||
// remoteAudioPipelines (HTMLMediaElement.volume caps at 1.0 on its own).
|
||||
const MAX_VOLUME = 2;
|
||||
|
||||
function clamp(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
if (v < 0) return 0;
|
||||
if (v > 1) return 1;
|
||||
if (v > MAX_VOLUME) return MAX_VOLUME;
|
||||
return v;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Central registry of Web-Audio pipelines for every remote audio track we're
|
||||
// playing. Exists because `HTMLMediaElement.volume` caps at 1.0 — to let a
|
||||
// user boost a quiet peer past 100% we need an explicit GainNode in the
|
||||
// output chain. Once a track is on the WebAudio path, `audio.muted` / volume
|
||||
// no longer drive output (the MediaElementSource diverts samples through the
|
||||
// graph), so all gating — deafen, watch-state, manual mute, per-user volume —
|
||||
// is collapsed into a single effective-gain value per pipeline.
|
||||
//
|
||||
// The registry is a plain module-level Map. Attach/detach lifecycle is owned
|
||||
// by CallContext's attachTrack/detachTrack helpers; gain recomputation is
|
||||
// also triggered from CallContext when any state the formula depends on
|
||||
// changes.
|
||||
|
||||
export type RemoteTrackSource = 'microphone' | 'screenshare';
|
||||
|
||||
export interface RemoteAudioPipeline {
|
||||
/** LiveKit track sid — stable identifier for this track's lifetime. */
|
||||
trackSid: string;
|
||||
participantId: string;
|
||||
trackSource: RemoteTrackSource;
|
||||
audio: HTMLAudioElement;
|
||||
ctx: AudioContext;
|
||||
source: MediaElementAudioSourceNode;
|
||||
gain: GainNode;
|
||||
}
|
||||
|
||||
const pipelines = new Map<string, RemoteAudioPipeline>();
|
||||
|
||||
// Build the WebAudio chain for an already-attached HTMLAudioElement. Returns
|
||||
// null when WebAudio isn't available (older browsers) — callers should fall
|
||||
// back to `audio.volume` in that case.
|
||||
export function createPipeline(
|
||||
audio: HTMLAudioElement,
|
||||
info: { trackSid: string; participantId: string; trackSource: RemoteTrackSource },
|
||||
): RemoteAudioPipeline | null {
|
||||
const AudioCtx =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioCtx) return null;
|
||||
try {
|
||||
const ctx = new AudioCtx();
|
||||
const source = ctx.createMediaElementSource(audio);
|
||||
const gain = ctx.createGain();
|
||||
// Start silent; the caller (CallContext) applies the correct effective
|
||||
// gain immediately after registering via `setPipelineGain`.
|
||||
gain.gain.value = 0;
|
||||
source.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
// createMediaElementSource diverts the element's direct output through
|
||||
// the audio graph. Muting the element is then a double-guard — if the
|
||||
// diversion ever fails (older WebKit), the element stays silent instead
|
||||
// of bypassing the gain chain entirely.
|
||||
audio.muted = true;
|
||||
const pipeline: RemoteAudioPipeline = {
|
||||
trackSid: info.trackSid,
|
||||
participantId: info.participantId,
|
||||
trackSource: info.trackSource,
|
||||
audio,
|
||||
ctx,
|
||||
source,
|
||||
gain,
|
||||
};
|
||||
pipelines.set(info.trackSid, pipeline);
|
||||
return pipeline;
|
||||
} catch (err: unknown) {
|
||||
console.warn('createPipeline failed', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function destroyPipeline(trackSid: string): void {
|
||||
const p = pipelines.get(trackSid);
|
||||
if (!p) return;
|
||||
pipelines.delete(trackSid);
|
||||
try {
|
||||
p.source.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
p.gain.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
void p.ctx.close().catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
}
|
||||
|
||||
export function allPipelines(): IterableIterator<RemoteAudioPipeline> {
|
||||
return pipelines.values();
|
||||
}
|
||||
|
||||
export function getPipeline(trackSid: string): RemoteAudioPipeline | undefined {
|
||||
return pipelines.get(trackSid);
|
||||
}
|
||||
|
||||
export function pipelinesFor(
|
||||
participantId: string,
|
||||
source?: RemoteTrackSource,
|
||||
): RemoteAudioPipeline[] {
|
||||
const out: RemoteAudioPipeline[] = [];
|
||||
for (const p of pipelines.values()) {
|
||||
if (p.participantId !== participantId) continue;
|
||||
if (source && p.trackSource !== source) continue;
|
||||
out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Ramp the gain slightly (~20ms) so 0→2x doesn't introduce a click and so
|
||||
// rapid slider drags stay smooth. ctx.currentTime is the right anchor —
|
||||
// setValueAtTime jumps abruptly.
|
||||
export function setPipelineGain(pipeline: RemoteAudioPipeline, value: number): void {
|
||||
const v = clampGain(value);
|
||||
try {
|
||||
pipeline.gain.gain.setTargetAtTime(v, pipeline.ctx.currentTime, 0.02);
|
||||
} catch {
|
||||
// Some contexts in a closed state throw — just direct-assign as a
|
||||
// fallback; worst case the next valid call smooths it out.
|
||||
pipeline.gain.gain.value = v;
|
||||
}
|
||||
}
|
||||
|
||||
export function clampGain(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
if (v < 0) return 0;
|
||||
// 2.0 matches Discord's upper bound (200%). Going higher invites clipping
|
||||
// since the source is already peaking at 1.0 for most mics.
|
||||
if (v > 2) return 2;
|
||||
return v;
|
||||
}
|
||||
|
||||
// Route every pipeline's AudioContext to the given sink. Feature-detects
|
||||
// `AudioContext.setSinkId` (Chrome 115+); older runtimes silently keep the
|
||||
// default sink, which is the behaviour we had before the WebAudio refactor
|
||||
// so this is a strict improvement rather than a regression.
|
||||
export async function setAllPipelinesSinkId(deviceId: string): Promise<void> {
|
||||
const maybeId = deviceId.length === 0 ? 'default' : deviceId;
|
||||
for (const p of pipelines.values()) {
|
||||
const ctxAny = p.ctx as unknown as {
|
||||
setSinkId?: (id: string) => Promise<void>;
|
||||
};
|
||||
if (typeof ctxAny.setSinkId !== 'function') continue;
|
||||
try {
|
||||
await ctxAny.setSinkId(maybeId);
|
||||
} catch (err: unknown) {
|
||||
console.warn('AudioContext.setSinkId failed', { trackSid: p.trackSid, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@
|
||||
// with how the context menu surfaces the control ("Dennis's share").
|
||||
|
||||
const DEFAULT_VOLUME = 1;
|
||||
// Matches Discord's slider range — up to 200% via the WebAudio GainNode in
|
||||
// remoteAudioPipelines. HTMLMediaElement.volume only goes to 1.0, so the
|
||||
// above-100% values are only meaningful on the WebAudio output path.
|
||||
const MAX_VOLUME = 2;
|
||||
|
||||
type VolumeMap = Record<string, number>;
|
||||
type Listener = (map: VolumeMap) => void;
|
||||
@@ -18,7 +22,7 @@ const listeners = new Set<Listener>();
|
||||
function clamp(v: number): number {
|
||||
if (!Number.isFinite(v)) return DEFAULT_VOLUME;
|
||||
if (v < 0) return 0;
|
||||
if (v > 1) return 1;
|
||||
if (v > MAX_VOLUME) return MAX_VOLUME;
|
||||
return v;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user