fix(voice): keep the playback boost while the window is hidden (#3444)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-13 04:36:23 +02:00
1 parent aa0ebdb97e
commit 72e14c17ba
2 files changed
+10 -56

No files matched your search

@@ -38,12 +38,10 @@ interface RemoteSpeakingAnalyserState {
export type VoiceRemoteSpeakingCommand =
| {type: 'setAudioLevelSpeaking'; identity: string; speaking: boolean}
| {type: 'setPlaybackBoost'; identity: string; boost: number}
| {type: 'clearPlaybackBoost'; identity: string}
| {type: 'rehydrateRemoteAnalysers'};
| {type: 'clearPlaybackBoost'; identity: string};
interface VoiceRemoteSpeakingMachineContext {
analysers: ReadonlyMap<string, RemoteSpeakingAnalyserState>;
analyserSuspendedByVisibility: boolean;
commands: ReadonlyArray<VoiceRemoteSpeakingCommand>;
}
@@ -51,8 +49,6 @@ export type VoiceRemoteSpeakingEvent =
| {type: 'remote.attach'; identity: string; track: unknown}
| {type: 'remote.detach'; identity: string}
| {type: 'remote.tick'; identity: string; rms: number; threshold: number; nowMs: number; trackEnded?: boolean}
| {type: 'remote.visibilityHidden'}
| {type: 'remote.visibilityVisible'}
| {type: 'remote.clear'}
| {type: 'remote.clearCommands'};
const EMPTY_REMOTE_COMMANDS: ReadonlyArray<VoiceRemoteSpeakingCommand> = [];
@@ -68,10 +64,9 @@ const REMOTE_PLAYBACK_UNITY_EPSILON = 0.01;
function remoteContext(
analysers: ReadonlyMap<string, RemoteSpeakingAnalyserState> = new Map(),
analyserSuspendedByVisibility = false,
commands: ReadonlyArray<VoiceRemoteSpeakingCommand> = EMPTY_REMOTE_COMMANDS,
): VoiceRemoteSpeakingMachineContext {
return {analysers, analyserSuspendedByVisibility, commands};
return {analysers, commands};
}
function appendRemoteCommands(
@@ -210,26 +205,14 @@ function tickRemoteAnalyser(
return appendRemoteCommands({...context, analysers}, commands);
}
function detachAllRemoteAnalysers(
context: VoiceRemoteSpeakingMachineContext,
analyserSuspendedByVisibility: boolean,
): VoiceRemoteSpeakingMachineContext {
if (context.analysers.size === 0) {
return context.analyserSuspendedByVisibility === analyserSuspendedByVisibility
? context
: {...context, analyserSuspendedByVisibility};
}
function detachAllRemoteAnalysers(context: VoiceRemoteSpeakingMachineContext): VoiceRemoteSpeakingMachineContext {
if (context.analysers.size === 0) return context;
const commands: Array<VoiceRemoteSpeakingCommand> = [];
for (const identity of context.analysers.keys()) {
commands.push({type: 'setAudioLevelSpeaking', identity, speaking: false});
commands.push({type: 'clearPlaybackBoost', identity});
}
return appendRemoteCommands(remoteContext(new Map(), analyserSuspendedByVisibility, context.commands), commands);
}
function showRemoteAnalysers(context: VoiceRemoteSpeakingMachineContext): VoiceRemoteSpeakingMachineContext {
if (!context.analyserSuspendedByVisibility) return context;
return appendRemoteCommands({...context, analyserSuspendedByVisibility: false}, [{type: 'rehydrateRemoteAnalysers'}]);
return appendRemoteCommands(remoteContext(new Map(), context.commands), commands);
}
const voiceRemoteSpeakingStateMachine = setup({
@@ -245,9 +228,7 @@ const voiceRemoteSpeakingStateMachine = setup({
event.type === 'remote.detach' ? detachRemoteAnalyser(context, event.identity) : context,
),
tick: assign(({context, event}) => (event.type === 'remote.tick' ? tickRemoteAnalyser(context, event) : context)),
hide: assign(({context}) => detachAllRemoteAnalysers(context, true)),
show: assign(({context}) => showRemoteAnalysers(context)),
clear: assign(({context}) => detachAllRemoteAnalysers(context, false)),
clear: assign(({context}) => detachAllRemoteAnalysers(context)),
clearCommands: assign(({context}) => ({...context, commands: EMPTY_REMOTE_COMMANDS})),
},
}).createMachine({
@@ -257,8 +238,6 @@ const voiceRemoteSpeakingStateMachine = setup({
'remote.attach': {actions: 'attach'},
'remote.detach': {actions: 'detach'},
'remote.tick': {actions: 'tick'},
'remote.visibilityHidden': {actions: 'hide'},
'remote.visibilityVisible': {actions: 'show'},
'remote.clear': {actions: 'clear'},
'remote.clearCommands': {actions: 'clearCommands'},
},
@@ -64,8 +64,7 @@ class VoiceEngineV2AppRemoteSpeakingAdapter {
private audioContext: AudioContext | null = null;
private audioContextIsShared = false;
private analysers = new Map<string, AnalyserHandle>();
private installedVisibilityListener = false;
private roomForRehydrate: Room | null = null;
private hydratedRoom: Room | null = null;
private machineSnapshot: VoiceRemoteSpeakingSnapshot = createVoiceRemoteSpeakingSnapshot();
private autoScheduleTimerId: number | null = null;
private readonly autoSchedule: boolean;
@@ -96,9 +95,6 @@ class VoiceEngineV2AppRemoteSpeakingAdapter {
clearRemoteVoicePlaybackBoost(command.identity);
this.reapplyParticipantVolume(command.identity);
break;
case 'rehydrateRemoteAnalysers':
if (this.roomForRehydrate) this.hydrateFromRoom(this.roomForRehydrate);
break;
}
}
}
@@ -112,22 +108,6 @@ class VoiceEngineV2AppRemoteSpeakingAdapter {
this.stopAutoSchedule();
}
private ensureVisibilityListener(): void {
if (this.installedVisibilityListener) return;
if (typeof document === 'undefined') return;
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (this.machineSnapshot.context.analyserSuspendedByVisibility) return;
this.detachAllAdapterAnalysers();
this.applyCommands(this.transition({type: 'remote.visibilityHidden'}));
} else {
if (!this.machineSnapshot.context.analyserSuspendedByVisibility) return;
this.applyCommands(this.transition({type: 'remote.visibilityVisible'}));
}
});
this.installedVisibilityListener = true;
}
private getAudioContext(): AudioContext | null {
if (typeof window === 'undefined') return null;
if (this.audioContext && this.audioContext.state !== 'closed') {
@@ -166,8 +146,6 @@ class VoiceEngineV2AppRemoteSpeakingAdapter {
this.analysers.size <= REMOTE_SPEAKING_ANALYSER_HANDLES_CAP,
'remote speaking analyser registry exceeded cap',
);
this.ensureVisibilityListener();
if (this.machineSnapshot.context.analyserSuspendedByVisibility) return;
if (participant.isLocal) return;
if (publication.source !== VoiceTrackSource.Microphone) return;
if (track.kind !== VoiceTrackKind.Audio) return;
@@ -249,12 +227,12 @@ class VoiceEngineV2AppRemoteSpeakingAdapter {
);
this.detachAllAdapterAnalysers();
this.applyCommands(this.transition({type: 'remote.clear'}));
this.roomForRehydrate = null;
this.hydratedRoom = null;
assert.equal(this.analysers.size, 0, 'clear post-condition: no analysers');
}
private reapplyParticipantVolume(identity: string): void {
const room = getRoomFromMediaEngine() ?? this.roomForRehydrate;
const room = getRoomFromMediaEngine() ?? this.hydratedRoom;
const participant = room?.remoteParticipants.get(identity);
if (!participant) return;
try {
@@ -267,9 +245,7 @@ class VoiceEngineV2AppRemoteSpeakingAdapter {
hydrateFromRoom(room: Room): void {
assertObjectLike<Room>(room, 'hydrateFromRoom.room');
assert.ok(room.remoteParticipants instanceof Map, 'hydrateFromRoom.room.remoteParticipants must be a Map');
this.ensureVisibilityListener();
this.roomForRehydrate = room;
if (this.machineSnapshot.context.analyserSuspendedByVisibility) return;
this.hydratedRoom = room;
room.remoteParticipants.forEach((participant) => {
participant.audioTrackPublications.forEach((publication) => {
const remotePub = publication as RemoteTrackPublication;
@@ -287,7 +263,6 @@ class VoiceEngineV2AppRemoteSpeakingAdapter {
this.analysers.size <= REMOTE_SPEAKING_ANALYSER_HANDLES_CAP,
'tickHost pre-condition: analyser cap respected',
);
if (this.machineSnapshot.context.analyserSuspendedByVisibility) return;
const sizeBefore = this.analysers.size;
const expired: Array<AnalyserHandle> = [];
for (const handle of this.analysers.values()) {