feat(voice): sequence join chimes ahead of entrance sounds (#2391)

This commit is contained in:
Hampus
2026-09-02 17:34:36 +02:00
committed by GitHub
parent e73285060e
commit e94f587535
6 changed files with 541 additions and 22 deletions
@@ -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();
@@ -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<boolean> {
return new Promise<boolean>(() => {});
}
function track(promise: Promise<VoiceJoinChimeSequenceResult>): {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<AbortSignal> = [];
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');
});
});
@@ -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<VoiceJoinChimeSequenceResult>;
resolveResult: (result: VoiceJoinChimeSequenceResult) => void;
startPromise: Promise<VoiceJoinChimeSequenceResult> | null;
deadline: ReturnType<typeof setTimeout> | null;
cleanup: ReturnType<typeof setTimeout> | null;
}
interface RecentSelfJoinChime {
playedAt: number;
source: SelfJoinChimeSource;
startPromise: Promise<boolean>;
}
const recentSelfJoinChimesByConnectionId = new Map<string, RecentSelfJoinChime>();
const voiceJoinChimeSequenceEntries = new Map<string, VoiceJoinChimeSequenceEntry>();
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<VoiceJoinChimeSequenceResult>((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<boolean>,
): Promise<VoiceJoinChimeSequenceResult> {
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<VoiceJoinChimeSequenceResult> {
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<boolean> {
const controller = new AbortController();
const abort = (): void => controller.abort();
if (externalSignal?.aborted) {
controller.abort();
} else {
externalSignal?.addEventListener('abort', abort, {once: true});
}
let deadline: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
SoundCommands.playOneShotSoundImmediatelyBypassingSelfDeafened(SoundType.UserJoin, controller.signal, {
ignoreGroupCooldown: true,
}),
new Promise<boolean>((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<boolean> {
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;
}
@@ -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';
});
});
@@ -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,
});
});
}
@@ -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);