Two of the items from the combined phraseology report were real.
The taxi-route rule treated the "via" of a departure clearance as the start of
a taxi route and comma-joined every token after it, so "cleared via MARUN7F
departure, climb 5000 feet" was read as "Marun, seven, Foxtrot, departure,
climb, fife, thousand, feet" — the SID's own name, number and suffix split into
three separate items, with the break in exactly the wrong place. The rule now
converts a run only when it is actually made of taxiway designators, which
leaves real taxi routes ("via N3, U4, A") reading as before.
"ILS Z" was read as a bare letter. The rule that should have expanded it
required the runway in the same match, and by the time it ran the runway had
already been rewritten to words, so it never fired on the phrasing the flows
use. Reading the variant is now independent of what follows it — where two
approaches serve the same runway, that letter is the only thing telling them
apart.
Checked the rest of the report and left it alone: taxiway sequences already
paused correctly, parallel runways already spoke their side in full, and no
approach clearance names a runway literally. Tests now pin all of it, including
two structural invariants over every flow — an approach clearance takes its
runway from the variable rather than a literal, and a readback quotes the
clearance items back in the order they were given.
Tuning is manual, so after a handoff nothing the pilot says goes through until
they dial the new frequency in. With the setting on, the radio does it for them
three seconds after the handoff was accepted, announcing "OpenSquawk changing
frequency to …" first so it is never a surprise.
Whether a change is due is decided from state rather than from an event, which
is what makes the two must-not-tune cases safe without a special case: a
frequency readback that was wrong and one not yet given both leave the session
on a state that still expects the frequency already dialled in, so nothing is
due. A pending change is dropped if the session ends or the pilot reaches for
the radio themselves — theirs wins.
Off by default: working the radio is part of what is being practised, so
handing it to the aircraft has to be a deliberate choice. The decision itself
is a pure function and covered by tests; the announcement goes through the same
speech and comm-log path as everything else, so the browser sim and the bridge
both see the tuned frequency the way they already do for a manual change.
Also folds the two copies of normalizedFrequencyValue into one in shared/, so
the auto-tune logic can compare frequencies without a composable import.
The timer was only armed for states carrying auto_advance_on_silence. Readback
states carry none — they wait for the pilot — so nothing was armed and ATC
never asked again when the readback did not arrive.
Which window applies to a state is now a pure function: the flow-authored one
for an auto-advance state, the much shorter server-published one for a readback
state. That also makes both cases testable instead of inline in the composable.
The PTT gate dropped any spoken transmission under two words, which is exactly
the length of the calls a pilot most often makes: "roger", "wilco", "affirm",
"negative". Those never reached the engine at all.
The gate is now phraseology-aware rather than length-only, and moved into a
pure function so it can be tested: standard short calls pass regardless of
length, while the things push-to-talk actually produces when nothing was said
are still dropped — punctuation from near-silent audio, a syllable clipped by
an early key release, and the phrases Whisper hallucinates on silence
("Thank you.", "Bye."), which are rejected only as a whole transcript so a real
sign-off still passes.
The ignore log now names the reason and the state it happened in, so a
transmission that vanished can be traced.
VFR scenarios drew from a fixed pool of D-E** registrations regardless of type,
so an A320 was assigned "D-ETMO". The letter after "D-" is assigned by weight
and type class (LuftVZO Anlage 1): D-E is single-engine pistons up to 2 t, so
an airliner has to be D-A.
Pick the class from the ICAO type designator — D-A for airliners, D-I for light
twins, D-H for helicopters, D-E for light singles and anything unrecognised,
since VFR scenarios are overwhelmingly light aircraft.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The broadcast and the flow variables used to read different sources: the audio
came from VATSIM while `information` was genATIS() — a random A-Z letter — and
the runway came from genRunway(), a hard-coded list unrelated to the airport.
So the pilot could hear "information Q" and be expected to call "information T".
Both now read the same AtisReport. The frequency list backfills the letter and
broadcast text from it, so an ATIS station always announces an information
letter even when no VATSIM controller is online. QNH and surface wind come from
the same observation, so the controller stops contradicting the ATIS.
A synthesised ATIS is labelled "Simulated ATIS" in the frequency picker rather
than being credited to VATSIM.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VATSIM is the only ATIS source today, so an airport with no controller online
(EDDF, on the day this was reported) falls back to a raw METAR — which carries
neither an information letter nor a runway in use.
Add shared/utils/atisReport.ts, which turns whatever the live sources returned
into one AtisReport:
vatsim station broadcasting text; letter from atis_code or parsed from the
text; runway parsed from the text
metar no station: letter derived from the observation time, runway from the
headwind component over the published runway ends
fallback no METAR either: letter from the clock, airport's main runway
Deriving the letter from the observation keeps it stable across background
refetches while still advancing once per METAR cycle. Runway selection compares
METAR true wind against OpenAIP trueHeading (no magnetic correction needed) and
honours takeOffOnly/landingOnly ends.
The synthesised broadcast leaves the METAR groups coded, because
normalizeAtisForSpeech() already expands them for TTS.
Also fixes runway extraction for "RUNWAY IN USE 22" — the phrasing German
VATSIM ATIS actually uses, which the previous pattern missed entirely.
Tests run offline against VATSIM/METAR/OpenAIP responses recorded 2026-07-26,
including the EDDC case where the wind-derived runway matches what the live
controller published.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
genSquawk() drew a decimal number in 1000-8999, so clearances could contain
the digits 8 or 9 — codes no transponder can dial. Replace it with a shared
generateSquawk() that draws four octal digits and re-rolls the reserved codes
(7500/7600/7700, 7000, 2000, 1200, 0000).
shared/learn/scenario.ts had its own octal generator that could still draw an
emergency code; it now uses the same helper.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Kokoro-82M generates several times slower than Piper — since the controller
pool moved onto it, every ATC reply arrived seconds late. Controllers return
to distinct Piper speakers with the standard US voice (ryan) as the product
default, so replies are fast again and the default sounds like before.
The classroom gets an instructor-voice setting: standard US voice by default,
'random per module' (stable instructor per module), or one of ten named
US/GB voices.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fnv1a's low bits barely mix: for the live-atc persona keys (shared session
prefix, ':TYPE:freq' suffix) hash % 4 was identical for Delivery, Ground and
Tower in 100% of sessions — every position spoke with one voice. A murmur3
fmix32 finalizer before the modulo restores uniform assignment; verified in
the browser (three stations, three voices, three paces).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Carrier-open click at speech start, ~230ms noise tail cut off by a closing
click at speech end. Playback holds the effect chain alive past the voice so
the tail rings out instead of being killed with the noise generators.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each ATC position (session + airport + type + frequency) now gets a stable
persona: a controller-pool voice and a base pace of 1.1-1.3x, jittered +/-0.05
per transmission. Anything spoken without an explicit voice — the controller
reply path and AI-traffic ATC lines — uses the persona; pilot voices are
untouched. A new session reshuffles the shift.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- NormalizedTelemetry carries lat/lon (0/0 no-GPS guard, position in the
change-detection signature) so the backend can derive distance triggers
- bridge polling moves from setInterval to a Worker tick: with the sim in the
foreground the /live-atc tab is backgrounded and Chrome throttles plain
intervals to ~1/min — telemetry-driven ATC lagged behind the silence fallback
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Move physics and telemetry/status ticking from requestAnimationFrame/setInterval
to a Web Worker (new shared/utils/intervalWorker.ts) — the intended usage is
WebSim in one tab and /live-atc in another, so the WebSim tab is almost always
backgrounded, and rAF stops entirely / setInterval throttles to ~1/min in that
state, dropping the bridge connection after a few minutes.
- Surface bridge failures in the header badge (connecting/error/connected) instead
of showing "verbindet…" forever when telemetry POSTs are failing.
- Replace the PFD's hardcoded 0.85 scale with a ResizeObserver-driven fit so it
no longer clips the VS tape/altitude readout at typical panel widths.
- Give the sidestick pad the same panel shell as the FCU/radio panels and stretch
all three to equal height.
- ExteriorView: camera height now reacts to altitude, the runway renders at its
real distance/lateral offset (was a fixed spot only shown under 3 NM) with
centerline/threshold markings, widened for visibility from the 12 NM range
it's now shown from, plus fog and a gradient sky.
Verified live: bridge badge stays green, PFD fits inside its panel at 1280x720,
and the runway becomes visible on approach for all four spawn presets.
- Neutralize the local engine's autonomous auto-advance (evaluateAutoTransitions/
evaluateSimpleAutoFlow) — the Python backend now drives state exclusively via
moveToSilent; the old walker could self-answer pilot states and race the
backend, producing loops.
- Dedupe applyBackendDecision's ATC log entry + TTS: moveToSilent no longer logs
say_tpl for backend-driven auto-advanced states (suppressSay), and a
lastAppliedSay guard drops a repeated decision from overlapping sources
(transmit reply, telemetry tick, silence timeout).
- Cap consecutive silence-timeout re-fires on the same state at 2 instead of
re-arming forever.
- Pause telemetry forwarding while a pilot transmission is in flight, and guard
against overlapping telemetry POSTs.
- Add request timeouts to TTS (20s) and backend transmit/telemetry/timeout
(30s)/createSession (60s) calls so a hung request can no longer freeze the
whole session.
Verified live against the Python backend: a full clearance→taxi chain now logs
each ATC line exactly once instead of 2-3x.
Flight model (ground/air physics, SELECTED/NAV/APPR/AUTOLAND autopilot with
STAR sequencing and ILS capture), bridge client that feeds the existing
/api/bridge/* endpoints so /live-atc can't tell it apart from a real bridge,
and the cockpit UI (PFD reuse, FCU, radio panel, Leaflet ND, three.js
exterior, spawn presets at EDDF/EDDS). Design doc:
docs/plans/2026-07-16-websim-design.md.
Also adds a local-dev-only auto-login (/dev-login, server/api/dev/login.post.ts)
that bypasses the invite-only login and MongoDB entirely via a fixed in-memory
user, so require-auth pages are reachable for local testing even when the dev
DB is unreachable. Hard-disabled outside development.
Status: unit tests green (yarn test) and typecheck clean (yarn typecheck).
Browser walkthrough of the actual cockpit (flying a preset, confirming
telemetry reaches /live-atc) is not yet done — picking up from a fresh dev
server + /dev-login?redirect=/flightlab/websim confirmed the spawn screen
renders past auth, but full instrument/map/exterior verification is still
outstanding.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
3fe0ea5 left `nuxt typecheck` failing on main:
useLiveAtcSession.ts: Property 'reason' does not exist on type
'SimControlParseResult'.
Cause: the union is discriminated by a boolean, and the project builds
with `strict: false` (nuxt.config.ts). With strictNullChecks off,
TypeScript does not treat `true`/`false` literal types as discriminants,
so `if (r.matched) … else r.reason` never narrows. Nothing about the
sim-control types themselves is wrong — the same three lines with any
boolean-discriminated union fail identically.
Adds isSimControlMatch/isSimControlRejection type predicates, which narrow
regardless of strictNullChecks, and routes the one caller through them.
Turning on strictNullChecks would fix it at the root but is a repo-wide
change, not a bug fix.
The pre-push hook was correctly refusing to push this; origin/main is
clean, so the breakage never escaped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the ai-traffic roadmap item per
docs/plans/2026-07-14-ai-traffic-architecture-design.md.
Simulated other aircraft on the user's frequency — callsigns, ATC
instructions, readbacks in their own stable voice, handovers — as pure
scenery. It never touches radioBackend: the Python backend keeps owning
the dialogue *with* the user, useAiTraffic owns the radio *around* the
user. The two share only the speech queue (arbitration) and the log.
Rules live as pure, seeded, framework-free modules under
shared/utils/aiTraffic/ so they run in tsx --test without a browser:
callsign collision rules, wake/in-trail separation, runway slots, the
speed ladder, direct validation, the §3 decision table, and the gating
chain. app/composables/useAiTraffic.ts wires them to Vue (1 Hz tick,
spawner, scheduler).
Gating is evaluated twice — before enqueue and again at playback, since
seconds pass in between. Traffic never keys up while the user holds PTT,
while their transmission is out at the backend, or inside the fresh
readback window. Off by default; the toggle surfaces the feature's v1
limitations rather than burying them in a doc.
Zero LLM calls: variance comes from seeded RNG over template variants.
Deviations from the design, both documented in the design doc:
- Adds SimAircraft.quietUntilSec. The design's rule table says "first
matching row per tick" but never says an instruction must be allowed to
take effect before the next one. Without it the planner re-derives the
same unresolved condition every second and nags one aircraft with the
same vector: 624 calls/30min measured, vs 90 with the cooldown.
- Airline pool limited to the 14 designators DEFAULT_AIRLINE_TELEPHONY
already knows; UAE/AUA/WZZ from the design would be spelled out letter
by letter instead of spoken as airline names.
Verified: 406 tests pass (176 new), no new typecheck errors, /live-atc
compiles and serves. The manual in-session walkthrough (audible traffic,
toggle mid-session) is NOT verified — it needs a login and the Python
backend. The 30-minute deterministic integration run stands in for it and
caught two of the three bugs found during development.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the open items from docs/plans/2026-07-14-frequency-sim-control-design.md
§4/§"Offen für die Implementierungsphase": a per-bridge-token in-memory command
queue piggybacked on the existing telemetry channel, plus the client-side gate
and TTS confirmations.
- server/utils/simControlQueue.ts: TTL-based queue keyed by bridge token
(enqueue → drainPending → resolve → drainResultsForClient).
- server/api/bridge/data.post.ts: response gains a `commands` field the
bridge drains on its next telemetry POST.
- server/api/bridge/command.post.ts (new): client enqueues a parsed command,
re-validated server-side via isValidSimControlCommand.
- server/api/bridge/command-result.post.ts (new): bridge reports ok/failed.
- server/api/bridge/live.get.ts: response gains `commandResults` so the
client can announce outcomes.
- shared/utils/simControl.ts: wire types, isValidSimControlCommand, and
simControlRejectionSpeech/simControlResultSpeech TTS phrasing.
- useLiveAtcSession.ts: parseSimControl() gated on bridgeConnected, wired in
right after the local special cases and before the frequency check —
matched commands never reach radioBackend.transmit().
- useSimBridgeSync.ts / live-atc.vue: bridgeToken threaded through, command
results forwarded from the telemetry poll to TTS.
43 new tests (shared parser/validation/speech + server queue lifecycle/TTL).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Roadmap item "frequency-sim-control": pilot speaks a self-service
sim setup command on frequency ("set me up for an approach from 5000
ft to EDDF 07R", "change my altitude to 8000"). Rule-based, not an
LLM call per transmission — a misparsed command changes the user's
real sim state, and it must never be confused with real ATC
phraseology routed through sttMatch.ts.
parseSimControl() gates on explicit self-service anchors first
("set me up", "put me", "change/set my <param>"); anything without
one of those anchors returns no_intent, so regular readbacks and
clearances can never be misrouted. Matched intents still refuse on
any ambiguous slot (missing unit, out-of-range, invalid runway) with
a typed reason instead of guessing.
Parameter vocabulary follows NormalizedTelemetry (altitude_ft,
ias_kts, heading_deg) since the command travels toward the bridge/sim,
not the decision engine's DecisionNodeAutoTrigger vocabulary.
Design doc covers the still-missing server→bridge write channel
(piggyback on the existing telemetry POST response) — not
implemented yet, this commit is the parser only.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The whole-string fuzzy fallback in callsignMatches() ran before the
digit-anchored check and let single-digit typos slip through (e.g.
"Lufthansa 350" matched expected "DLH359" at Levenshtein distance 1).
Spoken-digit-word candidates ("three five niner") bypassed the digit
anchor entirely since they contain no digit characters pre-fold.
Fold candidates through denormalizeSpokenAtc before checking for
digits, then require the flight number verbatim (word-boundary regex,
zero tolerance) while keeping the existing ~25% fuzzy tolerance on the
airline-name portion. Single-digit flight numbers (DLH4) now get the
same anchoring as longer ones.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Moves ATIS carrier/broadcast loop, TTS audio caching, METAR fallback,
and the background airport-data refresh scheduler out of live-atc.vue.
Also extracts the pmLog debug logger to shared/utils/pmLog.ts since it
has no Vue/component dependency and multiple composables need it —
threading it through every composable's parameter list would've been
worse than giving it one shared home. Verified via typecheck plus a
browser mount/redirect smoke test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- VFR scenarios get a German D-registration (e.g. D-EKLM) and its
abbreviated form (callsign_short, D-EKLM -> D-LM) instead of the airline
callsign; the pilot's first call uses the full registration, ATC uses the
short form thereafter.
- radioSpeech: spell aircraft registrations phonetically for TTS
("D-EKLM" -> "Delta Echo Kilo Lima Mike", "D-LM" -> "Delta Lima Mike").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From /pm tester bug reports:
- pre-tune COM1 to the opening pilot state's frequency on scenario start,
so the first call isn't met with a "wrong frequency" rejection (#5/#6/#21)
- taxi-route phonetics no longer stop at the first comma: "via A, V" now
speaks "via Alfa, Victor" (#31)
- barge-in: keying the mic stops any ATC speech still playing
- ignore empty / punctuation-only transmissions (silence, stray PTT taps)
- PTT pad turns green while transmitting (was red)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Whisper prompt seeding (per request):
- ptt.post.ts builds the prompt as generic ICAO bias + this state's expected
readback appended LAST (survives the 224-token truncation), in both raw token
form and spoken ICAO form via new radioSpeech.speakToken().
- pm.vue passes the expected phrase + active variable values; classroom.vue
passes the lesson's expected field values.
Per-field readback debug:
- sttMatch.matchTranscriptionToFields returns fields[] (matched/missing + which
view matched) plus normalized/denormalized transcription views.
- useRadioBackend types readback_report on the transmit response.
- pm.vue renders a "Readback check" panel in the right log rail; classroom.vue
renders per-field rows under the STT panel.
Radio-pronunciation fixes (radioSpeech.ts):
- callsign expander handles multi-letter suffixes (DLH6RK -> Lufthansa six Romeo
Kilo).
- toRadioSpeech now expands airports (EDDC -> Echo Delta Delta Charlie).
- bare altitudes >=1000 in a clearance context are spoken ("climb initially
5000" -> "climb initially five thousand feet"); speeds/headings untouched.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- per-airport ATIS loop keyed by station with a virtual start epoch, so
re-tuning resumes where the broadcast would be instead of restarting
- refetch airport data at :23/:53 to follow VATSIM ATIS regeneration from
real-world METAR publication, with faster retries while no ATIS is on
the feed; prefetch audio when the info letter changes
- support separate arrival/departure ATIS stations on different frequencies
- cancel the deferred audio teardown on retune so a fresh broadcast is not
killed by the previous stop()'s fade-out timer (atisAudioLoop)
- comm log shows newest entries first
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- SID basenames and 5-letter waypoints (ANEKI 7S, SULUS) are pronounceable
by design and are now spoken as words instead of letter-by-letter phonetics
- skip acronyms (ATIS, RNAV, MAIN, ...) when spelling 4-letter ICAO codes
- expand stand/gate designators, ATIS information letter, and surface wind
groups for TTS
- normalizeATCText now runs full client-side radiotelephony expansion
(callsigns, airports) since preNormalized texts skip the server normalizer
Note: tests/radioSpeech "normalizes SID suffix and METAR data" still expects
the old spelled-out SID behavior and fails until updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Scenario picker & completion:
- Login → scenario selection screen with complete chains + individual phases
- Completion screen with "fly again / try opposite / back to scenarios"
- Scenario.airport ('dep'|'arr') drives which airport frequencies to fetch;
arrival scenarios (vfr-arrival, circuit-landing, taxi-in) use arr ICAO
Backend session integration:
- createSession forwards no_chain; response carries active_flow + session_complete
- Pass all six airport frequency variables to session so every chained flow
has the real airport values from creation
- fetchAirportFrequencies now runs before session creation so resolved
frequencies are included in backendVariables
Wrong-frequency check:
- airportFreqMap computed (from airportFrequencies, always up-to-date)
used as primary source in expectedFrequencyForState — immune to flow
snapshot switches
- setActiveFlow called when response.active_flow changes so local engine
cursor moves to the correct flow's states after a chain
- Wrong-freq ATC reply appended to communication log (offSchema entry)
Engine fixes (communicationsEngine.ts):
- patchVariables / patchFlags: write directly to the internal reactive
store, bypassing readonly(ref) which silently blocked all (vars as any)
.value[k] = v mutations
- appendLogEntry: push ATC speech (and wrong-freq replies) into comm log
- ATC controller_say_rendered appended to comm log after every transmission
ATIS text often arrives in raw METAR form (e.g.
"METAR EDDF 281050Z AUTO 02008KT 320V070 CAVOK 24/02 Q1025 NOSIG"),
which TTS reads as letter-by-letter spelling. The normalizer now expands
the full WMO Code Form FM 15-XV vocabulary inline: DDHHMMZ date stamps,
compressed wind (with gusts, VRB, calm), wind variability ranges, RVR
(R25L/1500N), wind shear, slash-form temp/dewpoint, Q/A pressure,
NSC/SKC/CLR/NCD/VV cloud codes, weather phenomena (with intensity and
descriptors), recent-weather RE prefix, BECMG/TEMPO/FM/TL/AT trend
codes, and strips RMK remarks. Plus ATIS/METAR/SPECI get lowercased
so TTS pronounces them as words (pilots SPELL ILS/QNH/VOR so those
stay uppercase).
Airport ICAO codes are substituted with their OpenAIP name when the
frequencies endpoint returns one. New `airportName` field added to
the FrequencyResponse for that. Adds 7 test cases covering the user-
reported EDDF sample plus calm/VRB/gust winds, RVR, weather codes,
cloud specials, trend codes, and RMK stripping.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tuning to the ATIS frequency now plays carrier noise immediately so the
pilot gets feedback before the TTS comes back (synthesis takes a moment
on cold cache). When the ATIS audio is ready, the carrier ducks down to
a subtle bed level and stays underneath the announcement — mimicking
how a real radio channel always carries some noise floor.
Switches the loop from HTMLAudioElement (whose seek on data: URLs gets
quantized by some browsers) to a Web-Audio AudioBufferSourceNode.
`source.start(0, offset)` is sample-accurate per spec, so the
virtual-clock entry point lands exactly where computed. `window.__atisDebug`
exposes ctx/source/state for manual inspection, and pm.vue logs the
requestedOffset/duration/epochAge on each loop start.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The ATIS loop sent raw VATSIM `text_atis` to TTS, producing "Q-N-H one
thousand twenty-four", "WIND oh thirty degrees", "RUNWAY oh eight L".
New `normalizeAtisForSpeech` applies ATIS-specific transforms — info
letter → phonetic alphabet, wind/temperature/time digit-by-digit, TRL
expansion, NOSIG → "no significant change", cloud layers (BKN030 →
broken three thousand), visibility, bare runway designators — then
hands off to `normalizeRadioPhrase` for QNH/RWY/FL/freq. pm.vue calls
the normalizer before posting to /api/atc/say so the disk cache keys
the spoken form. Adds 4 test cases covering a full real-world EDDM
broadcast plus edge cases (cloud layers, negative temperatures, km/m
visibility).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Whisper routinely garbles the airline portion of a callsign while
nailing the flight number ("Loftansa three five niner", "Speed bird
27", "Lufthana 359"). Previously these slipped past the matcher.
Two changes:
1) Whole-string fuzzy distance for callsigns bumped to allowedDistance
+ 3 (was +1), which covers ~1–2 character substitutions in the
airline name.
2) New `callsignMatches()` splits each candidate into its alphabetic
airline prefix and trailing digit run and matches each part
independently:
- The digits (e.g. "359") are the strong anchor and must appear.
- The airline portion is matched both verbatim and with whitespace
stripped ("Speed bird" → "speedbird"), with a generous ~25%
character-distance allowance.
- Bare flight number without any airline trigger does NOT match —
verified by a dedicated false-positive test.
7 new test cases cover the realistic Whisper error modes (misspell,
split words, ICAO letter readout, reordered words, telephony glue).
All 69 tests green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Last pass fixed the crashes but the UX wasn't trustworthy — you'd hit
the mic, something happened, fields silently changed, and there was no
clear way to see what Whisper actually heard or which fields it touched.
This rebuilds that flow:
UI
- Dedicated transcription panel below the controls row replaces the
single-line "Heard:" hint. Has explicit states: recording (red,
pulsing dot, live MM:SS timer), transcribing (spinner), result
(editable textarea + summary chip), or error (red body text).
- Mic button label shows the elapsed recording time so the pilot knows
recording is actually running.
- Per-field mic icon appears on every blank that was filled by the
current transcription, so it's obvious what came from speech vs.
what was typed.
- Result panel exposes three explicit actions: Apply to fields (re-runs
the mapping after edits), Record again, Dismiss.
- Hard auto-stop at 45s (well under the server's 2 MB / ~60s cap).
- 503/unreachable responses from the PTT endpoint now flip
`sttServerAvailable` so the mic button gracefully hides itself.
Matching reliability (shared/utils/sttMatch.ts)
- Process fields longest-expected-first so a 6-char callsign claims its
substring before a 1-char digit field grabs an overlapping character.
- Short candidates (<3 chars) now require a whole-word boundary match,
so the digit "5" in callsign "359" no longer auto-fills an unrelated
readability field.
- Two new test cases cover both false-positive guards.
62 / 62 tests green, vue-tsc clean, dev server starts and serves the
classroom page without TDZ / hydration warnings in the log.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three bugs in yesterday's STT addition:
1) **TDZ crash** — `sttSupported` referenced `isClient` before its const
declaration, throwing on setup and breaking the whole classroom page.
`sttSupported` is now a ref that's populated in `onMounted`.
2) **Spoken vs. written mismatch** — Whisper returns natural ATC speech
("runway two five right", "lufthansa three five niner"), but the
lesson fields hold the canonical written form ("25R", "DLH359"). The
old `normalized.includes(...)` check never matched. Matching now lives
in `shared/utils/sttMatch.ts` and searches both the raw normalized
transcription *and* a denormalized projection that folds spoken
digits/letters back to written tokens (incl. SID suffix `7S`, runway
`25R`, scale words `five thousand → 5000`, frequency `decimal` as a
digit-run boundary).
3) **SSR hydration mismatch** — `sttSupported` evaluated differently on
server vs. client, causing visible-vs-hidden button divergence on
hydration. The ref-set-on-mount approach resolves it.
The new helper is fully unit-tested (15 cases covering radio check,
departure clearance, SIDs, squawks, Speedbird telephony, decimal
frequencies and edge cases).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fixes the most impactful issues from ~1.5h of testing:
- TTS: spell SID prefixes phonetically (ANEKI → Alfa November Echo Kilo
India) so unfamiliar waypoint names are intelligible without prior
briefing context.
- TTS: expand standalone uppercase waypoints after via/direct (with a
skip list for common ATC English tokens like MAYDAY, CLEARED, …).
- TTS: join taxi route tokens with ", " so pauses land between
taxiways (C5, Z5, U10, …) instead of running together.
- TTS: handle "ILS Z 25C" variant before the runway → "ILS Zulu runway
two five center" (was previously read as "Zee twentyfive cee").
- Scenarios: derive arrivalRunway from the chosen approach so the
controller no longer clears a flight for ILS 25C onto runway 18.
- Radio check: accept any readability 1–5 (numeric or spoken), shorten
placeholder so it fits the sm-width field.
- Line-up readback: clearer hint about the runway-first ICAO order.
- Classroom UI: disable browser autocomplete/autocorrect on readback
inputs (Edge autofill was injecting unrelated values).
- Classroom UI: "Speak answer" button replays the expected readback as
TTS so students can hear the correct phrasing.
Tests adjusted for the new SID and taxi-route phonetics.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Pass as top-level field in PTT requests so Whisper STT
results are linked to the correct Python backend session
- Add namespaced helper in pm.vue (info/warn/error/debug/group)
controlled by localStorage PM_DEBUG flag; logs transmit/response
cycles, TTS calls, flag/variable syncs, and fallback warnings
- Log backend session creation context (flow, start state, vars, flags)
in startMonitoring
- Fix typo in text input hint: STT fails not PTT fails
and
fix: sync backend variables to frontend after each transmission
The ATC say template was rendered using the frontend engine's local
variable defaults (squawk '1234', hardcoded SID, etc.) instead of
the authoritative values from the Python backend session. This caused
the spoken clearance and the readback prompt to show different squawk
codes.
- After each backend transmission response, sync all response.variables
into vars.value (same pattern already used for flags)
- Prefer controller_say_rendered (pre-rendered by backend) over the raw
template for TTS scheduling, eliminating any remaining dependency on
local variable state for the ATC speech text
Replace the LLM-per-request flow in /pm with a stateful Python backend
(OpenSquawk-LiveATC-api). The backend owns session state, does regex-first
routing with readback evaluation, and returns the next state + ATC speech.
The frontend keeps its local cursor (communicationsEngine) for TTS and
monitoring UI, but no longer calls /api/llm/decide.
Changes:
app/composables/useRadioBackend.ts (new)
Typed Nuxt composable wrapping the Python REST API:
createSession, transmit, deleteSession, fetchFlows.
Base URL read from NUXT_PUBLIC_RADIO_BACKEND_URL (default 127.0.0.1:8000).
nuxt.config.ts
Expose radioBackendUrl as a public runtime config key so the composable
and communicationsEngine can both reach the Python backend.
shared/utils/communicationsEngine.ts
- fetchRuntimeTree now accepts an optional baseUrl so it fetches from the
Python backend instead of the Nuxt server when a URL is provided.
- renderTpl handles both {var} (old MongoDB schema) and {{var}} (new YAML
schema) — double-brace matched first to avoid partial matches.
- stateSayTpl / stateUtteranceTpl helpers unify say_tpl|say_template and
utterance_tpl|expected_pilot_template across both schema versions.
- auto_transitions from the new YAML schema are included when collecting
eligible transitions in collectAtcStatesUntilPilotTurn.
shared/types/decision.ts
RuntimeDecisionState extended with say_template and expected_pilot_template
fields (new YAML schema field names alongside the existing legacy names).
app/pages/pm.vue
- startMonitoring: loads tree from Python backend, then creates a backend
session (backendSessionId). Cursor synced to session.current_state.
- handlePilotTransmission: calls radioBackend.transmit instead of
/api/llm/decide. Applies auto_advanced_states via moveToSilent, then
the final state. Speaks controller_say_template via TTS.
- Both fetchRuntimeTree calls now pass radioBackendUrl so they hit the
Python backend, not the Nuxt flow-from-MongoDB path.
AGENTS.md (new)
Project guide updated to document the new two-backend architecture,
the Python backend session lifecycle, and the dual template schema.
docs/plans/2026-05-06-pm-python-runtime-contract.md (new)
Implementation plan and API contract written before the work started.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix client: playbackRate was set to 1 for non-native-speed providers (Speaches/Piper),
making the speed slider ineffective in the main Pizzicato audio path
- Fix server: pass speed parameter to Speaches TTS API
- Add pitch-preserving playback via MediaElementSourceNode when rate != 1,
routing through the same Web Audio effects chain (radio filters, distortion, etc.)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>