Improve classroom readback tolerance

This commit is contained in:
itsrubberduck
2026-07-27 12:29:37 +02:00
parent d6b2b328f6
commit b470d805f8
3 changed files with 159 additions and 24 deletions

View File

@@ -1356,6 +1356,26 @@
</div>
</v-dialog>
<v-dialog v-model="showReplaySpeedHint" max-width="540">
<div class="panel dialog">
<p class="eyebrow">Playback help</p>
<h3 class="h3">Is the prompt too fast?</h3>
<p class="muted">
You have replayed this prompt a few times. You can slow the ATC speaking speed down in Settings whenever
you need a calmer pace.
</p>
<div class="row end">
<button class="btn ghost" type="button" @click="dismissReplaySpeedHint">
Keep current speed
</button>
<button class="btn primary" type="button" @click="openReplaySpeedSettings">
<v-icon size="18">mdi-tune</v-icon>
Open settings
</button>
</div>
</div>
</v-dialog>
<v-dialog v-model="showSpeechServerWarning" max-width="620" persistent>
<div class="panel dialog speech-server-dialog">
<div class="speech-server-icon">
@@ -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<string>()
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<ModuleDef[]>(learnModules)
type LessonSearchHit = {
@@ -1752,7 +1800,15 @@ const pendingLessonId = ref<string | null>(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<Record<string, FieldState>>(() => {
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()
}

View File

@@ -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<string, string> = {}
// Per-field diagnostic keyed by field.key (output in original order below).
const reportByKey: Record<string, SttFieldReport> = {}
@@ -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

View File

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