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:
@@ -63,10 +63,22 @@ import {
|
||||
isE2EESupported,
|
||||
} from '../lib/callE2EE';
|
||||
import { createMicPipeline, type MicPipeline } from '../lib/micPipeline';
|
||||
import { getParticipantVolume } from '../lib/participantVolumes';
|
||||
import {
|
||||
getParticipantVolume,
|
||||
subscribeParticipantVolumes,
|
||||
} from '../lib/participantVolumes';
|
||||
import {
|
||||
allPipelines,
|
||||
createPipeline,
|
||||
destroyPipeline,
|
||||
type RemoteAudioPipeline,
|
||||
setAllPipelinesSinkId,
|
||||
setPipelineGain,
|
||||
} from '../lib/remoteAudioPipelines';
|
||||
import {
|
||||
clearScreenShareVolumes,
|
||||
getScreenShareVolume,
|
||||
subscribeScreenShareVolumes,
|
||||
} from '../lib/screenShareVolumes';
|
||||
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
|
||||
import { playEntry } from '../lib/soundboardPlayback';
|
||||
@@ -1315,27 +1327,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
setIsDeafened((prev) => {
|
||||
const next = !prev;
|
||||
deafenedActive = next;
|
||||
// Apply to every currently-attached remote-audio element. Fresh tracks
|
||||
// that attach during a deafened session are muted in attachTrack above.
|
||||
// When un-deafening, screen-share-audio elements should fall back to
|
||||
// the watching state (muted unless the user clicked "Bildschirm
|
||||
// anschauen") rather than being blanket-unmuted like mic tracks.
|
||||
const els = document.querySelectorAll<HTMLAudioElement>(
|
||||
'audio[data-livekit-track]',
|
||||
);
|
||||
els.forEach((el) => {
|
||||
if (next) {
|
||||
el.muted = true;
|
||||
return;
|
||||
}
|
||||
const source = el.getAttribute('data-track-source');
|
||||
if (source === 'screenshare') {
|
||||
const pid = el.getAttribute('data-participant');
|
||||
el.muted = !(pid && watchingShareUserIdsMirror.has(pid));
|
||||
} else {
|
||||
el.muted = false;
|
||||
}
|
||||
});
|
||||
// Remote-audio gain is recomputed by the effective-gain useEffect
|
||||
// as soon as React picks up the new `isDeafened` state — no DOM
|
||||
// iteration needed here.
|
||||
|
||||
// Discord-parity: deafen implies mute. Remember the pre-deafen mute
|
||||
// state so un-deafening restores whatever the user had before.
|
||||
@@ -1900,37 +1894,63 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [state.kind]);
|
||||
|
||||
// Keep the module-level mirrors in sync so attachTrack (which is defined
|
||||
// outside the React component and runs from LiveKit event callbacks) can
|
||||
// decide the initial muted-state for ScreenShareAudio elements. Also
|
||||
// re-applies the muted state to already-attached elements on every flip
|
||||
// — covers both watching changes and manual mute toggles from the
|
||||
// context menu.
|
||||
// Mirror the watching / manual-mute sets into module-level variables so
|
||||
// attachTrack (which runs outside the React render cycle) can read them
|
||||
// when a ScreenShareAudio track lands. The runtime recompute for already-
|
||||
// attached tracks happens in the effective-gain useEffect below.
|
||||
useEffect(() => {
|
||||
watchingShareUserIdsMirror = watchingShareUserIds;
|
||||
screenShareAudioMutedIdsMirror = screenShareAudioMutedIds;
|
||||
const nodes = document.querySelectorAll<HTMLAudioElement>(
|
||||
'audio[data-track-source="screenshare"]',
|
||||
);
|
||||
nodes.forEach((el) => {
|
||||
if (deafenedActive) {
|
||||
el.muted = true;
|
||||
return;
|
||||
}
|
||||
const pid = el.getAttribute('data-participant');
|
||||
if (!pid) return;
|
||||
const watching = watchingShareUserIds.has(pid);
|
||||
const manualMuted = screenShareAudioMutedIds.has(pid);
|
||||
el.muted = !watching || manualMuted;
|
||||
});
|
||||
}, [watchingShareUserIds, screenShareAudioMutedIds]);
|
||||
|
||||
// Single source of truth for remote-audio output gain. Runs every time a
|
||||
// state that influences the effective-gain formula flips, plus on every
|
||||
// participantVolumes / screenShareVolumes subscriber ping. Keeps deafen,
|
||||
// watching, manual mute, and user volume all in one pass per pipeline.
|
||||
useEffect(() => {
|
||||
const recompute = () => {
|
||||
for (const pipeline of allPipelines()) {
|
||||
let g: number;
|
||||
if (isDeafened) {
|
||||
g = 0;
|
||||
} else if (pipeline.trackSource === 'screenshare') {
|
||||
const watching = watchingShareUserIds.has(pipeline.participantId);
|
||||
const manualMuted = screenShareAudioMutedIds.has(pipeline.participantId);
|
||||
g = watching && !manualMuted ? getScreenShareVolume(pipeline.participantId) : 0;
|
||||
} else {
|
||||
g = getParticipantVolume(pipeline.participantId);
|
||||
}
|
||||
setPipelineGain(pipeline, g);
|
||||
}
|
||||
};
|
||||
recompute();
|
||||
const unsubP = subscribeParticipantVolumes(recompute);
|
||||
const unsubS = subscribeScreenShareVolumes(recompute);
|
||||
return () => {
|
||||
unsubP();
|
||||
unsubS();
|
||||
};
|
||||
}, [
|
||||
isDeafened,
|
||||
watchingShareUserIds,
|
||||
screenShareAudioMutedIds,
|
||||
// `remoteParticipants` is a dep so the initial gain gets applied when a
|
||||
// brand new pipeline lands — attachTrack creates it asynchronously,
|
||||
// state flips, effect re-runs, gain updates.
|
||||
remoteParticipants,
|
||||
]);
|
||||
|
||||
const setAudioOutputDevice = useCallback(async (deviceId: string | null) => {
|
||||
updateAudioSettings({ outputDeviceId: deviceId });
|
||||
const sinkId = deviceId ?? '';
|
||||
// Apply to every <audio> element we've attached to the body. LiveKit's
|
||||
// switchActiveDevice only tracks elements it attached itself; our custom
|
||||
// appendChild path bypasses that, so we iterate and setSinkId manually.
|
||||
// WebAudio path: route every pipeline's AudioContext at the chosen
|
||||
// sink. Feature-detects AudioContext.setSinkId (Chrome 115+) — older
|
||||
// runtimes silently keep the default sink.
|
||||
await setAllPipelinesSinkId(sinkId);
|
||||
// HTMLAudioElement fallback path: elements stay in the DOM for track
|
||||
// lifetime even when WebAudio takes over their output; keep setSinkId
|
||||
// in sync there so a runtime that didn't support createMediaElement-
|
||||
// Source still routes to the right device.
|
||||
const els = document.querySelectorAll<HTMLAudioElement>(
|
||||
'audio[data-livekit-track]',
|
||||
);
|
||||
@@ -2148,30 +2168,45 @@ function attachTrack(
|
||||
}
|
||||
if (participant.identity) {
|
||||
audio.setAttribute('data-participant', participant.identity);
|
||||
audio.volume = isScreenShareAudio
|
||||
? getScreenShareVolume(participant.identity)
|
||||
: getParticipantVolume(participant.identity);
|
||||
}
|
||||
// Mute rules, in priority order:
|
||||
// 1. Deafen wins — user chose to hear nothing at all.
|
||||
// 2. ScreenShareAudio is muted until the user explicitly clicks
|
||||
// "Bildschirm anschauen" (watching gate).
|
||||
// 3. ScreenShareAudio is also muted when the user flipped the
|
||||
// manual mute toggle in the share context menu, regardless of
|
||||
// watching state.
|
||||
// 4. Everything else starts audible.
|
||||
if (deafenedActive) {
|
||||
audio.muted = true;
|
||||
} else if (isScreenShareAudio) {
|
||||
const pid = participant.identity ?? '';
|
||||
const watching = pid !== '' && watchingShareUserIdsMirror.has(pid);
|
||||
const manualMuted = pid !== '' && screenShareAudioMutedIdsMirror.has(pid);
|
||||
audio.muted = !watching || manualMuted;
|
||||
}
|
||||
document.body.appendChild(audio);
|
||||
// Apply persisted sinkId so the element routes to the user's chosen
|
||||
// speaker/headphone from the start (LiveKit's own `switchActiveDevice`
|
||||
// doesn't track custom-appended elements).
|
||||
|
||||
// Build the WebAudio pipeline so we have a single GainNode we can
|
||||
// drive past 100% (up to 200%). Setting `audio.volume` / `muted`
|
||||
// directly from here on is a no-op once the MediaElementSource
|
||||
// diverts the samples through the graph — every gating decision
|
||||
// flows through `setPipelineGain(effectiveGainFor(...))`.
|
||||
const trackSid = track.sid;
|
||||
if (trackSid && participant.identity) {
|
||||
const pipeline = createPipeline(audio, {
|
||||
trackSid,
|
||||
participantId: participant.identity,
|
||||
trackSource: isScreenShareAudio ? 'screenshare' : 'microphone',
|
||||
});
|
||||
if (pipeline) {
|
||||
setPipelineGain(pipeline, computeInitialEffectiveGain(pipeline));
|
||||
} else {
|
||||
// WebAudio unavailable — fall back to element-level volume so
|
||||
// the user at least hears something, even without 200% boost.
|
||||
audio.muted = false;
|
||||
audio.volume = isScreenShareAudio
|
||||
? getScreenShareVolume(participant.identity)
|
||||
: getParticipantVolume(participant.identity);
|
||||
if (deafenedActive) audio.muted = true;
|
||||
else if (isScreenShareAudio) {
|
||||
const pid = participant.identity;
|
||||
const watching = watchingShareUserIdsMirror.has(pid);
|
||||
const manualMuted = screenShareAudioMutedIdsMirror.has(pid);
|
||||
audio.muted = !watching || manualMuted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the user's chosen output device on the HTMLAudioElement
|
||||
// as a redundant guard — some WebView2 builds route the element
|
||||
// directly despite createMediaElementSource. When AudioContext
|
||||
// .setSinkId is available (see setAudioOutputDevice), that picks
|
||||
// up the same preference for the WebAudio graph.
|
||||
const sinkId = getAudioSettings().outputDeviceId;
|
||||
if (sinkId && typeof audio.setSinkId === 'function') {
|
||||
void audio.setSinkId(sinkId).catch((err: unknown) => {
|
||||
@@ -2183,12 +2218,27 @@ function attachTrack(
|
||||
// Video is handled later in M2.6/M3 by a dedicated <video> element.
|
||||
}
|
||||
|
||||
// Effective-gain formula in one place so both the initial attach and the
|
||||
// runtime recompute stay consistent. Read the comment chain in attachTrack
|
||||
// for the priority order.
|
||||
function computeInitialEffectiveGain(pipeline: RemoteAudioPipeline): number {
|
||||
if (deafenedActive) return 0;
|
||||
if (pipeline.trackSource === 'screenshare') {
|
||||
const watching = watchingShareUserIdsMirror.has(pipeline.participantId);
|
||||
const manualMuted = screenShareAudioMutedIdsMirror.has(pipeline.participantId);
|
||||
if (!watching || manualMuted) return 0;
|
||||
return getScreenShareVolume(pipeline.participantId);
|
||||
}
|
||||
return getParticipantVolume(pipeline.participantId);
|
||||
}
|
||||
|
||||
function detachTrack(
|
||||
track: RemoteTrack,
|
||||
_publication: RemoteTrackPublication,
|
||||
_participant: RemoteParticipant,
|
||||
): void {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
if (track.sid) destroyPipeline(track.sid);
|
||||
const els = track.detach();
|
||||
for (const el of els) {
|
||||
el.remove();
|
||||
|
||||
Reference in New Issue
Block a user