@@ -1465,6 +1485,7 @@ import {
classroomVoiceFor,
} from '~~/shared/utils/voicePool'
import {DEFAULT_AIRLINE_TELEPHONY, normalizeRadioPhrase, normalizeMetarPhrase} from '~~/shared/utils/radioSpeech'
+import {denormalizeSpokenAtc, looksLikeCallsignKey, normalizeForMatch} from '~~/shared/utils/sttMatch'
import {useBugReport} from '~/composables/useBugReport'
import BugReportDialog from '~/components/BugReportDialog.vue'
@@ -1546,6 +1567,33 @@ function tightenedThreshold(length: number, base: number): number {
return Math.max(base, 0.97)
}
+function compactReadbackComparable(value: string): string {
+ return value.replace(/\s+/g, '')
+}
+
+function buildReadbackComparableForms(value: string): string[] {
+ const forms = new Set()
+ const trimmed = value.trim()
+
+ if (!trimmed) return []
+
+ const add = (candidate: string) => {
+ const normalized = normalizeForMatch(candidate)
+ if (!normalized) return
+ forms.add(normalized)
+ forms.add(compactReadbackComparable(normalized))
+ }
+
+ add(trimmed)
+ add(denormalizeSpokenAtc(trimmed))
+
+ return Array.from(forms).filter(Boolean)
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
const modules = shallowRef(learnModules)
type LessonSearchHit = {
@@ -1752,7 +1800,15 @@ const pendingLessonId = ref(null)
function displayCallsign(value?: string | null, source?: CallsignContext | null): string {
if (!value) return ''
- return value
+ const context = source ?? scenario.value
+ const radioCall = context?.radioCall?.trim()
+ const callsign = context?.callsign?.trim()
+
+ if (!radioCall || !callsign) {
+ return value
+ }
+
+ return value.replace(new RegExp(escapeRegExp(radioCall), 'ig'), callsign)
}
const showLessonActions = computed(
@@ -2789,6 +2845,9 @@ const toast = ref({show: false, text: ''})
const showSettings = ref(false)
const showSpeechServerWarning = ref(false)
const showOnlineTtsSuggestion = ref(false)
+const showReplaySpeedHint = ref(false)
+const promptReplayCount = ref(0)
+const replaySpeedHintSeen = ref(false)
const api = useApi()
const isClient = typeof window !== 'undefined'
@@ -2881,6 +2940,27 @@ function enableOnlineTtsFromSuggestion() {
showOnlineTtsSuggestion.value = false
}
+function dismissReplaySpeedHint() {
+ showReplaySpeedHint.value = false
+}
+
+function openReplaySpeedSettings() {
+ showReplaySpeedHint.value = false
+ showSettings.value = true
+}
+
+function resetPromptReplayTracking() {
+ promptReplayCount.value = 0
+ showReplaySpeedHint.value = false
+}
+
+function maybeSuggestSlowerPlayback() {
+ if (replaySpeedHintSeen.value) return
+ if (promptReplayCount.value < 3) return
+ replaySpeedHintSeen.value = true
+ showReplaySpeedHint.value = true
+}
+
function loadLocalAtcSettings(): LearnConfigPatch | null {
if (!isClient) return null
const key = getAtcSettingsStorageKey()
@@ -3242,41 +3322,48 @@ const fieldStates = computed>(() => {
const expected = field.expected(scenario.value).trim()
const answer = (userAnswers[field.key] ?? '').trim()
const alternatives = field.alternatives?.(scenario.value) ?? []
- const options = [expected, ...alternatives].map(norm).filter(Boolean)
- const normalizedAnswer = norm(answer)
+ const options = [expected, ...alternatives].flatMap(buildReadbackComparableForms)
+ const answerForms = buildReadbackComparableForms(answer)
+ const allowFuzzy = field.matching === 'fuzzy' || field.matching === 'controlled' || looksLikeCallsignKey(field.key, field.label)
let best = 0
let pass = false
- if (answer && options.length) {
- for (const option of options) {
- if (!option) continue
- if (normalizedAnswer === option) {
- best = 1
- pass = true
- break
- }
+ if (answerForms.length && options.length) {
+ outer:
+ for (const answerForm of answerForms) {
+ if (!answerForm) continue
+ for (const option of options) {
+ if (!option) continue
+ if (answerForm === option) {
+ best = 1
+ pass = true
+ break outer
+ }
- if (field.matching !== 'fuzzy') continue
+ const distance = lev(answerForm, option)
+ const span = Math.max(option.length, answerForm.length, 1)
+ const score = 1 - distance / span
+ if (score > best) {
+ best = score
+ }
- const distance = lev(normalizedAnswer, option)
- const span = Math.max(option.length, normalizedAnswer.length, 1)
- const score = 1 - distance / span
- if (score > best) {
- best = score
- }
+ if (!allowFuzzy) continue
- const allowance = allowedDistance(span)
- if (allowance <= 0) continue
+ const allowance = allowedDistance(span)
+ if (allowance <= 0) continue
- const threshold = tightenedThreshold(span, field.threshold ?? 0.82)
- if (distance <= allowance && score >= threshold) {
- pass = true
+ const threshold = tightenedThreshold(span, field.threshold ?? 0.82)
+ if (distance <= allowance && score >= threshold) {
+ pass = true
+ }
}
}
if (!pass && !best && options.length) {
- best = Math.max(...options.map(option => similarity(normalizedAnswer, option)))
+ best = Math.max(
+ ...answerForms.flatMap(answerForm => options.map(option => similarity(answerForm, option)))
+ )
}
}
@@ -3697,11 +3784,18 @@ watch(lessonSearchAnchor, anchor => {
watch(scenario, newScenario => {
hasSpokenTarget.value = false
+ resetPromptReplayTracking()
if (!newScenario) {
pendingAutoSay.value = false
}
})
+watch(showSettings, open => {
+ if (open) {
+ showReplaySpeedHint.value = false
+ }
+})
+
function queueAutoSay() {
pendingAutoSay.value = true
void nextTick(() => {
@@ -3730,10 +3824,15 @@ async function speakTarget(auto = false) {
const phrase = targetPhrase.value?.trim()
if (!phrase) return
if (ttsLoading.value) return
+ const replay = !auto && hasSpokenTarget.value
if (!auto) {
pendingAutoSay.value = false
}
hasSpokenTarget.value = true
+ if (replay) {
+ promptReplayCount.value += 1
+ maybeSuggestSlowerPlayback()
+ }
if (auto) {
focusFirstReadbackField()
}
diff --git a/shared/utils/sttMatch.ts b/shared/utils/sttMatch.ts
index 7f24924..d30172b 100644
--- a/shared/utils/sttMatch.ts
+++ b/shared/utils/sttMatch.ts
@@ -75,6 +75,10 @@ export function normalizeForMatch(value: string): string {
.trim()
}
+function compactForMatch(value: string): string {
+ return normalizeForMatch(value).replace(/\s+/g, '')
+}
+
/** 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 {
@@ -295,6 +299,8 @@ export function matchTranscriptionToFields(
): SttMatchResult {
const normalized = normalizeForMatch(transcription)
const denormalized = normalizeForMatch(denormalizeSpokenAtc(transcription))
+ const normalizedCompact = compactForMatch(normalized)
+ const denormalizedCompact = compactForMatch(denormalized)
const matches: Record = {}
// Per-field diagnostic keyed by field.key (output in original order below).
const reportByKey: Record = {}
@@ -321,12 +327,19 @@ export function matchTranscriptionToFields(
for (const cand of candidates) {
if (!cand) continue
+ const compactCandidate = compactForMatch(cand)
if (candidateMatches(normalized, cand)) {
report.matched = true; report.matchedVia = cand; report.view = 'raw'; break
}
if (candidateMatches(denormalized, cand)) {
report.matched = true; report.matchedVia = cand; report.view = 'spoken'; break
}
+ if (compactCandidate.length >= 2 && normalizedCompact.includes(compactCandidate)) {
+ report.matched = true; report.matchedVia = cand; report.view = 'raw'; break
+ }
+ if (compactCandidate.length >= 2 && denormalizedCompact.includes(compactCandidate)) {
+ 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
diff --git a/tests/shared/sttMatch.test.ts b/tests/shared/sttMatch.test.ts
index e5aef19..7d8c941 100644
--- a/tests/shared/sttMatch.test.ts
+++ b/tests/shared/sttMatch.test.ts
@@ -33,6 +33,11 @@ describe('denormalizeSpokenAtc', () => {
it('handles runway suffix letters', () => {
assert.equal(denormalizeSpokenAtc('runway zero eight right'), 'runway 08r')
})
+
+ it('folds ICAO pronunciation variants and compact route tokens back into written form', () => {
+ assert.equal(denormalizeSpokenAtc('KLM fower wun wun wun'), 'klm 4111')
+ assert.equal(denormalizeSpokenAtc('TOBAK fife Quebec'), 'tobak 5q')
+ })
})
describe('matchTranscriptionToFields', () => {
@@ -73,6 +78,24 @@ describe('matchTranscriptionToFields', () => {
assert.equal(result.filled, 1)
})
+ it('matches SID fields with spoken suffixes and missing whitespace', () => {
+ const spoken = matchTranscriptionToFields(
+ 'cleared via tobak fife quebec',
+ [
+ { key: 'sid', expected: 'TOBAK 5Q' },
+ ],
+ )
+ assert.equal(spoken.filled, 1)
+
+ const compact = matchTranscriptionToFields(
+ 'cleared via TOBAK5Q',
+ [
+ { key: 'sid', expected: 'TOBAK 5Q' },
+ ],
+ )
+ assert.equal(compact.filled, 1)
+ })
+
it('matches a 4-digit squawk after digit collapse', () => {
const result = matchTranscriptionToFields(
'squawk seven five zero zero',