From d5daf61dbd37be0ec764f722b4e352ec402c6b00 Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 20 Aug 2026 17:45:17 +0200 Subject: [PATCH] fix(voice): recalibrate stored participant and stream volumes too (#1790) --- .../features/voice/state/ParticipantVolume.ts | 14 ++++++++- .../features/voice/state/StreamAudioPrefs.ts | 19 ++++++++++-- .../voice/utils/VoiceVolumeUtils.test.ts | 29 +++++++++++++++++++ .../features/voice/utils/VoiceVolumeUtils.ts | 15 ++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/fluxer_app/src/features/voice/state/ParticipantVolume.ts b/fluxer_app/src/features/voice/state/ParticipantVolume.ts index bc4092d7e..44a3222cb 100644 --- a/fluxer_app/src/features/voice/state/ParticipantVolume.ts +++ b/fluxer_app/src/features/voice/state/ParticipantVolume.ts @@ -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 = {}; localMutes: Record = {}; + outputVolumeRecalibratedV1 = false; connectionVolumesByLocalConnectionId: Record> = {}; private listeners = new Set<() => void>(); @@ -81,7 +83,17 @@ class ParticipantVolume { } private async initPersistence(): Promise { - 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 { diff --git a/fluxer_app/src/features/voice/state/StreamAudioPrefs.ts b/fluxer_app/src/features/voice/state/StreamAudioPrefs.ts index 83037577d..f46d507ea 100644 --- a/fluxer_app/src/features/voice/state/StreamAudioPrefs.ts +++ b/fluxer_app/src/features/voice/state/StreamAudioPrefs.ts @@ -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 = {}; + outputVolumeRecalibratedV1 = false; audioPrefsRevision = 0; private pruneIntervalId: number | null = null; private listeners = new Set<() => void>(); @@ -42,12 +43,26 @@ class StreamAudioPrefs { } private async initPersistence(): Promise { - 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; } diff --git a/fluxer_app/src/features/voice/utils/VoiceVolumeUtils.test.ts b/fluxer_app/src/features/voice/utils/VoiceVolumeUtils.test.ts index 0c2205c11..9012233ba 100644 --- a/fluxer_app/src/features/voice/utils/VoiceVolumeUtils.test.ts +++ b/fluxer_app/src/features/voice/utils/VoiceVolumeUtils.test.ts @@ -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, + }); + }); +}); diff --git a/fluxer_app/src/features/voice/utils/VoiceVolumeUtils.ts b/fluxer_app/src/features/voice/utils/VoiceVolumeUtils.ts index ba84196d9..d05bf6efc 100644 --- a/fluxer_app/src/features/voice/utils/VoiceVolumeUtils.ts +++ b/fluxer_app/src/features/voice/utils/VoiceVolumeUtils.ts @@ -37,6 +37,21 @@ export function composeVoiceVolumeGain(...volumePercents: Array): 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>(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;