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:
byGalax
2026-04-22 21:09:45 +02:00
parent 7ad8ba82b6
commit 8f9b823d69
7 changed files with 311 additions and 76 deletions
+5 -1
View File
@@ -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;
}