From 6830a28687f70f5f33034adee8cc839a8360a61b Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Mon, 27 Jul 2026 00:11:04 +0200 Subject: [PATCH] fix(live-atc): stop swallowing "roger" while still dropping dead air MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/composables/useLiveAtcSession.ts | 35 +++++---- shared/utils/transmissionGate.ts | 102 ++++++++++++++++++++++++++ tests/shared/transmissionGate.test.ts | 85 +++++++++++++++++++++ 3 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 shared/utils/transmissionGate.ts create mode 100644 tests/shared/transmissionGate.test.ts diff --git a/app/composables/useLiveAtcSession.ts b/app/composables/useLiveAtcSession.ts index 5c50a3d..f1c2cb3 100644 --- a/app/composables/useLiveAtcSession.ts +++ b/app/composables/useLiveAtcSession.ts @@ -8,6 +8,7 @@ import { useApi } from '~/composables/useApi' import type { useRadioSpeech } from '~/composables/useRadioSpeech' import useCommunicationsEngine from '../../shared/utils/communicationsEngine' import { generateGermanRegistration } from '../../shared/utils/registration' +import { gateTransmission } from '../../shared/utils/transmissionGate' import { isSimControlMatch, isSimControlRejection, @@ -304,22 +305,26 @@ export function useLiveAtcSession( const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' = 'text') => { const transcript = message.trim() - // 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 - // STT MINIMUM-WORD GATE — search here if a spoken transmission was ignored. - // Voice (PTT) only: drop transcripts shorter than the configured minimum. - // Whisper hallucinates short real words ("Test", "Thank you", "Okay") on - // near-silent or noisy audio; left unfiltered those reach the backend as a - // (wrong) readback attempt — counting toward the 3x-skip — and now also - // trigger a paid LLM-router call. Text input is exempt so deliberate short - // commands still work. Threshold is the NUXT_PUBLIC_PTT_MIN_WORDS env var - // (default 2); set it to 1 to effectively disable the gate. - const minPttWords = Number(config.public.pttMinWords ?? 2) - if (source === 'ptt' && transcript.split(/\s+/).filter(Boolean).length < minPttWords) { - pmLog.info(`IGNORED short PTT transcript (<${minPttWords} words):`, transcript) + // STT CONTENT GATE — search here if a spoken transmission was ignored. + // Drops what push-to-talk produces when nothing was actually said: a stray + // tap, punctuation from near-silent audio, a clipped syllable, or one of the + // phrases Whisper hallucinates on silence. Each of those would otherwise be + // graded as a wrong readback, count toward the 3x-skip, and cost an LLM + // router call. Standard short calls ("roger", "wilco") are recognised as + // phraseology and pass regardless of length; typed input is never gated. + // Threshold is the NUXT_PUBLIC_PTT_MIN_WORDS env var (default 2); set it to + // 1 to disable the length rule. + const gate = gateTransmission(transcript, { + source, + minPttWords: Number(config.public.pttMinWords ?? 2), + readbackRequired: currentState.value?.readback_required, + }) + if (!gate.accept) { + pmLog.info( + `IGNORED ${source} transcript (${gate.reason}) at state ${currentState.value?.id ?? '—'}:`, + JSON.stringify(transcript), + ) return } diff --git a/shared/utils/transmissionGate.ts b/shared/utils/transmissionGate.ts new file mode 100644 index 0000000..113717a --- /dev/null +++ b/shared/utils/transmissionGate.ts @@ -0,0 +1,102 @@ +/** + * Decides whether a pilot transmission carries enough content to be graded. + * + * Push-to-talk produces a lot of non-transmissions: a stray tap, an open mic on + * a noisy flight deck, a syllable clipped off when the key is released early. + * Whisper never returns "nothing" for those — it returns punctuation, a + * fragment, or one of a small set of phrases it hallucinates on near-silence + * ("Thank you.", "Bye."). Sent on, each of those is graded as a wrong readback, + * counts toward the three-strikes skip, and costs an LLM router call. + * + * The gate has to stay open for the short calls that are genuinely valid, + * though: "roger" and "wilco" are one word each and are exactly what a pilot + * says. So word count alone cannot decide — standard phraseology is recognised + * first, and only then does the length rule apply. + */ + +export type TransmissionGateReason = + | 'empty' + | 'no_speech' + | 'hallucination' + | 'too_short' + +export interface TransmissionGateOptions { + /** Typed input is never gated: a short command was deliberate. */ + source: 'text' | 'ptt' + /** Minimum word count for a PTT transmission; 1 disables the length rule. */ + minPttWords: number + /** + * Fields the current state expects to hear read back. A bare acknowledgement + * is not a valid readback, but it is still a real transmission and must reach + * the engine so the controller can ask again — never swallowed here. + */ + readbackRequired?: string[] +} + +export interface TransmissionGateResult { + accept: boolean + /** Why it was dropped — logged so an ignored transmission can be traced. */ + reason?: TransmissionGateReason +} + +/** + * Standard transmissions that are complete in one or two words. Kept as whole + * phrases rather than a word list so "say again" survives the length rule. + */ +const VALID_SHORT_CALLS = [ + 'roger', 'wilco', 'affirm', 'affirmative', 'negative', 'standby', 'stand by', + 'mayday', 'pan pan', 'correction', 'disregard', 'go ahead', 'say again', + 'unable', 'copied', 'copy', 'checked', 'ready', 'holding', 'wait', +] + +/** + * What Whisper writes when it hears nothing. These are ordinary English, so + * they can only be rejected as a *whole* transcript — "thank you, good day" is + * a real sign-off and must pass. + */ +const HALLUCINATIONS = [ + 'thank you', 'thanks', 'thanks for watching', 'thank you for watching', + 'bye', 'goodbye', 'bye bye', 'okay', 'ok', 'you', 'the', 'so', 'yeah', + 'please subscribe', 'subscribe', 'music', 'applause', 'silence', + 'transcription by castingwords', 'i love you', +] + +/** Lowercase, strip punctuation, collapse whitespace. */ +function canonical(transcript: string): string { + return transcript + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +export function gateTransmission( + transcript: string, + options: TransmissionGateOptions, +): TransmissionGateResult { + const raw = transcript.trim() + if (!raw) return { accept: false, reason: 'empty' } + + // Punctuation, dashes, musical notes — audio with no words in it. + if (!/[a-z0-9]/i.test(raw)) return { accept: false, reason: 'no_speech' } + + // Typed input is deliberate; only genuinely empty input is refused. + if (options.source !== 'ptt') return { accept: true } + + const normalized = canonical(raw) + if (!normalized) return { accept: false, reason: 'no_speech' } + + if (HALLUCINATIONS.includes(normalized)) { + return { accept: false, reason: 'hallucination' } + } + + // Standard phraseology passes however short it is. + if (VALID_SHORT_CALLS.includes(normalized)) return { accept: true } + + const words = normalized.split(' ').filter(Boolean) + if (words.length < Math.max(1, options.minPttWords)) { + return { accept: false, reason: 'too_short' } + } + + return { accept: true } +} diff --git a/tests/shared/transmissionGate.test.ts b/tests/shared/transmissionGate.test.ts new file mode 100644 index 0000000..47dd024 --- /dev/null +++ b/tests/shared/transmissionGate.test.ts @@ -0,0 +1,85 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { gateTransmission } from '~~/shared/utils/transmissionGate' + +const ptt = (transcript: string, extra = {}) => + gateTransmission(transcript, { source: 'ptt', minPttWords: 2, ...extra }) + +const text = (transcript: string, extra = {}) => + gateTransmission(transcript, { source: 'text', minPttWords: 2, ...extra }) + +describe('gateTransmission — content-free PTT is not graded', () => { + it('drops an empty or whitespace-only transcript', () => { + for (const t of ['', ' ', '\n']) { + assert.equal(ptt(t).accept, false, `accepted ${JSON.stringify(t)}`) + assert.equal(ptt(t).reason, 'empty') + } + }) + + it('drops punctuation-only output from near-silent audio', () => { + for (const t of ['.', '...', '?!', '—', '. . .']) { + const result = ptt(t) + assert.equal(result.accept, false, `accepted ${JSON.stringify(t)}`) + assert.equal(result.reason, 'no_speech') + } + }) + + it('drops a truncated syllable', () => { + for (const t of ['Lu', 'ah', 'D-']) { + assert.equal(ptt(t).accept, false, `accepted ${JSON.stringify(t)}`) + } + }) + + it('drops the phrases Whisper hallucinates on silence', () => { + for (const t of ['Thank you.', 'thanks for watching', 'Bye.', 'Okay', 'you']) { + const result = ptt(t) + assert.equal(result.accept, false, `accepted ${JSON.stringify(t)}`) + assert.equal(result.reason, 'hallucination') + } + }) +}) + +describe('gateTransmission — valid short calls still get through', () => { + it('accepts standard one-word transmissions', () => { + for (const t of [ + 'roger', 'Roger.', 'wilco', 'affirm', 'affirmative', + 'negative', 'standby', 'mayday', + ]) { + assert.equal(ptt(t).accept, true, `dropped ${JSON.stringify(t)}`) + } + }) + + it('accepts a short call carrying real content', () => { + for (const t of ['squawk 2341', 'runway 25L', 'say again']) { + assert.equal(ptt(t).accept, true, `dropped ${JSON.stringify(t)}`) + } + }) + + it('never gates typed input — a deliberate short command is intended', () => { + for (const t of ['Lu', 'Okay', 'roger']) { + assert.equal(text(t).accept, true, `dropped typed ${JSON.stringify(t)}`) + } + assert.equal(text('').accept, false, 'empty text should still be dropped') + }) + + it('honours a minimum of 1 word as "gate disabled"', () => { + assert.equal(ptt('Lu', { minPttWords: 1 }).accept, true) + }) +}) + +describe('gateTransmission — the current state decides', () => { + it('grades a bare acknowledgement where a readback is owed', () => { + // "roger" is not a valid readback of a clearance, but it IS a real + // transmission: it must reach the engine so ATC can ask for the readback, + // rather than being silently swallowed. + const result = ptt('roger', { readbackRequired: ['squawk', 'sid'] }) + assert.equal(result.accept, true) + }) + + it('still drops content-free audio where a readback is owed', () => { + const result = ptt('...', { readbackRequired: ['squawk'] }) + assert.equal(result.accept, false) + assert.equal(result.reason, 'no_speech') + }) +})