mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-11 20:15:32 +08:00
Address classroom tester feedback (Detlef / FSC e.V.)
Fixes the most impactful issues from ~1.5h of testing: - TTS: spell SID prefixes phonetically (ANEKI → Alfa November Echo Kilo India) so unfamiliar waypoint names are intelligible without prior briefing context. - TTS: expand standalone uppercase waypoints after via/direct (with a skip list for common ATC English tokens like MAYDAY, CLEARED, …). - TTS: join taxi route tokens with ", " so pauses land between taxiways (C5, Z5, U10, …) instead of running together. - TTS: handle "ILS Z 25C" variant before the runway → "ILS Zulu runway two five center" (was previously read as "Zee twentyfive cee"). - Scenarios: derive arrivalRunway from the chosen approach so the controller no longer clears a flight for ILS 25C onto runway 18. - Radio check: accept any readability 1–5 (numeric or spoken), shorten placeholder so it fits the sm-width field. - Line-up readback: clearer hint about the runway-first ICAO order. - Classroom UI: disable browser autocomplete/autocorrect on readback inputs (Edge autofill was injecting unrelated values). - Classroom UI: "Speak answer" button replays the expected readback as TTS so students can hear the correct phrasing. Tests adjusted for the new SID and taxi-route phonetics. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1077,6 +1077,10 @@
|
|||||||
:placeholder="fieldPlaceholder(segment.key)"
|
:placeholder="fieldPlaceholder(segment.key)"
|
||||||
:inputmode="fieldInputmode(segment.key)"
|
:inputmode="fieldInputmode(segment.key)"
|
||||||
:ref="el => assignReadbackFieldRef(segment.key, el as HTMLInputElement | null)"
|
:ref="el => assignReadbackFieldRef(segment.key, el as HTMLInputElement | null)"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="none"
|
||||||
|
spellcheck="false"
|
||||||
/>
|
/>
|
||||||
<v-icon v-if="fieldPass(segment.key)" size="16" class="blank-status ok">mdi-check</v-icon>
|
<v-icon v-if="fieldPass(segment.key)" size="16" class="blank-status ok">mdi-check</v-icon>
|
||||||
<v-icon v-else-if="fieldHasAnswer(segment.key)" size="16" class="blank-status warn">mdi-alert
|
<v-icon v-else-if="fieldHasAnswer(segment.key)" size="16" class="blank-status warn">mdi-alert
|
||||||
@@ -1101,6 +1105,17 @@
|
|||||||
<v-icon size="18">mdi-auto-fix</v-icon>
|
<v-icon size="18">mdi-auto-fix</v-icon>
|
||||||
Auto-fill
|
Auto-fill
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="correctReadbackText"
|
||||||
|
class="btn ghost"
|
||||||
|
type="button"
|
||||||
|
:disabled="ttsLoading"
|
||||||
|
@click="speakCorrectReadback"
|
||||||
|
title="Speak correct readback"
|
||||||
|
>
|
||||||
|
<v-icon size="18">mdi-volume-high</v-icon>
|
||||||
|
Speak answer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="result" class="score">
|
<div v-if="result" class="score">
|
||||||
<div class="score-num">{{ result.score }}%</div>
|
<div class="score-num">{{ result.score }}%</div>
|
||||||
@@ -3351,6 +3366,22 @@ const targetPhrase = computed(() => {
|
|||||||
if (!activeLesson.value || !scenario.value) return ''
|
if (!activeLesson.value || !scenario.value) return ''
|
||||||
return displayCallsign(activeLesson.value.phrase(scenario.value), scenario.value)
|
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 lessonInfo = computed(() => (activeLesson.value && scenario.value ? activeLesson.value.info(scenario.value) : []))
|
||||||
|
|
||||||
const lessonReference = computed(() => {
|
const lessonReference = computed(() => {
|
||||||
|
|||||||
@@ -480,12 +480,8 @@ const fundamentalsLessons: Lesson[] = [
|
|||||||
key: 'rc-readability',
|
key: 'rc-readability',
|
||||||
label: 'Readability',
|
label: 'Readability',
|
||||||
expected: scenario => scenario.readabilityWord,
|
expected: scenario => scenario.readabilityWord,
|
||||||
alternatives: scenario => [
|
alternatives: () => ['1', '2', '3', '4', '5', 'one', 'two', 'three', 'four', 'five', 'wun', 'too', 'tree', 'fife'],
|
||||||
scenario.readability.toString(),
|
placeholder: '1–5',
|
||||||
scenario.readabilityWord,
|
|
||||||
scenario.readabilityWord.toLowerCase()
|
|
||||||
],
|
|
||||||
placeholder: 'Enter readability (1-5)',
|
|
||||||
width: 'sm'
|
width: 'sm'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -927,8 +923,8 @@ const readbackLessons: Lesson[] = [
|
|||||||
desc: 'Acknowledge line up and wait',
|
desc: 'Acknowledge line up and wait',
|
||||||
keywords: ['Tower', 'Line Up'],
|
keywords: ['Tower', 'Line Up'],
|
||||||
hints: [
|
hints: [
|
||||||
'Repeat the runway, then say "line up and wait".',
|
'In the readback, state the runway first — even though the controller said it last.',
|
||||||
'Place the call sign at the end.'
|
'ICAO standard: runway leads the readback, then "line up and wait", then your call sign.'
|
||||||
],
|
],
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
@@ -2847,7 +2843,8 @@ const fullFlightLessons: Lesson[] = [
|
|||||||
desc: 'Acknowledge the line-up clearance',
|
desc: 'Acknowledge the line-up clearance',
|
||||||
keywords: ['Tower', 'Line Up', 'Flow'],
|
keywords: ['Tower', 'Line Up', 'Flow'],
|
||||||
hints: [
|
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: [
|
fields: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -332,12 +332,12 @@ export function createBaseScenario(): Scenario {
|
|||||||
const taxiRoute = choice(airport.taxi)
|
const taxiRoute = choice(airport.taxi)
|
||||||
const sid = choice(airport.sids)
|
const sid = choice(airport.sids)
|
||||||
const transition = choice(airport.transitions)
|
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 arrivalStand = choice(destination.stands)
|
||||||
const arrivalTaxiRoute = choice(destination.taxiIn ?? destination.taxi)
|
const arrivalTaxiRoute = choice(destination.taxiIn ?? destination.taxi)
|
||||||
const arrivalStar = choice(destination.stars ?? destination.sids)
|
const arrivalStar = choice(destination.stars ?? destination.sids)
|
||||||
const arrivalTransition = choice(destination.arrivalTransitions ?? destination.transitions)
|
const arrivalTransition = choice(destination.arrivalTransitions ?? destination.transitions)
|
||||||
const approach = choice(destination.approaches)
|
|
||||||
const altitude = choice([4000, 5000, 6000, 7000])
|
const altitude = choice([4000, 5000, 6000, 7000])
|
||||||
const climbAltitude = altitude + 2000
|
const climbAltitude = altitude + 2000
|
||||||
const squawk = generateSquawk()
|
const squawk = generateSquawk()
|
||||||
|
|||||||
@@ -74,17 +74,40 @@ const METAR_CLOUD: Record<string, string> = {
|
|||||||
'FEW': 'few', 'SCT': 'scattered', 'BKN': 'broken', 'OVC': 'overcast',
|
'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<string> = 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 {
|
export interface NormalizeRadioOptions {
|
||||||
airlineMap?: AirlineTelephonyMap;
|
airlineMap?: AirlineTelephonyMap;
|
||||||
expandCallsigns?: boolean;
|
expandCallsigns?: boolean;
|
||||||
expandAirports?: boolean;
|
expandAirports?: boolean;
|
||||||
sidSuffixIcao?: boolean;
|
sidSuffixIcao?: boolean;
|
||||||
|
expandWaypoints?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: Required<Omit<NormalizeRadioOptions, 'airlineMap'>> = {
|
const DEFAULT_OPTIONS: Required<Omit<NormalizeRadioOptions, 'airlineMap'>> = {
|
||||||
expandAirports: false,
|
expandAirports: false,
|
||||||
expandCallsigns: false,
|
expandCallsigns: false,
|
||||||
sidSuffixIcao: true,
|
sidSuffixIcao: true,
|
||||||
|
expandWaypoints: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function spellIcaoDigits(value: string, separator = ' '): string {
|
export function spellIcaoDigits(value: string, separator = ' '): string {
|
||||||
@@ -184,14 +207,10 @@ function speakTaxiSegment(segment: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function speakTaxiRoute(route: string): string {
|
function speakTaxiRoute(route: string): string {
|
||||||
const parts = route.trim().split(/(\s+)/);
|
const tokens = route.trim().split(/\s+/).filter(Boolean);
|
||||||
if (!parts.length) return route.trim();
|
if (!tokens.length) return route.trim();
|
||||||
const spokenParts = parts.map((part) => {
|
const spokenTokens = tokens.map(token => speakTaxiSegment(token) || token);
|
||||||
if (!part.trim()) return part;
|
return spokenTokens.join(', ');
|
||||||
const spoken = speakTaxiSegment(part);
|
|
||||||
return spoken || part;
|
|
||||||
});
|
|
||||||
return spokenParts.join('').replace(/\s+/g, ' ').trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTaxiRoutePhonetics(text: string): string {
|
function applyTaxiRoutePhonetics(text: string): string {
|
||||||
@@ -264,7 +283,7 @@ function icaoAirportSpeak(raw: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sidSuffixSpeak(prefix: string, digit: string, letter: 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 {
|
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(
|
out = out.replace(
|
||||||
/\b(ILS|VOR|RNAV|LOC|RNP)\s+(\d{2}[LCR]?)\s+([A-Z])\b/gi,
|
/\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)
|
(_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) {
|
if (opts.expandAirports) {
|
||||||
out = out.replace(/\b([A-Z]{4})\b/g, (_match, code: string) => icaoAirportSpeak(code));
|
out = out.replace(/\b([A-Z]{4})\b/g, (_match, code: string) => icaoAirportSpeak(code));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,14 +25,15 @@ describe('radioSpeech', () => {
|
|||||||
assert.match(normalized, /Lufthansa tree fife niner/)
|
assert.match(normalized, /Lufthansa tree fife niner/)
|
||||||
assert.match(normalized, /wun too wun decimal eight zero zero/)
|
assert.match(normalized, /wun too wun decimal eight zero zero/)
|
||||||
assert.match(normalized, /runway too fife right/)
|
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', () => {
|
it('normalizes SID suffix and METAR data', () => {
|
||||||
const sid = normalizeRadioPhrase('MARUN 7F')
|
const sid = normalizeRadioPhrase('MARUN 7F')
|
||||||
const metar = normalizeMetarPhrase('EDDF 171450Z 28015G25KT 9999 -RA SCT025 BKN040 15/08 Q1013')
|
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, /wind too eight zero degrees/)
|
||||||
assert.match(metar, /gusting too fife knots/)
|
assert.match(metar, /gusting too fife knots/)
|
||||||
assert.match(metar, /QNH wun zero wun tree/)
|
assert.match(metar, /QNH wun zero wun tree/)
|
||||||
|
|||||||
@@ -34,6 +34,6 @@ describe('normalize smoke', () => {
|
|||||||
|
|
||||||
assert.match(normalized, /Lufthansa tree fife niner/)
|
assert.match(normalized, /Lufthansa tree fife niner/)
|
||||||
assert.match(normalized, /runway too fife right/)
|
assert.match(normalized, /runway too fife right/)
|
||||||
assert.match(normalized, /November tree Uniform four/)
|
assert.match(normalized, /November tree,\s+Uniform four/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user