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

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