From 771e22d72825f38165055fbcdbf57086a10d7cc4 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Thu, 28 May 2026 09:09:12 +0200 Subject: [PATCH] feat(atis): ICAO phraseology normalizer for spoken ATIS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ATIS loop sent raw VATSIM `text_atis` to TTS, producing "Q-N-H one thousand twenty-four", "WIND oh thirty degrees", "RUNWAY oh eight L". New `normalizeAtisForSpeech` applies ATIS-specific transforms — info letter → phonetic alphabet, wind/temperature/time digit-by-digit, TRL expansion, NOSIG → "no significant change", cloud layers (BKN030 → broken three thousand), visibility, bare runway designators — then hands off to `normalizeRadioPhrase` for QNH/RWY/FL/freq. pm.vue calls the normalizer before posting to /api/atc/say so the disk cache keys the spoken form. Adds 4 test cases covering a full real-world EDDM broadcast plus edge cases (cloud layers, negative temperatures, km/m visibility). Co-Authored-By: Claude Opus 4.7 --- app/pages/pm.vue | 5 +- shared/utils/radioSpeech.ts | 124 +++++++++++++++++++++++++++++++ tests/shared/radioSpeech.test.ts | 44 +++++++++++ 3 files changed, 171 insertions(+), 2 deletions(-) diff --git a/app/pages/pm.vue b/app/pages/pm.vue index f8b1415..503bb98 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -986,7 +986,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { useRouter } from 'vue-router' import useCommunicationsEngine from "../../shared/utils/communicationsEngine"; -import { normalizeRadioPhrase, DEFAULT_AIRLINE_TELEPHONY } from '../../shared/utils/radioSpeech'; +import { normalizeRadioPhrase, normalizeAtisForSpeech, DEFAULT_AIRLINE_TELEPHONY } from '../../shared/utils/radioSpeech'; import { useAuthStore } from '~/stores/auth' import { useApi } from '~/composables/useApi' import { loadPizzicatoLite } from '../../shared/utils/pizzicatoLite' @@ -2707,13 +2707,14 @@ const startAtisLoop = async (entry: AirportFrequencyEntry) => { } const announcement = buildAtisAnnouncement({ ...entry, atisText: content }) + const spokenAnnouncement = normalizeAtisForSpeech(announcement) const epoch = resolveAtisEpoch(entry) const requestSeq = ++atisLoopSeq atisPlaybackLoading.value = true try { const response = await api.post('/api/atc/say', { - text: announcement, + text: spokenAnnouncement, level: signalStrength.value, voice: 'verse', speed: 0.9, diff --git a/shared/utils/radioSpeech.ts b/shared/utils/radioSpeech.ts index e46c888..d925d0b 100644 --- a/shared/utils/radioSpeech.ts +++ b/shared/utils/radioSpeech.ts @@ -363,6 +363,130 @@ export function normalizeMetarPhrase(metar: string): string { return parts.join(', '); } +/** + * Cloud-layer height words (no trailing "feet"): 30 → "tree thousand", 5 → "five hundred", + * 35 → "tree thousand five hundred". Input is the 3-digit METAR-cloud code interpreted + * as hundreds-of-feet. + */ +function cloudHeightWords(heightCodeHundreds: number): string { + const feet = heightCodeHundreds * 100; + const thousands = Math.floor(feet / 1000); + const hundreds = Math.round((feet % 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 parts.join(' ').trim() || spellIcaoDigits(String(feet)); +} + +/** + * 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. + */ +export function normalizeAtisForSpeech(text: string): 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. + out = normalizeRadioPhrase(out, { + expandAirports: false, + expandCallsigns: false, + expandWaypoints: false, + }); + + // 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}`; + }); + + // Time HHMM (TIME 0620, AT 0620Z) + out = out.replace(/\b(time|at)\s+(\d{4})z?\b/gi, (_m, prefix: string, t: string) => + `${prefix.toLowerCase()} ${spellIcaoDigits(t)}`); + + // Wind direction (3 digits, optionally followed by DEGREES) + out = out.replace(/\bwind\s+(\d{3})(?=\s+(?:degrees?\b|\d))/gi, (_m, d: string) => + `wind ${spellIcaoDigits(d)}`); + + // Wind variability ranges: "BETWEEN 340 AND 060 DEGREES" + out = out.replace(/\bbetween\s+(\d{3})\s+and\s+(\d{3})\s+degrees\b/gi, + (_m, a: string, b: string) => + `between ${spellIcaoDigits(a)} and ${spellIcaoDigits(b)} degrees`); + + // Wind/gust speed in knots (digit-by-digit per ICAO) + out = out.replace(/\b(\d{1,3})\s*(knots?|kt)\b/gi, (_m, n: string) => + `${spellIcaoDigits(n)} knots`); + + // Negative temperature: "TEMPERATURE -5" or "TEMPERATURE MINUS 5" + out = out.replace(/\btemperature\s+(?:-|minus\s+)(\d{1,3})\b/gi, (_m, n: string) => + `temperature minus ${spellIcaoDigits(n)}`); + out = out.replace(/\bdew\s*point\s+(?:-|minus\s+)(\d{1,3})\b/gi, (_m, n: string) => + `dew point minus ${spellIcaoDigits(n)}`); + + // Positive temperature / dewpoint + out = out.replace(/\btemperature\s+(\d{1,3})\b/gi, (_m, n: string) => + `temperature ${spellIcaoDigits(n)}`); + out = out.replace(/\bdew\s*point\s+(\d{1,3})\b/gi, (_m, n: string) => + `dew point ${spellIcaoDigits(n)}`); + + // Transition level: "TRL 60", "TL 60", "TRANSITION LEVEL 60" + out = out.replace(/\b(?:TRL|TL|TRANSITION\s+LEVEL)\s+(\d{2,3})\b/gi, + (_m, fl: string) => `transition level ${spellIcaoDigits(fl)}`); + + // NOSIG expansion + out = out.replace(/\bNOSIG\b/g, 'no significant change'); + + // METAR-style cloud layers: BKN030, FEW005 CB, SCT025, OVC100 TCU + out = out.replace(/\b(FEW|SCT|BKN|OVC|NSC)(\d{3})(?:\s*(CB|TCU))?\b/gi, + (_m, cover: string, height: string, type?: string) => { + const coverWord = METAR_CLOUD[cover.toUpperCase()] ?? cover.toLowerCase(); + const heightWords = cloudHeightWords(parseInt(height, 10)); + const typeSuffix = type + ? ` ${type.toUpperCase() === 'CB' ? 'cumulonimbus' : 'towering cumulus'}` + : ''; + return `${coverWord} ${heightWords}${typeSuffix}`; + }); + + // Visibility in kilometers / meters + out = out.replace(/\b(?:VIS|VISIBILITY)\s+(\d+)\s*(KM|M)\b/gi, + (_m, val: string, unit: string) => { + const u = unit.toUpperCase(); + if (u === 'KM') { + return `visibility ${spellIcaoDigits(val)} kilometers`; + } + // Meters: 5000 → "five thousand", 1500 → "one thousand five hundred" + const v = parseInt(val, 10); + if (Number.isFinite(v) && v >= 1000 && v % 100 === 0) { + const thousands = Math.floor(v / 1000); + const hundreds = Math.round((v % 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 `visibility ${parts.join(' ')} meters`; + } + return `visibility ${spellIcaoDigits(val)} meters`; + }); + + // Bare runway designators (without "RUNWAY" prefix), e.g. "AND 08R", "USE 08L". + // Excludes patterns preceded by "flight level" or "FL " (already normalized). + out = out.replace(/(? { + const sideWord = side.toUpperCase() === 'L' ? 'left' + : side.toUpperCase() === 'R' ? 'right' : 'center'; + return `${spellIcaoDigits(digits)} ${sideWord}`; + }); + + return out.replace(/\s+/g, ' ').trim(); +} + export function normalizeRadioPhrase(text: string, options: NormalizeRadioOptions = {}): string { const opts = { ...DEFAULT_OPTIONS, ...options }; let out = text; diff --git a/tests/shared/radioSpeech.test.ts b/tests/shared/radioSpeech.test.ts index 0a0c21b..2ac0771 100644 --- a/tests/shared/radioSpeech.test.ts +++ b/tests/shared/radioSpeech.test.ts @@ -2,6 +2,7 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { + normalizeAtisForSpeech, normalizeMetarPhrase, normalizeRadioPhrase, spellIcaoDigits, @@ -38,4 +39,47 @@ describe('radioSpeech', () => { assert.match(metar, /gusting too fife knots/) assert.match(metar, /QNH wun zero wun tree/) }) + + it('normalizes a full VATSIM ATIS into ICAO phraseology', () => { + const eddmAtis = `MUENCHEN INFORMATION A AUTOMATIC MET REPORT TIME 0620 EXPECT VECTORS FOR INDEPENDENT PARALLEL ILS APPROACH RUNWAY 08L AND 08R RUNWAYS IN USE 08L AND 08R TRL 60 WIND 030 DEGREES 4 KNOTS VARIABLE BETWEEN 340 AND 060 DEGREES CAVOK TEMPERATURE 18 DEW POINT 7 QNH 1024 TREND NOSIG MUENCHEN INFORMATION A OUT` + const spoken = normalizeAtisForSpeech(eddmAtis) + + assert.match(spoken, /Information Alfa/) + assert.match(spoken, /time zero six too zero/) + assert.match(spoken, /wind zero tree zero/) + assert.match(spoken, /four knots/) + assert.match(spoken, /between tree four zero and zero six zero degrees/) + assert.match(spoken, /runway zero eight left/) + assert.match(spoken, /zero eight right/) + assert.match(spoken, /transition level six zero/) + assert.match(spoken, /temperature wun eight/) + assert.match(spoken, /dew point seven/) + assert.match(spoken, /QNH wun zero too four/) + assert.match(spoken, /no significant change/) + assert.doesNotMatch(spoken, /\bNOSIG\b/) + assert.doesNotMatch(spoken, /\bTRL 60\b/) + assert.doesNotMatch(spoken, /TEMPERATURE 18/) + }) + + it('normalizes ATIS cloud layers', () => { + const out = normalizeAtisForSpeech('BKN030 SCT025 FEW005 CB OVC100') + assert.match(out, /broken tree thousand/) + assert.match(out, /scattered too thousand five hundred/) + assert.match(out, /few five hundred cumulonimbus/) + assert.match(out, /overcast wun zero thousand/) + }) + + it('normalizes negative temperatures and low QNH', () => { + const out = normalizeAtisForSpeech('TEMPERATURE -5 DEW POINT -12 QNH 0995') + assert.match(out, /temperature minus fife/) + assert.match(out, /dew point minus wun too/) + assert.match(out, /QNH zero niner niner fife/) + }) + + it('normalizes visibility in km and meters', () => { + const out = normalizeAtisForSpeech('VIS 10 KM VIS 5000 M VISIBILITY 1500 M') + assert.match(out, /visibility wun zero kilometers/) + assert.match(out, /fife thousand meters/) + assert.match(out, /wun thousand five hundred meters/) + }) })