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,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
|
||||
|
|
|
|||
Loading…
Reference in a new issue