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,10 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
// Повторы запросов выключены: на занятии ошибка должна быть видна сразу,
// а не маскироваться тремя тихими попытками.
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
export function Providers({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

View file

@ -1,12 +1,13 @@
import { createBrowserRouter, Navigate } from "react-router-dom";
import { MicCheck } from "@/pages/trainee/MicCheck";
import { Stub } from "@/shared/ui/Stub";
// Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md).
// session_id живёт в URL: /instructor?session=..., монитор открывают ссылкой.
export const router = createBrowserRouter([
{ path: "/", element: <Navigate to="/trainee" replace /> },
{ path: "/trainee", element: <Stub title="АРМ курсанта" card="lct-08" /> },
{ path: "/trainee", element: <MicCheck /> },
{ path: "/instructor", element: <Stub title="Пульт преподавателя" card="lct-17" /> },
{ path: "/wall", element: <Stub title="Внешний монитор" card="lct-16" /> },
{ path: "/dds", element: <Stub title="АРМ диспетчера ДДС" card="lct-20" /> },

View file

@ -2,11 +2,14 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { Providers } from "./app/providers";
import { router } from "./app/router";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<RouterProvider router={router} />
<Providers>
<RouterProvider router={router} />
</Providers>
</React.StrictMode>,
);

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>
);
}

View file

@ -0,0 +1,66 @@
// HTTP-клиент и хуки. Типы ответов HTTP в generated.ts пока не попадают —
// там только события WS; здесь описаны руками по docs/arch/CONTRACT.md#http-api.
import { useMutation, useQuery } from "@tanstack/react-query";
import type { Level, SessionMode } from "@/shared/types/generated";
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
) {
super(`${status} ${code}`);
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new ApiError(response.status, body.detail ?? response.statusText);
}
return response.json() as Promise<T>;
}
export interface Health {
status: string;
models_ready: boolean;
embeddings_ready: boolean;
scenarios_loaded: number;
offline: boolean;
}
export interface ScenarioSummary {
id: string;
title: string;
type: string;
level: Level;
topics: string[];
modes: SessionMode[];
dds: string | null;
}
export interface SessionInfo {
session_id: string;
scenario_id: string;
mode: SessionMode;
attempt: number;
trainee_id: string | null;
group_id: string | null;
}
export const useHealth = () =>
useQuery({ queryKey: ["health"], queryFn: () => request<Health>("/api/health"), refetchInterval: 5_000 });
export const useScenarios = () =>
useQuery({ queryKey: ["scenarios"], queryFn: () => request<ScenarioSummary[]>("/api/scenarios") });
export const useCreateSession = () =>
useMutation({
mutationFn: (body: { scenario_id: string; mode: SessionMode; trainee?: string; group?: string }) =>
request<SessionInfo>("/api/sessions", { method: "POST", body: JSON.stringify(body) }),
});

View file

@ -0,0 +1,7 @@
// session_id живёт в URL, а не в сообщении: преподаватель открывает
// /instructor?session=..., монитор — /wall?session=..., без логина,
// со второй машины по ссылке (docs/arch/CONTRACT.md).
export function sessionIdFromUrl(): string | null {
return new URLSearchParams(location.search).get("session");
}

View file

