fix(live-atc): stop swallowing "roger" while still dropping dead air

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.
This commit is contained in:
itsrubberduck
2026-07-27 00:11:04 +02:00
parent df5ba2a102
commit 6830a28687
3 changed files with 207 additions and 15 deletions

View File

@@ -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
}

View File

@@ -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 }
}

View File

@@ -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')
})
})