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:
2026-09-20 19:30:43 +03:00
parent 2538e07ec3
commit 619bd91df4
40 changed files with 4269 additions and 48 deletions
+65
View File
@@ -1,7 +1,9 @@
import { parseGuildEmojiList } from './emojis';
import { parseGuildSoundList } from './sounds';
import { userStatuses, type Channel, type UserStatus } from './types';
import type { GatewayUser } from './gateway';
import type { GuildEmoji } from './emojis';
import type { GuildSound } from './sounds';
import type { Message, MessageAttachment, MessageReaction } from './messages';
/**
@@ -58,6 +60,22 @@ export interface GuildEmojisUpdateEvent {
emojis: GuildEmoji[];
}
export interface GuildSoundsUpdateEvent {
guild_id: string;
sounds: GuildSound[];
}
/** Событие `SOUNDBOARD_PLAY` (AGENT.md 7.13). */
export interface SoundboardPlayEvent {
guild_id: string;
channel_id: string;
sound_id: string;
file_id: string;
name: string;
user_id: string;
played_at: string;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
}
@@ -203,6 +221,53 @@ export function parseGuildEmojisUpdate(value: unknown): GuildEmojisUpdateEvent |
return { guild_id: guildId, emojis: parseGuildEmojiList(record['emojis']) };
}
/**
* `GUILD_SOUNDS_UPDATE`: набор звуков сервера изменился (AGENT.md 7.13).
* Список приходит целиком — тем же форматом, что в снапшоте READY.
*/
export function parseGuildSoundsUpdate(value: unknown): GuildSoundsUpdateEvent | null {
const record = asRecord(value);
const guildId = asString(record?.['guild_id']);
if (record === null || guildId === undefined) {
return null;
}
return { guild_id: guildId, sounds: parseGuildSoundList(record['sounds']) };
}
/**
* `SOUNDBOARD_PLAY`: кто-то сыграл звук в голосовой комнате. Событие
* приходит только участникам этой комнаты, и каждый клиент играет звук сам.
*/
export function parseSoundboardPlay(value: unknown): SoundboardPlayEvent | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const guildId = asString(record['guild_id']);
const channelId = asString(record['channel_id']);
const soundId = asString(record['sound_id']);
const fileId = asString(record['file_id']);
const userId = asString(record['user_id']);
if (
guildId === undefined ||
channelId === undefined ||
soundId === undefined ||
fileId === undefined ||
userId === undefined
) {
return null;
}
return {
guild_id: guildId,
channel_id: channelId,
sound_id: soundId,
file_id: fileId,
name: asString(record['name']) ?? '',
user_id: userId,
played_at: asString(record['played_at']) ?? '',
};
}
/** Канал из `CHANNEL_CREATE`/`CHANNEL_UPDATE` — та же форма, что в REST. */
export function parseChannelPayload(value: unknown): Channel | null {
const record = asRecord(value);