From b470d805f8c71fe4ee6295e5283a57cb4e40cf3b Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Mon, 27 Jul 2026 12:29:37 +0200 Subject: [PATCH] Improve classroom readback tolerance --- app/pages/classroom.vue | 147 ++++++++++++++++++++++++++++------ shared/utils/sttMatch.ts | 13 +++ tests/shared/sttMatch.test.ts | 23 ++++++ 3 files changed, 159 insertions(+), 24 deletions(-) diff --git a/app/pages/classroom.vue b/app/pages/classroom.vue index 6a5f352..ea9fe90 100644 --- a/app/pages/classroom.vue +++ b/app/pages/classroom.vue @@ -1356,6 +1356,26 @@ + +
+

Playback help

+

Is the prompt too fast?

+

+ You have replayed this prompt a few times. You can slow the ATC speaking speed down in Settings whenever + you need a calmer pace. +

+
+ + +
+
+
+
@@ -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',