@ -0,0 +1,104 @@
// Типизированный сокет с переподключением.
//
// Три канала — три типа. Направление зашито в тип: у наблюдателя нельзя вызвать
// send, у преподавателя нет входящих. Разделение прав архитектурное, а не
// дисциплинарное (docs/arch/CONTRACT.md#каналы) — и на фронте тоже.
import type {
InstructorToServer,
ServerToObserver,
ServerToTrainee,
TraineeToServer,
} from "@/shared/types/generated";
export type ChannelStatus = "connecting" | "open" | "reconnecting" | "closed";
interface ChannelOptions<In> {
onEvent?: (event: In) => void;
onStatus?: (status: ChannelStatus) => void;
}
const RECONNECT_MIN_MS = 500;
const RECONNECT_MAX_MS = 5_000;
export class Channel<In extends { type: string }, Out extends { type: string }> {
private socket: WebSocket | null = null;
private attempt = 0;
private timer: ReturnType<typeof setTimeout> | null = null;
private closedByUs = false;
constructor(
private readonly path: string,
private readonly options: ChannelOptions<In> = {},
) {}
connect(): this {
this.closedByUs = false;
this.open("connecting");
return this;
}
private open(status: ChannelStatus): void {
this.options.onStatus?.(status);
const scheme = location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${scheme}://${location.host}${this.path}`);
socket.binaryType = "arraybuffer";
socket.onopen = () => {
this.attempt = 0;
this.options.onStatus?.("open");
};
socket.onmessage = (message) => {
if (typeof message.data !== "string") return; // бинарь курсанту разбирает аудиослой (lct-09)
this.options.onEvent?.(JSON.parse(message.data) as In);
};
socket.onclose = () => {
this.socket = null;
if (this.closedByUs) {
this.options.onStatus?.("closed");
return;
}
// Экспоненциальная пауза: при падении сервера вкладки всего класса
// не должны долбить его переподключениями каждую миллисекунду.
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * 2 ** this.attempt++);
this.options.onStatus?.("reconnecting");
this.timer = setTimeout(() => this.open("reconnecting"), delay);
};
this.socket = socket;
}
get isOpen(): boolean {
return this.socket?.readyState === WebSocket.OPEN;
}
// Пока соединения нет, события не копятся: истина — на сервере, и после
// переподключения интерфейс получит актуальное состояние, а не очередь старых правок.
send(event: Out): boolean {
if (!this.isOpen) return false;
this.socket!.send(JSON.stringify(event));
return true;
}
sendBinary(frame: ArrayBuffer): boolean {
if (!this.isOpen) return false;
this.socket!.send(frame);
return true;
}
close(): void {
this.closedByUs = true;
if (this.timer) clearTimeout(this.timer);
this.socket?.close();
}
}
export const callChannel = (sessionId: string, options?: ChannelOptions<ServerToTrainee>) =>
new Channel<ServerToTrainee, TraineeToServer>(`/ws/call/${sessionId}`, options);
/** Наблюдатель: отправлять нечего и нельзя — `Out` равен `never`. */
export const observeChannel = (sessionId: string, options?: ChannelOptions<ServerToObserver>) =>
new Channel<ServerToObserver, never>(`/ws/observe/${sessionId}`, options);
/** Преподаватель: только передача, входящих в этом канале нет. */
export const controlChannel = (sessionId: string, options?: { onStatus?: (status: ChannelStatus) => void }) =>
new Channel<never, InstructorToServer>(`/ws/control/${sessionId}`, options);

View file

@ -0,0 +1,46 @@
// Захват микрофона: getUserMedia → AudioWorklet → кадры PCM16 16 кГц по 20 мс.
import workletUrl from "./worklet.ts?worker&url";
export interface Capture {
readonly sampleRate: number;
stop(): Promise<void>;
}
export async function startCapture(onFrame: (frame: ArrayBuffer) => void): Promise<Capture> {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
// Оператор работает в гарнитуре: микрофон не слышит динамик, и эхоподавление
// не нужно. А шумоподавление и автоусиление браузера портят речь для
// распознавания сильнее, чем помогают (docs/arch/STACK.md).
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
});
const context = new AudioContext();
await context.audioWorklet.addModule(workletUrl);
const source = context.createMediaStreamSource(stream);
const processor = new AudioWorkletNode(context, "pcm-capture");
processor.port.onmessage = (message: MessageEvent<ArrayBuffer>) => onFrame(message.data);
// Узел должен быть частью графа, иначе браузер может не вызывать его обработку.
// Нулевое усиление — чтобы оператор не слышал в гарнитуре сам себя.
const silence = context.createGain();
silence.gain.value = 0;
source.connect(processor).connect(silence).connect(context.destination);
return {
sampleRate: context.sampleRate,
async stop() {
processor.port.onmessage = null;
source.disconnect();
processor.disconnect();
stream.getTracks().forEach((track) => track.stop());
await context.close();
},
};
}

View file

