From 31e58d1a2ee1665b30a2014d31cd48cd004ad28e Mon Sep 17 00:00:00 2001 From: leubeem Date: Mon, 29 Jun 2026 06:02:03 +0200 Subject: [PATCH] fix(pm): tester-round readback, frequency tuning, and PTT fixes 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 --- app/pages/pm.vue | 38 ++++++++++++++++++++++++++----------- shared/utils/radioSpeech.ts | 6 +++--- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/app/pages/pm.vue b/app/pages/pm.vue index cd70222..3e80e3f 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -602,7 +602,7 @@

{{ isRecording ? 'Transmitting' : 'Hold to transmit' }}

{{ isRecording ? 'Hotkey transmitting' : 'Hotkey armed' }}

@@ -3199,7 +3199,10 @@ const speakPilotReadback = (text: string) => { const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = 'text') => { const transcript = message.trim() - if (!transcript) return + // Ignore empty or content-free transmissions: silence, a stray PTT tap, or + // Whisper hallucinating punctuation on near-silent audio. A genuine short call + // ("roger", "wilco") still contains letters/digits and passes. + if (!transcript || !/[a-z0-9]/i.test(transcript)) return const prefix = source === 'ptt' ? 'Pilot (PTT)' : 'Pilot' setLastTransmission(`${prefix}: ${transcript}`) @@ -3499,11 +3502,9 @@ const startMonitoring = async (flightPlan: any, scenario: Scenario) => { currentScreen.value = 'monitor' persistSelectedPlan(flightPlan) - // Start every scenario from a known baseline frequency. Tuning to the first - // controller is part of the exercise: the pilot must dial the correct - // frequency themselves, and the first call is rejected (telling them which - // frequency to switch to) until they are on it. We deliberately do NOT - // auto-tune to the expected frequency here. + // Start from a known baseline; the first controller's frequency is tuned in + // automatically below, once the initial state-walk tells us which pilot state + // the scenario opens on. frequencies.value.active = '121.900' frequencies.value.standby = '118.100' @@ -3519,6 +3520,17 @@ const startMonitoring = async (flightPlan: any, scenario: Scenario) => { } catch (err) { console.warn('Initial state advance failed:', err) } + + // Pre-tune COM1 active to the frequency the opening pilot call is expected on, + // so the very first transmission isn't met with a confusing 'wrong frequency' + // rejection. Later handoffs stay manual — tuning to the next controller + // remains part of the exercise. + await nextTick() + const firstFreq = expectedFrequencyForState() + if (firstFreq) { + frequencies.value.active = firstFreq + pmLog.info('Pre-tuned COM1 active to first frequency:', firstFreq) + } } const startDemoFlight = () => { @@ -3745,6 +3757,10 @@ const startRecording = async (isIntercom = false) => { return } + // Barge-in: keying the mic cuts any ATC speech still playing, mirroring a real + // half-duplex radio where transmitting overrides the controller's output. + stopCurrentSpeech() + // Pre-recording path: ring buffer + active capture, encoded to WAV on release if (prerecEnabled.value) { if (!prerecCtx) { diff --git a/shared/utils/radioSpeech.ts b/shared/utils/radioSpeech.ts index 379e8d3..7fdba52 100644 --- a/shared/utils/radioSpeech.ts +++ b/shared/utils/radioSpeech.ts @@ -226,11 +226,11 @@ function freqSpeak(raw: string): string { return `${leftSpoken} decimal ${rightSpoken}`; } -const VIA_TAXI_ROUTE_PATTERN = /\b((?:expect\s+taxi\s+)?via\s+)([A-Z0-9\s/\-]+?)(?=(?:,|\s+(?:hold short|cross|then|contact|monitor|with|for|to|left|right)\b|\.)|$)/gi; +const VIA_TAXI_ROUTE_PATTERN = /\b((?:expect\s+taxi\s+)?via\s+)([A-Z0-9\s/,\-]+?)(?=\s*,?\s*(?:hold short|cross|then|contact|monitor|with|for|to|left|right)\b|\s*\.|$)/gi; -const TAXI_ROUTE_LABEL_PATTERN = /\b(taxi(?:-?in)?\s+route[:\s]+)([A-Z0-9\s/\-]+?)(?=(?:,|\s+(?:hold short|cross|then|contact|monitor|with|for|to|left|right)\b|\.)|$)/gi; +const TAXI_ROUTE_LABEL_PATTERN = /\b(taxi(?:-?in)?\s+route[:\s]+)([A-Z0-9\s/,\-]+?)(?=\s*,?\s*(?:hold short|cross|then|contact|monitor|with|for|to|left|right)\b|\s*\.|$)/gi; -const STAND_ROUTE_PATTERN = /\b(taxi\s+to\s+stand\s+[A-Z0-9]+\s+via\s+)([A-Z0-9\s/\-]+?)(?=(?:,|\s+(?:hold short|cross|then|contact|monitor|with|for|to|left|right)\b|\.)|$)/gi; +const STAND_ROUTE_PATTERN = /\b(taxi\s+to\s+stand\s+[A-Z0-9]+\s+via\s+)([A-Z0-9\s/,\-]+?)(?=\s*,?\s*(?:hold short|cross|then|contact|monitor|with|for|to|left|right)\b|\s*\.|$)/gi; const TAXI_SEGMENT_SINGLE = /^[A-Z]{1,2}$/; const TAXI_SEGMENT_WITH_DIGITS = /^[A-Z]{1,3}\d{1,3}$/;