mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-02 21:04:06 +03:00
feat(voice): sequence join chimes ahead of entrance sounds (#2391)
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user