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
@@ -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({
>
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
<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)}%
</span>
</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
type="range"
min={0}
max={1}
max={2}
step={0.01}
value={volume}
onChange={(e) => {
@@ -81,6 +87,11 @@ export function ParticipantVolumeMenu({
aria-label={'Lautstärke ' + displayName}
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>,
document.body,
);
@@ -185,7 +185,7 @@ function Row({ row, speaking }: { row: ParticipantRow; speaking: boolean }) {
<input
type="range"
min={0}
max={1}
max={2}
step={0.01}
value={volume}
onChange={(e) => {
@@ -196,7 +196,12 @@ function Row({ row, speaking }: { row: ParticipantRow; speaking: boolean }) {
aria-label={'Lautstärke ' + row.displayName}
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)}%
</span>
</div>
@@ -104,14 +104,18 @@ export function ScreenShareContextMenu({
<span className="font-medium text-fg">
{t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
</span>
<span className="tabular-nums text-fg-muted">
<span
className={
'tabular-nums ' + (volume > 1 ? 'text-amber-500' : 'text-fg-muted')
}
>
{Math.round(volume * 100)}%
</span>
</div>
<input
type="range"
min={0}
max={1}
max={2}
step={0.01}
value={volume}
onChange={(e) => {
@@ -122,6 +126,11 @@ export function ScreenShareContextMenu({
aria-label={t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
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>
<button
type="button"