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:
parent
f01f2dab76
commit
f24031b5b2
21 changed files with 594 additions and 4 deletions
5
Makefile
5
Makefile
|
|
@ -36,6 +36,9 @@ models: ## Скачать модели в backend/models/ (сейчас — эм
|
|||
seed: ## Залить сценарии из /scenarios в БД
|
||||
cd backend && $(UV) run python scripts/seed.py
|
||||
|
||||
lesson: ## Запустить занятие и напечатать ссылки: make lesson s=<сценарий> m=<режим>
|
||||
cd backend && $(UV) run --no-project --with websockets python scripts/start_lesson.py "$(s)" "$(m)"
|
||||
|
||||
migrate: ## Накатить миграции
|
||||
cd backend && $(UV) run alembic upgrade head
|
||||
|
||||
|
|
@ -51,4 +54,4 @@ pregen: ## Дерево диалога и WAV первых реплик для
|
|||
demo: ## Поднять всё в демо-режиме: офлайн, прогретые модели
|
||||
@echo "не реализовано — карточка tasks/lct-21-demo-readiness.md"; exit 1
|
||||
|
||||
.PHONY: help dev down back front types test typecheck migrate revision models seed repl pregen demo
|
||||
.PHONY: help dev down back front types test typecheck lesson migrate revision models seed repl pregen demo
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
|
|
@ -31,9 +32,28 @@ from app.session.state import now_utc
|
|||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
#: Кадр контракта: 20 мс PCM16 моно 16 кГц = 320 сэмплов = 640 байт.
|
||||
FRAME_BYTES = 640
|
||||
FRAMES_PER_LOG = 250 # раз в пять секунд звука
|
||||
|
||||
_adapter = TypeAdapter(TraineeToServer)
|
||||
|
||||
|
||||
def _on_audio(session_id: UUID, state, frame: bytes) -> None:
|
||||
"""Приём аудиокадра. Голосовой контур (lct-06) заменит счёт на VAD → STT."""
|
||||
if len(frame) != FRAME_BYTES:
|
||||
state.bad_frames += 1
|
||||
if state.bad_frames == 1:
|
||||
# Один раз, а не на каждый кадр: неверный формат повторяется 50 раз в секунду.
|
||||
log.warning("сессия %s: кадр %d байт вместо %d — проверь ресемплинг на фронте",
|
||||
session_id, len(frame), FRAME_BYTES)
|
||||
return
|
||||
state.audio_frames += 1
|
||||
if state.audio_frames % FRAMES_PER_LOG == 0:
|
||||
log.info("сессия %s: получено %d кадров (%.0f с звука)",
|
||||
session_id, state.audio_frames, state.audio_frames * 0.02)
|
||||
|
||||
|
||||
def _next_hint(state) -> tuple[str, str] | None:
|
||||
"""Следующий неотработанный пункт чек-листа, который ещё не подсказывали.
|
||||
|
||||
|
|
@ -140,7 +160,22 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
|
|||
writer = asyncio.create_task(_pump(ws, queue))
|
||||
try:
|
||||
while True:
|
||||
payload = await ws.receive_json()
|
||||
message = await ws.receive()
|
||||
if message["type"] == "websocket.disconnect":
|
||||
return
|
||||
# Бинарные кадры — аудио, текстовые — события. Направление определяется
|
||||
# каналом, обёртки JSON вокруг звука нет (docs/arch/CONTRACT.md).
|
||||
if message.get("bytes") is not None:
|
||||
_on_audio(session_id, state, message["bytes"])
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(message.get("text") or "")
|
||||
except json.JSONDecodeError:
|
||||
hub.to_trainee(
|
||||
session_id,
|
||||
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message="не JSON"),
|
||||
)
|
||||
continue
|
||||
try:
|
||||
event = _adapter.validate_python(payload)
|
||||
except ValidationError:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Сборка приложения. Роутеры подключаются по мере готовности — см. tasks/."""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
|
@ -22,6 +23,12 @@ from app.scenarios.loader import ScenarioError
|
|||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
||||
# uvicorn настраивает только собственные логгеры: без этого INFO из модулей
|
||||
# приложения («получено N кадров», замеры задержки голоса) молча теряется,
|
||||
# а до лога доходят одни предупреждения. Чужие библиотеки — от WARNING.
|
||||
logging.basicConfig(level=logging.WARNING, format="%(levelname)-8s %(name)s: %(message)s")
|
||||
logging.getLogger("app").setLevel(logging.INFO)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
|
|
|
|||
|
|
@ -51,6 +51,11 @@ class SessionState:
|
|||
slots: SlotMachine | None = None
|
||||
persona: PersonaState | None = None
|
||||
|
||||
# Аудио курсанта. До голосового контура (lct-06) кадры только считаются —
|
||||
# этого достаточно, чтобы доказать, что звук доходит от микрофона до сервера.
|
||||
audio_frames: int = 0
|
||||
bad_frames: int = 0
|
||||
|
||||
started_at: datetime | None = None
|
||||
ended_at: datetime | None = None
|
||||
end_reason: CallEndReason | None = None
|
||||
|
|
|
|||
42
backend/scripts/start_lesson.py
Normal file
42
backend/scripts/start_lesson.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""make lesson: запустить занятие и напечатать ссылки на экраны.
|
||||
|
||||
Нужен, пока нет пульта преподавателя (lct-17): без запущенного занятия
|
||||
АРМ курсанта открыть не с чем. Занятие живёт в памяти сервера и после
|
||||
выхода скрипта остаётся запущенным.
|
||||
|
||||
make lesson сценарий по умолчанию, тренировочный режим
|
||||
make lesson s=fire-apartment-l2 m=exam
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import websockets
|
||||
|
||||
BACKEND = "ws://localhost:8000"
|
||||
FRONTEND = "http://localhost:5173"
|
||||
|
||||
|
||||
async def main(scenario_id: str, mode: str) -> None:
|
||||
session_id = str(uuid.uuid4())
|
||||
async with websockets.connect(f"{BACKEND}/ws/control/{session_id}") as control:
|
||||
await control.send(json.dumps({
|
||||
"type": "scenario.start",
|
||||
"scenario_id": scenario_id,
|
||||
"trainee": "Курсант",
|
||||
"mode": mode,
|
||||
}))
|
||||
await asyncio.sleep(0.5) # дать серверу зарегистрировать занятие до закрытия сокета
|
||||
|
||||
print(f"занятие {session_id} — {scenario_id}, режим {mode}")
|
||||
print(f" курсант: {FRONTEND}/trainee?session={session_id}")
|
||||
print(f" монитор: {FRONTEND}/wall?session={session_id}")
|
||||
print(f" преподаватель: {FRONTEND}/instructor?session={session_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
scenario = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] else "fire-apartment-l2"
|
||||
mode = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] else "training"
|
||||
asyncio.run(main(scenario, mode))
|
||||
|
|
@ -168,3 +168,29 @@ def test_call_socket_refuses_session_that_was_not_started(client):
|
|||
with client.websocket_connect(f"/ws/call/{uuid4()}") as trainee:
|
||||
message = trainee.receive_json()
|
||||
assert message["type"] == "error" and message["code"] == "session_not_found"
|
||||
|
||||
|
||||
def test_audio_frames_reach_the_server(client):
|
||||
"""Веха lct-08: кадры PCM16 16 кГц по 20 мс долетают до бэкенда."""
|
||||
with lesson(client) as (session_id, _):
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
for _ in range(50):
|
||||
trainee.send_bytes(b"\x00\x00" * 320) # секунда тишины
|
||||
trainee.send_bytes(b"\x00" * 100) # кадр не того размера
|
||||
wait_value(lambda: state.bad_frames or None)
|
||||
|
||||
assert state.audio_frames == 50
|
||||
assert state.bad_frames == 1, "кадр не того размера должен отбрасываться, а не считаться звуком"
|
||||
|
||||
|
||||
def test_events_still_work_between_audio_frames(client):
|
||||
"""Звук и события идут по одному сокету: бинарь не должен ломать разбор JSON."""
|
||||
with lesson(client) as (session_id, _):
|
||||
state = hub.get(session_id)
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
trainee.send_bytes(b"\x00\x00" * 320)
|
||||
trainee.send_json({"type": "kio.patch", "fields": {"floor": "5"}})
|
||||
trainee.send_bytes(b"\x00\x00" * 320)
|
||||
wait_for(lambda: state.kio.floor == "5")
|
||||
assert state.audio_frames == 2
|
||||
|
|
|
|||
27
frontend/package-lock.json
generated
27
frontend/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"name": "lct-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
|
|
@ -1126,6 +1127,32 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.103.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.103.1.tgz",
|
||||
"integrity": "sha512-rms8HqTGp6zA00dM+cUQ2eBcgzNJefuu5CAMB37i/6MiGT1zulPOytCFu2a0qjLqVR2n1jENPj9woqFQNuCzWA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.103.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.103.1.tgz",
|
||||
"integrity": "sha512-rmAPPApNEK17VXRJhtE1rja53n23VV5w30C0saCgJhhX+EpdUnVqMGV/s5nnzV43X75KTHKzuoiuxSzGTBIkag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.103.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
|
|
|
|||
10
frontend/src/app/providers.tsx
Normal file
10
frontend/src/app/providers.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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" /> },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<Providers>
|
||||
<RouterProvider router={router} />
|
||||
</Providers>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
|
|
|||
95
frontend/src/pages/trainee/MicCheck.tsx
Normal file
95
frontend/src/pages/trainee/MicCheck.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
frontend/src/shared/api/http.ts
Normal file
66
frontend/src/shared/api/http.ts
Normal 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) }),
|
||||
});
|
||||
7
frontend/src/shared/api/session.ts
Normal file
7
frontend/src/shared/api/session.ts
Normal 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");
|
||||
}
|
||||
104
frontend/src/shared/api/ws.ts
Normal file
104
frontend/src/shared/api/ws.ts
Normal 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);
|
||||
46
frontend/src/shared/audio/capture.ts
Normal file
46
frontend/src/shared/audio/capture.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
59
frontend/src/shared/audio/resample.ts
Normal file
59
frontend/src/shared/audio/resample.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
18
frontend/src/shared/audio/worklet-env.d.ts
vendored
Normal file
18
frontend/src/shared/audio/worklet-env.d.ts
vendored
Normal 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;
|
||||
21
frontend/src/shared/audio/worklet.ts
Normal file
21
frontend/src/shared/audio/worklet.ts
Normal 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);
|
||||
|
|
@ -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
2
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="vite/client" />
|
||||
// Типы клиента Vite: без них TypeScript не знает импорты вида `./worklet.ts?worker&url`.
|
||||
Loading…
Reference in a new issue