Case study
Разбор проекта
SkyTalk.Website
Architecture of a real-time ICAO radio-communication trainer: orchestrating live voice, a lesson state machine, and a cockpit instrument display.
Архитектура real-time тренажёра радиообмена ICAO: оркестрация живого голоса, машины состояний и кабинного индикатора.
React 18ViteFastifyWebSocketSQLitePeerJS (self-hosted) + WebRTCWeb Audio API
01 · The engineering challenge
01 · Инженерный вызов
Three processes that have to move as one
SkyTalk isn't just "an app for practicing phraseology." It's a distributed system in which three parallel processes have to move in lockstep, without losing the pace of live interaction:
- Live voice — direct peer-to-peer audio between tutor and student, with a simulated radio channel.
- A strict lesson state machine — steps, data input (frequency, squawk code, heading, altitude), server-side validation, transitions driven by user actions.
- A reactive instrument panel (PFD) — artificial horizon, speed, altitude, compass — that comes alive as the lesson progresses.
The real engineering difficulty isn't hiding the correct answer — the student is learning, not sitting an exam. It's making all of these components behave as a single organism despite network instability, asynchronous browser APIs, and the need for strict event ordering.
The central architectural question: how do you reconcile two unsynchronized real-time channels, guarantee lesson-state consistency across every participant, and drive instrument animation at the same time — without creating races, desyncs, or data loss?
This page shows how we answered that, and which engineering decisions shaped the system's final shape.
Три процесса, которые должны двигаться как один
SkyTalk — это не просто «приложение для тренировки фразеологии». Это распределённая система, в которой три параллельных процесса должны двигаться синхронно, не теряя темпа живого взаимодействия:
- Живой голос — прямое peer-to-peer аудио между инструктором и студентом с имитацией радиоканала.
- Строгая машина состояний урока — шаги, ввод данных (частота, код ответчика, курс, высота), проверка на сервере, переходы по действиям пользователей.
- Реактивная приборная панель (PFD) — авиагоризонт, скорость, высота, компас, которые оживают по мере прохождения урока.
Настоящая инженерная сложность — не в том, чтобы спрятать правильный ответ (студент учится, а не сдаёт экзамен), а в том, чтобы все эти компоненты работали как единый организм, несмотря на нестабильность сети, асинхронность браузерных API и необходимость строгой очерёдности событий.
Центральный вопрос архитектуры: как совместить два несинхронизированных канала реального времени, гарантировать консистентность состояния урока на всех участниках и одновременно управлять анимацией приборов — не создав гонок, рассинхронов и потери данных?
Этот документ показывает, как мы ответили на этот вызов, и какие инженерные решения определили финальный облик системы.
02 · Requirements & constraints
02 · Требования и ограничения
What it must do — and under what conditions
| FR | Tutor uploads a lesson file (flight.json) and creates a room; the server validates it against the JSON Schema and returns structured errors. Student joins via a one-time link, no registration — the tutor enters their own room the same way, but only after presenting the shared password used to create it. A direct voice channel between them: the student can only speak while holding push-to-talk. Where the scenario requires it, the student enters exact values (frequency, squawk, heading, altitude) into structured fields, validated against the specification's regexes. Advancing to the next step happens only once the tutor's client confirms it — or through verbal coordination, which the system doesn't force. The cockpit instrument display (PFD) visualizes flight parameters — squawk, COM, altitude, heading, artificial horizon, speed — with state arriving from the server as part of the lesson scenario. |
|---|---|
| NFR | A dropped WebSocket connection must restore lesson state without losing the step, input index, or attempt count. The two real-time channels — application state and WebRTC signaling — must coordinate correctly despite asynchronous microphone access and peer registration. Browser audio-context restrictions (autoplay policy) must not block the radio-degradation effects. The correct value for any step lives only on the server and never reaches the student's browser — as a natural consequence of an architecture with server-side validation, not as a separate "security feature." |
| Constraints | Lesson authoring happens outside the system; SkyTalk consumes finished files capped at 256 KB. The formal specification is locked before implementation: normative Annexes outrank the design document, which outranks operational guidance. |
Что система обязана делать — и в каких условиях
| FR | Инструктор загружает файл урока (flight.json) и создаёт комнату. Сервер валидирует его по JSON Schema, возвращает структурированные ошибки. Студент присоединяется по одноразовой ссылке без регистрации. Инструктор попадает в свою комнату аналогично, но после предъявления общего пароля для создания. Прямой голосовой канал между ними: студент говорит только при удержании Push-to-Talk (PTT). Там, где требует сценарий, студент вводит точные значения (частоту, код ответчика, курс, высоту) в структурированные поля — валидация по регулярным выражениям из спецификации. Переход на следующий шаг — только после подтверждения клиентом инструктора (или устной координации, что система не форсирует). Кабинный индикатор (PFD) визуализирует параметры полёта: squawk, com, высоту, курс, авиагоризонт, скорость. Состояние приходит с сервера как часть сценария урока. |
|---|---|
| NFR | Оборванное WebSocket-соединение должно восстанавливать состояние урока без потери шага, индекса ввода и счётчика попыток. Два канала реального времени (состояние приложения и сигналинг WebRTC) должны координироваться корректно, несмотря на асинхронное получение доступа к микрофону и регистрацию пира. Ограничения браузерного аудиоконтекста (autoplay policy) не должны блокировать эффекты радиодеградации. Правильное значение любого шага хранится только на сервере — в браузер студента оно не попадает как естественное следствие архитектуры с серверной валидацией, а не как отдельная «фича безопасности». |
| Ограничения | Авторинг уроков вне системы; SkyTalk потребляет готовые файлы ≤ 256 КБ. Формальная спецификация зафиксирована до реализации: нормативные приложения (Annexes) главнее проектного документа, который главнее операционных рекомендаций. |
03 · Architecture
03 · Архитектура
Two independent channels, one orchestrator
The pnpm monorepo has three packages. @skytalk/core is pure, I/O-free code holding lesson types, validation regexes, the room state machine, and a role-based state-projection function, viewStepForRole. Both server and web depend on it; it depends on nothing. That function decides what each side sees at any given moment — not for the sake of "secrecy," but for a clean separation of responsibilities.
At runtime the system holds two separate real-time channels:
- The application WebSocket (
/ws/rooms/:id) — the single source of truth for the lesson's progress. It carries state snapshots, input results, step transitions, PTT signals, and readiness coordination for the voice call. The server owns the state machine. - PeerJS signaling + WebRTC — the direct audio channel between browsers. Signaling runs through its own PeerServer on the same host (
/peerjs). STUN lives there too, on port 3478. No external service — everything stays on one machine.
These two channels are coordinated by one narrow bridge: a peer_ready message, sent over the WebSocket by whichever client has both gotten microphone access and finished peer registration. The server relays it to the other side, which triggers the call. No protocol mixing — audio goes directly, and all coordination goes through the server.
Два независимых канала, один оркестратор
В pnpm-монорепозитории три пакета. @skytalk/core — чистый код без ввода-вывода, содержащий типы урока, регулярки валидации, конечный автомат комнаты и функцию проекции состояния по роли (viewStepForRole). От него зависят server и web, сам он не зависит ни от чего. Именно эта функция определяет, что видит каждая сторона в конкретный момент — но не ради «секретности», а ради чёткого разделения зон ответственности.
В рантайме система держит два раздельных канала реального времени:
- Прикладной WebSocket (
/ws/rooms/:id) — единственный источник истины о ходе урока. Через него передаются снимки состояния, результаты ввода, переходы шагов, сигналы PTT и координация готовности к голосовому звонку. Сервер — хозяин машины состояний. - PeerJS-сигналинг + WebRTC — прямой аудиоканал между браузерами. Сигналинг работает через собственный PeerServer на том же хосте (
/peerjs). STUN — там же, на порту 3478. Никакого внешнего сервиса, всё в пределах одной машины.
Эти два канала скоординированы одним узким мостиком: сообщением peer_ready, которое клиент отправляет в WebSocket, когда одновременно получен доступ к микрофону и завершена регистрация пира. Сервер ретранслирует его противоположной стороне, что запускает инициацию звонка. Никакого смешивания протоколов — аудиоданные идут напрямую, а всё управление — через сервер.
Only the tutor's outgoing voice passes through the Web Audio chain — the radio-degradation filters — before being sent to the peer. The return channel from the student isn't processed, so the tutor hears exact speech for grading.
Только исходящий голос инструктора проходит через цепочку Web Audio (фильтры радиодеградации) перед отправкой в пир. Обратный канал от студента не обрабатывается, чтобы инструктор слышал точную речь для оценки.
Access and room management
The tutor's shared password is needed only to create a room (/api/rooms). Once created, both sides receive unique tokens embedded in their URL: /tutor/{room}?token=… and /student/{room}?token=…. The WebSocket connection authenticates with this token at handshake. One role, one active connection per room — a new connection evicts the old one. A 30-second heartbeat check reclaims any slot that's gone quiet for a minute. Every state change — a transition, an input, a completion — is written to SQLite first, then broadcast to clients. Under any failure, stored state never lags behind what users have already seen.
The state machine and its triggers
The heart of the lesson is the finite state machine in @skytalk/core. It defines the states LISTENING, INPUT_REQUIRED, INPUT_NOT_REQUIRED, READY_TO_ADVANCE, with transitions driven by messages:
start_lesson→LISTENINGptt(by tutor,pressed: false) →INPUT_REQUIREDorINPUT_NOT_REQUIRED, depending on whether the step defines required inputssubmit_input(student only) → validated, a result returned; once attempts are exhausted or the input is correct →INPUT_NOT_REQUIREDskip_step,repeat_step,end_lesson— only from the tutor
Important: the tutor releasing PTT is the one domain event "grounded" in an observable signal. The specification described it as "the tutor finished speaking" without specifying how to determine that. Reusing an already-existing voice-channel gesture gave a precise trigger without any extra machinery. Other transitions, where the specification deliberately left verbal coordination in place — the final step transition — remain unenforced by software.
The instrument panel as a state consumer
The student's PFD isn't just decoration. It subscribes to the lesson's current step and extracts parameters from it: COM frequency, squawk code, assigned heading, altitude, speed, artificial-horizon angles. This data arrives as part of state_snapshot or step_changed and is reflected immediately on the animated instruments. Because step state is part of the model the server broadcasts to everyone, the PFD is always in sync with the scenario, with no separate control channel required. This reflects a deliberate framing: not "lesson control plus a picture," but a single reactive view over shared state.
Управление доступом и комнатами
Общий пароль инструктора нужен лишь для создания комнаты (/api/rooms). После создания обе стороны получают уникальные токены, встроенные в URL: /tutor/{room}?token=… и /student/{room}?token=…. WebSocket-соединение аутентифицируется этим токеном при рукопожатии. Одна роль — одно активное соединение на комнату. Новое соединение вытесняет старое. 30-секундная проверка пульса освобождает слот, если соединение молчит минуту. Каждое изменение состояния (переход, ввод, завершение) сначала записывается в SQLite, а затем рассылается клиентам. При любом сбое сохранённое состояние никогда не отстаёт от того, что увидели пользователи.
Машина состояний и триггеры
Сердце урока — конечный автомат в @skytalk/core. Он определяет состояния LISTENING, INPUT_REQUIRED, INPUT_NOT_REQUIRED, READY_TO_ADVANCE и переходы по сообщениям:
start_lesson→LISTENINGptt(by tutor,pressed: false) →INPUT_REQUIREDилиINPUT_NOT_REQUIRED(в зависимости от наличия required_inputs в шаге)submit_input(только студент) → проверка, возврат результата; при исчерпании попыток или успехе →INPUT_NOT_REQUIREDskip_step,repeat_step,end_lesson— только от инструктора
Важно: отпускание PTT инструктором — единственное доменное событие, которое «заземлено» на наблюдаемый сигнал. Спецификация описывала его как «инструктор закончил говорить», не указывая, как именно это определить. Переиспользование уже существующего жеста для голосового канала дало точный триггер без дополнительных механизмов. Другие переходы, где спецификация намеренно оставила устную координацию (финальный переход шага), остались программно непринуждаемыми.
Приборная панель как потребитель состояния
PFD студента — не просто декорация. Она подписывается на текущий шаг урока и извлекает из него параметры: частота COM, код ответчика, заданные курс, высоту, скорость, углы авиагоризонта. Эти данные приходят в составе state_snapshot или step_changed и немедленно отражаются на анимированных приборах. Поскольку состояние шага — это часть модели, которую сервер рассылает всем, PFD всегда синхронизирован со сценарием, не требуя отдельного канала управления. Такой подход демонстрирует, что система мыслилась не как «управление уроком + картинка», а как единое реактивное представление общего состояния.
04 · Key decisions
04 · Ключевые инженерные решения
Five decisions that shaped the system
Each names what the decision cost — a trade-off you don't acknowledge is a trade-off you didn't see.
Пять решений, определивших систему
В каждой названа цена решения — трейд-офф, который не назван, это трейд-офф, который не был замечен.
Two channels instead of one — separation of responsibilities
Два канала вместо одного — разделение зон ответственности
- Options
- Merge lesson state and WebRTC signaling into a single WebSocket-based protocol · route audio itself through the server as a relay · keep two independent channels, coordinated by a single flag.
- Chosen
- WebSocket for state, WebRTC for audio, coordinated via a
peer_readyflag. - Why
- The audio stream shouldn't pass through the server, to avoid latency and load. Mixing signaling and lesson control into one protocol would tie their reliability together — a WebSocket drop would also break the ability to reconnect the audio. Separating them lets each channel live its own life: voice can drop out temporarily without the lesson losing progress; the WebSocket can reconnect without touching the ongoing call. The bridge through a single
peer_readymessage is minimal and transparent. - What it cost
- The need for its own signaling server (PeerServer) and manual management of the call's lifecycle, plus the complexity of synchronizing readiness — covered in Hard Problems below.
- Варианты
- Слить состояние урока и сигналинг WebRTC в единый протокол на базе WebSocket · пропускать сам звук через сервер как ретранслятор · держать два независимых канала, скоординированных одним флагом.
- Выбрано
- WebSocket для состояния, WebRTC для аудио, координация через флаг
peer_ready. - Почему
- Аудиопоток не должен проходить через сервер, чтобы избежать задержек и нагрузки. Смешивать сигналинг и управление уроком в одном протоколе значило бы связать их надёжность — разрыв WebSocket обрывал бы и возможность переподключения аудио. Разделение позволило каждому каналу жить своей жизнью: голос может пропасть временно, но урок не потеряет прогресс; WebSocket может переподключиться, не трогая текущий аудиозвонок. Связка через единственное сообщение
peer_readyминимальна и прозрачна. - Цена
- Необходимость в собственном сигналинг-сервере (PeerServer) и ручном управлении жизненным циклом звонка, плюс сложность синхронизации готовности — разобрана в «Трудных местах» ниже.
The tutor's PTT as a deterministic progress trigger
PTT инструктора как детерминированный триггер прогресса
- Options
- Leave the "tutor finished speaking" moment to timers or heuristics · leave it entirely to verbal, human-judged coordination · tie it to the tutor's own PTT release, a signal already crossing the wire for audio control.
- Chosen
- PTT release by the tutor moves a step from
LISTENINGinto the input phase — or straight to waiting for advancement. The student cannot speed this transition up. - Why
- In real radio exchanges, the tutor finishes a transmission and the student acts. The simulation has to rest on an objective fact, not timers or guesses. The PTT-press signal is already carried over the WebSocket for audio control; using it as the state trigger too is natural and needs no extra implementation. This decision turns a behavioral requirement into a guaranteed event.
- What it cost
- A tutor who releases the button accidentally — a pause, interference — opens the student's input field prematurely. That can't be undone; only the step can be reset or the lesson continued. The trade-off is accepted deliberately: strict determinism removes ambiguity elsewhere.
- Варианты
- Оставить момент «инструктор закончил говорить» таймерам или эвристикам · оставить его целиком устной координации, на суд человека · привязать к собственному отпусканию PTT инструктором — сигналу, и так уже идущему по проводу ради управления звуком.
- Выбрано
- Отпускание кнопки PTT инструктором переводит шаг из
LISTENINGв фазу ввода — или сразу к ожиданию продвижения. Студент не может ускорить этот переход. - Почему
- В реальном радиообмене инструктор заканчивает сообщение, и студент действует. Эмуляция должна опираться на объективный факт, а не на таймеры или догадки. Сигнал нажатия PTT уже передаётся по WebSocket для управления звуком; использовать его для триггера состояния — естественно и без дополнительной реализации. Это решение превращает поведенческое требование в гарантированное событие.
- Цена
- Инструктор, случайно отпустивший кнопку (пауза, помеха), преждевременно открывает поле ввода студента. Отменить это нельзя — только сбросить шаг или продолжить. Такой компромисс принят осознанно: строгая детерминированность исключает неоднозначность в другом месте.
One-way radio degradation — fidelity to the training goal
Однонаправленная радиодеградация — верность учебной цели
- Options
- Degrade both directions symmetrically, like a real two-way radio · degrade only the student's outgoing voice · degrade only the tutor's outgoing voice.
- Chosen
- Only the tutor's outgoing voice passes through filters — static, band limiting; the student's voice is transmitted clean.
- Why
- The skill being trained is perceiving degraded speech, not producing it. The tutor needs to hear the student's phraseology clearly to judge whether it's correct. Fully simulating two-way interference would degrade the training itself.
- What it cost
- An unphysical radio-channel model — in reality interference is symmetric. Accepted deliberately: pedagogical value outweighs engineering purism.
- Варианты
- Деградировать обе стороны симметрично, как настоящее двустороннее радио · деградировать только исходящий голос студента · деградировать только исходящий голос инструктора.
- Выбрано
- Только исходящий голос инструктора проходит через фильтры (статика, ограничение полосы), голос студента передаётся чисто.
- Почему
- Отрабатывается навык восприятия искажённой речи, а не её генерации. Инструктор должен отчётливо слышать фразеологию студента, чтобы оценить правильность. Полная симуляция двусторонних помех ухудшила бы качество обучения.
- Цена
- Нефизичная модель радиоканала — в реальности помехи симметричны. Принято осознанно: педагогическая ценность выше инженерного перфекционизма.
Self-hosted signaling and STUN
Self-hosted сигналинг и STUN
- Options
- A hosted WebRTC platform · the public cloud PeerJS broker · a self-hosted PeerServer and STUN on the same host as the main application.
- Chosen
- PeerServer and STUN, self-hosted on the same machine.
- Why
- Dropping the dependency on third-party WebRTC providers makes the system self-contained. For a training tool with a predictable number of concurrent sessions, this is justified: operational simplicity and no external limits matter more than global scalability.
- What it cost
- No TURN relay is deployed, so networks with strict NAT may have problems. A conscious limitation, easily lifted by adding TURN if it becomes necessary.
- Варианты
- Хостинговая WebRTC-платформа · публичный облачный брокер PeerJS · self-hosted PeerServer и STUN на том же хосте, что и основное приложение.
- Выбрано
- PeerServer и STUN на том же хосте, что и основное приложение.
- Почему
- Отказ от зависимости от сторонних WebRTC-провайдеров делает систему автономной. Для учебного инструмента с предсказуемым числом одновременных сессий это оправдано: простота эксплуатации и отсутствие внешних лимитов важнее, чем глобальная масштабируемость.
- Цена
- TURN-релей не развёрнут, поэтому в сетях с жёстким NAT могут быть проблемы. Осознанное ограничение, легко снимаемое добавлением TURN при необходимости.
Write to the database before broadcasting — a consistency guarantee
Запись в БД до рассылки — гарантия консистентности
- Options
- Broadcast first, persist after (optimistic) · persist and broadcast concurrently · persist first, then broadcast.
- Chosen
- Every state transition is saved to SQLite first; only then is the event sent to clients.
- Why
- If the server crashes between sending and writing, users would see state that then gets lost. The reverse order creates only a small window where an event could be lost — and then the client can simply reconnect and not see its own last input reflected. The "database first, then clients" strategy guarantees that stored state never lags behind what's visible. This is a classic technique in systems with a reliability requirement.
- What it cost
- A slight delay for the synchronous SQLite write — within milliseconds — before broadcasting. At the scale of a single lesson, imperceptible.
- Варианты
- Сначала рассылка, потом запись (оптимистично) · запись и рассылка параллельно · сначала запись, потом рассылка.
- Выбрано
- Каждый переход состояния сначала сохраняется в SQLite, и только потом клиентам отправляется событие.
- Почему
- Если сервер упадёт между отправкой и записью, пользователи увидят состояние, которое потом потеряно. Обратный порядок даёт лишь небольшое окно потери события — тогда клиент может переподключиться и просто не увидеть свой последний ввод отражённым. Стратегия «сначала БД, потом клиенты» гарантирует, что сохранённое состояние никогда не отстаёт от видимого. Это классический приём в системах с требованием надёжности.
- Цена
- Незначительная задержка на синхронный SQLite (в пределах миллисекунд) перед рассылкой. Для масштаба одного урока — незаметно.
05 · Data model
05 · Модель данных
Role-based views, not a hidden answer
A room's SQLite row holds: the validated lesson, tutor and student tokens, an expiry time, the current step, input index, attempt and reset counters, and the machine's state. @skytalk/core operates on this row as pure logic.
The session_events table logs every meaningful transition — state change, step change, input, completion — tagged with the initiating role and a full JSON payload. Frequent, low-information events (PTT toggles and repeat signals) are excluded, so the log doesn't get bloated.
Splitting visibility by role isn't about "hiding the answer" — it's about each role seeing its own interface. The tutor gets the full step, including the expected readback and value, and also sees exactly what the student typed. The student sees only their own input and the result — correct or incorrect — not the reference value. This projection is implemented by a single function, viewStepForRole, which rules out permission checks scattered across the codebase.
Представления по роли, а не спрятанный ответ
SQLite-строка комнаты содержит: валидированный урок, токены инструктора и студента, время истечения, текущий шаг, индекс ввода, счётчики попыток и сбросов, состояние машины. @skytalk/core оперирует этой строкой как чистая логика.
Таблица session_events логирует все значимые переходы (смена состояния, шага, ввод, завершение) с указанием роли-инициатора и полным JSON-пейлодом. Частые, но неинформативные события (переключения PTT и сигналы повтора) исключены, чтобы не раздувать лог.
Разделение видимости по ролям — не про «спрятать ответ», а про то, что каждая роль должна видеть свой интерфейс. Инструктор получает полный шаг с ожидаемым повтором и значением, а также видит, что именно ввёл студент. Студент видит только свой ввод и результат (верно/неверно), но не эталон. Эта проекция реализована единственной функцией viewStepForRole, что исключает разбросанные проверки прав по коду.
06 · Hard problems: races and recovery
06 · Трудные места: гонки и восстановление
The microphone-vs-signaling-readiness race
Scenario: the student enters the room, and the browser asynchronously requests getUserMedia (microphone) and registers an identifier with PeerJS. Neither action waits for the other. An early version sent peer_ready immediately after peer registration, before the microphone had been granted. As a result, the tutor's call arrived at a peer with no media stream, and ICE negotiation failed. The failure showed up simply as "the call won't connect," with no visible cause.
Fix: peer_ready is sent only once both flags are up — peerReady && micReady. A simple but critical gate, without which the system was unusable on slow devices or whenever microphone permission was delayed. The fix deserves an explicit code comment so a future refactor doesn't remove it by accident.
Automatic retry of the first call
Even with correct synchronization, the first call attempt can still fail from network jitter or signaling delay. The system retries automatically — up to three attempts with a short interval — before surfacing an error. Otherwise any transient instability would look like the system had failed outright.
Reconnecting without losing progress
When the WebSocket drops, the client reconnects and receives a state_snapshot with the current step, input index, and attempt count. This guarantees an accidental disconnect never turns into having to restart the lesson. It's implemented by keeping all state on the server and sending a fresh snapshot at every new handshake.
Гонка микрофона и сигнализации готовности
Сценарий: студент заходит в комнату, браузер асинхронно запрашивает getUserMedia (микрофон) и регистрирует идентификатор в PeerJS. Оба действия не ждут друг друга. Ранняя версия отправляла peer_ready сразу после регистрации пира, до того как микрофон был получен. В результате звонок от инструктора приходил к пиру без медиапотока, и ICE-согласование падало. Ошибка проявлялась как «звонок не соединяется» без видимой причины.
Исправление: peer_ready отправляется только когда подняты оба флага: peerReady && micReady. Простой, но критически важный барьер, без которого система была неработоспособна на медленных устройствах или при задержке разрешения микрофона. Решение стоит явного комментария в коде, чтобы будущий рефакторинг не убрал его случайно.
Автоматический ретрай первого звонка
Даже при корректной синхронизации первая попытка звонка может сорваться из-за сетевого джиттера или задержки сигналинга. Система автоматически повторяет попытку — до трёх раз с небольшим интервалом — прежде чем показать ошибку. Иначе любая временная нестабильность выглядела бы как отказ системы.
Восстановление соединения без потери прогресса
При обрыве WebSocket клиент переподключается и получает state_snapshot с текущим шагом, индексом ввода и счётчиком попыток. Это гарантирует, что случайный разрыв не превратится в необходимость начинать урок заново. Реализовано за счёт хранения всего состояния на сервере и отправки снимка при новом рукопожатии.
07 · Verification
07 · Верификация
The architecture checked against its own claims
- The input validator accepts and rejects values strictly per the normative table (Annex C): frequencies, codes, flight levels.
- Invalid JSON on the WebSocket is ignored without dropping the connection; unrecognized messages return a
validation_failederror carrying the original sequence ID. - The role gate blocks commands from the wrong side: the student can't start, skip, or end the lesson; the tutor can't submit input.
- A student's PTT release doesn't trigger a transition out of
LISTENING; the tutor's does — exactly per specification. - On a WebSocket drop and reconnect, state is restored exactly.
- The database write always happens before the client receives the corresponding event.
Архитектура, проверенная против собственных заявлений
- Валидатор ввода принимает и отклоняет значения строго по нормативной таблице (Annex C): частоты, коды, эшелоны.
- Невалидный JSON на WebSocket игнорируется без разрыва соединения, нераспознанные сообщения возвращают ошибку
validation_failedс исходным seq_id. - Ролевой шлюз блокирует чужие команды: студент не может начать/пропустить/завершить урок, инструктор не может вводить значения.
- Отпускание PTT студентом не вызывает перехода из
LISTENING, а инструктором — вызывает, строго в соответствии со спецификацией. - При обрыве WebSocket и переподключении состояние восстанавливается в точности.
- Сохранение в БД всегда происходит раньше, чем клиент получает соответствующее событие.
08 · Retrospective
08 · Ретроспектива
If we built it again
- Add a TURN relay from the start, to avoid potential NAT problems on difficult networks.
- Replace the tutor's shared password with individual credentials, as soon as the number of tutors stops being "a small trusted circle."
- Turn the
peer_readygate into a regression test with simulated microphone delay, to prevent the fix from being accidentally rolled back. - Document the
required_inputs(array) divergence from the idealized JSON Schema (single input) more explicitly in the specification itself, not just in code comments.
Если бы мы строили систему заново
- Добавили бы TURN-релей с самого начала, чтобы избежать потенциальных проблем с NAT в сложных сетях.
- Заменили бы общий пароль инструктора на индивидуальные учётные данные, как только количество инструкторов перестало быть «маленьким доверенным кругом».
- Оформили бы шлюз
peer_readyкак регрессионный тест с симулированной задержкой микрофона, чтобы предотвратить случайный откат исправления. - Более явно задокументировали бы расхождение формата
required_inputs(массив) с идеализированной JSON Schema (один ввод) в самой спецификации, а не только в комментариях к коду.