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