From d6cae201edff4fbee4f253e28023bb6c085d1057 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Thu, 28 May 2026 13:45:15 +0200 Subject: [PATCH] feat(atis): inline METAR expansion, airport name lookup, acronym fix ATIS text often arrives in raw METAR form (e.g. "METAR EDDF 281050Z AUTO 02008KT 320V070 CAVOK 24/02 Q1025 NOSIG"), which TTS reads as letter-by-letter spelling. The normalizer now expands the full WMO Code Form FM 15-XV vocabulary inline: DDHHMMZ date stamps, compressed wind (with gusts, VRB, calm), wind variability ranges, RVR (R25L/1500N), wind shear, slash-form temp/dewpoint, Q/A pressure, NSC/SKC/CLR/NCD/VV cloud codes, weather phenomena (with intensity and descriptors), recent-weather RE prefix, BECMG/TEMPO/FM/TL/AT trend codes, and strips RMK remarks. Plus ATIS/METAR/SPECI get lowercased so TTS pronounces them as words (pilots SPELL ILS/QNH/VOR so those stay uppercase). Airport ICAO codes are substituted with their OpenAIP name when the frequencies endpoint returns one. New `airportName` field added to the FrequencyResponse for that. Adds 7 test cases covering the user- reported EDDF sample plus calm/VRB/gust winds, RVR, weather codes, cloud specials, trend codes, and RMK stripping. Co-Authored-By: Claude Opus 4.7 --- app/pages/pm.vue | 9 +- server/api/airports/[icao]/frequencies.get.ts | 9 + shared/utils/radioSpeech.ts | 198 ++++++++++++++++-- tests/shared/radioSpeech.test.ts | 83 ++++++++ 4 files changed, 284 insertions(+), 15 deletions(-) diff --git a/app/pages/pm.vue b/app/pages/pm.vue index de6527a..e12735f 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -1524,6 +1524,7 @@ const frequencies = ref({ }) const airportFrequencies = ref([]) +const airportName = ref(undefined) const airportFrequencyLoading = ref(false) const frequencySources = ref({ vatsim: false, openaip: false }) const atisPlaybackLoading = ref(false) @@ -2566,12 +2567,14 @@ const fetchAirportFrequencies = async (icao: string | undefined) => { airportFrequencyLoading.value = true airportFrequencies.value = [] + airportName.value = undefined frequencySources.value = { vatsim: false, openaip: false } try { const response = await api.get(`/api/airports/${encodeURIComponent(icao)}/frequencies`) const entries = Array.isArray(response?.frequencies) ? response.frequencies as AirportFrequencyEntry[] : [] airportFrequencies.value = entries + airportName.value = typeof response?.airportName === 'string' ? response.airportName : undefined frequencySources.value = { vatsim: Boolean(response?.sources?.vatsim), openaip: Boolean(response?.sources?.openaip) @@ -2581,6 +2584,7 @@ const fetchAirportFrequencies = async (icao: string | undefined) => { } catch (err) { console.error('Failed to load airport frequencies:', err) airportFrequencies.value = [] + airportName.value = undefined frequencySources.value = { vatsim: false, openaip: false } } finally { airportFrequencyLoading.value = false @@ -2724,7 +2728,10 @@ const startAtisLoop = async (entry: AirportFrequencyEntry) => { } const announcement = buildAtisAnnouncement({ ...entry, atisText: content }) - const spokenAnnouncement = normalizeAtisForSpeech(announcement) + const spokenAnnouncement = normalizeAtisForSpeech(announcement, { + airportIcao: flightContext.value.dep, + airportName: airportName.value, + }) const epoch = resolveAtisEpoch(entry) const requestSeq = ++atisLoopSeq atisPlaybackLoading.value = true diff --git a/server/api/airports/[icao]/frequencies.get.ts b/server/api/airports/[icao]/frequencies.get.ts index 5d9e90e..f0b3844 100644 --- a/server/api/airports/[icao]/frequencies.get.ts +++ b/server/api/airports/[icao]/frequencies.get.ts @@ -14,6 +14,7 @@ interface FrequencyEntry { interface FrequencyResponse { icao: string + airportName?: string sources: { vatsim: boolean openaip: boolean @@ -124,6 +125,7 @@ export default defineEventHandler(async (event): Promise => { const icao = icaoParam.toUpperCase() const frequencyMap = new Map() + let airportName: string | undefined let vatsimSuccess = false let openaipSuccess = false @@ -205,6 +207,12 @@ export default defineEventHandler(async (event): Promise => { for (const airport of items) { // Real field is `icaoCode`, not `icao` if ((airport?.icaoCode || '').toUpperCase() !== icao) continue + // Airport-level metadata: `name` (e.g. "Frankfurt am Main"), `municipality` (city only) + if (!airportName) { + const name = typeof airport?.name === 'string' ? airport.name.trim() : '' + const muni = typeof airport?.municipality === 'string' ? airport.municipality.trim() : '' + airportName = name || muni || undefined + } // Frequencies live under `airport.frequencies[]`; each item has: // value (MHz string, e.g. "122.035") // type (numeric code, e.g. 5 for Delivery) @@ -253,6 +261,7 @@ export default defineEventHandler(async (event): Promise => { return { icao, + airportName, sources: { vatsim: vatsimSuccess, openaip: openaipSuccess diff --git a/shared/utils/radioSpeech.ts b/shared/utils/radioSpeech.ts index d925d0b..0bc5e31 100644 --- a/shared/utils/radioSpeech.ts +++ b/shared/utils/radioSpeech.ts @@ -70,6 +70,28 @@ const METAR_WEATHER: Record = { 'GR': 'hail', 'GS': 'small hail', }; +// METAR weather code components (intensity / descriptor / precipitation / obscuration / other) +// used by the inline weather-token parser. Source: WMO Code Form FM 15-XV / ICAO Annex 3. +const WX_INTENSITY: Record = { + '-': 'light', '+': 'heavy', 'VC': 'in the vicinity', +}; +const WX_DESCRIPTOR: Record = { + MI: 'shallow', BC: 'patches', DR: 'low drifting', BL: 'blowing', + SH: 'shower', TS: 'thunderstorm', FZ: 'freezing', PR: 'partial', +}; +const WX_PHENOMENON: Record = { + // Precipitation + DZ: 'drizzle', RA: 'rain', SN: 'snow', SG: 'snow grains', + IC: 'ice crystals', PL: 'ice pellets', GR: 'hail', GS: 'small hail', + UP: 'unknown precipitation', + // Obscuration + BR: 'mist', FG: 'fog', FU: 'smoke', VA: 'volcanic ash', + DU: 'widespread dust', SA: 'sand', HZ: 'haze', + // Other + PO: 'dust devils', SQ: 'squall', FC: 'funnel cloud', + SS: 'sandstorm', DS: 'duststorm', +}; + const METAR_CLOUD: Record = { 'FEW': 'few', 'SCT': 'scattered', 'BKN': 'broken', 'OVC': 'overcast', }; @@ -378,31 +400,172 @@ function cloudHeightWords(heightCodeHundreds: number): string { return parts.join(' ').trim() || spellIcaoDigits(String(feet)); } +function spellWeatherCode(code: string): string | null { + let remaining = code; + let intensity = ''; + // Strip intensity prefix + const intensityMatch = remaining.match(/^(VC|\+|-)(.+)$/); + if (intensityMatch) { + intensity = WX_INTENSITY[intensityMatch[1]!] ?? ''; + remaining = intensityMatch[2]!; + } + // Walk through the remaining string in 2-char chunks + const parts: string[] = []; + let i = 0; + while (i < remaining.length) { + const chunk = remaining.slice(i, i + 2); + const descr = WX_DESCRIPTOR[chunk]; + const phen = WX_PHENOMENON[chunk]; + if (!descr && !phen) { + return null; // unrecognized — bail + } + parts.push(descr ?? phen!); + i += 2; + } + if (i !== remaining.length || parts.length === 0) return null; + return [intensity, ...parts].filter(Boolean).join(' '); +} + +interface NormalizeAtisOptions { + /** Current airport ICAO — its 4-letter code in the text is substituted with airportName. */ + airportIcao?: string; + /** Display name for the current airport (e.g. "Frankfurt am Main"). */ + airportName?: string; +} + /** - * Normalize VATSIM-style expanded ATIS text into ICAO-radiotelephony spoken form. - * Applies ATIS-specific rules (info-letter phonetic, wind/temp/time digit-by-digit, - * cloud layers, transition-level, NOSIG expansion, bare runway designators) and - * then runs the general normalizeRadioPhrase to catch QNH/RWY/FL/freq. - * - * Designed for the VATSIM `text_atis` field which is already expanded English, - * not raw METAR code. + * Normalize ATIS text (expanded VATSIM form OR compressed METAR form) into ICAO + * radiotelephony spoken form. Handles: + * - ICAO airport-code substitution (with caller-provided name) + * - METAR inline tokens (date/time, wind, RVR, weather, clouds, pressure, trends) + * - Expanded ATIS rules (info letter, wind/temp/time digit-by-digit, NOSIG, ...) + * - General radio phrases via normalizeRadioPhrase (QNH/RWY/FL/freq) + * - Acronym lowercasing (ATIS/METAR/SPECI) so TTS reads them as words */ -export function normalizeAtisForSpeech(text: string): string { +export function normalizeAtisForSpeech(text: string, opts: NormalizeAtisOptions = {}): string { if (!text) return text; let out = text.replace(/\s+/g, ' ').trim(); - // Run the general radio normalizer first so explicit "RUNWAY 08L", "QNH 1024", - // "FL250", "ft", "HDG", and frequencies get rewritten before our ATIS-specific - // rules touch raw digits. Otherwise our bare-runway regex would consume the digit - // pair before normalizeRadioPhrase's `RUNWAY \d{2}[LCR]?` rule could fire, - // leaving "RUNWAY" stuck in caps without lowercase normalization. + // 1. Airport-code substitution. Done before METAR expansion so a station code + // like "EDDF" before a "281050Z" date stamp is replaced with "Frankfurt". + if (opts.airportIcao && opts.airportName) { + const icaoRe = new RegExp(`\\b${opts.airportIcao.toUpperCase()}\\b`, 'g'); + out = out.replace(icaoRe, opts.airportName); + } + + // 2. METAR-coded inline tokens. + + // Date/Time: DDHHMMZ (e.g. "281050Z" → "on the too eight at wun zero five zero zulu") + out = out.replace(/\b(\d{2})(\d{2})(\d{2})Z\b/g, (_m, d: string, h: string, mi: string) => + `on the ${spellIcaoDigits(d)} at ${spellIcaoDigits(h)} ${spellIcaoDigits(mi)} zulu`); + + // Modifier + out = out.replace(/\bAUTO\b/g, 'automatic observation'); + out = out.replace(/\bCOR\b/g, 'correction'); + + // Wind variability "320V070" → "variable between three two zero and zero seven zero degrees" + // (must run BEFORE wind regex so it doesn't get confused by the surrounding digits) + out = out.replace(/\b(\d{3})V(\d{3})\b/g, (_m, a: string, b: string) => + `variable between ${spellIcaoDigits(a)} and ${spellIcaoDigits(b)} degrees`); + + // Calm wind: 00000KT + out = out.replace(/\b00000KT\b/g, 'wind calm'); + + // Wind: VRB05KT / 28015KT / 28015G25KT + out = out.replace(/\b(VRB|\d{3})(\d{2,3})(?:G(\d{2,3}))?KT\b/g, + (_m, dir: string, spd: string, gust?: string) => { + const dirSpeech = dir === 'VRB' ? 'variable' : `${spellIcaoDigits(dir)} degrees`; + const spdSpeech = `${spellIcaoDigits(spd)} knots`; + const gustSpeech = gust ? `, gusting ${spellIcaoDigits(gust)} knots` : ''; + return `wind ${dirSpeech} at ${spdSpeech}${gustSpeech}`; + }); + + // Runway Visual Range: R25/1500N, R25L/P2000, R25/M0050U + out = out.replace(/\bR(\d{2}[LCR]?)\/([MP]?)(\d{4})([UDN]?)\b/g, + (_m, rwy: string, mp: string, dist: string, trend: string) => { + const rwSpoken = runwaySpeak(rwy); + const prefix = mp === 'M' ? 'less than ' : mp === 'P' ? 'more than ' : ''; + const trendWord = trend === 'U' ? ', increasing' : trend === 'D' ? ', decreasing' : ''; + return `${rwSpoken} visibility ${prefix}${spellIcaoDigits(dist)} meters${trendWord}`; + }); + + // Wind shear: WS R25L + out = out.replace(/\bWS\s+R(\d{2}[LCR]?)\b/g, (_m, rwy: string) => `wind shear ${runwaySpeak(rwy)}`); + + // Visibility 9999 (≥10 km) + out = out.replace(/\b9999\b/g, 'visibility wun zero kilometers or more'); + + // METAR temp/dewpoint with slash: "24/02" or "M02/M05" or "02/M01" + out = out.replace(/\b(M?\d{2})\/(M?\d{2})\b/g, (_m, t: string, d: string) => { + const speakTemp = (raw: string) => raw.startsWith('M') + ? `minus ${spellIcaoDigits(raw.slice(1))}` + : spellIcaoDigits(raw); + return `temperature ${speakTemp(t)}, dewpoint ${speakTemp(d)}`; + }); + + // QNH: Q1025 (hPa) + out = out.replace(/\bQ(\d{4})\b/g, (_m, q: string) => `QNH ${spellIcaoDigits(q)}`); + // Altimeter: A2992 (inches Hg, US-style) + out = out.replace(/\bA(\d{4})\b/g, (_m, a: string) => `altimeter ${spellIcaoDigits(a)}`); + + // Cloud cover special codes + out = out.replace(/\bNSC\b/g, 'no significant cloud'); + out = out.replace(/\bSKC\b/g, 'sky clear'); + out = out.replace(/\bCLR\b/g, 'sky clear'); + out = out.replace(/\bNCD\b/g, 'no cloud detected'); + // Vertical visibility VV003 → "vertical visibility three hundred feet" + out = out.replace(/\bVV(\d{3})\b/g, (_m, h: string) => { + const ft = parseInt(h, 10) * 100; + const thousands = Math.floor(ft / 1000); + const hundreds = Math.round((ft % 1000) / 100) * 100; + const parts: string[] = []; + if (thousands) parts.push(`${spellIcaoDigits(String(thousands))} thousand`); + if (hundreds) parts.push(HUNDRED_WORDS[hundreds] ?? spellIcaoDigits(String(hundreds))); + return `vertical visibility ${parts.join(' ').trim()} feet`; + }); + + // Recent weather: REtype (e.g. RERA → "recent rain") + out = out.replace(/\bRE([A-Z]{2,8})\b/g, (m, code: string) => { + const spoken = spellWeatherCode(code); + return spoken ? `recent ${spoken}` : m; + }); + + // Weather phenomena (intensity + descriptor + 1-2 codes). Match conservative: + // optional intensity prefix, then 2-8 uppercase letters. + out = out.replace(/(?:^|\s)([+\-]|VC)?([A-Z]{2,8})(?=\s|$)/g, (match, intensity: string | undefined, code: string) => { + // Skip if it's a known non-weather token. We're greedy; let unknown codes pass. + const full = `${intensity ?? ''}${code}`; + const spoken = spellWeatherCode(full); + if (!spoken) return match; + // Don't accidentally swallow words like "DEW", "POINT", "WIND" — must look like a weather code (2-char chunks) + if (code.length % 2 !== 0) return match; + return match.replace(full, spoken); + }); + + // Trend forecasts + out = out.replace(/\bNOSIG\b/g, 'no significant change'); + out = out.replace(/\bBECMG\b/g, 'becoming'); + out = out.replace(/\bTEMPO\b/g, 'temporary'); + // Trend time: FM1230 / TL1500 / AT1100 + out = out.replace(/\b(FM|TL|AT)(\d{2})(\d{2})\b/g, (_m, prefix: string, h: string, mi: string) => { + const word = prefix === 'FM' ? 'from' : prefix === 'TL' ? 'until' : 'at'; + return `${word} ${spellIcaoDigits(h)} ${spellIcaoDigits(mi)} zulu`; + }); + + // Strip RMK section (remarks — operator notes, not for pilots) + out = out.replace(/\bRMK\b.*$/g, '').trim(); + + // 3. Run the general radio normalizer so RUNWAY/QNH/FL/HDG/freq (digits still + // intact in expanded ATIS text) get spoken correctly. Order matters: + // this runs BEFORE our bare-runway rule so the explicit RUNWAY prefix is + // consumed before bare designators get rewritten. out = normalizeRadioPhrase(out, { expandAirports: false, expandCallsigns: false, expandWaypoints: false, }); - // INFORMATION letter → phonetic alphabet + // 4. Expanded-ATIS rules. INFORMATION letter → phonetic alphabet out = out.replace(/\binformation\s+([A-Z])\b/gi, (_match, l: string) => { const phonetic = ICAO_LETTERS[l.toUpperCase()] ?? l; return `Information ${phonetic}`; @@ -484,6 +647,13 @@ export function normalizeAtisForSpeech(text: string): string { return `${spellIcaoDigits(digits)} ${sideWord}`; }); + // 6. Acronyms TTS spells letter-by-letter unless given as a word. Lowercase the + // ones pilots SAY as a word (ATIS, METAR, SPECI). Leave others uppercase — + // pilots actually spell ILS/VOR/QNH/DME letter-by-letter in radiotelephony. + out = out.replace(/\bATIS\b/g, 'atis'); + out = out.replace(/\bMETAR\b/g, 'metar'); + out = out.replace(/\bSPECI\b/g, 'speci'); + return out.replace(/\s+/g, ' ').trim(); } diff --git a/tests/shared/radioSpeech.test.ts b/tests/shared/radioSpeech.test.ts index 2ac0771..bf183f2 100644 --- a/tests/shared/radioSpeech.test.ts +++ b/tests/shared/radioSpeech.test.ts @@ -82,4 +82,87 @@ describe('radioSpeech', () => { assert.match(out, /fife thousand meters/) assert.match(out, /wun thousand five hundred meters/) }) + + it('normalizes a full METAR-form ATIS with EDDF (user report sample)', () => { + const input = 'EDDF ATIS. METAR EDDF 281050Z AUTO 02008KT 320V070 CAVOK 24/02 Q1025 NOSIG. Frequency 118.030' + const out = normalizeAtisForSpeech(input, { + airportIcao: 'EDDF', + airportName: 'Frankfurt am Main', + }) + + // Airport code → city name + assert.match(out, /Frankfurt am Main atis/) + assert.doesNotMatch(out, /\bEDDF\b/) + // ATIS / METAR lowercased so TTS reads as word + assert.match(out, /\batis\b/) + assert.match(out, /\bmetar\b/) + // Date/time DDHHMMZ + assert.match(out, /on the too eight at wun zero fife zero zulu/) + // AUTO + assert.match(out, /automatic observation/) + // Wind 02008KT + assert.match(out, /wind zero too zero degrees at zero eight knots/) + // Wind variability 320V070 + assert.match(out, /variable between tree too zero and zero seven zero degrees/) + // CAVOK stays as a word + assert.match(out, /\bCAVOK\b/) + // Temp/Dewpoint slash form + assert.match(out, /temperature too four, dewpoint zero too/) + // QNH Q-form + assert.match(out, /QNH wun zero too fife/) + // NOSIG expanded + assert.match(out, /no significant change/) + // Frequency normalized + assert.match(out, /wun wun eight decimal zero tree zero/) + }) + + it('handles wind edge cases (calm, VRB, gusts)', () => { + assert.match(normalizeAtisForSpeech('00000KT'), /wind calm/) + assert.match(normalizeAtisForSpeech('VRB05KT'), /wind variable at zero fife knots/) + assert.match(normalizeAtisForSpeech('28015G25KT'), /wind too eight zero degrees at wun fife knots, gusting too fife knots/) + }) + + it('expands METAR weather phenomena', () => { + const out = normalizeAtisForSpeech('-RA +TSRA VCSH RERA FZRA BR FG') + assert.match(out, /light rain/) + assert.match(out, /heavy thunderstorm rain/) + assert.match(out, /in the vicinity shower/) + assert.match(out, /recent rain/) + assert.match(out, /freezing rain/) + assert.match(out, /mist/) + assert.match(out, /fog/) + }) + + it('expands RVR and wind shear', () => { + const out = normalizeAtisForSpeech('R25L/1500N R07/P2000U R34/M0050 WS R25') + assert.match(out, /runway too fife left visibility wun fife zero zero meters/) + assert.match(out, /runway zero seven visibility more than too zero zero zero meters, increasing/) + assert.match(out, /runway tree four visibility less than zero zero fife zero meters/) + assert.match(out, /wind shear runway too fife/) + }) + + it('handles cloud cover special codes', () => { + const out = normalizeAtisForSpeech('NSC SKC CLR NCD VV003') + assert.match(out, /no significant cloud/) + assert.match(out, /sky clear/) + assert.match(out, /no cloud detected/) + assert.match(out, /vertical visibility tree hundred feet/) + }) + + it('handles trend forecasts and altimeter', () => { + const out = normalizeAtisForSpeech('BECMG TEMPO FM1230 TL1500 A2992') + assert.match(out, /becoming/) + assert.match(out, /temporary/) + assert.match(out, /from wun too tree zero zulu/) + assert.match(out, /until wun fife zero zero zulu/) + assert.match(out, /altimeter too niner niner too/) + }) + + it('strips RMK remarks', () => { + const out = normalizeAtisForSpeech('Q1013 NOSIG RMK AO2 SLP188 T01510092') + assert.match(out, /QNH wun zero wun tree/) + assert.match(out, /no significant change/) + assert.doesNotMatch(out, /\bRMK\b/) + assert.doesNotMatch(out, /SLP188/) + }) })