From b80feb80d6d69c76ae3181b7faf13074ebeb34c8 Mon Sep 17 00:00:00 2001 From: leubeem Date: Wed, 17 Jun 2026 14:12:54 +0200 Subject: [PATCH] feat(stt): seed Whisper prompt with expected readback + per-field debug UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/composables/useRadioBackend.ts | 13 +++++ app/pages/classroom.vue | 58 ++++++++++++++++++- app/pages/pm.vue | 90 ++++++++++++++++++++++++++++++ server/api/atc/ptt.post.ts | 37 +++++++++++- shared/utils/radioSpeech.ts | 61 ++++++++++++++++++-- shared/utils/sttMatch.ts | 59 ++++++++++++++++---- tests/shared/radioSpeech.test.ts | 48 ++++++++++++++++ tests/shared/sttMatch.test.ts | 25 +++++++++ 8 files changed, 373 insertions(+), 18 deletions(-) diff --git a/app/composables/useRadioBackend.ts b/app/composables/useRadioBackend.ts index 886a3a7..6f1320f 100644 --- a/app/composables/useRadioBackend.ts +++ b/app/composables/useRadioBackend.ts @@ -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() { diff --git a/app/pages/classroom.vue b/app/pages/classroom.vue index d81e0c4..d23d510 100644 --- a/app/pages/classroom.vue +++ b/app/pages/classroom.vue @@ -1175,6 +1175,23 @@ + +
+
+ {{ f.matched ? 'mdi-check-circle' : 'mdi-close-circle' }} + {{ f.key }} + {{ f.expected || '—' }} + recognised ({{ f.view }}) + not recognised +
+
folded: {{ sttLastReport.denormalized }}
+
+
{{ sttError }}
@@ -2900,6 +2917,7 @@ const sttLastTranscription = ref('') const sttEditableTranscription = ref('') const sttFilledFields = reactive>({}) const sttLastFillSummary = ref<{ filled: number; total: number } | null>(null) +const sttLastReport = ref(null) const sttRecordingSeconds = ref(0) const sttMediaRecorder = ref(null) const sttChunks = ref([]) @@ -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; diff --git a/app/pages/pm.vue b/app/pages/pm.vue index 587a6ce..39e9fb2 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -739,6 +739,31 @@
@@ -1323,12 +1348,16 @@ const backendSessionId = ref(null) const lastControllerSay = ref(null) // Authoritative expected pilot phrase from the backend — replaces local engine rendering. const backendExpectedPhrase = ref(null) +// Per-field readback diagnostic from the last transmission (STT debug panel). +const lastReadbackReport = ref([]) +const lastReadbackTranscript = ref('') // 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 + 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; diff --git a/server/api/atc/ptt.post.ts b/server/api/atc/ptt.post.ts index cf04d66..410a692 100644 --- a/server/api/atc/ptt.post.ts +++ b/server/api/atc/ptt.post.ts @@ -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; @@ -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(); diff --git a/shared/utils/radioSpeech.ts b/shared/utils/radioSpeech.ts index 568ce23..379e8d3 100644 --- a/shared/utils/radioSpeech.ts +++ b/shared/utils/radioSpeech.ts @@ -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); diff --git a/shared/utils/sttMatch.ts b/shared/utils/sttMatch.ts index f0350af..012efff 100644 --- a/shared/utils/sttMatch.ts +++ b/shared/utils/sttMatch.ts @@ -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 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 = {} + // Per-field diagnostic keyed by field.key (output in original order below). + const reportByKey: Record = {} 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 { diff --git a/tests/shared/radioSpeech.test.ts b/tests/shared/radioSpeech.test.ts index c0523b7..f33d659 100644 --- a/tests/shared/radioSpeech.test.ts +++ b/tests/shared/radioSpeech.test.ts @@ -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') diff --git a/tests/shared/sttMatch.test.ts b/tests/shared/sttMatch.test.ts index bb8e81d..844b62f 100644 --- a/tests/shared/sttMatch.test.ts +++ b/tests/shared/sttMatch.test.ts @@ -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)