lct-08: каркас фронта, типизированный сокет, захват микрофона

Направление канала зашито в тип: у наблюдателя Out = never, отправить
нечего и нельзя; у преподавателя нет входящих. Разделение прав
архитектурное и на фронте тоже. Пока соединения нет, события не копятся:
истина на сервере, после переподключения придёт актуальное состояние.

Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра
всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено
числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц,
помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера
выключены: гарнитура эхо не даёт, а обработка портит речь для STT.

Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр
не того размера отбрасывается с одним предупреждением, а не 50 в секунду.

Логирование приложения не было настроено: uvicorn настраивает только
свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда
в логе не оказалось строки о принятых кадрах.

make lesson запускает занятие и печатает ссылки на экраны — пульта
преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем.

Путь из браузера с живым микрофоном не проверен: нужен человек, шаги
записаны в карточке.
This commit is contained in:
Ivan Gerasimov 2026-09-17 14:19:38 +03:00
commit f24031b5b2
21 changed files with 594 additions and 4 deletions

View file

@ -0,0 +1,95 @@
// Проверка сквозного пути звука до сервера — веха карточки lct-08.
// Полный АРМ курсанта собирается поверх этого в lct-09 и lct-10.
import { useEffect, useRef, useState } from "react";
import { type Capture, startCapture } from "@/shared/audio/capture";
import { type ChannelStatus, callChannel } from "@/shared/api/ws";
import { sessionIdFromUrl } from "@/shared/api/session";
import type { ServerToTrainee } from "@/shared/types/generated";
const STATUS_LABEL: Record<ChannelStatus, string> = {
connecting: "подключение",
open: "на связи",
reconnecting: "связь потеряна, переподключение",
closed: "отключено",
};
export function MicCheck() {
const sessionId = sessionIdFromUrl();
const [status, setStatus] = useState<ChannelStatus>("connecting");
const [events, setEvents] = useState<ServerToTrainee[]>([]);
const [framesSent, setFramesSent] = useState(0);
const [micRate, setMicRate] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
const capture = useRef<Capture | null>(null);
useEffect(() => {
if (!sessionId) return;
const ch = callChannel(sessionId, {
onStatus: setStatus,
onEvent: (event) => setEvents((prev) => [event, ...prev].slice(0, 12)),
}).connect();
channel.current = ch;
return () => {
ch.close();
void capture.current?.stop();
};
}, [sessionId]);
async function toggleMic() {
if (capture.current) {
await capture.current.stop();
capture.current = null;
setMicRate(null);
return;
}
try {
setError(null);
capture.current = await startCapture((frame) => {
if (channel.current?.sendBinary(frame)) setFramesSent((n) => n + 1);
});
setMicRate(capture.current.sampleRate);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
if (!sessionId) {
return (
<main className="page">
<h1>АРМ курсанта</h1>
<p className="warn">В адресе нет номера занятия: откройте ссылку вида /trainee?session=…</p>
</main>
);
}
return (
<main className="page">
<h1>АРМ курсанта — проверка звука</h1>
<table className="grid">
<tbody>
<tr><th>Занятие</th><td><code>{sessionId}</code></td></tr>
<tr><th>Канал</th><td className={status === "open" ? "" : "warn"}>{STATUS_LABEL[status]}</td></tr>
<tr><th>Микрофон</th><td>{micRate ? `включён, ${micRate} Гц → 16000 Гц` : "выключен"}</td></tr>
<tr><th>Кадров отправлено</th><td>{framesSent} ({(framesSent * 0.02).toFixed(1)} с звука)</td></tr>
</tbody>
</table>
<p>
<button type="button" onClick={toggleMic} disabled={status !== "open"}>
{micRate ? "Выключить микрофон" : "Включить микрофон"}
</button>
</p>
{error && <p className="violated">Ошибка микрофона: {error}</p>}
<h2>События сервера</h2>
<table className="grid">
<tbody>
{events.map((event, index) => (
<tr key={index}><th>{event.type}</th><td><code>{JSON.stringify(event).slice(0, 140)}</code></td></tr>
))}
</tbody>
</table>
</main>
);
}