feat(web): второй ряд дашборда и пинг участников в голосовой комнате
- дашборд инстанса: два ряда полукругов с подписями сверху — «Контейнер инстанса» (лимиты профиля) и «Сервер целиком» (вся машина); - голосовая комната: рядом с качеством связи видно пинг участника («Отличное соединение, 42 мс») и он обновляется раз в секунду. Свой RTT измеряется по статистике WebRTC, чужой приходит по data-каналу LiveKit (`glchat.latency`, lossy), поэтому пинг виден для каждого участника; - статистика берётся с любой своей дорожки: в аудио-сессии RTT раньше был недоступен (отчёт читался только с видео); - в «Аудио-видео» появился тумблер «Показывать пинг в голосовой комнате»: выключенный не опрашивает SDK и не публикует своё значение; - тесты: ряд хоста и подписи, пинг в плитке своего и чужого участника, публикация по служебной теме, выключенный пинг не дёргает SDK.
This commit is contained in:
+105
-3
@@ -1,4 +1,4 @@
|
||||
import type { LocalVideoTrack, Participant, Room, Track } from 'livekit-client';
|
||||
import type { LocalAudioTrack, LocalVideoTrack, Participant, Room, Track } from 'livekit-client';
|
||||
|
||||
import type { VoiceDeviceKind, VoicePublishQuality } from '@/stores/voice';
|
||||
|
||||
@@ -106,8 +106,20 @@ export interface VoiceRoomCallbacks {
|
||||
onStatus: (status: VoiceRoomStatus) => void;
|
||||
/** SDK сообщает, что свой микрофон говорит, хотя публикация выключена. */
|
||||
onSpeakingWhileMuted: () => void;
|
||||
/**
|
||||
* Пришёл (или измерился) пинг участника: `identity` — идентификатор
|
||||
* пользователя, `ms` — задержка до инстанса. `null` означает, что участник
|
||||
* отключился и значение нужно забыть.
|
||||
*/
|
||||
onLatency?: (identity: string, ms: number | null) => void;
|
||||
}
|
||||
|
||||
/** Топик служебных сообщений с пингом: чужие data-пакеты игнорируем. */
|
||||
export const LATENCY_TOPIC = 'glchat.latency';
|
||||
|
||||
/** Как часто участник публикует свой пинг. */
|
||||
export const LATENCY_INTERVAL_MS = 1_000;
|
||||
|
||||
export interface VoiceRoomHandle {
|
||||
connect: (url: string, token: string) => Promise<void>;
|
||||
disconnect: () => Promise<void>;
|
||||
@@ -121,6 +133,12 @@ export interface VoiceRoomHandle {
|
||||
setVideoQuality: (quality: VoicePublishQuality) => Promise<void>;
|
||||
/** Статистика своей публикации для попапа «Статистика». */
|
||||
fetchStats: () => Promise<VoiceStats>;
|
||||
/**
|
||||
* Запускает публикацию своего пинга раз в секунду и отдаёт функцию остановки.
|
||||
* Значение уходит всем участникам комнаты по data-каналу, поэтому пинг
|
||||
* каждого виден остальным (AGENT.md 7.14).
|
||||
*/
|
||||
startLatencyReporting: (shouldReport?: () => boolean) => () => void;
|
||||
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
|
||||
participants: () => VoiceParticipant[];
|
||||
}
|
||||
@@ -234,6 +252,68 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* startLatencyReporting измеряет свой RTT до инстанса раз в секунду,
|
||||
* отдаёт его локально и публикует в комнату. Ошибки глушим: телеметрия не
|
||||
* должна ломать голосовую сессию.
|
||||
*/
|
||||
const startLatencyReporting = (shouldReport?: () => boolean): (() => void) => {
|
||||
let stopped = false;
|
||||
const tick = async (): Promise<void> => {
|
||||
// Выключенный пинг не только не публикуется, но и не опрашивает SDK.
|
||||
if (stopped || shouldReport?.() === false) {
|
||||
return;
|
||||
}
|
||||
const identity = room.localParticipant.identity;
|
||||
let latency: number | null;
|
||||
try {
|
||||
latency = (await collectStats()).roundTripMs;
|
||||
} catch {
|
||||
// Отчёт WebRTC может быть недоступен — просто пропускаем такт.
|
||||
latency = null;
|
||||
}
|
||||
if (stopped || latency === null) {
|
||||
return;
|
||||
}
|
||||
callbacks.onLatency?.(identity, latency);
|
||||
const payload = new TextEncoder().encode(JSON.stringify({ ms: latency }));
|
||||
void room.localParticipant
|
||||
.publishData(payload, { reliable: false, topic: LATENCY_TOPIC })
|
||||
.catch(() => undefined);
|
||||
};
|
||||
void tick();
|
||||
const timer = setInterval(() => {
|
||||
void tick();
|
||||
}, LATENCY_INTERVAL_MS);
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
};
|
||||
|
||||
/** Своя дорожка микрофона: нужна для статистики в аудио-сессии. */
|
||||
const microphoneTrack = (): LocalAudioTrack | undefined => {
|
||||
const track = room.localParticipant.getTrackPublication(Track.Source.Microphone)?.track;
|
||||
return track as LocalAudioTrack | undefined;
|
||||
};
|
||||
|
||||
/** parseLatency разбирает полезную нагрузку с пингом участника. */
|
||||
function parseLatency(payload: Uint8Array): number | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(new TextDecoder().decode(payload));
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return null;
|
||||
}
|
||||
const value = (parsed as { ms?: unknown }).ms;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 60_000) {
|
||||
return null;
|
||||
}
|
||||
return Math.round(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Число из отчёта WebRTC: `null`, если поля нет или оно не число. */
|
||||
const num = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
@@ -248,7 +328,9 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
||||
* Чего SDK не отдал — остаётся `null`, интерфейс покажет «—».
|
||||
*/
|
||||
const collectStats = async (): Promise<VoiceStats> => {
|
||||
const track = videoPublication();
|
||||
// Качество канала (RTT, jitter, потери) есть и в аудио-сессии, поэтому
|
||||
// отчёт берём с любой своей дорожки: видео, иначе микрофон.
|
||||
const track = videoPublication() ?? microphoneTrack();
|
||||
if (track === undefined || typeof track.getRTCStatsReport !== 'function') {
|
||||
previousSample = null;
|
||||
return { ...EMPTY_VOICE_STATS };
|
||||
@@ -350,8 +432,26 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
||||
callbacks.onParticipants(snapshotAll(room));
|
||||
};
|
||||
|
||||
// Пинг участника: своё значение измеряем по WebRTC-статистике и рассылаем
|
||||
// по data-каналу, чужие значения принимаем и отдаём в колбэк.
|
||||
room.on(
|
||||
RoomEvent.DataReceived,
|
||||
(payload: Uint8Array, participant?: { identity: string }, _kind?: unknown, topic?: string) => {
|
||||
if (topic !== LATENCY_TOPIC || participant === undefined) {
|
||||
return;
|
||||
}
|
||||
const parsed = parseLatency(payload);
|
||||
if (parsed !== null) {
|
||||
callbacks.onLatency?.(participant.identity, parsed);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
room.on(RoomEvent.ParticipantConnected, refresh);
|
||||
room.on(RoomEvent.ParticipantDisconnected, refresh);
|
||||
room.on(RoomEvent.ParticipantDisconnected, (participant: Participant) => {
|
||||
callbacks.onLatency?.(participant.identity, null);
|
||||
refresh();
|
||||
});
|
||||
room.on(RoomEvent.TrackSubscribed, refresh);
|
||||
room.on(RoomEvent.TrackUnsubscribed, refresh);
|
||||
room.on(RoomEvent.TrackPublished, refresh);
|
||||
@@ -389,6 +489,8 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
||||
await room.disconnect();
|
||||
},
|
||||
|
||||
startLatencyReporting,
|
||||
|
||||
dispose() {
|
||||
room.removeAllListeners();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user