fix(voice): recalibrate stored participant and stream volumes too (#1790)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-20 17:45:17 +02:00
1 parent 06c42ce02f
commit d5daf61dbd
4 files changed
+74 -3

No files matched your search

@@ -12,6 +12,7 @@ import {
clampVoiceTrackTotalGain,
clampVoiceVolumePercent,
composeVoiceVolumeGain,
recalibrateStoredVoiceVolumes,
} from '@app/features/voice/utils/VoiceVolumeUtils';
import type {RemoteAudioTrack, RemoteParticipant, Room} from 'livekit-client';
import {makeAutoObservable} from 'mobx';
@@ -62,6 +63,7 @@ function getVoiceConnectionContext(): VoiceConnectionContextAccess {
class ParticipantVolume {
volumes: Record<string, number> = {};
localMutes: Record<string, boolean> = {};
outputVolumeRecalibratedV1 = false;
connectionVolumesByLocalConnectionId: Record<string, Record<string, number>> = {};
private listeners = new Set<() => void>();
@@ -81,7 +83,17 @@ class ParticipantVolume {
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'ParticipantVolume', ['volumes', 'localMutes']);
await makePersistent(this, 'ParticipantVolume', ['volumes', 'localMutes', 'outputVolumeRecalibratedV1']);
this.applyOutputVolumeRecalibration();
}
private applyOutputVolumeRecalibration(): void {
if (this.outputVolumeRecalibratedV1) {
return;
}
this.volumes = recalibrateStoredVoiceVolumes(this.volumes);
this.outputVolumeRecalibratedV1 = true;
this.notifyListeners();
}
setVolume(userId: string, volume: number): void {
@@ -2,7 +2,7 @@
import {Logger} from '@app/features/platform/utils/AppLogger';
import {makePersistent} from '@app/features/platform/utils/MobXPersistence';
import {clampVoiceVolumePercent} from '@app/features/voice/utils/VoiceVolumeUtils';
import {clampVoiceVolumePercent, recalibrateStoredVoiceVolumePercent} from '@app/features/voice/utils/VoiceVolumeUtils';
import {
STREAM_AUDIO_PREFS_PRUNE_INTERVAL_MS,
STREAM_AUDIO_PREFS_TOUCH_INTERVAL_MS,
@@ -20,6 +20,7 @@ interface StreamAudioPrefsEntry {
class StreamAudioPrefs {
entries: Record<string, StreamAudioPrefsEntry> = {};
outputVolumeRecalibratedV1 = false;
audioPrefsRevision = 0;
private pruneIntervalId: number | null = null;
private listeners = new Set<() => void>();
@@ -42,12 +43,26 @@ class StreamAudioPrefs {
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'StreamAudioPrefs', ['entries']);
await makePersistent(this, 'StreamAudioPrefs', ['entries', 'outputVolumeRecalibratedV1']);
this.applyOutputVolumeRecalibration();
if (this.hasAnyEntries()) {
this.startPruneInterval();
}
}
private applyOutputVolumeRecalibration(): void {
if (this.outputVolumeRecalibratedV1) {
return;
}
this.entries = Object.fromEntries(
Object.entries(this.entries).map(([streamKey, entry]) => [
streamKey,
{...entry, volume: recalibrateStoredVoiceVolumePercent(entry.volume)},
]),
);
this.outputVolumeRecalibratedV1 = true;
}
hasEntry(streamKey: string): boolean {
return streamKey in this.entries;
}
@@ -4,6 +4,8 @@ import {
boostedVoiceVolumePercentToTrackVolume,
clampVoiceTrackTotalGain,
composeVoiceVolumeGain,
recalibrateStoredVoiceVolumePercent,
recalibrateStoredVoiceVolumes,
VOICE_TRACK_MAX_TOTAL_GAIN,
VOICE_VOLUME_MAX_GAIN,
VOICE_VOLUME_MAX_PERCENT,
@@ -70,3 +72,30 @@ describe('clampVoiceTrackTotalGain', () => {
expect(clampVoiceTrackTotalGain(-1)).toBe(0);
});
});
describe('recalibrateStoredVoiceVolumePercent', () => {
it('pulls every boosted stored value back to unity', () => {
expect(recalibrateStoredVoiceVolumePercent(200)).toBe(100);
expect(recalibrateStoredVoiceVolumePercent(150)).toBe(100);
expect(recalibrateStoredVoiceVolumePercent(101)).toBe(100);
});
it('leaves unity and quieter values untouched', () => {
expect(recalibrateStoredVoiceVolumePercent(100)).toBe(100);
expect(recalibrateStoredVoiceVolumePercent(40)).toBe(40);
expect(recalibrateStoredVoiceVolumePercent(0)).toBe(0);
});
it('keeps the same object when nothing needs recalibrating', () => {
const volumes = {alice: 100, bob: 40};
expect(recalibrateStoredVoiceVolumes(volumes)).toBe(volumes);
});
it('caps only the boosted entries in a stored map', () => {
expect(recalibrateStoredVoiceVolumes({alice: 200, bob: 60, carol: 150})).toEqual({
alice: 100,
bob: 60,
carol: 100,
});
});
});
@@ -37,6 +37,21 @@ export function composeVoiceVolumeGain(...volumePercents: Array<number>): number
return volumePercents.reduce((gain, percent) => gain * boostedVoiceVolumePercentToTrackVolume(percent), 1);
}
export function recalibrateStoredVoiceVolumePercent(value: number): number {
if (!Number.isFinite(value) || value <= UNITY_GAIN_PERCENT) {
return value;
}
return UNITY_GAIN_PERCENT;
}
export function recalibrateStoredVoiceVolumes<T extends Record<string, number>>(volumes: T): T {
const entries = Object.entries(volumes);
if (!entries.some(([, value]) => recalibrateStoredVoiceVolumePercent(value) !== value)) {
return volumes;
}
return Object.fromEntries(entries.map(([key, value]) => [key, recalibrateStoredVoiceVolumePercent(value)])) as T;
}
export function clampVoiceTrackTotalGain(gain: number): number {
if (!Number.isFinite(gain)) {
return 1;