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
+36
View File
@@ -41,6 +41,8 @@ export const PERMISSION_MANAGE_GUILD = 'MANAGE_GUILD';
export const PERMISSION_MANAGE_CHANNELS = 'MANAGE_CHANNELS';
export const PERMISSION_MANAGE_MESSAGES = 'MANAGE_MESSAGES';
export const PERMISSION_MANAGE_EMOJIS = 'MANAGE_EMOJIS';
export const PERMISSION_MANAGE_SOUNDS = 'MANAGE_SOUNDS';
export const PERMISSION_USE_SOUNDBOARD = 'USE_SOUNDBOARD';
export const PERMISSION_SEND_MESSAGES = 'SEND_MESSAGES';
export const PERMISSION_ATTACH_FILES = 'ATTACH_FILES';
export const PERMISSION_ADD_REACTIONS = 'ADD_REACTIONS';
@@ -186,6 +188,40 @@ export function canManageEmojis(
);
}
/**
* Может ли пользователь управлять звуками сервера (AGENT.md 7.13): владелец,
* ADMINISTRATOR, MANAGE_SOUNDS или администратор инстанса.
*/
export function canManageSounds(
permissions: readonly string[],
ownerId: string,
userId: string | undefined,
isInstanceAdmin = false,
): boolean {
if (isInstanceAdmin || (userId !== undefined && userId === ownerId)) {
return true;
}
return (
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
hasPermission(permissions, PERMISSION_MANAGE_SOUNDS)
);
}
/**
* Может ли пользователь играть звуки саундборда (AGENT.md 7.13): право
* `USE_SOUNDBOARD`. Присутствие в голосовой комнате проверяется отдельно —
* сервер отвечает `voice.not_connected`, если мы не в комнате.
*/
export function canUseSoundboard(permissions: readonly string[], isInstanceAdmin = false): boolean {
if (isInstanceAdmin) {
return true;
}
return (
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
hasPermission(permissions, PERMISSION_USE_SOUNDBOARD)
);
}
/** Может ли пользователь отправлять сообщения в комнату. */
export function canSendMessages(
permissions: readonly string[],
+232 -5
View File
@@ -1,6 +1,6 @@
import type { Participant, Room, Track } from 'livekit-client';
import type { LocalVideoTrack, Participant, Room, Track } from 'livekit-client';
import type { VoiceDeviceKind } from '@/stores/voice';
import type { VoiceDeviceKind, VoicePublishQuality } from '@/stores/voice';
/**
* Обёртка над `livekit-client`: единственное место, где клиент знает про SDK.
@@ -46,6 +46,60 @@ export interface VoiceParticipant {
export type VoiceRoomStatus = 'connected' | 'reconnecting' | 'reconnected' | 'disconnected';
/** Пресеты демонстрации экрана: разрешение и fps выбирает пользователь. */
export type ScreenSharePresetId = '1080p60' | '1080p30' | '720p30' | '480p30';
/** Разрешение и fps, которые уходят в `ScreenShareCaptureOptions`. */
export const SCREEN_SHARE_PRESETS: Record<
ScreenSharePresetId,
{ width: number; height: number; frameRate: number }
> = {
'1080p60': { width: 1920, height: 1080, frameRate: 60 },
'1080p30': { width: 1920, height: 1080, frameRate: 30 },
'720p30': { width: 1280, height: 720, frameRate: 30 },
'480p30': { width: 854, height: 480, frameRate: 30 },
};
/** Список пресетов в порядке показа: от лучшего к экономному. */
export const SCREEN_SHARE_PRESET_IDS: ScreenSharePresetId[] = [
'1080p60',
'1080p30',
'720p30',
'480p30',
];
/**
* Статистика своей публикации (AGENT.md 7.14): фактические разрешение, fps,
* кодек и битрейт, а также качество канала. `null` означает «SDK поле не
* отдал» — интерфейс показывает прочерк.
*/
export interface VoiceStats {
width: number | null;
height: number | null;
frameRate: number | null;
codec: string | null;
/** Битрейт отправки в килобитах в секунду (по приросту `bytesSent`). */
bitrateKbps: number | null;
roundTripMs: number | null;
jitterMs: number | null;
packetsLost: number | null;
/** Доля потерянных пакетов, 0–100. */
packetLossPercent: number | null;
}
/** Пустая статистика: SDK ещё не отдал отчёт. */
export const EMPTY_VOICE_STATS: VoiceStats = {
width: null,
height: null,
frameRate: null,
codec: null,
bitrateKbps: null,
roundTripMs: null,
jitterMs: null,
packetsLost: null,
packetLossPercent: null,
};
export interface VoiceRoomCallbacks {
/** Изменился состав комнаты или медиа-состояние участников. */
onParticipants: (participants: VoiceParticipant[]) => void;
@@ -61,7 +115,12 @@ export interface VoiceRoomHandle {
dispose: () => void;
setMicrophoneEnabled: (enabled: boolean) => Promise<void>;
setCameraEnabled: (enabled: boolean) => Promise<void>;
setScreenShareEnabled: (enabled: boolean) => Promise<void>;
/** Демонстрация экрана: пресет задаёт разрешение и fps захвата. */
setScreenShareEnabled: (enabled: boolean, preset?: ScreenSharePresetId) => Promise<void>;
/** Качество публикации своей камеры (и шаринга, если он идёт). */
setVideoQuality: (quality: VoicePublishQuality) => Promise<void>;
/** Статистика своей публикации для попапа «Статистика». */
fetchStats: () => Promise<VoiceStats>;
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
participants: () => VoiceParticipant[];
}
@@ -152,6 +211,140 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
return participants;
};
/**
* Своя видеодорожка для смены качества публикации и статистики: сначала
* активная (камера, затем демонстрация экрана), иначе — выключенная.
* `setPublishingQuality` есть только у `LocalVideoTrack`, поэтому проверяем
* метод, а не тип.
*/
const videoPublication = (): LocalVideoTrack | undefined => {
const local = room.localParticipant;
let fallback: LocalVideoTrack | undefined;
for (const source of [Track.Source.Camera, Track.Source.ScreenShare]) {
const publication = local.getTrackPublication(source);
const track = publication?.track;
if (publication === undefined || track === undefined) {
continue;
}
if (!publication.isMuted) {
return track as LocalVideoTrack;
}
fallback = fallback ?? (track as LocalVideoTrack);
}
return fallback;
};
/** Число из отчёта WebRTC: `null`, если поля нет или оно не число. */
const num = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
/** Предыдущий замер `bytesSent`: битрейт считается по приросту. */
let previousSample: { bytes: number; at: number } | null = null;
/**
* Статистика своей публикации. Источники — те же, что в `chrome://webrtc`:
* `outbound-rtp` (разрешение, fps, кодек, отправленные байты),
* `remote-inbound-rtp` (RTT, jitter, потери) и `candidate-pair` (RTT).
* Чего SDK не отдал — остаётся `null`, интерфейс покажет «—».
*/
const collectStats = async (): Promise<VoiceStats> => {
const track = videoPublication();
if (track === undefined || typeof track.getRTCStatsReport !== 'function') {
previousSample = null;
return { ...EMPTY_VOICE_STATS };
}
let report: RTCStatsReport | undefined;
try {
report = await track.getRTCStatsReport();
} catch {
return { ...EMPTY_VOICE_STATS };
}
if (report === undefined) {
return { ...EMPTY_VOICE_STATS };
}
const entries: Record<string, unknown>[] = [];
report.forEach((entry: unknown) => {
if (typeof entry === 'object' && entry !== null) {
entries.push(entry as Record<string, unknown>);
}
});
const codecs = new Map<string, string>();
for (const entry of entries) {
if (entry['type'] === 'codec' && typeof entry['id'] === 'string') {
const mime = entry['mimeType'];
codecs.set(entry['id'], typeof mime === 'string' ? mime : '');
}
}
const outbound = entries.find(
(entry) =>
entry['type'] === 'outbound-rtp' &&
(entry['kind'] === 'video' || entry['mediaType'] === 'video'),
);
const remoteInbound = entries.find(
(entry) => entry['type'] === 'remote-inbound-rtp' && entry['kind'] !== 'audio',
);
const candidatePair = entries.find(
(entry) => entry['type'] === 'candidate-pair' && entry['state'] === 'succeeded',
);
const stats: VoiceStats = { ...EMPTY_VOICE_STATS };
const frameWidth = num(outbound?.['frameWidth']);
const frameHeight = num(outbound?.['frameHeight']);
const framesPerSecond = num(outbound?.['framesPerSecond']);
if (frameWidth !== null) {
stats.width = Math.round(frameWidth);
}
if (frameHeight !== null) {
stats.height = Math.round(frameHeight);
}
if (framesPerSecond !== null) {
stats.frameRate = Math.round(framesPerSecond);
}
const codecId = outbound?.['codecId'];
const mimeType = typeof codecId === 'string' ? codecs.get(codecId) : undefined;
if (mimeType !== undefined && mimeType !== '') {
// «video/VP8» → «VP8»: в интерфейсе нужен короткий кодек.
stats.codec = mimeType.split('/')[1]?.toLocaleUpperCase() ?? mimeType;
}
// Битрейт: разница `bytesSent` между замерами, делённая на интервал.
const bytesSent = num(outbound?.['bytesSent']);
const timestamp = num(outbound?.['timestamp']) ?? Date.now();
if (bytesSent !== null) {
if (previousSample !== null) {
const elapsedMs = Math.max(1, timestamp - previousSample.at);
const deltaBytes = Math.max(0, bytesSent - previousSample.bytes);
stats.bitrateKbps = Math.round((deltaBytes * 8) / elapsedMs);
}
previousSample = { bytes: bytesSent, at: timestamp };
} else {
previousSample = null;
}
const roundTrip =
num(remoteInbound?.['roundTripTime']) ?? num(candidatePair?.['currentRoundTripTime']);
if (roundTrip !== null) {
stats.roundTripMs = Math.round(roundTrip * 1000);
}
const jitter = num(remoteInbound?.['jitter']);
if (jitter !== null) {
stats.jitterMs = Math.round(jitter * 1000);
}
const packetsLost = num(remoteInbound?.['packetsLost']);
const packetsSent = num(outbound?.['packetsSent']);
if (packetsLost !== null) {
stats.packetsLost = Math.max(0, Math.round(packetsLost));
if (packetsSent !== null && packetsSent + packetsLost > 0) {
stats.packetLossPercent =
Math.round(((packetsLost / (packetsSent + packetsLost)) * 100 + Number.EPSILON) * 10) /
10;
}
}
return stats;
};
const room = new Room({ adaptiveStream: true, dynacast: true });
const refresh = (): void => {
callbacks.onParticipants(snapshotAll(room));
@@ -210,11 +403,45 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
refresh();
},
async setScreenShareEnabled(enabled) {
await room.localParticipant.setScreenShareEnabled(enabled);
async setScreenShareEnabled(enabled, preset) {
if (!enabled) {
await room.localParticipant.setScreenShareEnabled(false);
refresh();
return;
}
// Разрешение и fps выбирает пользователь: они уходят в захват экрана.
const resolution = SCREEN_SHARE_PRESETS[preset ?? '1080p30'];
await room.localParticipant.setScreenShareEnabled(true, {
resolution: {
width: resolution.width,
height: resolution.height,
frameRate: resolution.frameRate,
},
});
refresh();
},
async setVideoQuality(quality) {
const track = videoPublication();
// `setPublishingQuality` есть только у локальной видеодорожки: если
// публикации нет, менять нечего — предпочтение применится при старте.
if (track === undefined || typeof track.setPublishingQuality !== 'function') {
return;
}
const { VideoQuality } = await import('livekit-client');
const levels: Record<VoicePublishQuality, (typeof VideoQuality)[keyof typeof VideoQuality]> =
{
low: VideoQuality.LOW,
medium: VideoQuality.MEDIUM,
high: VideoQuality.HIGH,
};
track.setPublishingQuality(levels[quality]);
},
async fetchStats() {
return collectStats();
},
async switchActiveDevice(kind, deviceId) {
await room.switchActiveDevice(kind, deviceId === '' ? 'default' : deviceId);
},
+198
View File
@@ -0,0 +1,198 @@
import { fileContentUrl } from '@/api/files';
import { useVoiceStore } from '@/stores/voice';
/**
* Локальное воспроизведение звуков саундборда и звуковой палитры
* (AGENT.md 7.13): сервер только рассылает событие, а файл каждый клиент
* скачивает сам (`/files/{file_id}`, тот же origin) и играет локально.
*
* Движок держит кэш object URL (повторное нажатие не качает файл заново) и
* список играющих элементов, чтобы звук можно было остановить: свой звук не
* накладывается сам на себя, разные звуки звучат одновременно, а при выходе
* из комнаты всё глохнет.
*
* Громкость берётся из общей громкости приложения (`stores/voice`).
*/
/** Сколько звуков может звучать одновременно: защита от «стены» звука. */
export const SOUNDBOARD_MAX_CONCURRENT = 8;
/** Кэш ссылок на файлы: `file_id` → object URL (или прямой адрес). */
const sources = new Map<string, Promise<string>>();
/** Играющие элементы по `file_id`: их останавливает `stopSoundFile`. */
const playing = new Map<string, Set<HTMLAudioElement>>();
/** Порядок запусков: самый старый звук глушится при переполнении. */
const order: HTMLAudioElement[] = [];
/**
* Общая громкость приложения в долях единицы (0–1). «Звук выключен» (в том
* числе серверный дефен) глушит и саундборд, и звуки событий: 0 означает
* «не играть вовсе».
*/
export function soundVolume(): number {
const { outputVolume, headphonesEnabled } = useVoiceStore.getState();
if (!headphonesEnabled) {
return 0;
}
return Math.min(1, Math.max(0, outputVolume / 100));
}
function clampVolume(value: number): number {
if (!Number.isFinite(value)) {
return 1;
}
return Math.min(1, Math.max(0, value));
}
/**
* Ссылка на файл звука: сначала пробуем скачать его в blob (тогда браузер
* играет из памяти и файл не запрашивается дважды), при неудаче — играем по
* прямому адресу `/files/{id}`. Ошибка скачивания не должна ломать звук.
*/
function resolveSource(fileId: string): Promise<string> {
const cached = sources.get(fileId);
if (cached !== undefined) {
return cached;
}
const url = fileContentUrl(fileId);
const pending = (async (): Promise<string> => {
try {
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) {
return url;
}
const blob = await response.blob();
if (typeof URL.createObjectURL !== 'function') {
return url;
}
return URL.createObjectURL(blob);
} catch {
return url;
}
})();
sources.set(fileId, pending);
return pending;
}
function track(fileId: string, element: HTMLAudioElement): void {
const set = playing.get(fileId) ?? new Set<HTMLAudioElement>();
set.add(element);
playing.set(fileId, set);
order.push(element);
}
function untrack(fileId: string, element: HTMLAudioElement): void {
const set = playing.get(fileId);
if (set !== undefined) {
set.delete(element);
if (set.size === 0) {
playing.delete(fileId);
}
}
const index = order.indexOf(element);
if (index >= 0) {
order.splice(index, 1);
}
}
/** Сколько звуков играет прямо сейчас. */
export function playingCount(): number {
return order.length;
}
/** Останавливает все экземпляры одного звука (например, перед повтором). */
export function stopSoundFile(fileId: string): void {
const set = playing.get(fileId);
if (set === undefined) {
return;
}
for (const element of [...set]) {
stopElement(element);
untrack(fileId, element);
}
}
/** Останавливает всё: используется при выходе из комнаты и в тестах. */
export function stopAllSounds(): void {
for (const [fileId, set] of [...playing]) {
for (const element of [...set]) {
stopElement(element);
untrack(fileId, element);
}
}
order.length = 0;
}
function stopElement(element: HTMLAudioElement): void {
try {
element.pause();
element.currentTime = 0;
} catch {
// Элемент мог не начать играть — это не ошибка.
}
}
/**
* Проигрывает звук. Возвращает `false`, если звук играть не нужно или не
* удалось (звук выключен, автовоспроизведение запрещено, формат не
* поддержан): вызывающий код не должен показывать ошибку — звук не критичен
* для интерфейса.
*/
export async function playSoundFile(fileId: string, volume = soundVolume()): Promise<boolean> {
if (fileId === '' || volume <= 0) {
return false;
}
// Тот же звук не накладывается сам на себя: повтор начинается заново.
stopSoundFile(fileId);
while (order.length >= SOUNDBOARD_MAX_CONCURRENT) {
const oldest = order[0];
if (oldest === undefined) {
break;
}
for (const [key, set] of playing) {
if (set.has(oldest)) {
untrack(key, oldest);
break;
}
}
stopElement(oldest);
}
let element: HTMLAudioElement;
try {
element = new Audio(await resolveSource(fileId));
} catch {
return false;
}
element.volume = clampVolume(volume);
track(fileId, element);
// Конец звука освобождает место для следующего; в jsdom события нет.
element.addEventListener?.('ended', () => untrack(fileId, element));
try {
await element.play();
return true;
} catch {
untrack(fileId, element);
return false;
}
}
/** Запуск без ожидания: обработчики событий не должны ждать звука. */
export function playSoundFileInBackground(fileId: string, volume?: number): void {
void playSoundFile(fileId, volume).catch(() => undefined);
}
/** Полная очистка движка: кэш ссылок, играющие элементы (тесты и выход). */
export function resetSoundboardEngine(): void {
stopAllSounds();
for (const pending of sources.values()) {
void pending
.then((url) => {
if (url.startsWith('blob:') && typeof URL.revokeObjectURL === 'function') {
URL.revokeObjectURL(url);
}
})
.catch(() => undefined);
}
sources.clear();
}
+2 -1
View File
@@ -14,6 +14,7 @@
/** Событие системного сообщения. */
export type SystemMessageEvent =
| { kind: 'member_join'; userId: string }
| { kind: 'member_leave'; userId: string }
| { kind: 'member_kick'; userId: string }
| { kind: 'channel_created'; name: string; channelType: string };
@@ -33,7 +34,7 @@ export function parseSystemMessage(content: string): SystemMessageEvent | null {
const record = asRecord(payload);
const event = record?.['event'];
const userId = record?.['user_id'];
if (event === 'member_join' || event === 'member_kick') {
if (event === 'member_join' || event === 'member_leave' || event === 'member_kick') {
return typeof userId === 'string' && userId !== '' ? { kind: event, userId } : null;
}
if (event === 'channel_created') {