feat(stt): seed Whisper prompt with expected readback + per-field debug UI

Whisper prompt seeding (per request):
- ptt.post.ts builds the prompt as generic ICAO bias + this state's expected
  readback appended LAST (survives the 224-token truncation), in both raw token
  form and spoken ICAO form via new radioSpeech.speakToken().
- pm.vue passes the expected phrase + active variable values; classroom.vue
  passes the lesson's expected field values.

Per-field readback debug:
- sttMatch.matchTranscriptionToFields returns fields[] (matched/missing + which
  view matched) plus normalized/denormalized transcription views.
- useRadioBackend types readback_report on the transmit response.
- pm.vue renders a "Readback check" panel in the right log rail; classroom.vue
  renders per-field rows under the STT panel.

Radio-pronunciation fixes (radioSpeech.ts):
- callsign expander handles multi-letter suffixes (DLH6RK -> Lufthansa six Romeo
  Kilo).
- toRadioSpeech now expands airports (EDDC -> Echo Delta Delta Charlie).
- bare altitudes >=1000 in a clearance context are spoken ("climb initially
  5000" -> "climb initially five thousand feet"); speeds/headings untouched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leubeem
2026-06-17 14:12:54 +02:00
parent 453b04881f
commit b80feb80d6
8 changed files with 373 additions and 18 deletions

View File

@@ -22,6 +22,19 @@ export interface RadioTransmitResponse {
auto_advanced_states?: string[]
/** True when the full chain is done — show the completion screen. */
session_complete?: boolean
/** Per-field readback diagnostic for the pilot state just evaluated. */
readback_report?: ReadbackFieldDetail[]
}
export interface ReadbackFieldDetail {
field: string
expected: string
matched: boolean
/** Which accepted spoken form matched ("two five right", "icao_phonetic"), or null. */
matched_via: string | null
/** All spoken forms that would have matched this field. */
accepted_forms: string[]
note?: string | null
}
export function useRadioBackend() {

View File

@@ -1175,6 +1175,23 @@
</div>
</div>
<!-- Per-field readback debug: what was recognised vs missing. -->
<div v-if="!sttRecording && !sttTranscribing && sttLastReport && sttLastReport.fields.length" class="stt-report">
<div
v-for="f in sttLastReport.fields"
:key="f.key"
class="stt-report-row"
:class="f.matched ? 'is-ok' : 'is-missing'"
>
<v-icon size="13">{{ f.matched ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
<span class="stt-report-field">{{ f.key }}</span>
<span class="stt-report-expected">{{ f.expected || '—' }}</span>
<span v-if="f.matched" class="stt-report-via">recognised ({{ f.view }})</span>
<span v-else class="stt-report-via">not recognised</span>
</div>
<div class="stt-report-folded">folded: {{ sttLastReport.denormalized }}</div>
</div>
<div v-if="sttError" class="stt-error-body">{{ sttError }}</div>
<div v-else-if="sttRecording || sttTranscribing" class="stt-waiting">
@@ -2900,6 +2917,7 @@ const sttLastTranscription = ref('')
const sttEditableTranscription = ref('')
const sttFilledFields = reactive<Record<string, boolean>>({})
const sttLastFillSummary = ref<{ filled: number; total: number } | null>(null)
const sttLastReport = ref<import('~~/shared/utils/sttMatch').SttMatchResult | null>(null)
const sttRecordingSeconds = ref(0)
const sttMediaRecorder = ref<MediaRecorder | null>(null)
const sttChunks = ref<Blob[]>([])
@@ -3561,9 +3579,13 @@ function buildSttFieldDefs(): SttFieldDef[] {
}
function mapTranscriptionToFields(transcription: string): { filled: number; total: number } {
if (!activeLesson.value || !scenario.value) return { filled: 0, total: 0 }
if (!activeLesson.value || !scenario.value) {
sttLastReport.value = null
return { filled: 0, total: 0 }
}
const defs = buildSttFieldDefs()
const result = matchTranscriptionToFields(transcription, defs)
sttLastReport.value = result
// Clear stale mic markers for fields not in this match round
Object.keys(sttFilledFields).forEach(k => { delete sttFilledFields[k] })
for (const [key, value] of Object.entries(result.matches)) {
@@ -3585,6 +3607,7 @@ function clearSttResult() {
sttLastTranscription.value = ''
sttEditableTranscription.value = ''
sttLastFillSummary.value = null
sttLastReport.value = null
sttError.value = ''
Object.keys(sttFilledFields).forEach(k => { delete sttFilledFields[k] })
}
@@ -3610,11 +3633,19 @@ async function processSTTAudio(blob: Blob) {
return
}
const base64 = await blobToBase64(blob)
// Seed Whisper with this lesson's expected field values (raw + alternatives);
// the server expands them to spoken ICAO form and biases recognition.
const sttDefs = buildSttFieldDefs()
const expectedTokens = Array.from(new Set(
sttDefs.flatMap(d => [d.expected, ...(d.alternatives ?? [])]).map(t => (t || '').trim()).filter(Boolean)
))
const expectedPhrase = sttDefs.map(d => d.expected).filter(Boolean).join(', ')
const result = await api.post<{ success: boolean; transcription: string }>('/api/atc/ptt', {
audio: base64,
moduleId: current.value?.id || 'classroom',
lessonId: activeLesson.value.id,
format: 'webm',
expected: { phrase: expectedPhrase || undefined, tokens: expectedTokens },
})
if (result?.success && result.transcription) {
const text = result.transcription.trim()
@@ -7034,6 +7065,31 @@ onBeforeUnmount(() => {
letter-spacing: .02em;
}
.stt-report {
display: flex;
flex-direction: column;
gap: 3px;
margin-top: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11.5px;
}
.stt-report-row {
display: flex;
align-items: center;
gap: 6px;
}
.stt-report-row.is-ok { color: #6ee7a8; }
.stt-report-row.is-missing { color: #fca5a5; }
.stt-report-field { color: var(--t2); min-width: 90px; }
.stt-report-expected { color: var(--text); font-weight: 600; }
.stt-report-via { color: var(--t2); opacity: .8; }
.stt-report-folded {
margin-top: 2px;
color: var(--t2);
opacity: .6;
font-size: 11px;
}
.stt-waiting {
display: inline-flex;
align-items: center;

View File

@@ -739,6 +739,31 @@
<!-- Desktop log rail -->
<aside class="pm-lograil">
<!-- STT readback check: what was recognised vs missing in the last call. -->
<div v-if="lastReadbackReport.length" class="pm-readback-check">
<div class="pm-readback-head">
<v-icon size="15">mdi-magnify-scan</v-icon>
<span>Readback check</span>
</div>
<p v-if="lastReadbackTranscript" class="pm-readback-heard">heard: {{ lastReadbackTranscript }}</p>
<div
v-for="r in lastReadbackReport"
:key="r.field"
class="pm-readback-row"
:class="r.matched ? 'is-ok' : 'is-missing'"
>
<v-icon size="13">{{ r.matched ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
<span class="pm-readback-text">
<span class="pm-readback-field">{{ r.field }}</span>
= <span class="pm-readback-expected">{{ r.expected || '—' }}</span>
<template v-if="r.matched"> {{ r.matched_via }}</template>
<template v-else>
not recognised<span v-if="r.accepted_forms.length" class="pm-readback-forms">
(say: {{ r.accepted_forms.join(' / ') }})</span>
</template>
</span>
</div>
</div>
<CommLog :entries="log" :limit="30" dense @clear="clearLog" />
</aside>
</div>
@@ -1323,12 +1348,16 @@ const backendSessionId = ref<string | null>(null)
const lastControllerSay = ref<string | null>(null)
// Authoritative expected pilot phrase from the backend — replaces local engine rendering.
const backendExpectedPhrase = ref<string | null>(null)
// Per-field readback diagnostic from the last transmission (STT debug panel).
const lastReadbackReport = ref<import('../composables/useRadioBackend').ReadbackFieldDetail[]>([])
const lastReadbackTranscript = ref<string>('')
// Toggle: show radio pronunciation (wun, tree, squawk niner…) vs plain text.
const showRadioPronunciation = ref(false)
function toRadioSpeech(text: string): string {
return normalizeRadioPhrase(text, {
expandCallsigns: true,
expandAirports: true,
airlineMap: DEFAULT_AIRLINE_TELEPHONY,
sidSuffixIcao: true,
})
@@ -2762,6 +2791,10 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' =
pmLog.debug('moveToSilent ← next_state_id:', response.next_state_id)
moveToSilent(response.next_state_id)
// Capture the per-field readback diagnostic for the STT debug panel.
lastReadbackReport.value = response.readback_report ?? []
lastReadbackTranscript.value = transcript
// Update expected phrase AFTER cursor moves so any local-engine reactive
// updates from moveToSilent have already settled.
backendExpectedPhrase.value = response.expected_pilot_template ?? null
@@ -3306,6 +3339,24 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
return btoa(binary)
}
// Build the Whisper-prompt seed for the current state: the expected pilot
// phrase plus the active variable values (callsign, SID, squawk, runway,
// frequencies…). The server expands these to spoken ICAO form and appends them
// to the bias prompt so Whisper is steered toward exactly what's expected.
function buildSttExpected(): { phrase?: string; tokens: string[] } {
const phrase = backendExpectedPhrase.value?.trim() || undefined
const dict = ((vars as any).value ?? {}) as Record<string, unknown>
const tokens: string[] = []
for (const val of Object.values(dict)) {
if (typeof val === 'number') { tokens.push(String(val)); continue }
if (typeof val === 'string') {
const t = val.trim()
if (t && t.length <= 24) tokens.push(t)
}
}
return { phrase, tokens }
}
const processTransmission = async (audioBlob: Blob, isIntercom: boolean, format: 'wav' | 'webm' = 'webm') => {
const channel = isIntercom ? 'INTERCOM' : 'RADIO'
pmLog.info(`PTT ▶ ${channel} blob=${(audioBlob.size / 1024).toFixed(1)}KB fmt=${format} session=${backendSessionId.value?.slice(0,8) ?? 'none'}`)
@@ -3342,6 +3393,7 @@ const processTransmission = async (audioBlob: Blob, isIntercom: boolean, format:
lessonId: currentState.value?.id || 'general',
format,
sessionId: backendSessionId.value || undefined,
expected: buildSttExpected(),
})
if (result.success) {
@@ -4644,6 +4696,44 @@ onUnmounted(() => {
display: none;
}
.pm-readback-check {
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
font-size: 11.5px;
}
.pm-readback-head {
display: flex;
align-items: center;
gap: 6px;
text-transform: uppercase;
letter-spacing: .12em;
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.55);
margin-bottom: 6px;
}
.pm-readback-heard {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 6px;
font-size: 11px;
}
.pm-readback-row {
display: flex;
align-items: flex-start;
gap: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
line-height: 1.5;
}
.pm-readback-row.is-ok { color: #6ee7a8; }
.pm-readback-row.is-missing { color: #fca5a5; }
.pm-readback-field { color: rgba(255, 255, 255, 0.7); }
.pm-readback-expected { color: #fff; font-weight: 600; }
.pm-readback-forms { color: rgba(255, 255, 255, 0.4); }
.pm-bottomnav {
flex: 0 0 auto;
display: flex;

View File

@@ -11,7 +11,7 @@ import { TransmissionLog } from "../../models/TransmissionLog";
import { getUserFromEvent } from "../../utils/auth";
import { enforceRateLimit, getClientIp } from "../../utils/rateLimit";
import { recordUsage } from "../../utils/usage";
import { DEFAULT_AIRLINE_TELEPHONY } from "../../../shared/utils/radioSpeech";
import { DEFAULT_AIRLINE_TELEPHONY, normalizeRadioPhrase, speakToken } from "../../../shared/utils/radioSpeech";
type AudioFormat = 'wav' | 'mp3' | 'ogg' | 'webm'
@@ -29,12 +29,45 @@ const STT_BIAS_PROMPT = [
"Common phrases: ready for pushback, request taxi, holding point, line up and wait, cleared for takeoff, contact tower, QNH, flight level, squawk, wilco, roger, affirm, negative, say again, runway, heading, descend, climb, maintain.",
].join(" ");
// Whisper keeps only the final ~224 prompt tokens, so the session-specific
// expected readback is appended LAST (after the generic bias) to ensure it
// survives truncation. We add the expected transmission both as written tokens
// (spelling bias for callsigns/SIDs) and in spoken ICAO form (the words the
// pilot actually says) — e.g. "25R" → "two five right", "BIBAX1N" → phonetics.
function buildSttPrompt(expected?: { phrase?: string; tokens?: string[] }): string {
const segments: string[] = [STT_BIAS_PROMPT];
const phrase = expected?.phrase?.trim();
if (phrase) {
segments.push(`Expected pilot transmission: ${phrase}.`);
const spoken = normalizeRadioPhrase(phrase, { expandAirports: true, expandCallsigns: true });
if (spoken && spoken.toLowerCase() !== phrase.toLowerCase()) {
segments.push(`Spoken: ${spoken}.`);
}
}
const tokens = Array.from(
new Set((expected?.tokens ?? []).map(t => `${t ?? ''}`.trim()).filter(Boolean))
);
if (tokens.length) {
segments.push(`Expected values: ${tokens.join(', ')}.`);
const spokenTokens = tokens.map(speakToken).filter(Boolean);
if (spokenTokens.length) segments.push(`Read as: ${spokenTokens.join(', ')}.`);
}
return segments.join(" ");
}
interface PTTRequest {
audio: string; // Base64 encoded audio
moduleId: string;
lessonId: string;
format?: AudioFormat;
sessionId?: string; // Python backend session ID — used for TransmissionLog correlation
expected?: { // Seeds Whisper toward this state's expected readback
phrase?: string; // rendered expected_pilot_template (PM) or joined field values
tokens?: string[]; // discrete expected values (variables / lesson field values)
};
context?: { // Legacy field; kept for backwards compat but not used for routing
state_id?: string;
flags?: Record<string, any>;
@@ -140,7 +173,7 @@ export default defineEventHandler(async (event) => {
model: "whisper-1",
language: "en",
temperature: 0,
prompt: STT_BIAS_PROMPT
prompt: buildSttPrompt(body.expected)
});
const transcribedText = transcription.text.trim();

View File

@@ -164,6 +164,42 @@ export function toIcaoPhonetic(value: string, separator = ' '): string {
.trim();
}
/**
* Spoken ICAO form of a single written value (callsign/SID/runway/squawk/
* frequency/flight-level/number). Used to seed Whisper's `prompt` with the
* exact tokens the pilot is about to read back, in spoken form, so recognition
* is biased toward e.g. "two five right" for "25R" and "bravo india bravo alpha
* x-ray one november" for "BIBAX1N". Returns '' when no distinct spoken form
* applies (caller should fall back to the raw token).
*/
export function speakToken(raw: string): string {
const v = `${raw ?? ''}`.trim();
if (!v) return '';
// Frequency: 118.700 → "one one eight decimal seven zero zero"
if (/^\d{2,3}\.\d+$/.test(v)) {
const [left, right] = v.split('.') as [string, string];
return `${spellIcaoDigits(left)} decimal ${spellIcaoDigits(right)}`;
}
// Runway: 25R → "two five right"
const rwy = v.match(/^(\d{2})([LCR])?$/i);
if (rwy) {
const side = rwy[2]?.toUpperCase();
const suffix = side === 'L' ? ' left' : side === 'R' ? ' right' : side === 'C' ? ' center' : '';
return `${spellIcaoDigits(rwy[1]!)}${suffix}`;
}
// Flight level: FL150 → "flight level one five zero"
const fl = v.match(/^FL(\d+)$/i);
if (fl) return `flight level ${spellIcaoDigits(fl[1]!)}`;
// Pure number (squawk/altitude/QNH): 2341 → "two three four one"
if (/^\d+$/.test(v)) return spellIcaoDigits(v);
// Alphanumeric identifier mixing letters AND digits (SID/STAR/callsign,
// e.g. BIBAX1N, MARUN7F, DLH39A): spell it out phonetically. Pure-letter
// tokens (plain words like "west", airport codes, bare waypoints) are left
// raw so they are not mis-spelled letter by letter.
if (/^[A-Z0-9]+$/i.test(v) && /[A-Z]/i.test(v) && /\d/.test(v)) return toIcaoPhonetic(v);
return '';
}
function runwaySpeak(raw: string): string {
const match = raw.match(/^(\d{2})([LCR])?$/i);
if (!match) return raw;
@@ -291,12 +327,14 @@ function qnhSpeak(raw: string): string {
function callsignSpeak(raw: string, map: AirlineTelephonyMap): string {
const upper = raw.toUpperCase();
const match = upper.match(/^([A-Z]{2,3})(\d{1,4})([A-Z])?$/);
// Allow one or more trailing letters so suffixes like "6RK" (→ "six romeo
// kilo") are spelled out, not just a single letter.
const match = upper.match(/^([A-Z]{2,3})(\d{1,4})([A-Z]{0,3})$/);
if (!match) return raw;
const [, prefix, digitsPart, suffixLetter] = match;
const [, prefix, digitsPart, suffixLetters] = match;
const telephony = map[prefix] ?? spellIcaoLetters(prefix);
const digitsSpoken = spellIcaoDigits(digitsPart);
const suffix = suffixLetter ? ` ${spellIcaoLetters(suffixLetter)}` : '';
const suffix = suffixLetters ? ` ${spellIcaoLetters(suffixLetters)}` : '';
return `${telephony} ${digitsSpoken}${suffix}`.trim();
}
@@ -676,6 +714,21 @@ export function normalizeRadioPhrase(text: string, options: NormalizeRadioOption
out = out.replace(/\b(\d{3,5})\s*(?:ft|feet)\b/gi, (_, ft: string) => altitudeSpeak(Number(ft)));
out = out.replace(/\bQNH\s*(\d{3,4})\b/gi, (_, qnh: string) => qnhSpeak(qnh));
// Bare altitude/height numbers without an explicit "feet" unit, in a
// clearance/readback context ("climb initially 5000", "passing 1500",
// "descend to 3000"). Runs after the squawk/QNH/FL rules above have already
// consumed their numbers, so it only sees genuine altitudes. The keyword may
// be separated from the number by a word ("climb initially 5000").
out = out.replace(
/\b(climb|climbing|descend|descending|maintain|passing|initially)\b((?:\s+\w+){0,2}?\s+)(\d{3,5})\b/gi,
(m: string, verb: string, gap: string, num: string) => {
const v = Number(num);
// Only treat as an altitude when ≥ 1000 ft, so speeds/headings
// ("maintain 250") are left untouched.
return v >= 1000 ? `${verb}${gap}${altitudeSpeak(v)}` : m;
},
);
// Stand/gate designators: "stand A12" → "stand alfa wun too"
out = out.replace(/\b(stand|gate)\s+([A-Z]{1,2}\d{1,4}[A-Z]?)\b/gi, (_m, word: string, code: string) =>
`${word} ${toIcaoPhonetic(code)}`);
@@ -723,7 +776,7 @@ export function normalizeRadioPhrase(text: string, options: NormalizeRadioOption
if (opts.expandCallsigns) {
const airlineMap = opts.airlineMap ?? {};
out = out.replace(/\b([A-Z]{2,3}\d{1,4}[A-Z]?)\b/g, (match: string) => callsignSpeak(match, airlineMap));
out = out.replace(/\b([A-Z]{2,3}\d{1,4}[A-Z]{0,3})\b/g, (match: string) => callsignSpeak(match, airlineMap));
}
out = applyTaxiRoutePhonetics(out);

View File

@@ -229,10 +229,26 @@ export interface SttFieldDef {
isCallsign?: boolean
}
export interface SttFieldReport {
key: string
expected: string
matched: boolean
/** The normalized candidate form that matched the transcription, or null. */
matchedVia: string | null
/** Which transcription view the match landed in. */
view: 'raw' | 'spoken' | 'callsign' | null
}
export interface SttMatchResult {
matches: Record<string, string>
filled: number
total: number
/** Normalized raw transcription (what Whisper returned, cleaned). */
normalized: string
/** Spoken→written folded view ("two five right" → "25r"). */
denormalized: string
/** Per-field diagnostic, in the original field order, for the comm log. */
fields: SttFieldReport[]
}
function escapeRegex(value: string): string {
@@ -265,11 +281,22 @@ export function matchTranscriptionToFields(
const normalized = normalizeForMatch(transcription)
const denormalized = normalizeForMatch(denormalizeSpokenAtc(transcription))
const matches: Record<string, string> = {}
// Per-field diagnostic keyed by field.key (output in original order below).
const reportByKey: Record<string, SttFieldReport> = {}
let filled = 0
for (const field of pickLongestExpected(fields)) {
const expectedRaw = (field.expected || '').trim()
const report: SttFieldReport = {
key: field.key,
expected: expectedRaw,
matched: false,
matchedVia: null,
view: null,
}
reportByKey[field.key] = report
if (!expectedRaw) continue
const altList = field.alternatives || []
const candidates = Array.from(new Set([expectedRaw, ...altList]
.map(c => (c || '').trim())
@@ -277,26 +304,36 @@ export function matchTranscriptionToFields(
.map(normalizeForMatch)
.filter(c => c.length >= 1)))
let matched = false
for (const cand of candidates) {
if (!cand) continue
if (candidateMatches(normalized, cand) || candidateMatches(denormalized, cand)) {
matched = true
break
if (candidateMatches(normalized, cand)) {
report.matched = true; report.matchedVia = cand; report.view = 'raw'; break
}
if (field.isCallsign && cand.length >= 4) {
if (callsignMatches(normalized, cand) || callsignMatches(denormalized, cand)) {
matched = true
break
}
if (candidateMatches(denormalized, cand)) {
report.matched = true; report.matchedVia = cand; report.view = 'spoken'; break
}
if (field.isCallsign && cand.length >= 4
&& (callsignMatches(normalized, cand) || callsignMatches(denormalized, cand))) {
report.matched = true; report.matchedVia = cand; report.view = 'callsign'; break
}
}
if (matched) {
if (report.matched) {
matches[field.key] = expectedRaw
filled++
}
}
return { matches, filled, total: fields.length }
return {
matches,
filled,
total: fields.length,
normalized,
denormalized,
fields: fields.map(f => reportByKey[f.key] ?? {
key: f.key, expected: (f.expected || '').trim(), matched: false, matchedVia: null, view: null,
}),
}
}
export function looksLikeCallsignKey(key: string, label?: string): boolean {

View File

@@ -2,14 +2,62 @@ import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
DEFAULT_AIRLINE_TELEPHONY,
normalizeAtisForSpeech,
normalizeMetarPhrase,
normalizeRadioPhrase,
speakToken,
spellIcaoDigits,
spellIcaoLetters,
toIcaoPhonetic,
} from '~~/shared/utils/radioSpeech'
describe('normalizeRadioPhrase — PM radio pronunciation', () => {
const opts = {
expandCallsigns: true,
expandAirports: true,
airlineMap: DEFAULT_AIRLINE_TELEPHONY,
sidSuffixIcao: true,
}
it('expands a callsign with a multi-letter suffix (DLH6RK)', () => {
assert.equal(normalizeRadioPhrase('DLH6RK', opts), 'Lufthansa six Romeo Kilo')
})
it('spells a 4-letter ICAO airport code', () => {
assert.equal(normalizeRadioPhrase('EDDC', opts), spellIcaoLetters('EDDC'))
})
it('speaks a bare altitude in a clearance context', () => {
assert.match(normalizeRadioPhrase('climb initially 5000', opts), /thousand feet$/)
})
it('leaves sub-1000 numbers (speeds/headings) untouched', () => {
assert.equal(normalizeRadioPhrase('maintain 250 knots', opts), 'maintain 250 knots')
})
})
describe('speakToken', () => {
it('speaks a runway with its side', () => {
assert.equal(speakToken('25R'), `${spellIcaoDigits('25')} right`)
})
it('speaks a frequency with decimal', () => {
assert.equal(speakToken('121.800'), `${spellIcaoDigits('121')} decimal ${spellIcaoDigits('800')}`)
})
it('speaks a flight level', () => {
assert.equal(speakToken('FL150'), `flight level ${spellIcaoDigits('150')}`)
})
it('spells a pure number digit by digit', () => {
assert.equal(speakToken('2341'), spellIcaoDigits('2341'))
})
it('spells an alphanumeric identifier phonetically', () => {
assert.equal(speakToken('BIBAX1N'), toIcaoPhonetic('BIBAX1N'))
})
it('returns empty for plain words (caller keeps raw)', () => {
assert.equal(speakToken('west'), '')
})
})
describe('radioSpeech', () => {
it('spells ICAO digits and letters', () => {
assert.equal(spellIcaoDigits('120'), 'wun too zero')

View File

@@ -120,6 +120,31 @@ describe('fuzzyContains', () => {
})
})
describe('matchTranscriptionToFields — per-field report', () => {
it('reports matched and missing fields with the view that matched', () => {
const result = matchTranscriptionToFields('runway two five right squawk seven five zero zero', [
{ key: 'runway', expected: '25R' },
{ key: 'squawk', expected: '7500' },
{ key: 'altitude', expected: '5000' },
])
const byKey = Object.fromEntries(result.fields.map(f => [f.key, f]))
assert.equal(byKey.runway!.matched, true)
assert.equal(byKey.runway!.view, 'spoken') // folded from "two five right"
assert.equal(byKey.squawk!.matched, true)
assert.equal(byKey.altitude!.matched, false) // never spoken
assert.equal(byKey.altitude!.matchedVia, null)
assert.match(result.denormalized, /25r/)
})
it('preserves original field order in the report', () => {
const result = matchTranscriptionToFields('nothing here', [
{ key: 'a', expected: '111' },
{ key: 'b', expected: '222' },
])
assert.deepEqual(result.fields.map(f => f.key), ['a', 'b'])
})
})
describe('looksLikeCallsignKey', () => {
it('detects common callsign field keys', () => {
assert.equal(looksLikeCallsignKey('callsign'), true)