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
+219
View File
@@ -0,0 +1,219 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClientProvider } from '@tanstack/react-query';
vi.mock('livekit-client', async () => {
const fake = await import('./fakeLiveKit');
return {
Room: fake.FakeRoom,
RoomEvent: fake.RoomEvent,
ConnectionQuality: fake.ConnectionQuality,
Track: fake.Track,
VideoQuality: fake.VideoQuality,
};
});
import { FakeRoom, VideoQuality } from './fakeLiveKit';
import {
createTestQueryClient,
installFetch,
json,
makeUser,
resetStores,
type FetchRoute,
} from './helpers';
import { makeVoiceState, voiceRoutes } from './voiceRoutes';
import type { Channel } from '@/api/types';
import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel';
import { setQueryClient } from '@/lib/queryClient';
import { resetSoundboardEngine } from '@/lib/soundboard';
import { useSessionStore } from '@/stores/session';
import { useVoiceStore } from '@/stores/voice';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
/**
* Статистика своей публикации, выбор качества видео и пресетов демонстрации
* экрана (AGENT.md 7.14). Значения берутся из отчёта WebRTC подставного SDK,
* опрос идёт раз в секунду и останавливается при закрытии попапа.
*/
const voiceChannel: Channel = { id: 'c-voice', name: 'Голосовая', type: 'voice', position: 0 };
/** Отчёт WebRTC: кодек, отправка видео, обратный канал (RTT, потери). */
function statsReport(bytesSent: number, timestamp: number): [string, unknown][] {
return [
['codec-1', { type: 'codec', id: 'codec-1', mimeType: 'video/VP8' }],
[
'out-1',
{
type: 'outbound-rtp',
kind: 'video',
frameWidth: 1920,
frameHeight: 1080,
framesPerSecond: 30,
bytesSent,
packetsSent: 197,
codecId: 'codec-1',
timestamp,
},
],
[
'in-1',
{
type: 'remote-inbound-rtp',
kind: 'video',
roundTripTime: 0.042,
jitter: 0.003,
packetsLost: 3,
},
],
];
}
/** Рендер панели комнаты с поднятой голосовой сессией и включённой камерой. */
async function bootPanel() {
const queryClient = createTestQueryClient();
setQueryClient(queryClient);
resetStores();
resetSoundboardEngine();
useSessionStore.setState({
user: {
id: 'user-1',
username: 'alice',
display_name: 'Alice',
is_instance_admin: false,
badges: [],
},
guilds: [
{
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: true,
member_count: 1,
my_role_ids: [],
my_permissions: ['VIEW_GUILD'],
channels: [voiceChannel],
emojis: [],
sounds: [],
},
],
voiceStates: { 'g-1': [makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice' })] },
});
const voice = voiceRoutes({
channelIds: ['c-voice'],
states: [makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice' })],
});
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) },
{ match: '/api/v1/meta', response: () => json({ features: { voice_enabled: true } }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
...voice.routes,
] as FetchRoute[]);
render(
<QueryClientProvider client={queryClient}>
<VoiceRoomPanel guildId="g-1" channel={voiceChannel} />
</QueryClientProvider>,
);
await act(async () => {
await useVoiceConnectionStore.getState().join('g-1', 'c-voice');
await useVoiceConnectionStore.getState().setCameraEnabled(true);
});
return { room: FakeRoom.latest() };
}
beforeEach(() => {
FakeRoom.reset();
});
describe('статистика соединения', () => {
it('показывает данные SDK и обновляет их раз в секунду', async () => {
const { room } = await bootPanel();
room.setStats(statsReport(0, 1_000));
await userEvent.click(await screen.findByTestId('voice-stats-button'));
const panel = await screen.findByTestId('voice-stats-panel');
// Разрешение, fps и кодек приходят из отчёта; битрейт — по двум замерам.
expect(panel).toHaveTextContent('1920×1080');
expect(panel).toHaveTextContent('30 fps');
expect(panel).toHaveTextContent('VP8');
expect(panel).toHaveTextContent('42 мс');
expect(panel).toHaveTextContent('3 мс');
expect(panel).toHaveTextContent('1.5%');
room.setStats(statsReport(125_000, 2_000));
await waitFor(
() => {
expect(screen.getByTestId('voice-stats-panel')).toHaveTextContent('1000 кбит/с');
},
{ timeout: 2_500 },
);
});
it('показывает прочерк, когда SDK не отдал поле', async () => {
const { room } = await bootPanel();
room.setStats([]);
await userEvent.click(await screen.findByTestId('voice-stats-button'));
const panel = await screen.findByTestId('voice-stats-panel');
expect(within(panel).getAllByText('—').length).toBeGreaterThanOrEqual(5);
});
it('останавливает опрос при закрытии попапа', async () => {
const { room } = await bootPanel();
room.setStats(statsReport(0, 1_000));
await userEvent.click(await screen.findByTestId('voice-stats-button'));
await screen.findByTestId('voice-stats-panel');
const track = room.localParticipant.getTrackPublication('camera')?.track;
const before = track?.statsRequests ?? 0;
expect(before).toBeGreaterThan(0);
await userEvent.click(screen.getByTestId('voice-stats-button'));
await waitFor(() => {
expect(screen.queryByTestId('voice-stats-panel')).toBeNull();
});
// Ждём больше периода опроса: счётчик обращений к SDK не должен расти.
await new Promise((resolve) => setTimeout(resolve, 1_200));
expect(track?.statsRequests).toBe(before);
});
it('передаёт SDK выбранное качество публикации', async () => {
const { room } = await bootPanel();
await userEvent.click(await screen.findByTestId('voice-quality-button'));
await userEvent.click(await screen.findByTestId('voice-publish-quality-low'));
await waitFor(() => {
expect(useVoiceStore.getState().videoQuality).toBe('low');
});
const track = room.localParticipant.getTrackPublication('camera')?.track;
expect(track?.publishingQuality).toContain(VideoQuality.LOW);
});
it('публикует экран с выбранным разрешением и fps', async () => {
const { room } = await bootPanel();
await userEvent.click(await screen.findByTestId('voice-screen-button'));
await userEvent.click(await screen.findByTestId('voice-screen-preset-720p30'));
await waitFor(() => {
expect(useVoiceConnectionStore.getState().screenEnabled).toBe(true);
});
expect(useVoiceStore.getState().screenSharePreset).toBe('720p30');
expect(room.localParticipant.screenOptions.at(-1)).toEqual({
enabled: true,
options: { resolution: { width: 1280, height: 720, frameRate: 30 } },
});
});
});