From ea51e1dcc3ecdf6584a2eddcf80b4c230557097d Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Sat, 14 Feb 2026 14:21:35 +0100 Subject: [PATCH] =?UTF-8?q?feat(classroom):=20integrate=20user=20feedback?= =?UTF-8?q?=20=E2=80=94=20audio=20speed,=20METAR=20TTS,=20phonetics,=20UI?= =?UTF-8?q?=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lower default audio speed to 0.85x, extend slider range to 0.5-1.3x - Add METAR normalization for intelligible TTS (wind, vis, clouds, temp, QNH) - Expand SID/STAR suffix regex to handle spaces (SUGOL 2S) - Add approach suffix phonetic expansion (ILS 08R Y → Yankee) - Fix "Soll:" → "Expected:" in readback feedback - Accept numeric values for pushback delay field - Add news article documenting the changes Co-Authored-By: Claude Opus 4.6 --- app/pages/classroom.vue | 21 ++--- content/news/classroom-feedback-update.md | 59 ++++++++++++++ shared/data/learnModules.ts | 3 +- shared/learn/config.ts | 2 +- shared/utils/radioSpeech.ts | 99 ++++++++++++++++++++++- 5 files changed, 172 insertions(+), 12 deletions(-) create mode 100644 content/news/classroom-feedback-update.md diff --git a/app/pages/classroom.vue b/app/pages/classroom.vue index 17fd3ff..68910d9 100644 --- a/app/pages/classroom.vue +++ b/app/pages/classroom.vue @@ -1065,7 +1065,7 @@ mdi-alert - Soll: {{ fieldExpectedValue(segment.key) }} + Expected: {{ fieldExpectedValue(segment.key) }} @@ -1262,7 +1262,7 @@ scenario.pushDelayWords, alternatives: scenario => [ scenario.pushDelayWords, - `${scenario.pushDelayMinutes} minutes` + `${scenario.pushDelayMinutes} minutes`, + scenario.pushDelayMinutes.toString() ], width: 'md' }, diff --git a/shared/learn/config.ts b/shared/learn/config.ts index 68096e0..29004e8 100644 --- a/shared/learn/config.ts +++ b/shared/learn/config.ts @@ -25,7 +25,7 @@ export const LEARN_CONFIG_DEFAULTS: LearnConfig = { radioLevel: 5, voice: '', audioChallenge: true, - audioSpeed: 1, + audioSpeed: 0.85, } export function createDefaultLearnConfig(): LearnConfig { diff --git a/shared/utils/radioSpeech.ts b/shared/utils/radioSpeech.ts index 0ed3cd4..8da8ddf 100644 --- a/shared/utils/radioSpeech.ts +++ b/shared/utils/radioSpeech.ts @@ -59,6 +59,21 @@ export const DEFAULT_AIRLINE_TELEPHONY: AirlineTelephonyMap = { EZY: "Easy", }; +const METAR_WEATHER: Record = { + '+TSRA': 'thunderstorm with heavy rain', 'TSRA': 'thunderstorm with rain', + '+SHRA': 'heavy rain showers', '-SHRA': 'light rain showers', 'SHRA': 'rain showers', + '+RA': 'heavy rain', '-RA': 'light rain', 'RA': 'rain', + '+SN': 'heavy snow', '-SN': 'light snow', 'SN': 'snow', + '+DZ': 'heavy drizzle', '-DZ': 'light drizzle', 'DZ': 'drizzle', + 'FG': 'fog', 'BR': 'mist', 'HZ': 'haze', + 'TS': 'thunderstorm', 'SH': 'showers', 'FZ': 'freezing', + 'GR': 'hail', 'GS': 'small hail', +}; + +const METAR_CLOUD: Record = { + 'FEW': 'few', 'SCT': 'scattered', 'BKN': 'broken', 'OVC': 'overcast', +}; + export interface NormalizeRadioOptions { airlineMap?: AirlineTelephonyMap; expandCallsigns?: boolean; @@ -252,6 +267,83 @@ function sidSuffixSpeak(prefix: string, digit: string, letter: string): string { return `${prefix} ${spellIcaoDigits(digit)} ${spellIcaoLetters(letter)}`; } +function approachSpeak(type: string, runway: string, suffix: string): string { + const rw = runwaySpeak(runway); + const phonetic = ICAO_LETTERS[suffix.toUpperCase()] ?? suffix; + return `${type} ${rw} ${phonetic}`; +} + +export function normalizeMetarPhrase(metar: string): string { + const parts: string[] = []; + + // Wind: 28015KT or 28015G25KT or VRB05KT + const windMatch = metar.match(/\b(VRB|\d{3})(\d{2,3})(G(\d{2,3}))?KT\b/); + if (windMatch) { + const dir = windMatch[1] === 'VRB' ? 'variable' : `${spellIcaoDigits(windMatch[1]!)} degrees`; + const speed = spellIcaoDigits(windMatch[2]!); + let windPart = `wind ${dir}, ${speed} knots`; + if (windMatch[4]) { + windPart += `, gusting ${spellIcaoDigits(windMatch[4])} knots`; + } + parts.push(windPart); + } + + // Visibility: 9999, 0800, CAVOK + if (metar.includes('CAVOK')) { + parts.push('CAVOK'); + } else { + const visMatch = metar.match(/(?= 9999) { + parts.push(`visibility, ${spellIcaoDigits('1')} ${spellIcaoDigits('0')} kilometers or more`); + } else { + parts.push(`visibility, ${spellIcaoDigits(vis.toString())} meters`); + } + } + } + + // Weather phenomena (match longest codes first) + const wxPatterns = Object.keys(METAR_WEATHER).sort((a, b) => b.length - a.length); + for (const wx of wxPatterns) { + if (new RegExp(`\\b${wx.replace('+', '\\+')}\\b`).test(metar) || metar.includes(` ${wx} `)) { + const spoken = METAR_WEATHER[wx]; + if (spoken) parts.push(spoken); + break; + } + } + + // Clouds: BKN025, SCT040, FEW010, OVC008 + const cloudRegex = /\b(FEW|SCT|BKN|OVC)(\d{3})\b/g; + let cloudMatch; + while ((cloudMatch = cloudRegex.exec(metar)) !== null) { + const cover = METAR_CLOUD[cloudMatch[1]!] ?? cloudMatch[1]; + const alt = parseInt(cloudMatch[2]!) * 100; + parts.push(`${cover}, ${altitudeSpeak(alt)}`); + } + + // Temperature: 15/08 or M02/M05 + const tempMatch = metar.match(/\b(M?\d{2})\/(M?\d{2})\b/); + if (tempMatch) { + const speakTemp = (raw: string) => { + if (raw.startsWith('M')) { + return `minus ${spellIcaoDigits(raw.slice(1))}`; + } + return spellIcaoDigits(raw); + }; + parts.push(`temperature ${speakTemp(tempMatch[1]!)}, dew point ${speakTemp(tempMatch[2]!)}`); + } + + // QNH: Q1013 + const qnhMatch = metar.match(/\bQ(\d{4})\b/); + if (qnhMatch) { + parts.push(qnhSpeak(qnhMatch[1]!)); + } + + if (!parts.length) return metar; + return parts.join(', '); +} + export function normalizeRadioPhrase(text: string, options: NormalizeRadioOptions = {}): string { const opts = { ...DEFAULT_OPTIONS, ...options }; let out = text; @@ -265,11 +357,16 @@ export function normalizeRadioPhrase(text: string, options: NormalizeRadioOption out = out.replace(/\bQNH\s*(\d{3,4})\b/gi, (_, qnh: string) => qnhSpeak(qnh)); if (opts.sidSuffixIcao) { - out = out.replace(/\b([A-Z]{4,6})(\s?)(\d)([A-Z])\b/g, (_match, prefix: string, _gap: string, digit: string, letter: string) => { + out = out.replace(/\b([A-Z]{4,6})\s*(\d)\s*([A-Z])\b/g, (_match, prefix: string, digit: string, letter: string) => { return sidSuffixSpeak(prefix, digit, letter); }); } + 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.expandAirports) { out = out.replace(/\b([A-Z]{4})\b/g, (_match, code: string) => icaoAirportSpeak(code)); }