feat(voice): саундборд, звуковая палитра, push-to-talk и статистика соединения
Клиентская часть Фазы 3 (AGENT.md 7.13, 7.14): звуки сервера, локальные
звуки интерфейса и телеметрия медиа.
- саундборд: `api/sounds.ts` (лимиты 512 КБ и 30 на вид, multipart-загрузка,
play, `SOUND_EVENTS`), `lib/soundboard.ts` (blob-кэш `/files/{id}`,
наложение до 8 звуков, громкость = `outputVolume`, тишина при дефене),
попап в панели комнаты (`USE_SOUNDBOARD`), таймер на `rate_limited`,
подпись «включил звук» и защита от двойного воспроизведения;
- звуковая палитра: `SOUNDBOARD_PLAY` и события сервера (вход/выход
участника, упоминание, сообщение, вход/выход из голосовой, свои
микрофон/камера/экран, серверный мьют) → `playEventSound`;
- секция «Звуки» в настройках сервера (`MANAGE_SOUNDS`): два вида со
счётчиками, drag&drop, имя, эмодзи, событие палитры, переименование,
удаление и клиентские проверки файла;
- push-to-talk: режим и клавиша (`event.code`), хук в `AppLayout`, вход в
комнату с выключенным микрофоном, удержание включает публикацию, отпускание
и потеря фокуса выключают, серверный мьют приоритетнее;
- статистика: поповер с разрешением, fps, кодеком, битрейтом, RTT, потерями
и jitter (опрос раз в секунду, остановка при закрытии), выбор качества
публикации low/medium/high, пресеты шаринга экрана 1080p60…480p30;
- данные: `guilds[].sounds` из READY, `GUILD_SOUNDS_UPDATE`, селектор
`useGuildSounds`, тумблеры звуков в «Аудио-видео» (localStorage);
- тесты: `soundboard`, `soundSettings`, `pushToTalk`, `voiceStats` и
расширенный фейковый LiveKit SDK.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
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<string, Promise<string>>();
|
||||
/** Играющие элементы по `file_id`: их останавливает `stopSoundFile`. */
|
||||
const playing = new Map<string, Set<HTMLAudioElement>>();
|
||||
/** Порядок запусков: самый старый звук глушится при переполнении. */
|
||||
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<string> {
|
||||
const cached = sources.get(fileId);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
const url = fileContentUrl(fileId);
|
||||
const pending = (async (): Promise<string> => {
|
||||
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<HTMLAudioElement>();
|
||||
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<boolean> {
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user