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 <noreply@anthropic.com>
This commit is contained in:
leubeem
2026-06-29 06:02:03 +02:00
parent 2ea37b1da3
commit 31e58d1a2e
2 changed files with 30 additions and 14 deletions

View File

@@ -602,7 +602,7 @@
<ClientOnly>
<div
class="ptt-pad flex h-52 lg:h-60 items-center justify-center rounded-2xl border text-center transition cursor-pointer"
:class="isRecording ? 'border-red-400/40 ring-4 ring-red-400/40 bg-red-500/10' : 'border-white/10 ring-1 ring-white/5 bg-black/40'"
:class="isRecording ? 'border-green-400/40 ring-4 ring-green-400/40 bg-green-500/10' : 'border-white/10 ring-1 ring-white/5 bg-black/40'"
@touchstart.prevent="startRecording(false)"
@touchend.prevent="stopRecording"
@touchcancel.prevent="stopRecording"
@@ -614,22 +614,22 @@
<v-icon
:icon="isRecording ? 'mdi-access-point' : 'mdi-radio-handheld'"
size="44"
:class="isRecording ? 'text-red-400 animate-pulse' : 'text-cyan-300'"
:class="isRecording ? 'text-green-400 animate-pulse' : 'text-cyan-300'"
/>
<p
class="text-[11px] uppercase tracking-[0.35em] mt-1"
:class="isRecording ? 'text-red-300' : 'text-white/40'"
:class="isRecording ? 'text-green-300' : 'text-white/40'"
>
{{ isRecording ? 'Transmitting' : 'Hold to transmit' }}
</p>
<p
v-if="bridgePttConnected"
class="text-[10px] uppercase tracking-[0.25em] flex items-center justify-center gap-1.5"
:class="isRecording ? 'text-red-300' : 'text-cyan-300/70'"
:class="isRecording ? 'text-green-300' : 'text-cyan-300/70'"
>
<span
class="inline-block h-1.5 w-1.5 rounded-full"
:class="isRecording ? 'bg-red-400 animate-pulse' : 'bg-cyan-300/60'"
:class="isRecording ? 'bg-green-400 animate-pulse' : 'bg-cyan-300/60'"
/>
{{ isRecording ? 'Hotkey transmitting' : 'Hotkey armed' }}
</p>
@@ -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) {

View File

@@ -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}$/;