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
@@ -1,12 +1,27 @@
import { useTranslation } from 'react-i18next';
import { VolumeSlider, VoiceDeviceList } from '@/components/voice/VoiceControls';
import { PUSH_TO_TALK_KEYS, useVoiceStore, type PushToTalkKey } from '@/stores/voice';
import { useDeviceChoice } from '@/lib/useMediaDevices';
import { useVoiceStore } from '@/stores/voice';
import { SettingsSection } from '@/pages/settings/SettingsSection';
/**
* «Аудио-видео»: выбор устройств ввода/вывода и громкости.
* Подписи клавиш push-to-talk: `code` не зависит от раскладки, поэтому
* хранится код, а показывается человекочитаемое имя.
*/
const PTT_KEY_LABELS: Record<PushToTalkKey, string> = {
Space: 'settings.voice.pttKeys.space',
KeyV: 'settings.voice.pttKeys.v',
KeyB: 'settings.voice.pttKeys.b',
KeyN: 'settings.voice.pttKeys.n',
AltLeft: 'settings.voice.pttKeys.alt',
ControlLeft: 'settings.voice.pttKeys.ctrl',
};
/**
* «Аудио-видео»: устройства ввода/вывода, громкости, режим активации
* микрофона (голосовая активация или push-to-talk) и переключатели звуков
* сервера (AGENT.md 7.13/7.14).
*
* Значения хранятся локально (zustand + localStorage) и применяются на лету:
* выбранное устройство сразу переключается в активной голосовой сессии.
@@ -20,6 +35,14 @@ export function VoiceSettingsSection() {
const outputVolume = useVoiceStore((state) => state.outputVolume);
const setMicVolume = useVoiceStore((state) => state.setMicVolume);
const setOutputVolume = useVoiceStore((state) => state.setOutputVolume);
const micActivation = useVoiceStore((state) => state.micActivation);
const pttKey = useVoiceStore((state) => state.pttKey);
const eventSoundsEnabled = useVoiceStore((state) => state.eventSoundsEnabled);
const soundboardEnabled = useVoiceStore((state) => state.soundboardEnabled);
const setMicActivation = useVoiceStore((state) => state.setMicActivation);
const setPttKey = useVoiceStore((state) => state.setPttKey);
const setEventSoundsEnabled = useVoiceStore((state) => state.setEventSoundsEnabled);
const setSoundboardEnabled = useVoiceStore((state) => state.setSoundboardEnabled);
return (
<SettingsSection
@@ -58,6 +81,74 @@ export function VoiceSettingsSection() {
/>
</div>
</div>
{/* Режим активации микрофона: голосовая активация или push-to-talk. */}
<fieldset className="mt-5 border-t border-border/40 pt-4">
<legend className="text-sm font-semibold">{t('settings.voice.micMode')}</legend>
<div className="mt-2 flex flex-col gap-1">
{(['voice', 'push_to_talk'] as const).map((mode) => (
<label key={mode} className="flex items-center gap-2 text-sm">
<input
type="radio"
name="mic_activation"
value={mode}
checked={micActivation === mode}
aria-label={t(`settings.voice.micModes.${mode}`)}
onChange={() => setMicActivation(mode)}
/>
<span>{t(`settings.voice.micModes.${mode}`)}</span>
</label>
))}
</div>
{micActivation === 'push_to_talk' ? (
<div className="mt-3 flex flex-col gap-2">
<label className="flex items-center gap-2 text-sm">
<span className="text-fg-muted">{t('settings.voice.pttKey')}</span>
<select
value={pttKey}
aria-label={t('settings.voice.pttKey')}
data-testid="ptt-key-select"
onChange={(event) => setPttKey(event.target.value as PushToTalkKey)}
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-1 px-2 py-1 text-sm"
>
{PUSH_TO_TALK_KEYS.map((key) => (
<option key={key} value={key}>
{t(PTT_KEY_LABELS[key])}
</option>
))}
</select>
</label>
<p className="text-xs text-fg-muted">{t('settings.voice.pttHint')}</p>
</div>
) : null}
</fieldset>
{/* Звуки сервера: палитра событий и саундборд (AGENT.md 7.13). */}
<fieldset className="mt-5 border-t border-border/40 pt-4">
<legend className="text-sm font-semibold">{t('settings.voice.sounds')}</legend>
<label className="mt-2 flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={eventSoundsEnabled}
aria-label={t('settings.voice.eventSounds')}
onChange={(event) => setEventSoundsEnabled(event.target.checked)}
/>
<span>{t('settings.voice.eventSounds')}</span>
</label>
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.eventSoundsHint')}</p>
<label className="mt-3 flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={soundboardEnabled}
aria-label={t('settings.voice.soundboard')}
onChange={(event) => setSoundboardEnabled(event.target.checked)}
/>
<span>{t('settings.voice.soundboard')}</span>
</label>
<p className="ml-6 text-xs text-fg-muted">{t('settings.voice.soundboardHint')}</p>
</fieldset>
<p className="mt-4 text-xs text-fg-muted">{t('settings.voice.stub')}</p>
</SettingsSection>
);