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:
@@ -16,7 +16,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MENU_W = 240;
|
const MENU_W = 240;
|
||||||
const MENU_H = 84;
|
const MENU_H = 96;
|
||||||
|
|
||||||
export function ParticipantVolumeMenu({
|
export function ParticipantVolumeMenu({
|
||||||
userId,
|
userId,
|
||||||
@@ -63,14 +63,20 @@ export function ParticipantVolumeMenu({
|
|||||||
>
|
>
|
||||||
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
|
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
|
||||||
<span className="truncate font-semibold text-fg">{displayName}</span>
|
<span className="truncate font-semibold text-fg">{displayName}</span>
|
||||||
<span className="tabular-nums text-fg-muted">
|
<span
|
||||||
|
className={
|
||||||
|
'tabular-nums ' + (volume > 1 ? 'text-amber-500' : 'text-fg-muted')
|
||||||
|
}
|
||||||
|
>
|
||||||
{Math.round(volume * 100)}%
|
{Math.round(volume * 100)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Up to 200% via WebAudio gain. Values above 100% can clip on loud
|
||||||
|
mics — the amber count-up hints at that without a verbose warning. */}
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
max={1}
|
max={2}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
value={volume}
|
value={volume}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -81,6 +87,11 @@ export function ParticipantVolumeMenu({
|
|||||||
aria-label={'Lautstärke ' + displayName}
|
aria-label={'Lautstärke ' + displayName}
|
||||||
className="w-full accent-accent"
|
className="w-full accent-accent"
|
||||||
/>
|
/>
|
||||||
|
<div className="mt-1 flex justify-between text-[10px] text-fg-muted">
|
||||||
|
<span>0%</span>
|
||||||
|
<span className="tabular-nums">100%</span>
|
||||||
|
<span>200%</span>
|
||||||
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ function Row({ row, speaking }: { row: ParticipantRow; speaking: boolean }) {
|
|||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
max={1}
|
max={2}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
value={volume}
|
value={volume}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -196,7 +196,12 @@ function Row({ row, speaking }: { row: ParticipantRow; speaking: boolean }) {
|
|||||||
aria-label={'Lautstärke ' + row.displayName}
|
aria-label={'Lautstärke ' + row.displayName}
|
||||||
className="flex-1 accent-accent"
|
className="flex-1 accent-accent"
|
||||||
/>
|
/>
|
||||||
<span className="w-8 text-right tabular-nums">
|
<span
|
||||||
|
className={
|
||||||
|
'w-10 text-right tabular-nums ' +
|
||||||
|
(volume > 1 ? 'text-amber-500' : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
{Math.round(volume * 100)}%
|
{Math.round(volume * 100)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -104,14 +104,18 @@ export function ScreenShareContextMenu({
|
|||||||
<span className="font-medium text-fg">
|
<span className="font-medium text-fg">
|
||||||
{t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
|
{t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
|
||||||
</span>
|
</span>
|
||||||
<span className="tabular-nums text-fg-muted">
|
<span
|
||||||
|
className={
|
||||||
|
'tabular-nums ' + (volume > 1 ? 'text-amber-500' : 'text-fg-muted')
|
||||||
|
}
|
||||||
|
>
|
||||||
{Math.round(volume * 100)}%
|
{Math.round(volume * 100)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
max={1}
|
max={2}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
value={volume}
|
value={volume}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -122,6 +126,11 @@ export function ScreenShareContextMenu({
|
|||||||
aria-label={t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
|
aria-label={t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
|
||||||
className="w-full accent-accent"
|
className="w-full accent-accent"
|
||||||
/>
|
/>
|
||||||
|
<div className="flex justify-between text-[10px] text-fg-muted">
|
||||||
|
<span>0%</span>
|
||||||
|
<span>100%</span>
|
||||||
|
<span>200%</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -63,10 +63,22 @@ import {
|
|||||||
isE2EESupported,
|
isE2EESupported,
|
||||||
} from '../lib/callE2EE';
|
} from '../lib/callE2EE';
|
||||||
import { createMicPipeline, type MicPipeline } from '../lib/micPipeline';
|
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 {
|
import {
|
||||||
clearScreenShareVolumes,
|
clearScreenShareVolumes,
|
||||||
getScreenShareVolume,
|
getScreenShareVolume,
|
||||||
|
subscribeScreenShareVolumes,
|
||||||
} from '../lib/screenShareVolumes';
|
} from '../lib/screenShareVolumes';
|
||||||
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
|
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
|
||||||
import { playEntry } from '../lib/soundboardPlayback';
|
import { playEntry } from '../lib/soundboardPlayback';
|
||||||
@@ -1315,27 +1327,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsDeafened((prev) => {
|
setIsDeafened((prev) => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
deafenedActive = next;
|
deafenedActive = next;
|
||||||
// Apply to every currently-attached remote-audio element. Fresh tracks
|
// Remote-audio gain is recomputed by the effective-gain useEffect
|
||||||
// that attach during a deafened session are muted in attachTrack above.
|
// as soon as React picks up the new `isDeafened` state — no DOM
|
||||||
// When un-deafening, screen-share-audio elements should fall back to
|
// iteration needed here.
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Discord-parity: deafen implies mute. Remember the pre-deafen mute
|
// Discord-parity: deafen implies mute. Remember the pre-deafen mute
|
||||||
// state so un-deafening restores whatever the user had before.
|
// state so un-deafening restores whatever the user had before.
|
||||||
@@ -1900,37 +1894,63 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [state.kind]);
|
}, [state.kind]);
|
||||||
|
|
||||||
// Keep the module-level mirrors in sync so attachTrack (which is defined
|
// Mirror the watching / manual-mute sets into module-level variables so
|
||||||
// outside the React component and runs from LiveKit event callbacks) can
|
// attachTrack (which runs outside the React render cycle) can read them
|
||||||
// decide the initial muted-state for ScreenShareAudio elements. Also
|
// when a ScreenShareAudio track lands. The runtime recompute for already-
|
||||||
// re-applies the muted state to already-attached elements on every flip
|
// attached tracks happens in the effective-gain useEffect below.
|
||||||
// — covers both watching changes and manual mute toggles from the
|
|
||||||
// context menu.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
watchingShareUserIdsMirror = watchingShareUserIds;
|
watchingShareUserIdsMirror = watchingShareUserIds;
|
||||||
screenShareAudioMutedIdsMirror = screenShareAudioMutedIds;
|
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]);
|
}, [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) => {
|
const setAudioOutputDevice = useCallback(async (deviceId: string | null) => {
|
||||||
updateAudioSettings({ outputDeviceId: deviceId });
|
updateAudioSettings({ outputDeviceId: deviceId });
|
||||||
const sinkId = deviceId ?? '';
|
const sinkId = deviceId ?? '';
|
||||||
// Apply to every <audio> element we've attached to the body. LiveKit's
|
// WebAudio path: route every pipeline's AudioContext at the chosen
|
||||||
// switchActiveDevice only tracks elements it attached itself; our custom
|
// sink. Feature-detects AudioContext.setSinkId (Chrome 115+) — older
|
||||||
// appendChild path bypasses that, so we iterate and setSinkId manually.
|
// 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>(
|
const els = document.querySelectorAll<HTMLAudioElement>(
|
||||||
'audio[data-livekit-track]',
|
'audio[data-livekit-track]',
|
||||||
);
|
);
|
||||||
@@ -2148,30 +2168,45 @@ function attachTrack(
|
|||||||
}
|
}
|
||||||
if (participant.identity) {
|
if (participant.identity) {
|
||||||
audio.setAttribute('data-participant', 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);
|
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`
|
// Build the WebAudio pipeline so we have a single GainNode we can
|
||||||
// doesn't track custom-appended elements).
|
// 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;
|
const sinkId = getAudioSettings().outputDeviceId;
|
||||||
if (sinkId && typeof audio.setSinkId === 'function') {
|
if (sinkId && typeof audio.setSinkId === 'function') {
|
||||||
void audio.setSinkId(sinkId).catch((err: unknown) => {
|
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.
|
// 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(
|
function detachTrack(
|
||||||
track: RemoteTrack,
|
track: RemoteTrack,
|
||||||
_publication: RemoteTrackPublication,
|
_publication: RemoteTrackPublication,
|
||||||
_participant: RemoteParticipant,
|
_participant: RemoteParticipant,
|
||||||
): void {
|
): void {
|
||||||
if (track.kind === Track.Kind.Audio) {
|
if (track.kind === Track.Kind.Audio) {
|
||||||
|
if (track.sid) destroyPipeline(track.sid);
|
||||||
const els = track.detach();
|
const els = track.detach();
|
||||||
for (const el of els) {
|
for (const el of els) {
|
||||||
el.remove();
|
el.remove();
|
||||||
|
|||||||
@@ -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 {
|
function clamp(v: number): number {
|
||||||
|
if (!Number.isFinite(v)) return 0;
|
||||||
if (v < 0) return 0;
|
if (v < 0) return 0;
|
||||||
if (v > 1) return 1;
|
if (v > MAX_VOLUME) return MAX_VOLUME;
|
||||||
return v;
|
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").
|
// with how the context menu surfaces the control ("Dennis's share").
|
||||||
|
|
||||||
const DEFAULT_VOLUME = 1;
|
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 VolumeMap = Record<string, number>;
|
||||||
type Listener = (map: VolumeMap) => void;
|
type Listener = (map: VolumeMap) => void;
|
||||||
@@ -18,7 +22,7 @@ const listeners = new Set<Listener>();
|
|||||||
function clamp(v: number): number {
|
function clamp(v: number): number {
|
||||||
if (!Number.isFinite(v)) return DEFAULT_VOLUME;
|
if (!Number.isFinite(v)) return DEFAULT_VOLUME;
|
||||||
if (v < 0) return 0;
|
if (v < 0) return 0;
|
||||||
if (v > 1) return 1;
|
if (v > MAX_VOLUME) return MAX_VOLUME;
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user