From e94f587535e573d9f0e8ce642ba4611ae1a5a01f Mon Sep 17 00:00:00 2001 From: Hampus Date: Wed, 2 Sep 2026 17:34:36 +0200 Subject: [PATCH] feat(voice): sequence join chimes ahead of entrance sounds (#2391) --- .../voice/engine/VoiceRoomEventBinder.ts | 15 +- .../voice/engine/VoiceSelfJoinChime.test.ts | 232 ++++++++++++++++++ .../voice/engine/VoiceSelfJoinChime.ts | 195 ++++++++++++++- .../voice/events/EntranceSoundPlay.test.ts | 76 ++++++ .../voice/events/EntranceSoundPlay.ts | 16 +- .../features/voice/events/VoiceStateUpdate.ts | 29 ++- 6 files changed, 541 insertions(+), 22 deletions(-) create mode 100644 fluxer_app/src/features/voice/engine/VoiceSelfJoinChime.test.ts create mode 100644 fluxer_app/src/features/voice/events/EntranceSoundPlay.test.ts diff --git a/fluxer_app/src/features/voice/engine/VoiceRoomEventBinder.ts b/fluxer_app/src/features/voice/engine/VoiceRoomEventBinder.ts index 53f74ae13..be13d6eee 100644 --- a/fluxer_app/src/features/voice/engine/VoiceRoomEventBinder.ts +++ b/fluxer_app/src/features/voice/engine/VoiceRoomEventBinder.ts @@ -10,7 +10,7 @@ import ScreenSharePublicationMigration from '@app/features/voice/engine/ScreenSh import {getEffectiveAudioState} from '@app/features/voice/engine/VoiceEffectiveAudioState'; import {noteLocalVoiceActivity} from '@app/features/voice/engine/VoiceIdleActivityBridge'; import {voiceMediaGraphStore} from '@app/features/voice/engine/VoiceMediaGraphStore'; -import {playSelfJoinChimeOnce} from '@app/features/voice/engine/VoiceSelfJoinChime'; +import {playSelfJoinChimeOnce, startVoiceJoinChimeSequence} from '@app/features/voice/engine/VoiceSelfJoinChime'; import { cancelDeferredStopWatchingStreamKey, deferStopWatchingStreamKey, @@ -325,9 +325,15 @@ export function bindRoomEvents( dependencies.permissions.applyDeafen(room, getEffectiveAudioState().effectiveDeaf); dependencies.connection.markConnected(); await callbacks.onConnected(); - if (!suppressSelfJoinSound) { - const {connectionId} = parseVoiceParticipantIdentity(room.localParticipant.identity); - playSelfJoinChimeOnce(connectionId || null, 'livekit-room'); + const {userId, connectionId} = parseVoiceParticipantIdentity(room.localParticipant.identity); + if (userId) { + void startVoiceJoinChimeSequence({userId, channelId}, connectionId || null, (signal) => + suppressSelfJoinSound + ? Promise.resolve(false) + : playSelfJoinChimeOnce(connectionId || null, 'livekit-room', signal), + ); + } else if (!suppressSelfJoinSound) { + void playSelfJoinChimeOnce(connectionId || null, 'livekit-room'); } await dependencies.media.playEntranceSound(); if (guildId && channelId) { @@ -373,7 +379,6 @@ export function bindRoomEvents( bindParticipantSpeakingEvents(room.localParticipant); room.remoteParticipants.forEach((participant) => bindParticipantSpeakingEvents(participant)); dependencies.remoteSpeaking.hydrateFromRoom(room); - void ScreenShareCodecNegotiation.publishLocalCapabilities(room, 'reconnected'); dependencies.permissions.applyDeafen(room, getEffectiveAudioState().effectiveDeaf); dependencies.connection.markReconnected(); callbacks.onReconnected(); diff --git a/fluxer_app/src/features/voice/engine/VoiceSelfJoinChime.test.ts b/fluxer_app/src/features/voice/engine/VoiceSelfJoinChime.test.ts new file mode 100644 index 000000000..77540cc57 --- /dev/null +++ b/fluxer_app/src/features/voice/engine/VoiceSelfJoinChime.test.ts @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + discardVoiceJoinChimeSequence, + resetSelfJoinChimesForTests, + SELF_JOIN_CHIME_DEDUPE_WINDOW_MS, + SELF_JOIN_CHIME_START_DEADLINE_MS, + startVoiceJoinChimeSequence, + VOICE_JOIN_CHIME_SEQUENCE_MAX_ENTRIES, + VOICE_JOIN_CHIME_SEQUENCE_RETENTION_MS, + type VoiceJoinChimeSequenceResult, + waitForVoiceJoinChimeSequence, +} from './VoiceSelfJoinChime'; + +vi.mock('@app/features/ui/commands/SoundCommands', () => ({ + playOneShotSoundImmediatelyBypassingSelfDeafened: vi.fn(() => Promise.resolve(true)), +})); + +vi.mock('@app/features/voice/state/VoiceRegionTeleport', () => ({ + default: {shouldSuppressRejoinSounds: () => false}, +})); + +const identity = {userId: 'user-1', channelId: 'channel-1'}; + +function neverSettles(): Promise { + return new Promise(() => {}); +} + +function track(promise: Promise): {value: VoiceJoinChimeSequenceResult | null} { + const state: {value: VoiceJoinChimeSequenceResult | null} = {value: null}; + void promise.then((result) => { + state.value = result; + }); + return state; +} + +describe('voice join chime sequence', () => { + beforeEach(() => { + vi.useFakeTimers(); + resetSelfJoinChimesForTests(); + }); + + afterEach(() => { + resetSelfJoinChimesForTests(); + vi.useRealTimers(); + }); + + it('dedupes two starts sharing the same join token', async () => { + const start = vi.fn(() => Promise.resolve(true)); + const first = startVoiceJoinChimeSequence(identity, 'connection-1', start); + const second = startVoiceJoinChimeSequence(identity, 'connection-1', start); + expect(second).toBe(first); + await vi.advanceTimersByTimeAsync(0); + expect(start).toHaveBeenCalledTimes(1); + await expect(first).resolves.toBe('started'); + await expect(second).resolves.toBe('started'); + }); + + it('does not swallow a genuinely new connection token', async () => { + const signals: Array = []; + const start = vi.fn((signal: AbortSignal) => { + signals.push(signal); + return neverSettles(); + }); + const first = startVoiceJoinChimeSequence(identity, 'connection-1', start); + await vi.advanceTimersByTimeAsync(0); + const second = startVoiceJoinChimeSequence(identity, 'connection-2', start); + await vi.advanceTimersByTimeAsync(0); + expect(start).toHaveBeenCalledTimes(2); + expect(signals[0]?.aborted).toBe(true); + expect(signals[1]?.aborted).toBe(false); + expect(second).not.toBe(first); + await expect(first).resolves.toBe('unavailable'); + }); + + it('adopts an existing entry when the join token is null', async () => { + const firstStart = vi.fn(() => Promise.resolve(true)); + const secondStart = vi.fn(() => Promise.resolve(true)); + const first = startVoiceJoinChimeSequence(identity, 'connection-1', firstStart); + const second = startVoiceJoinChimeSequence(identity, null, secondStart); + expect(second).toBe(first); + await vi.advanceTimersByTimeAsync(0); + expect(firstStart).toHaveBeenCalledTimes(1); + expect(secondStart).not.toHaveBeenCalled(); + }); + + it('maps the start outcome onto the sequence result', async () => { + const started = startVoiceJoinChimeSequence({userId: 'a', channelId: 'c'}, 'connection-a', () => + Promise.resolve(true), + ); + const unavailable = startVoiceJoinChimeSequence({userId: 'b', channelId: 'c'}, 'connection-b', () => + Promise.resolve(false), + ); + const threw = startVoiceJoinChimeSequence({userId: 'c', channelId: 'c'}, 'connection-c', () => + Promise.reject(new Error('no audio device')), + ); + await vi.advanceTimersByTimeAsync(0); + await expect(started).resolves.toBe('started'); + await expect(unavailable).resolves.toBe('unavailable'); + await expect(threw).resolves.toBe('unavailable'); + }); + + it('expires a wait that no start ever adopts, and not one tick early', async () => { + const waiting = track(waitForVoiceJoinChimeSequence(identity)); + await vi.advanceTimersByTimeAsync(SELF_JOIN_CHIME_START_DEADLINE_MS - 1); + expect(waiting.value).toBeNull(); + await vi.advanceTimersByTimeAsync(1); + expect(waiting.value).toBe('expired-before-start'); + }); + + it('lets a later start adopt the entry a wait created', async () => { + const waiting = waitForVoiceJoinChimeSequence(identity); + await vi.advanceTimersByTimeAsync(100); + const start = vi.fn(() => Promise.resolve(true)); + const started = startVoiceJoinChimeSequence(identity, 'connection-1', start); + await vi.advanceTimersByTimeAsync(0); + expect(start).toHaveBeenCalledTimes(1); + await expect(waiting).resolves.toBe('started'); + await expect(started).resolves.toBe('started'); + }); + + it('bounds a start that never settles and aborts its signal', async () => { + let captured: AbortSignal | null = null; + const started = startVoiceJoinChimeSequence(identity, 'connection-1', (signal) => { + captured = signal; + return neverSettles(); + }); + await vi.advanceTimersByTimeAsync(SELF_JOIN_CHIME_START_DEADLINE_MS); + await expect(started).resolves.toBe('unavailable'); + expect(captured).not.toBeNull(); + expect((captured as unknown as AbortSignal).aborted).toBe(true); + }); + + it('keeps a settled started result across a discard and issues a fresh entry afterwards', async () => { + const started = startVoiceJoinChimeSequence(identity, 'connection-1', () => Promise.resolve(true)); + await vi.advanceTimersByTimeAsync(0); + await expect(started).resolves.toBe('started'); + discardVoiceJoinChimeSequence(identity); + await expect(started).resolves.toBe('started'); + const waiting = track(waitForVoiceJoinChimeSequence(identity)); + await vi.advanceTimersByTimeAsync(SELF_JOIN_CHIME_START_DEADLINE_MS); + expect(waiting.value).toBe('expired-before-start'); + }); + + it('expires an unsettled entry on discard and aborts its signal', async () => { + let captured: AbortSignal | null = null; + const started = startVoiceJoinChimeSequence(identity, 'connection-1', (signal) => { + captured = signal; + return neverSettles(); + }); + await vi.advanceTimersByTimeAsync(0); + discardVoiceJoinChimeSequence(identity); + await expect(started).resolves.toBe('expired-before-start'); + expect((captured as unknown as AbortSignal).aborted).toBe(true); + }); + + it('evicts the oldest entry rather than leaving its promise hanging', async () => { + const evicted = track(waitForVoiceJoinChimeSequence({userId: 'user-0', channelId: 'channel-1'})); + for (let index = 1; index <= VOICE_JOIN_CHIME_SEQUENCE_MAX_ENTRIES; index++) { + waitForVoiceJoinChimeSequence({userId: `user-${index}`, channelId: 'channel-1'}); + } + await vi.advanceTimersByTimeAsync(0); + expect(evicted.value).toBe('expired-before-start'); + }); + + it('keeps a settled outcome claimable by a late entrance event past the dedupe window', async () => { + const startedIdentity = {userId: 'user-started', channelId: 'channel-1'}; + const unavailableIdentity = {userId: 'user-unavailable', channelId: 'channel-1'}; + const started = startVoiceJoinChimeSequence(startedIdentity, 'connection-started', () => Promise.resolve(true)); + const unavailable = startVoiceJoinChimeSequence(unavailableIdentity, 'connection-unavailable', () => + Promise.resolve(false), + ); + await vi.advanceTimersByTimeAsync(0); + await expect(started).resolves.toBe('started'); + await expect(unavailable).resolves.toBe('unavailable'); + await vi.advanceTimersByTimeAsync(SELF_JOIN_CHIME_DEDUPE_WINDOW_MS + 1); + const lateStarted = track(waitForVoiceJoinChimeSequence(startedIdentity)); + const lateUnavailable = track(waitForVoiceJoinChimeSequence(unavailableIdentity)); + await vi.advanceTimersByTimeAsync(SELF_JOIN_CHIME_START_DEADLINE_MS); + expect(lateStarted.value).toBe('started'); + expect(lateUnavailable.value).toBe('unavailable'); + }); + + it('removes a settled entry after the retention window and leaves no timers behind', async () => { + const started = startVoiceJoinChimeSequence(identity, 'connection-1', () => Promise.resolve(true)); + await vi.advanceTimersByTimeAsync(0); + await expect(started).resolves.toBe('started'); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(VOICE_JOIN_CHIME_SEQUENCE_RETENTION_MS); + expect(vi.getTimerCount()).toBe(0); + const late = track(waitForVoiceJoinChimeSequence(identity)); + await vi.advanceTimersByTimeAsync(SELF_JOIN_CHIME_START_DEADLINE_MS); + expect(late.value).toBe('expired-before-start'); + }); + + it('plays the chime once across the gateway and livekit-room sources for the same join', async () => { + const gatewayStart = vi.fn(() => Promise.resolve(true)); + const livekitRoomStart = vi.fn(() => Promise.resolve(true)); + const fromGateway = startVoiceJoinChimeSequence(identity, 'connection-1', gatewayStart); + const fromLivekitRoom = startVoiceJoinChimeSequence(identity, 'connection-1', livekitRoomStart); + expect(fromLivekitRoom).toBe(fromGateway); + await vi.advanceTimersByTimeAsync(0); + expect(gatewayStart).toHaveBeenCalledTimes(1); + expect(livekitRoomStart).not.toHaveBeenCalled(); + await expect(fromGateway).resolves.toBe('started'); + await expect(fromLivekitRoom).resolves.toBe('started'); + }); + + it('discards the previous channel sequence on a quick channel hop and starts a fresh one', async () => { + const firstChannel = {userId: 'user-1', channelId: 'channel-1'}; + const secondChannel = {userId: 'user-1', channelId: 'channel-2'}; + let firstSignal: AbortSignal | null = null; + const firstStart = vi.fn((signal: AbortSignal) => { + firstSignal = signal; + return neverSettles(); + }); + const first = startVoiceJoinChimeSequence(firstChannel, 'connection-1', firstStart); + const firstEntrance = track(waitForVoiceJoinChimeSequence(firstChannel)); + await vi.advanceTimersByTimeAsync(0); + discardVoiceJoinChimeSequence(firstChannel); + await expect(first).resolves.toBe('expired-before-start'); + expect((firstSignal as unknown as AbortSignal).aborted).toBe(true); + expect(firstEntrance.value).toBe('expired-before-start'); + const secondStart = vi.fn(() => Promise.resolve(true)); + const second = startVoiceJoinChimeSequence(secondChannel, 'connection-2', secondStart); + await vi.advanceTimersByTimeAsync(0); + expect(secondStart).toHaveBeenCalledTimes(1); + await expect(second).resolves.toBe('started'); + await expect(waitForVoiceJoinChimeSequence(secondChannel)).resolves.toBe('started'); + }); +}); diff --git a/fluxer_app/src/features/voice/engine/VoiceSelfJoinChime.ts b/fluxer_app/src/features/voice/engine/VoiceSelfJoinChime.ts index 89ff5f7b6..117a57577 100644 --- a/fluxer_app/src/features/voice/engine/VoiceSelfJoinChime.ts +++ b/fluxer_app/src/features/voice/engine/VoiceSelfJoinChime.ts @@ -5,19 +5,169 @@ import * as SoundCommands from '@app/features/ui/commands/SoundCommands'; import VoiceRegionTeleport from '@app/features/voice/state/VoiceRegionTeleport'; export const SELF_JOIN_CHIME_DEDUPE_WINDOW_MS = 2000; +export const SELF_JOIN_CHIME_START_DEADLINE_MS = 1500; +export const VOICE_JOIN_CHIME_SEQUENCE_RETENTION_MS = 30_000; const RECENT_SELF_JOIN_CHIME_MAX_ENTRIES = 16; +export const VOICE_JOIN_CHIME_SEQUENCE_MAX_ENTRIES = 64; export type SelfJoinChimeSource = 'gateway' | 'livekit-room' | 'native-ready'; +export interface VoiceJoinChimeSequenceIdentity { + userId: string; + channelId: string; +} + +export type VoiceJoinChimeSequenceResult = 'started' | 'unavailable' | 'expired-before-start'; + +interface VoiceJoinChimeSequenceEntry { + key: string; + joinToken: string | null; + controller: AbortController; + result: VoiceJoinChimeSequenceResult | null; + expired: boolean; + resultPromise: Promise; + resolveResult: (result: VoiceJoinChimeSequenceResult) => void; + startPromise: Promise | null; + deadline: ReturnType | null; + cleanup: ReturnType | null; +} + interface RecentSelfJoinChime { playedAt: number; source: SelfJoinChimeSource; + startPromise: Promise; } const recentSelfJoinChimesByConnectionId = new Map(); +const voiceJoinChimeSequenceEntries = new Map(); export function resetSelfJoinChimesForTests(): void { recentSelfJoinChimesByConnectionId.clear(); + for (const entry of voiceJoinChimeSequenceEntries.values()) { + entry.controller.abort(); + if (entry.deadline) clearTimeout(entry.deadline); + if (entry.cleanup) clearTimeout(entry.cleanup); + if (entry.result === null) { + entry.result = 'unavailable'; + entry.resolveResult('unavailable'); + } + } + voiceJoinChimeSequenceEntries.clear(); +} + +function getVoiceJoinChimeSequenceKey(identity: VoiceJoinChimeSequenceIdentity): string { + return JSON.stringify([identity.channelId, identity.userId]); +} + +function removeVoiceJoinChimeSequenceEntry( + entry: VoiceJoinChimeSequenceEntry, + pendingResult: VoiceJoinChimeSequenceResult = entry.startPromise ? 'unavailable' : 'expired-before-start', +): void { + if (voiceJoinChimeSequenceEntries.get(entry.key) !== entry) return; + if (entry.result === null) { + entry.controller.abort(); + entry.result = pendingResult; + entry.resolveResult(pendingResult); + } + if (entry.deadline) clearTimeout(entry.deadline); + if (entry.cleanup) clearTimeout(entry.cleanup); + voiceJoinChimeSequenceEntries.delete(entry.key); +} + +function settleVoiceJoinChimeSequenceEntry( + entry: VoiceJoinChimeSequenceEntry, + result: VoiceJoinChimeSequenceResult, +): void { + if (entry.result !== null) return; + entry.result = result; + if (entry.deadline) clearTimeout(entry.deadline); + entry.deadline = null; + entry.resolveResult(result); + entry.cleanup = setTimeout(() => { + removeVoiceJoinChimeSequenceEntry(entry); + }, VOICE_JOIN_CHIME_SEQUENCE_RETENTION_MS); +} + +function createVoiceJoinChimeSequenceEntry(key: string, joinToken: string | null = null): VoiceJoinChimeSequenceEntry { + while (voiceJoinChimeSequenceEntries.size >= VOICE_JOIN_CHIME_SEQUENCE_MAX_ENTRIES) { + const oldest = voiceJoinChimeSequenceEntries.values().next().value; + if (!oldest) break; + removeVoiceJoinChimeSequenceEntry(oldest); + } + let resolveResult!: (result: VoiceJoinChimeSequenceResult) => void; + const resultPromise = new Promise((resolve) => { + resolveResult = resolve; + }); + const entry: VoiceJoinChimeSequenceEntry = { + key, + joinToken, + controller: new AbortController(), + result: null, + expired: false, + resultPromise, + resolveResult, + startPromise: null, + deadline: null, + cleanup: null, + }; + entry.deadline = setTimeout(() => { + entry.expired = true; + entry.controller.abort(); + settleVoiceJoinChimeSequenceEntry(entry, entry.startPromise ? 'unavailable' : 'expired-before-start'); + }, SELF_JOIN_CHIME_START_DEADLINE_MS); + voiceJoinChimeSequenceEntries.set(key, entry); + return entry; +} + +function getOrCreateVoiceJoinChimeSequenceEntry(identity: VoiceJoinChimeSequenceIdentity): VoiceJoinChimeSequenceEntry { + const key = getVoiceJoinChimeSequenceKey(identity); + return voiceJoinChimeSequenceEntries.get(key) ?? createVoiceJoinChimeSequenceEntry(key); +} + +export function startVoiceJoinChimeSequence( + identity: VoiceJoinChimeSequenceIdentity, + joinToken: string | null, + start: (signal: AbortSignal) => Promise, +): Promise { + let entry = getOrCreateVoiceJoinChimeSequenceEntry(identity); + const exactJoinToken = joinToken && joinToken.length > 0 ? joinToken : null; + if ( + (exactJoinToken && entry.joinToken && exactJoinToken !== entry.joinToken) || + (entry.expired && !entry.startPromise) + ) { + removeVoiceJoinChimeSequenceEntry(entry); + entry = createVoiceJoinChimeSequenceEntry(getVoiceJoinChimeSequenceKey(identity), exactJoinToken); + } + if (entry.joinToken === null) entry.joinToken = exactJoinToken; + if (entry.startPromise) return entry.startPromise; + if (entry.result !== null) return Promise.resolve(entry.result); + const startedEntry = entry; + const startPromise = Promise.resolve() + .then(() => start(startedEntry.controller.signal)) + .then( + (result) => { + const sequenceResult = result ? 'started' : 'unavailable'; + settleVoiceJoinChimeSequenceEntry(startedEntry, sequenceResult); + return startedEntry.result ?? sequenceResult; + }, + () => { + settleVoiceJoinChimeSequenceEntry(startedEntry, 'unavailable'); + return 'unavailable' as const; + }, + ); + entry.startPromise = Promise.race([startPromise, entry.resultPromise]); + return entry.startPromise; +} + +export function waitForVoiceJoinChimeSequence( + identity: VoiceJoinChimeSequenceIdentity, +): Promise { + return getOrCreateVoiceJoinChimeSequenceEntry(identity).resultPromise; +} + +export function discardVoiceJoinChimeSequence(identity: VoiceJoinChimeSequenceIdentity): void { + const entry = voiceJoinChimeSequenceEntries.get(getVoiceJoinChimeSequenceKey(identity)); + if (entry) removeVoiceJoinChimeSequenceEntry(entry, 'expired-before-start'); } function pruneRecentSelfJoinChimes(now: number): void { @@ -33,20 +183,51 @@ function pruneRecentSelfJoinChimes(now: number): void { } } -export function playSelfJoinChimeOnce(connectionId: string | null | undefined, source: SelfJoinChimeSource): void { +async function startSelfJoinChime(externalSignal?: AbortSignal): Promise { + const controller = new AbortController(); + const abort = (): void => controller.abort(); + if (externalSignal?.aborted) { + controller.abort(); + } else { + externalSignal?.addEventListener('abort', abort, {once: true}); + } + let deadline: ReturnType | null = null; + try { + return await Promise.race([ + SoundCommands.playOneShotSoundImmediatelyBypassingSelfDeafened(SoundType.UserJoin, controller.signal, { + ignoreGroupCooldown: true, + }), + new Promise((resolve) => { + deadline = setTimeout(() => { + controller.abort(); + resolve(false); + }, SELF_JOIN_CHIME_START_DEADLINE_MS); + }), + ]); + } finally { + if (deadline) clearTimeout(deadline); + externalSignal?.removeEventListener('abort', abort); + } +} + +export function playSelfJoinChimeOnce( + connectionId: string | null | undefined, + source: SelfJoinChimeSource, + signal?: AbortSignal, +): Promise { if (VoiceRegionTeleport.shouldSuppressRejoinSounds()) { - return; + return Promise.resolve(false); } if (!connectionId) { - SoundCommands.playSoundBypassingSelfDeafened(SoundType.UserJoin); - return; + return startSelfJoinChime(signal); } const now = Date.now(); pruneRecentSelfJoinChimes(now); const recent = recentSelfJoinChimesByConnectionId.get(connectionId); - if (recent && now - recent.playedAt < SELF_JOIN_CHIME_DEDUPE_WINDOW_MS) return; + if (recent && now - recent.playedAt < SELF_JOIN_CHIME_DEDUPE_WINDOW_MS) return recent.startPromise; - recentSelfJoinChimesByConnectionId.set(connectionId, {playedAt: now, source}); - SoundCommands.playSoundBypassingSelfDeafened(SoundType.UserJoin); + const startPromise = startSelfJoinChime(signal); + recentSelfJoinChimesByConnectionId.set(connectionId, {playedAt: now, source, startPromise}); + return startPromise; } diff --git a/fluxer_app/src/features/voice/events/EntranceSoundPlay.test.ts b/fluxer_app/src/features/voice/events/EntranceSoundPlay.test.ts new file mode 100644 index 000000000..358063434 --- /dev/null +++ b/fluxer_app/src/features/voice/events/EntranceSoundPlay.test.ts @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {GatewayHandlerContext} from '@app/features/gateway/events/EventRouter'; +import EntranceSoundPlaybackEngine from '@app/features/voice/engine/EntranceSoundPlaybackEngine'; +import MediaEngine from '@app/features/voice/engine/MediaEngineFacade'; +import {waitForVoiceJoinChimeSequence} from '@app/features/voice/engine/VoiceSelfJoinChime'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {handleEntranceSoundPlay} from './EntranceSoundPlay'; + +vi.mock('@app/features/voice/engine/EntranceSoundPlaybackEngine', () => ({ + default: {play: vi.fn(() => Promise.resolve())}, +})); + +vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({ + default: {connected: true, channelId: 'channel-1'}, +})); + +vi.mock('@app/features/voice/engine/VoiceSelfJoinChime', () => ({ + waitForVoiceJoinChimeSequence: vi.fn(), +})); + +const payload = { + user_id: 'user-1', + channel_id: 'channel-1', + guild_id: 'guild-1', + sound_id: 'sound-1', + hash: 'hash-1', + url: 'https://example.invalid/entrance.mp3', + duration_ms: 1200, + content_type: 'audio/mpeg', +}; + +const context = {} as GatewayHandlerContext; + +describe('handleEntranceSoundPlay', () => { + beforeEach(() => { + vi.mocked(EntranceSoundPlaybackEngine.play).mockClear(); + vi.mocked(waitForVoiceJoinChimeSequence).mockReset(); + }); + + it('plays the entrance sound after the join chime started', async () => { + vi.mocked(waitForVoiceJoinChimeSequence).mockResolvedValue('started'); + handleEntranceSoundPlay(payload, context); + await vi.waitFor(() => expect(EntranceSoundPlaybackEngine.play).toHaveBeenCalledTimes(1)); + expect(waitForVoiceJoinChimeSequence).toHaveBeenCalledWith({userId: 'user-1', channelId: 'channel-1'}); + }); + + it('plays the entrance sound when the join chime was unavailable', async () => { + vi.mocked(waitForVoiceJoinChimeSequence).mockResolvedValue('unavailable'); + handleEntranceSoundPlay(payload, context); + await vi.waitFor(() => expect(EntranceSoundPlaybackEngine.play).toHaveBeenCalledTimes(1)); + }); + + it('drops the entrance sound when the join chime expired before starting', async () => { + vi.mocked(waitForVoiceJoinChimeSequence).mockResolvedValue('expired-before-start'); + handleEntranceSoundPlay(payload, context); + await Promise.resolve(); + await Promise.resolve(); + expect(EntranceSoundPlaybackEngine.play).not.toHaveBeenCalled(); + }); + + it('drops the entrance sound when the channel changed while waiting', async () => { + vi.mocked(waitForVoiceJoinChimeSequence).mockImplementation(() => + Promise.resolve().then(() => { + vi.mocked(MediaEngine).channelId = 'channel-2'; + return 'started' as const; + }), + ); + handleEntranceSoundPlay(payload, context); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(EntranceSoundPlaybackEngine.play).not.toHaveBeenCalled(); + vi.mocked(MediaEngine).channelId = 'channel-1'; + }); +}); diff --git a/fluxer_app/src/features/voice/events/EntranceSoundPlay.ts b/fluxer_app/src/features/voice/events/EntranceSoundPlay.ts index 9e64baccb..ecfe15d70 100644 --- a/fluxer_app/src/features/voice/events/EntranceSoundPlay.ts +++ b/fluxer_app/src/features/voice/events/EntranceSoundPlay.ts @@ -4,6 +4,7 @@ import type {GatewayHandlerContext} from '@app/features/gateway/events/EventRout import {Logger} from '@app/features/platform/utils/AppLogger'; import EntranceSoundPlaybackEngine from '@app/features/voice/engine/EntranceSoundPlaybackEngine'; import MediaEngine from '@app/features/voice/engine/MediaEngineFacade'; +import {waitForVoiceJoinChimeSequence} from '@app/features/voice/engine/VoiceSelfJoinChime'; const logger = new Logger('EntranceSoundPlay'); @@ -27,10 +28,17 @@ export function handleEntranceSoundPlay(data: EntranceSoundPlayPayload, _context }); return; } - void EntranceSoundPlaybackEngine.play({ + void waitForVoiceJoinChimeSequence({ userId: data.user_id, - hash: data.hash, - url: data.url, - durationMs: data.duration_ms, + channelId: data.channel_id, + }).then((result) => { + if (result === 'expired-before-start') return; + if (!MediaEngine.connected || MediaEngine.channelId !== data.channel_id) return; + return EntranceSoundPlaybackEngine.play({ + userId: data.user_id, + hash: data.hash, + url: data.url, + durationMs: data.duration_ms, + }); }); } diff --git a/fluxer_app/src/features/voice/events/VoiceStateUpdate.ts b/fluxer_app/src/features/voice/events/VoiceStateUpdate.ts index 94f36d367..819464738 100644 --- a/fluxer_app/src/features/voice/events/VoiceStateUpdate.ts +++ b/fluxer_app/src/features/voice/events/VoiceStateUpdate.ts @@ -7,7 +7,11 @@ import {SoundType} from '@app/features/notification/utils/SoundUtils'; import {Logger} from '@app/features/platform/utils/AppLogger'; import * as SoundCommands from '@app/features/ui/commands/SoundCommands'; import MediaEngine from '@app/features/voice/engine/MediaEngineFacade'; -import {playSelfJoinChimeOnce} from '@app/features/voice/engine/VoiceSelfJoinChime'; +import { + discardVoiceJoinChimeSequence, + playSelfJoinChimeOnce, + startVoiceJoinChimeSequence, +} from '@app/features/voice/engine/VoiceSelfJoinChime'; import VoiceRegionTeleport from '@app/features/voice/state/VoiceRegionTeleport'; import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; @@ -128,14 +132,27 @@ export function handleVoiceStateUpdate(data: VoiceStateUpdatePayload, _context: const playJoinChime = !teleportingInPlace && shouldPlayJoinChime(data) && !shouldSuppressDuplicateJoinChime(data, now); const playLeaveChime = !teleportingInPlace && !playJoinChime && shouldPlayLeaveChime(data); + const previousState = data.connection_id ? MediaEngine.getVoiceStateByConnectionId(data.connection_id) : null; + if (previousState && previousState.channel_id !== data.channel_id) { + const otherConnectionRemains = Object.values( + MediaEngine.getAllVoiceStatesInChannel(previousState.guild_id, previousState.channel_id), + ).some((state) => state.user_id === data.user_id && state.connection_id !== data.connection_id); + if (!otherConnectionRemains) { + discardVoiceJoinChimeSequence({userId: data.user_id, channelId: previousState.channel_id}); + } + } MediaEngine.handleGatewayVoiceStateUpdate(guildId, voiceState); if (playJoinChime) { + if (!data.channel_id) return; rememberJoinChime(data, now); - if (shouldBypassSelfDeafenedForJoinChime(data)) { - playSelfJoinChimeOnce(data.connection_id, 'gateway'); - } else { - SoundCommands.playSoundBypassingSelfDeafened(SoundType.UserJoin); - } + void startVoiceJoinChimeSequence( + {userId: data.user_id, channelId: data.channel_id}, + data.connection_id ?? null, + (signal) => + shouldBypassSelfDeafenedForJoinChime(data) + ? playSelfJoinChimeOnce(data.connection_id, 'gateway', signal) + : SoundCommands.playOneShotSoundImmediatelyBypassingSelfDeafened(SoundType.UserJoin, signal), + ); } else if (playLeaveChime) { if (data.connection_id) { recentJoinChimesByConnectionId.delete(data.connection_id);