mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-07 18:15:49 +08:00
Fix STT readback — TDZ crash, spoken-form mismatch, hydration
Three bugs in yesterday's STT addition:
1) **TDZ crash** — `sttSupported` referenced `isClient` before its const
declaration, throwing on setup and breaking the whole classroom page.
`sttSupported` is now a ref that's populated in `onMounted`.
2) **Spoken vs. written mismatch** — Whisper returns natural ATC speech
("runway two five right", "lufthansa three five niner"), but the
lesson fields hold the canonical written form ("25R", "DLH359"). The
old `normalized.includes(...)` check never matched. Matching now lives
in `shared/utils/sttMatch.ts` and searches both the raw normalized
transcription *and* a denormalized projection that folds spoken
digits/letters back to written tokens (incl. SID suffix `7S`, runway
`25R`, scale words `five thousand → 5000`, frequency `decimal` as a
digit-run boundary).
3) **SSR hydration mismatch** — `sttSupported` evaluated differently on
server vs. client, causing visible-vs-hidden button divergence on
hydration. The ref-set-on-mount approach resolves it.
The new helper is fully unit-tested (15 cases covering radio check,
departure clearance, SIDs, squawks, Speedbird telephony, decimal
frequencies and edge cases).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
226
shared/utils/sttMatch.ts
Normal file
226
shared/utils/sttMatch.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
// Map a Whisper transcription back onto lesson readback fields.
|
||||
//
|
||||
// Whisper returns natural ATC speech ("lufthansa three five niner runway two
|
||||
// five right squawk seven five zero zero"), but the lesson fields store the
|
||||
// canonical written form ("DLH359", "25R", "7500"). Matching purely on the
|
||||
// raw transcription misses most fields. We therefore build a *denormalized*
|
||||
// view of the transcription where spoken digits/letters are folded back to
|
||||
// their written tokens, and search both forms when looking for each field's
|
||||
// expected value (or any alternative).
|
||||
|
||||
const SPOKEN_DIGIT: Record<string, string> = {
|
||||
zero: '0',
|
||||
one: '1', wun: '1',
|
||||
two: '2', too: '2',
|
||||
three: '3', tree: '3',
|
||||
four: '4', fower: '4',
|
||||
five: '5', fife: '5',
|
||||
six: '6',
|
||||
seven: '7',
|
||||
eight: '8',
|
||||
nine: '9', niner: '9',
|
||||
}
|
||||
|
||||
const SPOKEN_LETTER: Record<string, string> = {
|
||||
alfa: 'a', alpha: 'a',
|
||||
bravo: 'b',
|
||||
charlie: 'c',
|
||||
delta: 'd',
|
||||
echo: 'e',
|
||||
foxtrot: 'f',
|
||||
golf: 'g',
|
||||
hotel: 'h',
|
||||
india: 'i',
|
||||
juliett: 'j', juliet: 'j',
|
||||
kilo: 'k',
|
||||
lima: 'l',
|
||||
mike: 'm',
|
||||
november: 'n',
|
||||
oscar: 'o',
|
||||
papa: 'p',
|
||||
quebec: 'q',
|
||||
romeo: 'r',
|
||||
sierra: 's',
|
||||
tango: 't',
|
||||
uniform: 'u',
|
||||
victor: 'v',
|
||||
whiskey: 'w', whisky: 'w',
|
||||
xray: 'x',
|
||||
yankee: 'y',
|
||||
zulu: 'z',
|
||||
}
|
||||
|
||||
const RUNWAY_SUFFIX: Record<string, string> = {
|
||||
left: 'l',
|
||||
right: 'r',
|
||||
center: 'c', centre: 'c',
|
||||
}
|
||||
|
||||
const SCALE_WORDS: Record<string, string> = {
|
||||
hundred: '00',
|
||||
thousand: '000',
|
||||
}
|
||||
|
||||
// Decorative words that should be stripped AFTER digit-collapse so they still
|
||||
// act as a boundary while collapsing (otherwise "one one eight decimal seven"
|
||||
// would fold to "1187" instead of the intended "118 7").
|
||||
const DECORATION_RE = /\b(decimal|point|dash|and)\b/g
|
||||
|
||||
export function normalizeForMatch(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[^a-z0-9 ]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Convert spoken ATC English back to written tokens (digits, runway letters,
|
||||
* collapsed callsign codes). Returned text is also `normalizeForMatch`-safe. */
|
||||
export function denormalizeSpokenAtc(input: string): string {
|
||||
const cleaned = input
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/x[- ]?ray/g, 'xray')
|
||||
.replace(/[^a-z0-9 ]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!cleaned) return ''
|
||||
|
||||
const tokens = cleaned.split(' ')
|
||||
const mapped: string[] = []
|
||||
for (const tok of tokens) {
|
||||
if (SPOKEN_DIGIT[tok] !== undefined) mapped.push(SPOKEN_DIGIT[tok]!)
|
||||
else if (SPOKEN_LETTER[tok] !== undefined) mapped.push(SPOKEN_LETTER[tok]!)
|
||||
else if (RUNWAY_SUFFIX[tok] !== undefined) mapped.push(RUNWAY_SUFFIX[tok]!)
|
||||
else if (SCALE_WORDS[tok] !== undefined) mapped.push(SCALE_WORDS[tok]!)
|
||||
else mapped.push(tok)
|
||||
}
|
||||
|
||||
let result = mapped.join(' ')
|
||||
|
||||
// "five thousand" → "5 000" → "5000"
|
||||
result = result.replace(/\b(\d)\s+(0{2,3})\b/g, (_m, d, z) => `${d}${z}`)
|
||||
|
||||
// Collapse runs of single digits: "3 5 9" → "359"
|
||||
result = result.replace(/\b(\d(?:\s+\d)+)\b/g, m => m.replace(/\s+/g, ''))
|
||||
|
||||
// Glue trailing runway letter to its numeric prefix: "25 r" → "25r"
|
||||
result = result.replace(/\b(\d{1,3})\s+([lrc])\b/g, '$1$2')
|
||||
|
||||
// Glue single digit + single letter (SID/STAR suffix pattern): "7 s" → "7s"
|
||||
result = result.replace(/\b(\d)\s+([a-z])\b/g, '$1$2')
|
||||
|
||||
// Glue runs of single letters into a callsign code, but only when followed
|
||||
// by digits or end-of-string so we don't crush ordinary words.
|
||||
// "d l h 359" → "dlh 359"
|
||||
result = result.replace(/\b([a-z](?:\s+[a-z]){1,4})\b(?=\s+\d|\s*$)/g, m => m.replace(/\s+/g, ''))
|
||||
|
||||
// Strip decoration words now that the digit runs around them have been
|
||||
// collapsed and the boundary they provided is no longer needed.
|
||||
result = result.replace(DECORATION_RE, ' ')
|
||||
|
||||
return result.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function levenshtein(a: string, b: string): number {
|
||||
const m = a.length
|
||||
const n = b.length
|
||||
if (m === 0) return n
|
||||
if (n === 0) return m
|
||||
const dp = new Array(n + 1).fill(0)
|
||||
for (let j = 0; j <= n; j++) dp[j] = j
|
||||
for (let i = 1; i <= m; i++) {
|
||||
let prev = dp[0]
|
||||
dp[0] = i
|
||||
for (let j = 1; j <= n; j++) {
|
||||
const temp = dp[j]
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
||||
dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + cost)
|
||||
prev = temp
|
||||
}
|
||||
}
|
||||
return dp[n]
|
||||
}
|
||||
|
||||
function allowedDistance(length: number): number {
|
||||
if (length <= 12) return 0
|
||||
if (length <= 24) return 1
|
||||
if (length <= 36) return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
/** Loose substring search using a sliding Levenshtein window. */
|
||||
export function fuzzyContains(haystack: string, needle: string): boolean {
|
||||
if (!needle) return false
|
||||
if (!haystack) return false
|
||||
if (haystack.includes(needle)) return true
|
||||
const tolerance = allowedDistance(needle.length) + 1
|
||||
const minLen = Math.max(3, needle.length - 2)
|
||||
const maxLen = needle.length + 3
|
||||
for (let start = 0; start + minLen <= haystack.length; start++) {
|
||||
for (let len = minLen; len <= maxLen; len++) {
|
||||
if (start + len > haystack.length) break
|
||||
const window = haystack.slice(start, start + len)
|
||||
if (levenshtein(window, needle) <= tolerance) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export interface SttFieldDef {
|
||||
key: string
|
||||
expected: string
|
||||
alternatives?: string[]
|
||||
isCallsign?: boolean
|
||||
}
|
||||
|
||||
export interface SttMatchResult {
|
||||
matches: Record<string, string>
|
||||
filled: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export function matchTranscriptionToFields(
|
||||
transcription: string,
|
||||
fields: SttFieldDef[],
|
||||
): SttMatchResult {
|
||||
const normalized = normalizeForMatch(transcription)
|
||||
const denormalized = normalizeForMatch(denormalizeSpokenAtc(transcription))
|
||||
const matches: Record<string, string> = {}
|
||||
let filled = 0
|
||||
for (const field of fields) {
|
||||
const expectedRaw = (field.expected || '').trim()
|
||||
if (!expectedRaw) continue
|
||||
const altList = field.alternatives || []
|
||||
const candidates = Array.from(new Set([expectedRaw, ...altList]
|
||||
.map(c => (c || '').trim())
|
||||
.filter(Boolean)
|
||||
.map(normalizeForMatch)
|
||||
.filter(c => c.length >= 1)))
|
||||
|
||||
let matched = false
|
||||
for (const cand of candidates) {
|
||||
if (!cand) continue
|
||||
if (normalized.includes(cand) || denormalized.includes(cand)) { matched = true; break }
|
||||
if (field.isCallsign && cand.length >= 4) {
|
||||
if (fuzzyContains(normalized, cand) || fuzzyContains(denormalized, cand)) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matched) {
|
||||
matches[field.key] = expectedRaw
|
||||
filled++
|
||||
}
|
||||
}
|
||||
return { matches, filled, total: fields.length }
|
||||
}
|
||||
|
||||
export function looksLikeCallsignKey(key: string, label?: string): boolean {
|
||||
const probe = `${key} ${label || ''}`.toLowerCase()
|
||||
return /\b(callsign|call sign|callup)\b/.test(probe)
|
||||
|| /callsign$/.test(key)
|
||||
|| /-callsign\b/.test(key)
|
||||
}
|
||||
Reference in New Issue
Block a user