mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 16:22:48 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
102
shared/utils/transmissionGate.ts
Normal file
102
shared/utils/transmissionGate.ts
Normal 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 }
|
||||
}
|
||||
85
tests/shared/transmissionGate.test.ts
Normal file
85
tests/shared/transmissionGate.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user