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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user