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

@ -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:

View file

@ -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):

View file

@ -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

View 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))

View file

@ -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