diff --git a/app/pages/classroom.vue b/app/pages/classroom.vue index 645be1a..e9de6d3 100644 --- a/app/pages/classroom.vue +++ b/app/pages/classroom.vue @@ -1077,6 +1077,10 @@ :placeholder="fieldPlaceholder(segment.key)" :inputmode="fieldInputmode(segment.key)" :ref="el => assignReadbackFieldRef(segment.key, el as HTMLInputElement | null)" + autocomplete="off" + autocorrect="off" + autocapitalize="none" + spellcheck="false" /> mdi-check mdi-alert @@ -1101,6 +1105,17 @@ mdi-auto-fix Auto-fill +
{{ result.score }}%
@@ -3351,6 +3366,22 @@ const targetPhrase = computed(() => { if (!activeLesson.value || !scenario.value) return '' return displayCallsign(activeLesson.value.phrase(scenario.value), scenario.value) }) + +const correctReadbackText = computed(() => { + if (!activeLesson.value || !scenario.value) return '' + return activeLesson.value.readback.map(seg => { + if (seg.type === 'text') return typeof seg.text === 'function' ? seg.text(scenario.value!) : seg.text + const field = activeLesson.value!.fields.find(f => f.key === seg.key) + return field ? field.expected(scenario.value!) : '' + }).join('').trim() +}) + +async function speakCorrectReadback() { + const text = correctReadbackText.value + if (!text || ttsLoading.value) return + await say(text) +} + const lessonInfo = computed(() => (activeLesson.value && scenario.value ? activeLesson.value.info(scenario.value) : [])) const lessonReference = computed(() => { diff --git a/shared/data/learnModules.ts b/shared/data/learnModules.ts index 33dc12d..6a4d186 100644 --- a/shared/data/learnModules.ts +++ b/shared/data/learnModules.ts @@ -480,12 +480,8 @@ const fundamentalsLessons: Lesson[] = [ key: 'rc-readability', label: 'Readability', expected: scenario => scenario.readabilityWord, - alternatives: scenario => [ - scenario.readability.toString(), - scenario.readabilityWord, - scenario.readabilityWord.toLowerCase() - ], - placeholder: 'Enter readability (1-5)', + alternatives: () => ['1', '2', '3', '4', '5', 'one', 'two', 'three', 'four', 'five', 'wun', 'too', 'tree', 'fife'], + placeholder: '1–5', width: 'sm' } ], @@ -927,8 +923,8 @@ const readbackLessons: Lesson[] = [ desc: 'Acknowledge line up and wait', keywords: ['Tower', 'Line Up'], hints: [ - 'Repeat the runway, then say "line up and wait".', - 'Place the call sign at the end.' + 'In the readback, state the runway first — even though the controller said it last.', + 'ICAO standard: runway leads the readback, then "line up and wait", then your call sign.' ], fields: [ { @@ -2847,7 +2843,8 @@ const fullFlightLessons: Lesson[] = [ desc: 'Acknowledge the line-up clearance', keywords: ['Tower', 'Line Up', 'Flow'], hints: [ - 'Repeat the runway and add "line up and wait".' + 'In the readback, state the runway first — even though the controller said it last.', + 'ICAO standard: runway leads the readback, then "line up and wait", then your call sign.' ], fields: [ { diff --git a/shared/learn/scenario.ts b/shared/learn/scenario.ts index f54cbfa..750628a 100644 --- a/shared/learn/scenario.ts +++ b/shared/learn/scenario.ts @@ -332,12 +332,12 @@ export function createBaseScenario(): Scenario { const taxiRoute = choice(airport.taxi) const sid = choice(airport.sids) const transition = choice(airport.transitions) - const arrivalRunway = choice(destination.runways) + const approach = choice(destination.approaches) + const arrivalRunway = approach.match(/(\d{2}[LCR]?)$/)?.[1] ?? choice(destination.runways) const arrivalStand = choice(destination.stands) const arrivalTaxiRoute = choice(destination.taxiIn ?? destination.taxi) const arrivalStar = choice(destination.stars ?? destination.sids) const arrivalTransition = choice(destination.arrivalTransitions ?? destination.transitions) - const approach = choice(destination.approaches) const altitude = choice([4000, 5000, 6000, 7000]) const climbAltitude = altitude + 2000 const squawk = generateSquawk() diff --git a/shared/utils/radioSpeech.ts b/shared/utils/radioSpeech.ts index 8da8ddf..e46c888 100644 --- a/shared/utils/radioSpeech.ts +++ b/shared/utils/radioSpeech.ts @@ -74,17 +74,40 @@ const METAR_CLOUD: Record = { 'FEW': 'few', 'SCT': 'scattered', 'BKN': 'broken', 'OVC': 'overcast', }; +// Uppercase ATC/English tokens of 5-6 chars that must NOT be spelled phonetically +// when `expandWaypoints` is active. Waypoints (SUGOL, UNOKO, ANEKI, ...) are not in this set. +const WAYPOINT_SKIP: Set = new Set([ + 'MAYDAY', 'PANPAN', 'CLEAR', 'CHECK', 'RIGHT', 'LIGHT', 'EIGHT', 'THREE', + 'SEVEN', 'NINER', 'AFTER', 'BEFORE', 'CROSS', 'SHORT', 'ABEAM', + 'BELOW', 'ABOVE', 'TOWER', 'GROUND', 'APRON', 'RAMP', + 'NORTH', 'SOUTH', 'WINDS', 'GUSTS', 'HEAVY', + 'WHEN', 'WITH', 'YOUR', 'THEN', 'THIS', 'THAT', 'WILL', 'OVER', + 'TAXI', 'STAND', 'PUSH', 'START', 'INTO', 'FROM', 'ONTO', 'GATE', + 'FINAL', 'TURN', 'CLIMB', 'DESCEND', 'MAINTAIN', 'CONTACT', + 'SQUAWK', 'IDENT', 'ROGER', 'WILCO', 'AFFIRM', 'NEGATIVE', 'STANDBY', + 'INBOUND', 'OUTBOUND', 'APPROACH', 'DEPARTURE', 'ARRIVAL', 'CLEARED', + 'EXPECT', 'REPORT', 'REQUEST', 'CONFIRM', 'PROCEED', 'CONTINUE', + 'DIRECT', 'VECTOR', 'HEADING', 'COURSE', 'INTERCEPT', 'ESTABLISHED', + 'RUNWAY', 'ACTIVE', 'CLOSED', 'LOOSE', 'BEHIND', + 'LANDING', 'TAKEOFF', 'HOLDING', + 'INDIA', 'ALPHA', 'BRAVO', 'DELTA', 'JULIET', 'OSCAR', + 'ROMEO', 'SIERRA', 'TANGO', 'VICTOR', 'YANKEE', + 'FOXTROT', 'WHISKEY', +]); + export interface NormalizeRadioOptions { airlineMap?: AirlineTelephonyMap; expandCallsigns?: boolean; expandAirports?: boolean; sidSuffixIcao?: boolean; + expandWaypoints?: boolean; } const DEFAULT_OPTIONS: Required> = { expandAirports: false, expandCallsigns: false, sidSuffixIcao: true, + expandWaypoints: true, }; export function spellIcaoDigits(value: string, separator = ' '): string { @@ -184,14 +207,10 @@ function speakTaxiSegment(segment: string): string { } function speakTaxiRoute(route: string): string { - const parts = route.trim().split(/(\s+)/); - if (!parts.length) return route.trim(); - const spokenParts = parts.map((part) => { - if (!part.trim()) return part; - const spoken = speakTaxiSegment(part); - return spoken || part; - }); - return spokenParts.join('').replace(/\s+/g, ' ').trim(); + const tokens = route.trim().split(/\s+/).filter(Boolean); + if (!tokens.length) return route.trim(); + const spokenTokens = tokens.map(token => speakTaxiSegment(token) || token); + return spokenTokens.join(', '); } function applyTaxiRoutePhonetics(text: string): string { @@ -264,7 +283,7 @@ function icaoAirportSpeak(raw: string): string { } function sidSuffixSpeak(prefix: string, digit: string, letter: string): string { - return `${prefix} ${spellIcaoDigits(digit)} ${spellIcaoLetters(letter)}`; + return `${toIcaoPhonetic(prefix)} ${spellIcaoDigits(digit)} ${spellIcaoLetters(letter)}`; } function approachSpeak(type: string, runway: string, suffix: string): string { @@ -362,11 +381,27 @@ export function normalizeRadioPhrase(text: string, options: NormalizeRadioOption }); } + // ILS/VOR variant letter before runway: "ILS Z 25C" → "ILS Zulu runway two five center" + out = out.replace( + /\b(ILS|VOR|RNAV|LOC|RNP)\s+([A-Z])\s+(\d{2}[LCR]?)\b/gi, + (_match, type: string, variant: string, runway: string) => + `${type.toUpperCase()} ${ICAO_LETTERS[variant.toUpperCase()] ?? variant} ${runwaySpeak(runway)}` + ); + // ILS/VOR suffix after runway: "ILS 25C Z" → legacy format out = out.replace( /\b(ILS|VOR|RNAV|LOC|RNP)\s+(\d{2}[LCR]?)\s+([A-Z])\b/gi, (_match, type: string, runway: string, suffix: string) => approachSpeak(type.toUpperCase(), runway, suffix) ); + if (opts.expandWaypoints) { + // Expand standalone 5-6-char uppercase waypoint names (not already expanded by sidSuffixIcao) + // Skip common ATC English words that may appear uppercase. + out = out.replace(/\b([A-Z]{5,6})\b/g, (match, wp: string) => { + if (WAYPOINT_SKIP.has(wp)) return match; + return toIcaoPhonetic(wp); + }); + } + if (opts.expandAirports) { out = out.replace(/\b([A-Z]{4})\b/g, (_match, code: string) => icaoAirportSpeak(code)); } diff --git a/tests/shared/radioSpeech.test.ts b/tests/shared/radioSpeech.test.ts index edc8ef6..0a0c21b 100644 --- a/tests/shared/radioSpeech.test.ts +++ b/tests/shared/radioSpeech.test.ts @@ -25,14 +25,15 @@ describe('radioSpeech', () => { assert.match(normalized, /Lufthansa tree fife niner/) assert.match(normalized, /wun too wun decimal eight zero zero/) assert.match(normalized, /runway too fife right/) - assert.match(normalized, /November tree Uniform four/) + assert.match(normalized, /November tree,\s+Uniform four/) }) it('normalizes SID suffix and METAR data', () => { const sid = normalizeRadioPhrase('MARUN 7F') const metar = normalizeMetarPhrase('EDDF 171450Z 28015G25KT 9999 -RA SCT025 BKN040 15/08 Q1013') - assert.equal(sid, 'MARUN seven Foxtrot') + // SID prefix is spelled phonetically so TTS pronounces unfamiliar waypoint names correctly + assert.equal(sid, 'Mike Alfa Romeo Uniform November seven Foxtrot') assert.match(metar, /wind too eight zero degrees/) assert.match(metar, /gusting too fife knots/) assert.match(metar, /QNH wun zero wun tree/) diff --git a/tests/smoke/normalize.smoke.test.ts b/tests/smoke/normalize.smoke.test.ts index 3e15ddc..d1d7269 100644 --- a/tests/smoke/normalize.smoke.test.ts +++ b/tests/smoke/normalize.smoke.test.ts @@ -34,6 +34,6 @@ describe('normalize smoke', () => { assert.match(normalized, /Lufthansa tree fife niner/) assert.match(normalized, /runway too fife right/) - assert.match(normalized, /November tree Uniform four/) + assert.match(normalized, /November tree,\s+Uniform four/) }) })