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
+5
View File
@@ -1,9 +1,11 @@
import { parseGuildEmojiList } from './emojis';
import { parseChannelPayload } from './gatewayEvents';
import { parseGuildSoundList } from './sounds';
import { parseVoiceStateList } from './voice';
import { describeUnknown } from '@/lib/http';
import type { GuildEmoji } from './emojis';
import type { GuildSound } from './sounds';
import type { VoiceState } from './voice';
import type { Channel, Role, UserStatus } from './types';
@@ -49,6 +51,8 @@ export interface GatewayGuild {
roles: Role[];
/** Кастомные эмодзи сервера (AGENT.md 7.12). */
emojis: GuildEmoji[];
/** Саундборд и звуковая палитра сервера (AGENT.md 7.13). */
sounds: GuildSound[];
/** Кто из участников в голосовых комнатах (AGENT.md 7.14). */
voice_states: VoiceState[];
member_ids: string[];
@@ -240,6 +244,7 @@ function parseGuild(value: unknown): GatewayGuild | null {
channels,
roles,
emojis: parseGuildEmojiList(record['emojis']),
sounds: parseGuildSoundList(record['sounds']),
voice_states: parseVoiceStateList(record['voice_states']),
member_ids: asStringArray(record['member_ids']),
my_role_ids: asStringArray(record['my_role_ids']),
+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);
+232
View File
@@ -0,0 +1,232 @@
import { request } from './client';
/**
* Саундборд и звуковая палитра сервера (AGENT.md 7.13).
*
* Контракт один и тот же в трёх местах: `GET /guilds/{id}/sounds`, снапшот
* READY (`guilds[].sounds`) и событие шлюза `GUILD_SOUNDS_UPDATE`, поэтому
* разбор payload'а общий для всех источников. Сам звук — обычный файл
* (`/files/{file_id}`), который клиент скачивает и играет локально.
*/
/** Вид набора: звуки саундборда или звуки интерфейса/событий. */
export type SoundKind = 'soundboard' | 'ui';
/**
* События, к которым сервер разрешает привязать UI-звук (AGENT.md 7.13).
* Список — контракт: сервер отвергает всё остальное.
*/
export const SOUND_EVENTS = [
'member_join',
'member_leave',
'mention',
'message',
'message_send',
'voice_join',
'voice_leave',
'mic_on',
'mic_off',
'camera_on',
'camera_off',
'screen_on',
'screen_off',
'kick',
'ban',
'mute',
'role_grant',
'role_revoke',
] as const;
export type SoundEvent = (typeof SOUND_EVENTS)[number];
/** Звук сервера: элемент `sounds` из READY, REST и `GUILD_SOUNDS_UPDATE`. */
export interface GuildSound {
id: string;
name: string;
file_id: string;
kind: SoundKind;
/** Событие UI-палитры: только у `kind: 'ui'`. */
event?: SoundEvent;
/** Необязательная эмодзи-иконка для сетки саундборда. */
emoji?: string;
/** Ссылка на файл (REST); в снапшоте READY её нет. */
url?: string;
}
/** Лимиты из контракта: 512 КБ на звук, 30 звуков каждого вида на сервер. */
export const SOUND_MAX_BYTES = 512 * 1024;
export const SOUND_LIMIT_PER_KIND = 30;
/** Имя звука: та же проверка, что на сервере. */
export const SOUND_NAME_PATTERN = /^[a-zA-Z0-9_]{2,32}$/u;
/**
* Форматы, которые принимает сервер: MP3/OGG/WAV/WebM/M4A. Браузеры
* называют одни и те же контейнеры по-разному, поэтому проверяем и MIME,
* и расширение файла.
*/
export const SOUND_CONTENT_TYPES = [
'audio/mpeg',
'audio/mp3',
'audio/ogg',
'application/ogg',
'audio/wav',
'audio/wave',
'audio/x-wav',
'audio/webm',
'audio/mp4',
'audio/m4a',
'audio/x-m4a',
] as const;
const SOUND_EXTENSIONS = ['.mp3', '.ogg', '.oga', '.wav', '.webm', '.m4a'] as const;
export const guildSoundsQueryKey = (guildId: string, kind?: SoundKind) =>
kind === undefined
? (['guilds', guildId, 'sounds'] as const)
: (['guilds', guildId, 'sounds', kind] as const);
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined;
}
/** Разрешён ли файл по формату (размер проверяется отдельно). */
export function isAllowedSoundType(file: File): boolean {
if ((SOUND_CONTENT_TYPES as readonly string[]).includes(file.type)) {
return true;
}
const name = file.name.toLocaleLowerCase();
return SOUND_EXTENSIONS.some((extension) => name.endsWith(extension));
}
/** Проходит ли имя клиентскую проверку (та же, что на сервере). */
export function isValidSoundName(name: string): boolean {
return SOUND_NAME_PATTERN.test(name);
}
/** Событие из контракта или `undefined` (неизвестные имена отбрасываем). */
export function asSoundEvent(value: unknown): SoundEvent | undefined {
return typeof value === 'string' && (SOUND_EVENTS as readonly string[]).includes(value)
? (value as SoundEvent)
: undefined;
}
/**
* Разбор одного звука. Поле `kind` может отсутствовать (старые снапшоты) —
* считаем такой звук саундбордом, как и сервер по умолчанию.
*/
export function parseGuildSound(value: unknown): GuildSound | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const name = asString(record['name']);
const fileId = asString(record['file_id']);
if (id === undefined || name === undefined || fileId === undefined) {
return null;
}
const kind = record['kind'] === 'ui' ? 'ui' : 'soundboard';
const sound: GuildSound = { id, name, file_id: fileId, kind };
const event = asSoundEvent(record['event']);
const emoji = asString(record['emoji']);
const url = asString(record['url']);
if (event !== undefined) {
sound.event = event;
}
if (emoji !== undefined) {
sound.emoji = emoji;
}
if (url !== undefined) {
sound.url = url;
}
return sound;
}
/** Разбор списка звуков: битые записи отбрасываются. */
export function parseGuildSoundList(value: unknown): GuildSound[] {
if (!Array.isArray(value)) {
return [];
}
return value.map(parseGuildSound).filter((item): item is GuildSound => item !== null);
}
/** Список звуков сервера; без `kind` сервер отдаёт оба набора. */
export async function fetchGuildSounds(
guildId: string,
kind?: SoundKind,
signal?: AbortSignal,
): Promise<GuildSound[]> {
const query = kind === undefined ? '' : `?kind=${kind}`;
const payload = await request<{ sounds?: unknown }>(
`/guilds/${encodeURIComponent(guildId)}/sounds${query}`,
signal === undefined ? {} : { signal },
);
return parseGuildSoundList(payload.sounds);
}
/** Тело загрузки: имя, файл, для палитры — событие, опционально эмодзи. */
export interface SoundUploadInput {
name: string;
file: File;
event?: SoundEvent;
emoji?: string;
}
/** Загрузка звука: multipart с полями `name`, `file`, `event` и `emoji`. */
export async function uploadGuildSound(
guildId: string,
kind: SoundKind,
input: SoundUploadInput,
): Promise<GuildSound | null> {
const form = new FormData();
form.append('name', input.name);
form.append('file', input.file);
if (input.event !== undefined) {
form.append('event', input.event);
}
if (input.emoji !== undefined && input.emoji !== '') {
form.append('emoji', input.emoji);
}
const payload = await request<{ sound?: unknown }>(
`/guilds/${encodeURIComponent(guildId)}/sounds?kind=${kind}`,
{ method: 'POST', form },
);
return parseGuildSound(payload.sound);
}
/** Переименование: сервер принимает `name` и необязательный `emoji`. */
export async function renameGuildSound(
guildId: string,
soundId: string,
input: { name: string; emoji?: string },
): Promise<GuildSound | null> {
const payload = await request<{ sound?: unknown }>(
`/guilds/${encodeURIComponent(guildId)}/sounds/${encodeURIComponent(soundId)}`,
{ method: 'PATCH', body: input },
);
return parseGuildSound(payload.sound);
}
export async function deleteGuildSound(guildId: string, soundId: string): Promise<void> {
await request<{ ok: true }>(
`/guilds/${encodeURIComponent(guildId)}/sounds/${encodeURIComponent(soundId)}`,
{ method: 'DELETE' },
);
}
/**
* Просьба сыграть звук в голосовой комнате. Сервер проверит право
* `USE_SOUNDBOARD`, присутствие в комнате (`voice.not_connected`) и лимит
* 3 звука за 10 секунд (`rate_limited`).
*/
export async function playSoundboardSound(guildId: string, soundId: string): Promise<void> {
await request<{ ok: true }>(
`/guilds/${encodeURIComponent(guildId)}/sounds/${encodeURIComponent(soundId)}/play`,
{ method: 'POST' },
);
}