diff --git a/apps/desktop/src/components/ParticipantVolumeMenu.tsx b/apps/desktop/src/components/ParticipantVolumeMenu.tsx
index 108b0a2..252a926 100644
--- a/apps/desktop/src/components/ParticipantVolumeMenu.tsx
+++ b/apps/desktop/src/components/ParticipantVolumeMenu.tsx
@@ -16,7 +16,7 @@ interface Props {
}
const MENU_W = 240;
-const MENU_H = 84;
+const MENU_H = 96;
export function ParticipantVolumeMenu({
userId,
@@ -63,14 +63,20 @@ export function ParticipantVolumeMenu({
>
{displayName}
-
+ 1 ? 'text-amber-500' : 'text-fg-muted')
+ }
+ >
{Math.round(volume * 100)}%
+ {/* 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. */}
{
@@ -81,6 +87,11 @@ export function ParticipantVolumeMenu({
aria-label={'Lautstärke ' + displayName}
className="w-full accent-accent"
/>
+
+ 0%
+ 100%
+ 200%
+
,
document.body,
);
diff --git a/apps/desktop/src/components/ParticipantsPopover.tsx b/apps/desktop/src/components/ParticipantsPopover.tsx
index bf39f1f..e5acb6e 100644
--- a/apps/desktop/src/components/ParticipantsPopover.tsx
+++ b/apps/desktop/src/components/ParticipantsPopover.tsx
@@ -185,7 +185,7 @@ function Row({ row, speaking }: { row: ParticipantRow; speaking: boolean }) {
{
@@ -196,7 +196,12 @@ function Row({ row, speaking }: { row: ParticipantRow; speaking: boolean }) {
aria-label={'Lautstärke ' + row.displayName}
className="flex-1 accent-accent"
/>
-
+ 1 ? 'text-amber-500' : '')
+ }
+ >
{Math.round(volume * 100)}%
diff --git a/apps/desktop/src/components/ScreenShareContextMenu.tsx b/apps/desktop/src/components/ScreenShareContextMenu.tsx
index 9550d0e..5c4fc76 100644
--- a/apps/desktop/src/components/ScreenShareContextMenu.tsx
+++ b/apps/desktop/src/components/ScreenShareContextMenu.tsx
@@ -104,14 +104,18 @@ export function ScreenShareContextMenu({
{t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
-
+ 1 ? 'text-amber-500' : 'text-fg-muted')
+ }
+ >
{Math.round(volume * 100)}%
{
@@ -122,6 +126,11 @@ export function ScreenShareContextMenu({
aria-label={t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
className="w-full accent-accent"
/>
+
+ 0%
+ 100%
+ 200%
+
{
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(
- '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(
- '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 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(
'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 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();
diff --git a/apps/desktop/src/lib/participantVolumes.ts b/apps/desktop/src/lib/participantVolumes.ts
index a4f20a7..2007378 100644
--- a/apps/desktop/src/lib/participantVolumes.ts
+++ b/apps/desktop/src/lib/participantVolumes.ts
@@ -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;
}
diff --git a/apps/desktop/src/lib/remoteAudioPipelines.ts b/apps/desktop/src/lib/remoteAudioPipelines.ts
new file mode 100644
index 0000000..890e172
--- /dev/null
+++ b/apps/desktop/src/lib/remoteAudioPipelines.ts
@@ -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();
+
+// 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 {
+ 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 {
+ const maybeId = deviceId.length === 0 ? 'default' : deviceId;
+ for (const p of pipelines.values()) {
+ const ctxAny = p.ctx as unknown as {
+ setSinkId?: (id: string) => Promise;
+ };
+ if (typeof ctxAny.setSinkId !== 'function') continue;
+ try {
+ await ctxAny.setSinkId(maybeId);
+ } catch (err: unknown) {
+ console.warn('AudioContext.setSinkId failed', { trackSid: p.trackSid, err });
+ }
+ }
+}
diff --git a/apps/desktop/src/lib/screenShareVolumes.ts b/apps/desktop/src/lib/screenShareVolumes.ts
index c8fda26..00d0c55 100644
--- a/apps/desktop/src/lib/screenShareVolumes.ts
+++ b/apps/desktop/src/lib/screenShareVolumes.ts
@@ -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;
type Listener = (map: VolumeMap) => void;
@@ -18,7 +22,7 @@ const listeners = new Set();
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;
}