From f24031b5b2660ba66430dca7216fc0cf66090183 Mon Sep 17 00:00:00 2001 From: Ivan Gerasimov Date: Thu, 17 Sep 2026 14:19:38 +0300 Subject: [PATCH] =?UTF-8?q?lct-08:=20=D0=BA=D0=B0=D1=80=D0=BA=D0=B0=D1=81?= =?UTF-8?q?=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=D0=B0,=20=D1=82=D0=B8=D0=BF?= =?UTF-8?q?=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=81=D0=BE=D0=BA=D0=B5=D1=82,=20=D0=B7=D0=B0=D1=85?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=20=D0=BC=D0=B8=D0=BA=D1=80=D0=BE=D1=84=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Направление канала зашито в тип: у наблюдателя Out = never, отправить нечего и нельзя; у преподавателя нет входящих. Разделение прав архитектурное и на фронте тоже. Пока соединения нет, события не копятся: истина на сервере, после переподключения придёт актуальное состояние. Ресемплинг 48 → 16 кГц усредняет по окну выходного сэмпла: без фильтра всё выше 8 кГц наложилось бы на речь и испортило распознавание. Проверено числами: секунда звука — ровно 50 кадров по 320 сэмплов из 48 и 44.1 кГц, помеха 15 кГц подавлена до 7.8%. Эхоподавление и автоусиление браузера выключены: гарнитура эхо не даёт, а обработка портит речь для STT. Бэкенд принимает бинарные кадры по тому же сокету, что и события; кадр не того размера отбрасывается с одним предупреждением, а не 50 в секунду. Логирование приложения не было настроено: uvicorn настраивает только свои логгеры, и весь INFO модулей app.* молча терялся — нашлось, когда в логе не оказалось строки о принятых кадрах. make lesson запускает занятие и печатает ссылки на экраны — пульта преподавателя ещё нет, а открыть АРМ курсанта без занятия не с чем. Путь из браузера с живым микрофоном не проверен: нужен человек, шаги записаны в карточке. --- Makefile | 5 +- backend/app/api/ws/call.py | 37 +++++++- backend/app/main.py | 7 ++ backend/app/session/state.py | 5 + backend/scripts/start_lesson.py | 42 +++++++++ backend/tests/test_ws.py | 26 ++++++ frontend/package-lock.json | 27 ++++++ frontend/package.json | 1 + frontend/src/app/providers.tsx | 10 ++ frontend/src/app/router.tsx | 3 +- frontend/src/main.tsx | 5 +- frontend/src/pages/trainee/MicCheck.tsx | 95 +++++++++++++++++++ frontend/src/shared/api/http.ts | 66 +++++++++++++ frontend/src/shared/api/session.ts | 7 ++ frontend/src/shared/api/ws.ts | 104 +++++++++++++++++++++ frontend/src/shared/audio/capture.ts | 46 +++++++++ frontend/src/shared/audio/resample.ts | 59 ++++++++++++ frontend/src/shared/audio/worklet-env.d.ts | 18 ++++ frontend/src/shared/audio/worklet.ts | 21 +++++ frontend/src/styles.css | 12 +++ frontend/src/vite-env.d.ts | 2 + 21 files changed, 594 insertions(+), 4 deletions(-) create mode 100644 backend/scripts/start_lesson.py create mode 100644 frontend/src/app/providers.tsx create mode 100644 frontend/src/pages/trainee/MicCheck.tsx create mode 100644 frontend/src/shared/api/http.ts create mode 100644 frontend/src/shared/api/session.ts create mode 100644 frontend/src/shared/api/ws.ts create mode 100644 frontend/src/shared/audio/capture.ts create mode 100644 frontend/src/shared/audio/resample.ts create mode 100644 frontend/src/shared/audio/worklet-env.d.ts create mode 100644 frontend/src/shared/audio/worklet.ts create mode 100644 frontend/src/vite-env.d.ts diff --git a/Makefile b/Makefile index 53acb95..2267eeb 100644 --- a/Makefile +++ b/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 diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py index 53a710f..6312758 100644 --- a/backend/app/api/ws/call.py +++ b/backend/app/api/ws/call.py @@ -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: diff --git a/backend/app/main.py b/backend/app/main.py index 05091ce..e958ee4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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): diff --git a/backend/app/session/state.py b/backend/app/session/state.py index de6d2cf..a2fdf90 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -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 diff --git a/backend/scripts/start_lesson.py b/backend/scripts/start_lesson.py new file mode 100644 index 0000000..30ce487 --- /dev/null +++ b/backend/scripts/start_lesson.py @@ -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)) diff --git a/backend/tests/test_ws.py b/backend/tests/test_ws.py index 9c50ba8..88b98a2 100644 --- a/backend/tests/test_ws.py +++ b/backend/tests/test_ws.py @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a63a26d..3e38b7f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index eabcc8d..775150e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx new file mode 100644 index 0000000..97810da --- /dev/null +++ b/frontend/src/app/providers.tsx @@ -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 {children}; +} diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 5eac4f0..0a16d13 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -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: }, - { path: "/trainee", element: }, + { path: "/trainee", element: }, { path: "/instructor", element: }, { path: "/wall", element: }, { path: "/dds", element: }, diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 4edc71c..4abd997 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( - + + + , ); diff --git a/frontend/src/pages/trainee/MicCheck.tsx b/frontend/src/pages/trainee/MicCheck.tsx new file mode 100644 index 0000000..d23aa80 --- /dev/null +++ b/frontend/src/pages/trainee/MicCheck.tsx @@ -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 = { + connecting: "подключение", + open: "на связи", + reconnecting: "связь потеряна, переподключение", + closed: "отключено", +}; + +export function MicCheck() { + const sessionId = sessionIdFromUrl(); + const [status, setStatus] = useState("connecting"); + const [events, setEvents] = useState([]); + const [framesSent, setFramesSent] = useState(0); + const [micRate, setMicRate] = useState(null); + const [error, setError] = useState(null); + const channel = useRef | null>(null); + const capture = useRef(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 ( +
+

АРМ курсанта

+

В адресе нет номера занятия: откройте ссылку вида /trainee?session=…

+
+ ); + } + + return ( +
+

АРМ курсанта — проверка звука

+ + + + + + + +
Занятие{sessionId}
Канал{STATUS_LABEL[status]}
Микрофон{micRate ? `включён, ${micRate} Гц → 16000 Гц` : "выключен"}
Кадров отправлено{framesSent} ({(framesSent * 0.02).toFixed(1)} с звука)
+

+ +

+ {error &&

Ошибка микрофона: {error}

} +

События сервера

+ + + {events.map((event, index) => ( + + ))} + +
{event.type}{JSON.stringify(event).slice(0, 140)}
+
+ ); +} diff --git a/frontend/src/shared/api/http.ts b/frontend/src/shared/api/http.ts new file mode 100644 index 0000000..dc43342 --- /dev/null +++ b/frontend/src/shared/api/http.ts @@ -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(path: string, init?: RequestInit): Promise { + 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; +} + +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("/api/health"), refetchInterval: 5_000 }); + +export const useScenarios = () => + useQuery({ queryKey: ["scenarios"], queryFn: () => request("/api/scenarios") }); + +export const useCreateSession = () => + useMutation({ + mutationFn: (body: { scenario_id: string; mode: SessionMode; trainee?: string; group?: string }) => + request("/api/sessions", { method: "POST", body: JSON.stringify(body) }), + }); diff --git a/frontend/src/shared/api/session.ts b/frontend/src/shared/api/session.ts new file mode 100644 index 0000000..cba4ac8 --- /dev/null +++ b/frontend/src/shared/api/session.ts @@ -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"); +} diff --git a/frontend/src/shared/api/ws.ts b/frontend/src/shared/api/ws.ts new file mode 100644 index 0000000..f40ac0c --- /dev/null +++ b/frontend/src/shared/api/ws.ts @@ -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 { + onEvent?: (event: In) => void; + onStatus?: (status: ChannelStatus) => void; +} + +const RECONNECT_MIN_MS = 500; +const RECONNECT_MAX_MS = 5_000; + +export class Channel { + private socket: WebSocket | null = null; + private attempt = 0; + private timer: ReturnType | null = null; + private closedByUs = false; + + constructor( + private readonly path: string, + private readonly options: ChannelOptions = {}, + ) {} + + 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) => + new Channel(`/ws/call/${sessionId}`, options); + +/** Наблюдатель: отправлять нечего и нельзя — `Out` равен `never`. */ +export const observeChannel = (sessionId: string, options?: ChannelOptions) => + new Channel(`/ws/observe/${sessionId}`, options); + +/** Преподаватель: только передача, входящих в этом канале нет. */ +export const controlChannel = (sessionId: string, options?: { onStatus?: (status: ChannelStatus) => void }) => + new Channel(`/ws/control/${sessionId}`, options); diff --git a/frontend/src/shared/audio/capture.ts b/frontend/src/shared/audio/capture.ts new file mode 100644 index 0000000..fdcabe6 --- /dev/null +++ b/frontend/src/shared/audio/capture.ts @@ -0,0 +1,46 @@ +// Захват микрофона: getUserMedia → AudioWorklet → кадры PCM16 16 кГц по 20 мс. + +import workletUrl from "./worklet.ts?worker&url"; + +export interface Capture { + readonly sampleRate: number; + stop(): Promise; +} + +export async function startCapture(onFrame: (frame: ArrayBuffer) => void): Promise { + 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) => 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(); + }, + }; +} diff --git a/frontend/src/shared/audio/resample.ts b/frontend/src/shared/audio/resample.ts new file mode 100644 index 0000000..f43a253 --- /dev/null +++ b/frontend/src/shared/audio/resample.ts @@ -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; + } +} diff --git a/frontend/src/shared/audio/worklet-env.d.ts b/frontend/src/shared/audio/worklet-env.d.ts new file mode 100644 index 0000000..bfb5f51 --- /dev/null +++ b/frontend/src/shared/audio/worklet-env.d.ts @@ -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, + ): boolean; +} + +declare function registerProcessor( + name: string, + processorCtor: new (options?: { processorOptions?: unknown }) => AudioWorkletProcessor, +): void; diff --git a/frontend/src/shared/audio/worklet.ts b/frontend/src/shared/audio/worklet.ts new file mode 100644 index 0000000..0de841f --- /dev/null +++ b/frontend/src/shared/audio/worklet.ts @@ -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); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 69abc60..7debe5b 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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); } diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..21a4cbc --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +// Типы клиента Vite: без них TypeScript не знает импорты вида `./worklet.ts?worker&url`.