Files
OpenSquawk/shared/utils/transmissionGate.ts
itsrubberduck 6830a28687 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.
2026-07-27 00:11:04 +02:00

103 lines
3.7 KiB
TypeScript

/**
* 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 }
}