@ -0,0 +1,59 @@
// Нарезка звука микрофона в кадры PCM16 16 кГц по 20 мс — формат из контракта
// (docs/arch/CONTRACT.md#аудио-по-websocket). Ресемплинг делает фронт, а не сервер:
// так с микрофона не летит 48 кГц лишнего трафика, и сервер получает ровно то,
// что ждёт GigaAM.
//
// Работает потоком: AudioWorklet отдаёт звук кусками по 128 сэмплов, и граница
// кадра почти никогда не совпадает с границей куска.
export const TARGET_RATE = 16_000;
export const FRAME_SAMPLES = 320; // 20 мс при 16 кГц = 640 байт PCM16
export class Pcm16Framer {
private readonly ratio: number;
private sum = 0;
private count = 0;
private consumed = 0;
private nextBoundary: number;
private frame: Int16Array;
private filled = 0;
constructor(
inputRate: number,
outputRate = TARGET_RATE,
private readonly frameSamples = FRAME_SAMPLES,
) {
if (inputRate < outputRate) {
throw new Error(`частота микрофона ${inputRate} ниже целевой ${outputRate}`);
}
this.ratio = inputRate / outputRate;
this.nextBoundary = this.ratio;
this.frame = new Int16Array(frameSamples);
}
// Усреднение входных сэмплов на интервале одного выходного. Это грубый
// фильтр нижних частот, но без него всё, что выше 8 кГц, наложилось бы
// на речь при прореживании 48 → 16 кГц и испортило распознавание.
push(input: Float32Array): Int16Array[] {
const frames: Int16Array[] = [];
for (let i = 0; i < input.length; i++) {
this.sum += input[i];
this.count += 1;
this.consumed += 1;
if (this.consumed < this.nextBoundary) continue;
const sample = Math.max(-1, Math.min(1, this.sum / this.count));
this.frame[this.filled++] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
this.sum = 0;
this.count = 0;
this.nextBoundary += this.ratio;
if (this.filled === this.frameSamples) {
frames.push(this.frame);
this.frame = new Int16Array(this.frameSamples);
this.filled = 0;
}
}
return frames;
}
}

View file

@ -0,0 +1,18 @@
// Глобальная область AudioWorklet. В lib.dom её нет: там описан главный поток.
declare const sampleRate: number;
declare abstract class AudioWorkletProcessor {
readonly port: MessagePort;
constructor(options?: { processorOptions?: unknown });
abstract process(
inputs: Float32Array[][],
outputs: Float32Array[][],
parameters: Record<string, Float32Array>,
): boolean;
}
declare function registerProcessor(
name: string,
processorCtor: new (options?: { processorOptions?: unknown }) => AudioWorkletProcessor,
): void;

View file

@ -0,0 +1,21 @@
// Процессор захвата: звук микрофона → кадры PCM16 16 кГц по 20 мс.
// Работает в отдельном аудиопотоке браузера — главный поток с React не тормозит звук.
import { FRAME_SAMPLES, Pcm16Framer, TARGET_RATE } from "./resample";
class PcmCaptureProcessor extends AudioWorkletProcessor {
private readonly framer = new Pcm16Framer(sampleRate, TARGET_RATE, FRAME_SAMPLES);
process(inputs: Float32Array[][]): boolean {
const channel = inputs[0]?.[0];
if (channel) {
for (const frame of this.framer.push(channel)) {
// Буфер передаётся, а не копируется: 50 кадров в секунду без лишних аллокаций.
this.port.postMessage(frame.buffer, [frame.buffer]);
}
}
return true;
}
}
registerProcessor("pcm-capture", PcmCaptureProcessor);

View file

@ -27,3 +27,15 @@ body {
font-weight: 600;
margin: 0 0 8px;
}
/* Плотная казённая сетка: таблица вместо карточек. */
.page { padding: 16px 24px; }
.page h1 { font-size: 20px; font-weight: 600; margin: 0 0 12px; }
.page h2 { font-size: 16px; font-weight: 600; margin: 20px 0 8px; }
.grid { border-collapse: collapse; background: var(--panel); min-width: 480px; }
.grid th, .grid td { border: 1px solid var(--line); padding: 4px 8px; text-align: left; vertical-align: top; }
.grid th { width: 200px; font-weight: 600; background: var(--bg); }
button { font: inherit; padding: 6px 16px; border: 1px solid var(--line); background: var(--panel); cursor: pointer; }
button:disabled { opacity: 0.5; cursor: default; }
.warn { color: var(--warn); }
.violated { color: var(--violated); }

2
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
// Типы клиента Vite: без них TypeScript не знает импорты вида `./worklet.ts?worker&url`.