mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 08:06:26 +08:00
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:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user