fix(app): keep hydrated state across gateway session resumes (#1862)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:33:27 +02:00
1 parent b7f75e25ad
commit 943b948de7
41 files changed
+81 -690

No files matched your search

@@ -1845,8 +1845,8 @@ class MediaEngineFacade extends Store {
);
}
handleConnectionOpen(guilds: Array<GuildReadyData>): void {
voiceEngineV2AppVoiceStateAdapter.handleConnectionOpen(guilds);
handleGatewayReady(guilds: Array<GuildReadyData>): void {
voiceEngineV2AppVoiceStateAdapter.handleGatewayReady(guilds);
}
handleGuildCreate(guild: GuildReadyData): void {
@@ -1,173 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Room} from 'livekit-client';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const updateSettings = vi.fn();
let outputDevices: Array<{deviceId: string}> = [];
vi.mock('@app/features/voice/state/VoiceSettings', () => ({
default: {
getOutputDeviceId: () => 'default',
updateSettings: (update: unknown) => updateSettings(update),
subscribe: () => () => undefined,
},
}));
vi.mock('@app/features/voice/utils/VoiceDeviceManager', () => ({
voiceDeviceManager: {
ensureDevices: async () => ({
inputDevices: [],
outputDevices,
videoDevices: [],
permissionStatus: 'granted',
}),
},
}));
const {applyOutputDeviceToRoom} = await import('./VoiceOutputDeviceSync');
class FakeMediaElement {
setSinkId = vi.fn(async () => undefined);
}
Object.defineProperty(FakeMediaElement.prototype, 'setSinkId', {
configurable: true,
writable: true,
value: async (): Promise<void> => undefined,
});
vi.stubGlobal('HTMLMediaElement', FakeMediaElement);
interface FakeRoomOptions {
webAudioMix?: boolean;
audioContextSupportsSetSinkId?: boolean;
switchActiveDevice?: () => Promise<boolean>;
}
function createRoom(options: FakeRoomOptions = {}) {
const {webAudioMix = true, audioContextSupportsSetSinkId = true} = options;
const switchActiveDevice = vi.fn(options.switchActiveDevice ?? (async () => true));
const mixerSetSinkId = vi.fn(async () => undefined);
const trackSetSinkId = vi.fn(async () => undefined);
const element = new FakeMediaElement();
const audioContext = audioContextSupportsSetSinkId ? {setSinkId: mixerSetSinkId} : {};
const room = {
options: {webAudioMix},
audioContext,
switchActiveDevice,
remoteParticipants: new Map([
[
'participant-with-track-sink',
{
audioTrackPublications: new Map([
['pub-track-sink', {kind: 'audio', track: {kind: 'audio', setSinkId: trackSetSinkId}}],
]),
},
],
[
'participant-with-elements',
{
audioTrackPublications: new Map([
['pub-elements', {kind: 'audio', track: {kind: 'audio', attachedElements: [element]}}],
]),
},
],
]),
};
return {room: room as unknown as Room, switchActiveDevice, mixerSetSinkId, trackSetSinkId, element};
}
describe('applyOutputDeviceToRoom', () => {
beforeEach(() => {
updateSettings.mockClear();
outputDevices = [{deviceId: 'speaker-1'}];
});
it('hands the Web Audio mixer room the literal default id so LiveKit resolves it to a real device', async () => {
const {room, switchActiveDevice} = createRoom();
await applyOutputDeviceToRoom(room, 'default');
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', 'default');
expect(switchActiveDevice).not.toHaveBeenCalledWith('audiooutput', '');
});
it('passes a concrete device id through verbatim', async () => {
const {room, switchActiveDevice} = createRoom();
await applyOutputDeviceToRoom(room, 'speaker-1');
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', 'speaker-1');
});
it('applies the normalized sink id to the Web Audio mixer and the attached elements', async () => {
const {room, mixerSetSinkId, trackSetSinkId, element} = createRoom();
await applyOutputDeviceToRoom(room, 'default');
expect(mixerSetSinkId).toHaveBeenCalledWith('');
expect(trackSetSinkId).toHaveBeenCalledWith('');
expect(element.setSinkId).toHaveBeenCalledWith('');
});
it('applies a concrete sink id to the Web Audio mixer and the attached elements', async () => {
const {room, mixerSetSinkId, trackSetSinkId, element} = createRoom();
await applyOutputDeviceToRoom(room, 'speaker-1');
expect(mixerSetSinkId).toHaveBeenCalledWith('speaker-1');
expect(trackSetSinkId).toHaveBeenCalledWith('speaker-1');
expect(element.setSinkId).toHaveBeenCalledWith('speaker-1');
});
it('still runs the mixer and element paths when LiveKit rejects with NotFoundError', async () => {
const notFound = new Error('Requested device not found');
notFound.name = 'NotFoundError';
const {room, switchActiveDevice, mixerSetSinkId, trackSetSinkId, element} = createRoom({
switchActiveDevice: async () => {
throw notFound;
},
});
await expect(applyOutputDeviceToRoom(room, 'default')).resolves.toBeUndefined();
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', 'default');
expect(mixerSetSinkId).toHaveBeenCalledWith('');
expect(trackSetSinkId).toHaveBeenCalledWith('');
expect(element.setSinkId).toHaveBeenCalledWith('');
});
it('skips the LiveKit switch when the Web Audio mixer context cannot switch sinks', async () => {
const {room, switchActiveDevice, trackSetSinkId, element} = createRoom({
audioContextSupportsSetSinkId: false,
});
await applyOutputDeviceToRoom(room, 'default');
expect(switchActiveDevice).not.toHaveBeenCalled();
expect(trackSetSinkId).toHaveBeenCalledWith('');
expect(element.setSinkId).toHaveBeenCalledWith('');
});
it('normalizes the sink id only when LiveKit forwards it straight to the browser', async () => {
const {room, switchActiveDevice} = createRoom({
webAudioMix: false,
audioContextSupportsSetSinkId: false,
});
await applyOutputDeviceToRoom(room, 'default');
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', '');
});
it('falls back to the default device when the stored output device disappeared', async () => {
outputDevices = [{deviceId: 'speaker-2'}];
const {room, switchActiveDevice} = createRoom();
await applyOutputDeviceToRoom(room, 'speaker-1');
expect(updateSettings).toHaveBeenCalledWith({outputDeviceId: 'default'});
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', 'default');
});
});
@@ -1,35 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it, vi} from 'vitest';
import {computeTimeDomainRms} from './VoiceEngineV2AppRemoteSpeakingAdapter';
vi.mock('@app/features/voice/state/ParticipantVolume', () => ({default: {}}));
vi.mock('@app/features/voice/state/VoiceSettings', () => ({default: {getVadThreshold: () => 50}}));
describe('computeTimeDomainRms', () => {
it('returns the amplitude of an alternating full-scale-relative signal', () => {
const samples = new Float32Array(512);
for (let i = 0; i < samples.length; i++) {
samples[i] = i % 2 === 0 ? 0.09 : -0.09;
}
expect(computeTimeDomainRms(samples)).toBe(Math.fround(0.09));
});
it('returns zero for digital silence', () => {
expect(computeTimeDomainRms(new Float32Array(512))).toBe(0);
});
it('returns zero for an empty buffer', () => {
expect(computeTimeDomainRms(new Float32Array(0))).toBe(0);
});
it('spreads a single full-scale sample across the window', () => {
const samples = new Float32Array(512);
samples[17] = 1;
expect(computeTimeDomainRms(samples)).toBeCloseTo(1 / Math.sqrt(512), 12);
});
it('resolves levels far below one 8-bit quantisation step', () => {
expect(computeTimeDomainRms(Float32Array.of(0.0005, -0.0005))).toBe(Math.fround(0.0005));
});
});
@@ -40,7 +40,7 @@ function guild(id: string, voiceStates: ReadonlyArray<VoiceState> = []): GuildRe
describe('VoiceEngineV2AppVoiceStateAdapter', () => {
it('keeps gateway voice-state indexes as immutable replacement projections', () => {
const adapter = new VoiceEngineV2AppVoiceStateAdapter();
adapter.handleConnectionOpen([
adapter.handleGatewayReady([
guild('guild-1', [voiceState({connection_id: 'connection-1', channel_id: 'channel-1'})]),
]);
const before = adapter.getConnectionVoiceStates();
@@ -95,7 +95,7 @@ export class VoiceEngineV2AppVoiceStateAdapter extends Store {
return connectionId != null && this.ignoredConnectionIds.has(connectionId);
}
handleConnectionOpen(guilds: Array<GuildReadyData>): void {
handleGatewayReady(guilds: Array<GuildReadyData>): void {
this.send({
type: 'connection.open',
guilds: guilds.map((guild) => filterIgnoredGuildVoiceStates(this.ignoredConnectionIds, guild)),
@@ -1,184 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getStreamKey} from '@app/features/voice/components/StreamKeys';
import {VoiceTrackKind, VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
import {
clearRemoteVoicePlaybackBoost,
setRemoteVoicePlaybackBoost,
} from '@app/features/voice/state/RemoteVoicePlaybackBoost';
import StreamAudioPrefs from '@app/features/voice/state/StreamAudioPrefs';
import type {RemoteParticipant} from 'livekit-client';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const outputVolumePercent = vi.fn<() => number>();
const effectiveDeaf = vi.fn<() => boolean>();
vi.mock('@app/features/platform/utils/MobXPersistence', () => ({
makePersistent: async () => {},
}));
vi.mock('@app/features/voice/state/VoiceSettings', () => ({
default: {getOutputVolume: () => outputVolumePercent()},
}));
vi.mock('@app/features/voice/engine/VoiceEffectiveAudioState', () => ({
getEffectiveAudioState: () => ({effectiveDeaf: effectiveDeaf()}),
}));
const GUILD_ID = 'guild-1';
const CHANNEL_ID = 'channel-1';
const LOCAL_CONNECTION_ID = 'local-connection';
const REMOTE_CONNECTION_ID = 'remote-connection';
const USER_ID = '42';
const IDENTITY = `user_${USER_ID}_${REMOTE_CONNECTION_ID}`;
const STREAM_KEY = getStreamKey(GUILD_ID, CHANNEL_ID, REMOTE_CONNECTION_ID);
interface FakePublication {
source: string;
trackName: string;
trackSid: string;
isDesired: boolean;
track: {kind: string; setVolume: (volume: number) => void};
setEnabled: (enabled: boolean) => void;
volumes: Array<number>;
enabled: Array<boolean>;
}
(
window as typeof window & {
_mediaEngineFacade?: {guildId: string; channelId: string; connectionId: string};
}
)._mediaEngineFacade = {guildId: GUILD_ID, channelId: CHANNEL_ID, connectionId: LOCAL_CONNECTION_ID};
const {default: ParticipantVolume} = await import('@app/features/voice/state/ParticipantVolume');
function createPublication(source: string, trackSid: string, throwOnSetVolume: boolean): FakePublication {
const volumes: Array<number> = [];
const enabled: Array<boolean> = [];
return {
source,
trackName: source,
trackSid,
isDesired: true,
track: {
kind: VoiceTrackKind.Audio,
setVolume: (volume: number) => {
volumes.push(volume);
if (throwOnSetVolume) {
throw new Error('setVolume failed');
}
},
},
setEnabled: (value: boolean) => {
enabled.push(value);
},
volumes,
enabled,
};
}
function applyToParticipant(options?: {throwOnMicrophoneVolume?: boolean}): {
microphone: FakePublication;
screenShareAudio: FakePublication;
} {
const microphone = createPublication(
VoiceTrackSource.Microphone,
'microphone-sid',
options?.throwOnMicrophoneVolume === true,
);
const screenShareAudio = createPublication(VoiceTrackSource.ScreenShareAudio, 'screen-share-audio-sid', false);
const audioTrackPublications = new Map<string, FakePublication>([
[microphone.trackSid, microphone],
[screenShareAudio.trackSid, screenShareAudio],
]);
ParticipantVolume.applySettingsToParticipant({
identity: IDENTITY,
audioTrackPublications,
} as unknown as RemoteParticipant);
return {microphone, screenShareAudio};
}
describe('ParticipantVolume.applySettingsToParticipant', () => {
beforeEach(() => {
outputVolumePercent.mockReturnValue(100);
effectiveDeaf.mockReturnValue(false);
clearRemoteVoicePlaybackBoost(IDENTITY);
ParticipantVolume.resetUserSettings(USER_ID);
StreamAudioPrefs.entries = {};
});
it('gives a saturated auto-leveller participant strictly more gain at output 200% than at 100%', () => {
setRemoteVoicePlaybackBoost(IDENTITY, 3);
const atUnity = applyToParticipant().microphone.volumes;
outputVolumePercent.mockReturnValue(200);
const atCeiling = applyToParticipant().microphone.volumes;
expect(atUnity).toEqual([3]);
expect(atCeiling).toEqual([12]);
expect(atCeiling[0]).toBeGreaterThan(atUnity[0]);
});
it('clamps the total, not each control', () => {
ParticipantVolume.setVolume(USER_ID, 200);
outputVolumePercent.mockReturnValue(200);
setRemoteVoicePlaybackBoost(IDENTITY, 3);
expect(applyToParticipant().microphone.volumes).toEqual([12]);
});
it('the per-user slider still moves with the leveller idle', () => {
ParticipantVolume.setVolume(USER_ID, 100);
expect(applyToParticipant().microphone.volumes).toEqual([1]);
ParticipantVolume.setVolume(USER_ID, 200);
expect(applyToParticipant().microphone.volumes).toEqual([4]);
});
it('screen-share audio composes stream and output volume', () => {
setRemoteVoicePlaybackBoost(IDENTITY, 3);
StreamAudioPrefs.setVolume(STREAM_KEY, 100);
expect(applyToParticipant().screenShareAudio.volumes).toEqual([1]);
StreamAudioPrefs.setVolume(STREAM_KEY, 200);
outputVolumePercent.mockReturnValue(200);
expect(applyToParticipant().screenShareAudio.volumes).toEqual([12]);
});
it('leaves the microphone gain untouched when the stream volume changes', () => {
StreamAudioPrefs.setVolume(STREAM_KEY, 50);
const atQuietStream = applyToParticipant().microphone.volumes;
StreamAudioPrefs.setVolume(STREAM_KEY, 200);
const atLoudStream = applyToParticipant().microphone.volumes;
expect(atQuietStream).toEqual([1]);
expect(atLoudStream).toEqual(atQuietStream);
});
it('leaves the screen share gain untouched when the per-user volume changes', () => {
StreamAudioPrefs.setVolume(STREAM_KEY, 100);
ParticipantVolume.setVolume(USER_ID, 50);
const atQuietUser = applyToParticipant().screenShareAudio.volumes;
ParticipantVolume.setVolume(USER_ID, 200);
const atLoudUser = applyToParticipant().screenShareAudio.volumes;
expect(atQuietUser).toEqual([1]);
expect(atLoudUser).toEqual(atQuietUser);
});
it('leaves the microphone publication enabled when the stream is muted', () => {
StreamAudioPrefs.setMuted(STREAM_KEY, true);
expect(applyToParticipant().microphone.enabled).toEqual([true]);
});
it('leaves screen share audio enabled when the participant is locally muted', () => {
ParticipantVolume.setLocalMute(USER_ID, true);
expect(applyToParticipant().screenShareAudio.enabled).toEqual([true]);
});
it('disables screen share audio while deafened', () => {
effectiveDeaf.mockReturnValue(true);
expect(applyToParticipant().screenShareAudio.enabled).toEqual([false]);
});
it('still applies the enabled state when a track volume write throws', () => {
ParticipantVolume.setLocalMute(USER_ID, true);
const {microphone} = applyToParticipant({throwOnMicrophoneVolume: true});
expect(microphone.volumes).toEqual([1]);
expect(microphone.enabled).toEqual([false]);
});
});
@@ -610,7 +610,7 @@ class VoiceSettings {
this.adaptiveScreenShareQualityPrefV2 = value;
}
handleConnectionOpen(user: UserPrivate): void {
handleGatewayReady(user: UserPrivate): void {
if (this.isUserPremium(user.premium_type)) {
return;
}
@@ -1,53 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {beforeEach, describe, expect, it, vi} from 'vitest';
const voiceBackgroundsAvailable = vi.fn<() => boolean>();
vi.mock('@app/features/voice/utils/VoiceBackgroundAvailability', () => ({
areVoiceBackgroundsAvailable: () => voiceBackgroundsAvailable(),
}));
vi.mock('@app/features/app/utils/LimitResolverAdapter', () => ({
LimitResolver: {resolve: () => 0},
}));
vi.mock('@app/features/app/utils/LimitUtils', () => ({
isLimitToggleEnabled: () => false,
}));
const {default: VoiceSettings, NONE_BACKGROUND_ID} = await import('./VoiceSettings');
const SAVED_BACKGROUND = {id: 'saved-background', createdAt: 1, mediaKind: 'static' as const};
describe('VoiceSettings background persistence when voice backgrounds are unavailable', () => {
beforeEach(() => {
voiceBackgroundsAvailable.mockReturnValue(true);
VoiceSettings.updateSettings({backgroundImages: [SAVED_BACKGROUND], backgroundImageId: SAVED_BACKGROUND.id});
voiceBackgroundsAvailable.mockReturnValue(false);
});
it('keeps the uploaded background list across a no-op revalidation', () => {
VoiceSettings.updateSettings({});
expect(VoiceSettings.backgroundImages).toEqual([SAVED_BACKGROUND]);
});
it('keeps the stored selection so it comes back once backgrounds are available again', () => {
VoiceSettings.updateSettings({});
expect(VoiceSettings.backgroundImageId).toBe(SAVED_BACKGROUND.id);
voiceBackgroundsAvailable.mockReturnValue(true);
expect(VoiceSettings.getBackgroundImageId()).toBe(SAVED_BACKGROUND.id);
});
it('never hands out an active background while the feature is unavailable', () => {
VoiceSettings.updateSettings({});
expect(VoiceSettings.getBackgroundImageId()).toBe(NONE_BACKGROUND_ID);
expect(VoiceSettings.getBackgroundImages()).toEqual([SAVED_BACKGROUND]);
});
it('still drops a selection that no longer matches a stored background', () => {
VoiceSettings.updateSettings({backgroundImages: []});
expect(VoiceSettings.backgroundImageId).toBe(NONE_BACKGROUND_ID);
});
});
@@ -1,76 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {beforeAll, describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/voice/utils/VoiceBackgroundAvailability', () => ({
areVoiceBackgroundsAvailable: () => true,
}));
vi.mock('@app/features/app/utils/LimitResolverAdapter', () => ({
LimitResolver: {resolve: () => 0},
}));
vi.mock('@app/features/app/utils/LimitUtils', () => ({
isLimitToggleEnabled: () => false,
}));
const STORAGE_KEY = 'VoiceSettings';
async function loadVoiceSettingsWith(stored: Record<string, unknown> | null) {
vi.resetModules();
const {default: AppStorage} = await import('@app/features/platform/state/PersistentStorage');
AppStorage.removeItem(STORAGE_KEY);
if (stored) {
AppStorage.setItem(STORAGE_KEY, JSON.stringify({...stored, __mps__: {version: 1}}));
}
const {default: VoiceSettings} = await import('./VoiceSettings');
const {awaitHydration} = await import('@app/features/platform/utils/MobXPersistence');
await awaitHydration(STORAGE_KEY);
const readStored = (): Record<string, unknown> => {
const raw = AppStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
};
return {VoiceSettings, readStored};
}
describe('VoiceSettings output volume recalibration migration', () => {
beforeAll(async () => {
await import('./VoiceSettings');
});
it('resets a boosted output volume once and records that it ran', async () => {
const {VoiceSettings, readStored} = await loadVoiceSettingsWith({outputVolume: 200});
expect(VoiceSettings.outputVolume).toBe(100);
expect(VoiceSettings.outputVolumeRecalibratedV1).toBe(true);
expect(readStored().outputVolumeRecalibratedV1).toBe(true);
});
it('never touches an output volume the user set after the migration ran', async () => {
const {VoiceSettings} = await loadVoiceSettingsWith({
outputVolume: 200,
outputVolumeRecalibratedV1: true,
});
expect(VoiceSettings.outputVolume).toBe(200);
expect(VoiceSettings.outputVolumeRecalibratedV1).toBe(true);
});
it('leaves an output volume at or below unity alone', async () => {
const {VoiceSettings, readStored} = await loadVoiceSettingsWith({outputVolume: 60});
expect(VoiceSettings.outputVolume).toBe(60);
expect(readStored().outputVolumeRecalibratedV1).toBe(true);
});
it('marks an empty stored record as recalibrated', async () => {
const {VoiceSettings, readStored} = await loadVoiceSettingsWith({});
expect(VoiceSettings.outputVolume).toBe(100);
expect(VoiceSettings.outputVolumeRecalibratedV1).toBe(true);
expect(readStored().outputVolumeRecalibratedV1).toBe(true);
});
it('marks a fresh install as recalibrated so a later launch cannot reset a volume set today', async () => {
const {VoiceSettings} = await loadVoiceSettingsWith(null);
expect(VoiceSettings.outputVolume).toBe(100);
expect(VoiceSettings.outputVolumeRecalibratedV1).toBe(true);
});
});