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
+50 -9
View File
@@ -31,6 +31,13 @@ export const ConnectionQuality = {
Unknown: 'unknown',
} as const;
/** Уровни качества публикации: значения совпадают с `VideoQuality` из SDK. */
export const VideoQuality = {
LOW: 0,
MEDIUM: 1,
HIGH: 2,
} as const;
export const Track = {
Source: {
Camera: 'camera',
@@ -44,9 +51,15 @@ export const Track = {
export class FakeTrack {
readonly kind: 'audio' | 'video';
readonly attached: HTMLMediaElement[] = [];
/** Качество публикации, выставленное `setPublishingQuality` (тесты ждут). */
readonly publishingQuality: number[] = [];
/** Сколько раз спросили отчёт WebRTC: по нему проверяют опрос статистики. */
statsRequests = 0;
private readonly report: () => [string, unknown][];
constructor(kind: 'audio' | 'video') {
constructor(kind: 'audio' | 'video', report: () => [string, unknown][] = () => []) {
this.kind = kind;
this.report = report;
}
attach(element: HTMLMediaElement): HTMLMediaElement {
@@ -61,6 +74,17 @@ export class FakeTrack {
}
return [];
}
/** Отчёт WebRTC: `Map`, как настоящий `RTCStatsReport`. */
getRTCStatsReport(): Promise<Map<string, unknown>> {
this.statsRequests += 1;
return Promise.resolve(new Map(this.report()));
}
/** Смена качества публикации: SDK-метод `LocalVideoTrack`. */
setPublishingQuality(quality: number): void {
this.publishingQuality.push(quality);
}
}
export class FakePublication {
@@ -82,6 +106,8 @@ export class FakeParticipant {
readonly isLocal: boolean;
/** Вызовы, которые проверяют тесты: `setMicrophoneEnabled(false)` и т.п. */
readonly calls: { method: string; value: unknown }[] = [];
/** Параметры захвата экрана: `{ enabled, options }` каждого вызова. */
readonly screenOptions: { enabled: boolean; options: unknown }[] = [];
private readonly publications = new Map<string, FakePublication>();
constructor(
@@ -113,15 +139,18 @@ export class FakeParticipant {
/** Публикация дорожки: используется и для своих, и для чужих участников. */
setPublication(source: string, muted = false): FakePublication {
const publication = new FakePublication(
source,
new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video'),
muted,
);
const publication = new FakePublication(source, this.newTrack(source), muted);
this.publications.set(source, publication);
return publication;
}
/** Дорожка знает про отчёт WebRTC комнаты: тесты задают его через `setStats`. */
private newTrack(source: string): FakeTrack {
return new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video', () =>
this.room.statsEntries(),
);
}
removePublication(source: string): void {
this.publications.delete(source);
this.room.emit(RoomEvent.TrackUnsubscribed);
@@ -139,8 +168,7 @@ export class FakeParticipant {
private setLocal(source: string, enabled: boolean, method: string): void {
this.calls.push({ method, value: enabled });
const existing = this.publications.get(source);
const track =
existing?.track ?? new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video');
const track = existing?.track ?? this.newTrack(source);
this.publications.set(source, new FakePublication(source, track, !enabled));
this.room.emit(RoomEvent.LocalTrackPublished);
}
@@ -155,7 +183,9 @@ export class FakeParticipant {
return Promise.resolve();
}
setScreenShareEnabled(enabled: boolean): Promise<void> {
/** Демонстрация экрана: параметры захвата (разрешение и fps) запоминаем. */
setScreenShareEnabled(enabled: boolean, options?: unknown): Promise<void> {
this.screenOptions.push({ enabled, options });
this.setLocal(Track.Source.ScreenShare, enabled, 'setScreenShareEnabled');
return Promise.resolve();
}
@@ -190,6 +220,8 @@ export class FakeRoom {
readonly deviceSwitches: { kind: string; deviceId: string }[] = [];
disconnectCalls = 0;
startAudioCalls = 0;
/** Отчёт WebRTC: пары «ключ → объект», как у `RTCStatsReport`. */
private stats: [string, unknown][] = [];
private readonly handlers = new Map<string, Set<Handler>>();
constructor(readonly options?: unknown) {
@@ -197,6 +229,15 @@ export class FakeRoom {
this.localParticipant = new FakeParticipant(this, 'user-1', 'Alice', true);
}
/** Отчёт WebRTC, который отдаёт `getRTCStatsReport()` своих дорожек. */
statsEntries(): [string, unknown][] {
return this.stats;
}
setStats(entries: [string, unknown][]): void {
this.stats = entries;
}
on(event: string, handler: Handler): this {
const handlers = this.handlers.get(event) ?? new Set<Handler>();
handlers.add(handler);