import { fileContentUrl } from '@/api/files'; import { useVoiceStore } from '@/stores/voice'; /** * Локальное воспроизведение звуков саундборда и звуковой палитры * (AGENT.md 7.13): сервер только рассылает событие, а файл каждый клиент * скачивает сам (`/files/{file_id}`, тот же origin) и играет локально. * * Движок держит кэш object URL (повторное нажатие не качает файл заново) и * список играющих элементов, чтобы звук можно было остановить: свой звук не * накладывается сам на себя, разные звуки звучат одновременно, а при выходе * из комнаты всё глохнет. * * Громкость берётся из общей громкости приложения (`stores/voice`). */ /** Сколько звуков может звучать одновременно: защита от «стены» звука. */ export const SOUNDBOARD_MAX_CONCURRENT = 8; /** Кэш ссылок на файлы: `file_id` → object URL (или прямой адрес). */ const sources = new Map>(); /** Играющие элементы по `file_id`: их останавливает `stopSoundFile`. */ const playing = new Map>(); /** Порядок запусков: самый старый звук глушится при переполнении. */ const order: HTMLAudioElement[] = []; /** * Общая громкость приложения в долях единицы (0–1). «Звук выключен» (в том * числе серверный дефен) глушит и саундборд, и звуки событий: 0 означает * «не играть вовсе». */ export function soundVolume(): number { const { outputVolume, headphonesEnabled } = useVoiceStore.getState(); if (!headphonesEnabled) { return 0; } return Math.min(1, Math.max(0, outputVolume / 100)); } function clampVolume(value: number): number { if (!Number.isFinite(value)) { return 1; } return Math.min(1, Math.max(0, value)); } /** * Ссылка на файл звука: сначала пробуем скачать его в blob (тогда браузер * играет из памяти и файл не запрашивается дважды), при неудаче — играем по * прямому адресу `/files/{id}`. Ошибка скачивания не должна ломать звук. */ function resolveSource(fileId: string): Promise { const cached = sources.get(fileId); if (cached !== undefined) { return cached; } const url = fileContentUrl(fileId); const pending = (async (): Promise => { try { const response = await fetch(url, { credentials: 'same-origin' }); if (!response.ok) { return url; } const blob = await response.blob(); if (typeof URL.createObjectURL !== 'function') { return url; } return URL.createObjectURL(blob); } catch { return url; } })(); sources.set(fileId, pending); return pending; } function track(fileId: string, element: HTMLAudioElement): void { const set = playing.get(fileId) ?? new Set(); set.add(element); playing.set(fileId, set); order.push(element); } function untrack(fileId: string, element: HTMLAudioElement): void { const set = playing.get(fileId); if (set !== undefined) { set.delete(element); if (set.size === 0) { playing.delete(fileId); } } const index = order.indexOf(element); if (index >= 0) { order.splice(index, 1); } } /** Сколько звуков играет прямо сейчас. */ export function playingCount(): number { return order.length; } /** Останавливает все экземпляры одного звука (например, перед повтором). */ export function stopSoundFile(fileId: string): void { const set = playing.get(fileId); if (set === undefined) { return; } for (const element of [...set]) { stopElement(element); untrack(fileId, element); } } /** Останавливает всё: используется при выходе из комнаты и в тестах. */ export function stopAllSounds(): void { for (const [fileId, set] of [...playing]) { for (const element of [...set]) { stopElement(element); untrack(fileId, element); } } order.length = 0; } function stopElement(element: HTMLAudioElement): void { try { element.pause(); element.currentTime = 0; } catch { // Элемент мог не начать играть — это не ошибка. } } /** * Проигрывает звук. Возвращает `false`, если звук играть не нужно или не * удалось (звук выключен, автовоспроизведение запрещено, формат не * поддержан): вызывающий код не должен показывать ошибку — звук не критичен * для интерфейса. */ export async function playSoundFile(fileId: string, volume = soundVolume()): Promise { if (fileId === '' || volume <= 0) { return false; } // Тот же звук не накладывается сам на себя: повтор начинается заново. stopSoundFile(fileId); while (order.length >= SOUNDBOARD_MAX_CONCURRENT) { const oldest = order[0]; if (oldest === undefined) { break; } for (const [key, set] of playing) { if (set.has(oldest)) { untrack(key, oldest); break; } } stopElement(oldest); } let element: HTMLAudioElement; try { element = new Audio(await resolveSource(fileId)); } catch { return false; } element.volume = clampVolume(volume); track(fileId, element); // Конец звука освобождает место для следующего; в jsdom события нет. element.addEventListener?.('ended', () => untrack(fileId, element)); try { await element.play(); return true; } catch { untrack(fileId, element); return false; } } /** Запуск без ожидания: обработчики событий не должны ждать звука. */ export function playSoundFileInBackground(fileId: string, volume?: number): void { void playSoundFile(fileId, volume).catch(() => undefined); } /** Полная очистка движка: кэш ссылок, играющие элементы (тесты и выход). */ export function resetSoundboardEngine(): void { stopAllSounds(); for (const pending of sources.values()) { void pending .then((url) => { if (url.startsWith('blob:') && typeof URL.revokeObjectURL === 'function') { URL.revokeObjectURL(url); } }) .catch(() => undefined); } sources.clear(); }