export const ICAO_DIGITS: Record = { '0': 'zero', '1': 'wun', '2': 'too', '3': 'tree', '4': 'four', '5': 'fife', '6': 'six', '7': 'seven', '8': 'eight', '9': 'niner', }; export const ICAO_LETTERS: Record = { A: 'Alfa', B: 'Bravo', C: 'Charlie', D: 'Delta', E: 'Echo', F: 'Foxtrot', G: 'Golf', H: 'Hotel', I: 'India', J: 'Juliett', K: 'Kilo', L: 'Lima', M: 'Mike', N: 'November', O: 'Oscar', P: 'Papa', Q: 'Quebec', R: 'Romeo', S: 'Sierra', T: 'Tango', U: 'Uniform', V: 'Victor', W: 'Whiskey', X: 'X-ray', Y: 'Yankee', Z: 'Zulu', }; export type AirlineTelephonyMap = Record; export const DEFAULT_AIRLINE_TELEPHONY: AirlineTelephonyMap = { DLH: "Lufthansa", EWG: "Eurowings", THY: "Turkish", JBU: "JetBlue", NAX: "Norwegian", SWR: "Swiss", BAW: "Speedbird", AFR: "Air France", KLM: "KLM", AAL: "American", UAL: "United", DAL: "Delta", RYR: "Ryanair", 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', }; // 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', }; // 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 { const trimmed = `${value}`.replace(/\s+/g, ''); if (!trimmed) return ''; return trimmed .split('') .map((ch) => ICAO_DIGITS[ch] ?? ch) .join(separator) .trim(); } export function spellIcaoLetters(value: string, separator = ' '): string { const trimmed = `${value}`.replace(/\s+/g, ''); if (!trimmed) return ''; return trimmed .toUpperCase() .split('') .map((ch) => ICAO_LETTERS[ch] ?? ch) .join(separator) .trim(); } export function toIcaoPhonetic(value: string, separator = ' '): string { const trimmed = `${value}`.replace(/\s+/g, ''); if (!trimmed) return ''; return trimmed .toUpperCase() .split('') .map((ch) => ICAO_LETTERS[ch] ?? ICAO_DIGITS[ch] ?? ch) .join(separator) .trim(); } /** * Spoken ICAO form of a single written value (callsign/SID/runway/squawk/ * frequency/flight-level/number). Used to seed Whisper's `prompt` with the * exact tokens the pilot is about to read back, in spoken form, so recognition * is biased toward e.g. "two five right" for "25R" and "bravo india bravo alpha * x-ray one november" for "BIBAX1N". Returns '' when no distinct spoken form * applies (caller should fall back to the raw token). */ export function speakToken(raw: string): string { const v = `${raw ?? ''}`.trim(); if (!v) return ''; // Frequency: 118.700 → "one one eight decimal seven zero zero" if (/^\d{2,3}\.\d+$/.test(v)) { const [left, right] = v.split('.') as [string, string]; return `${spellIcaoDigits(left)} decimal ${spellIcaoDigits(right)}`; } // Runway: 25R → "two five right" const rwy = v.match(/^(\d{2})([LCR])?$/i); if (rwy) { const side = rwy[2]?.toUpperCase(); const suffix = side === 'L' ? ' left' : side === 'R' ? ' right' : side === 'C' ? ' center' : ''; return `${spellIcaoDigits(rwy[1]!)}${suffix}`; } // Flight level: FL150 → "flight level one five zero" const fl = v.match(/^FL(\d+)$/i); if (fl) return `flight level ${spellIcaoDigits(fl[1]!)}`; // Pure number (squawk/altitude/QNH): 2341 → "two three four one" if (/^\d+$/.test(v)) return spellIcaoDigits(v); // Alphanumeric identifier mixing letters AND digits (SID/STAR/callsign, // e.g. BIBAX1N, MARUN7F, DLH39A): spell it out phonetically. Pure-letter // tokens (plain words like "west", airport codes, bare waypoints) are left // raw so they are not mis-spelled letter by letter. if (/^[A-Z0-9]+$/i.test(v) && /[A-Z]/i.test(v) && /\d/.test(v)) return toIcaoPhonetic(v); return ''; } function runwaySpeak(raw: string): string { const match = raw.match(/^(\d{2})([LCR])?$/i); if (!match) return raw; const digits = spellIcaoDigits(match[1]); const side = match[2]?.toUpperCase(); const suffix = side === 'L' ? 'left' : side === 'R' ? 'right' : side === 'C' ? 'center' : ''; return `runway ${digits}${suffix ? ` ${suffix}` : ''}`; } function headingSpeak(raw: string): string { const heading = raw.padStart(3, '0'); return `heading ${spellIcaoDigits(heading)}`; } function squawkSpeak(raw: string): string { return `squawk ${spellIcaoDigits(raw)}`; } function freqSpeak(raw: string): string { const [left, right] = raw.split('.') as [string, string?]; const leftSpoken = spellIcaoDigits(left); if (!right) return leftSpoken; const rightSpoken = spellIcaoDigits(right); return `${leftSpoken} decimal ${rightSpoken}`; } const VIA_TAXI_ROUTE_PATTERN = /\b((?:expect\s+taxi\s+)?via\s+)([A-Z0-9\s/,\-]+?)(?=\s*,?\s*(?:hold short|cross|then|contact|monitor|with|for|to|left|right)\b|\s*\.|$)/gi; const TAXI_ROUTE_LABEL_PATTERN = /\b(taxi(?:-?in)?\s+route[:\s]+)([A-Z0-9\s/,\-]+?)(?=\s*,?\s*(?:hold short|cross|then|contact|monitor|with|for|to|left|right)\b|\s*\.|$)/gi; const STAND_ROUTE_PATTERN = /\b(taxi\s+to\s+stand\s+[A-Z0-9]+\s+via\s+)([A-Z0-9\s/,\-]+?)(?=\s*,?\s*(?:hold short|cross|then|contact|monitor|with|for|to|left|right)\b|\s*\.|$)/gi; const TAXI_SEGMENT_SINGLE = /^[A-Z]{1,2}$/; const TAXI_SEGMENT_WITH_DIGITS = /^[A-Z]{1,3}\d{1,3}$/; const TAXI_ROUTE_SEPARATOR = /[-/]/g; function shouldConvertTaxiSegment(value: string): boolean { if (!value) return false; const upper = value.toUpperCase(); if (!/[A-Z]/.test(upper)) return false; if (TAXI_SEGMENT_SINGLE.test(upper)) return true; if (TAXI_SEGMENT_WITH_DIGITS.test(upper)) return true; return false; } function speakTaxiSegment(segment: string): string { const trimmed = segment.trim(); if (!trimmed) return ''; const cleaned = trimmed.replace(/[^A-Za-z0-9\-/]/g, ''); if (!cleaned) return trimmed; const expanded = cleaned.replace(TAXI_ROUTE_SEPARATOR, (match) => (match === '-' ? ' dash ' : ' slash ')); const tokens = expanded.split(/\s+/).filter(Boolean); const spoken = tokens.map((token) => { if (token === 'dash' || token === 'slash') return token; const upper = token.toUpperCase(); if (!shouldConvertTaxiSegment(upper)) { return token; } return toIcaoPhonetic(upper); }); return spoken.join(' '); } /** True when every token is a taxiway designator (A, V, N3, U4, A-V). */ function looksLikeTaxiRoute(route: string): boolean { const tokens = route.trim().split(/\s+/).filter(Boolean); if (!tokens.length) return false; return tokens.every(token => token .replace(/[^A-Za-z0-9\-/]/g, '') .split(TAXI_ROUTE_SEPARATOR) .filter(Boolean) .every(part => shouldConvertTaxiSegment(part.toUpperCase())), ); } function speakTaxiRoute(route: string): string { 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 { const replacer = (_match: string, prefix: string, rawRoute: string) => { const route = rawRoute.trim(); if (!route) return `${prefix}${rawRoute}`; // "via" also introduces the SID of a departure clearance. Comma-joining // every token after it turned "via Marun seven Foxtrot departure, climb // five thousand feet" into a list, splitting the SID's own name, number // and suffix into three separate items. Only convert a run that is // actually made of taxiway designators. if (!looksLikeTaxiRoute(route)) return `${prefix}${rawRoute}`; const spoken = speakTaxiRoute(route); if (!spoken) return `${prefix}${rawRoute}`; const needsSpace = /\s$/.test(prefix) ? '' : ' '; return `${prefix}${needsSpace}${spoken}`.replace(/\s+/g, ' '); }; let out = text.replace(VIA_TAXI_ROUTE_PATTERN, replacer); out = out.replace(TAXI_ROUTE_LABEL_PATTERN, replacer); out = out.replace(STAND_ROUTE_PATTERN, replacer); return out; } const HUNDRED_WORDS: Record = { 100: 'wun hundred', 200: 'too hundred', 300: 'tree hundred', 400: 'four hundred', 500: 'five hundred', 600: 'six hundred', 700: 'seven hundred', 800: 'eight hundred', 900: 'nine hundred', }; function altitudeSpeak(value: number): string { if (!Number.isFinite(value)) return `${value} feet`; const thousands = Math.floor(value / 1000); const hundreds = Math.round((value % 1000) / 100) * 100; const parts: string[] = []; if (thousands) { parts.push(`${spellIcaoDigits(String(thousands))} thousand`); } if (hundreds) { parts.push(HUNDRED_WORDS[hundreds] ?? spellIcaoDigits(String(hundreds))); } const spoken = parts.join(' ').trim(); return spoken ? `${spoken} feet` : 'feet'; } function flightLevelSpeak(raw: string): string { const digits = raw.replace(/^0+/, '') || '0'; return `flight level ${spellIcaoDigits(digits)}`; } function qnhSpeak(raw: string): string { return `QNH ${spellIcaoDigits(raw)}`; } function callsignSpeak(raw: string, map: AirlineTelephonyMap): string { const upper = raw.toUpperCase(); // Allow one or more trailing letters so suffixes like "6RK" (→ "six romeo // kilo") are spelled out, not just a single letter. const match = upper.match(/^([A-Z]{2,3})(\d{1,4})([A-Z]{0,3})$/); if (!match) return raw; const [, prefix, digitsPart, suffixLetters] = match; const telephony = map[prefix] ?? spellIcaoLetters(prefix); const digitsSpoken = spellIcaoDigits(digitsPart); const suffix = suffixLetters ? ` ${spellIcaoLetters(suffixLetters)}` : ''; return `${telephony} ${digitsSpoken}${suffix}`.trim(); } // 4-letter all-caps tokens that are acronyms, not ICAO airport codes. const AIRPORT_CODE_SKIP = new Set(['ATIS', 'RNAV', 'NDBS', 'VORS', 'MAIN']); function icaoAirportSpeak(raw: string): string { if (AIRPORT_CODE_SKIP.has(raw)) return raw; return /^[A-Z]{4}$/.test(raw) ? spellIcaoLetters(raw) : raw; } function sidSuffixSpeak(prefix: string, digit: string, letter: string): string { // SID basenames are pronounceable waypoint names, spoken as a word: // "ANEKI 7S" → "Aneki seven sierra", not "alpha november echo kilo india…". const word = prefix.charAt(0) + prefix.slice(1).toLowerCase(); return `${word} ${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(', '); } /** * 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)); } 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 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, opts: NormalizeAtisOptions = {}): string { if (!text) return text; let out = text.replace(/\s+/g, ' ').trim(); // 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, }); // 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}`; }); // 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}`; }); // 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(); } export function normalizeRadioPhrase(text: string, options: NormalizeRadioOptions = {}): string { const opts = { ...DEFAULT_OPTIONS, ...options }; let out = text; // Approach variant letter: "ILS Z" → "ILS Zulu". Where two approaches serve // the same runway they are told apart by this letter alone, so reading it // as a bare "Z" loses the distinction. Runs first, and independently of // what follows: the runway rule below rewrites "runway 25C" to words, and // an earlier attempt that required the runway in the same match therefore // never fired on the phrasing the flows actually use ("ILS Z approach // runway 25C"). out = out.replace( /\b(ILS|VOR|RNAV|LOC|RNP)\s+([A-Z])(?![A-Za-z0-9])/g, (_match, type: string, variant: string) => `${type} ${ICAO_LETTERS[variant] ?? variant}`, ); out = out.replace(/\b(\d{3})\.(\d{1,3})\b/g, (_, a: string, b: string) => freqSpeak(`${a}.${b}`)); out = out.replace(/\b(?:HDG|heading)\s*(\d{2,3})\b/gi, (_, hdg: string) => headingSpeak(hdg)); out = out.replace(/\b(?:RWY|runway)\s*(\d{2}[LCR]?)\b/gi, (_, rw: string) => runwaySpeak(rw)); out = out.replace(/\b(?:squawk|code)\s*(\d{4})\b/gi, (_, code: string) => squawkSpeak(code)); out = out.replace(/\bFL\s*(\d{2,3})\b/gi, (_, fl: string) => flightLevelSpeak(fl)); out = out.replace(/\b(\d{3,5})\s*(?:ft|feet)\b/gi, (_, ft: string) => altitudeSpeak(Number(ft))); out = out.replace(/\bQNH\s*(\d{3,4})\b/gi, (_, qnh: string) => qnhSpeak(qnh)); // Bare altitude/height numbers without an explicit "feet" unit, in a // clearance/readback context ("climb initially 5000", "passing 1500", // "descend to 3000"). Runs after the squawk/QNH/FL rules above have already // consumed their numbers, so it only sees genuine altitudes. The keyword may // be separated from the number by a word ("climb initially 5000"). out = out.replace( /\b(climb|climbing|descend|descending|maintain|passing|initially)\b((?:\s+\w+){0,2}?\s+)(\d{3,5})\b/gi, (m: string, verb: string, gap: string, num: string) => { const v = Number(num); // Only treat as an altitude when ≥ 1000 ft, so speeds/headings // ("maintain 250") are left untouched. return v >= 1000 ? `${verb}${gap}${altitudeSpeak(v)}` : m; }, ); // Stand/gate designators: "stand A12" → "stand alfa wun too" out = out.replace(/\b(stand|gate)\s+([A-Z]{1,2}\d{1,4}[A-Z]?)\b/gi, (_m, word: string, code: string) => `${word} ${toIcaoPhonetic(code)}`); // ATIS information letter: "information K" → "information Kilo" out = out.replace(/\b([Ii]nformation)\s+([A-Z])(?![A-Za-z0-9])/g, (_m, word: string, letter: string) => `${word} ${ICAO_LETTERS[letter] ?? letter}`); // Surface wind "wind 250/07" or "wind 250/07KT" → digit-by-digit with units out = out.replace(/\b(wind\s+)(\d{3})\/(\d{2,3})(?:KT)?\b/gi, (_m, prefix: string, dir: string, spd: string) => `${prefix}${spellIcaoDigits(dir)} degrees, ${spellIcaoDigits(spd)} knots`); // Aircraft registration callsign: "D-EMIL" → "Delta Echo Mike India Lima", // abbreviated "D-IL" → "Delta India Lima". Single prefix letter, hyphen, then // 1–5 registration letters, each spelled phonetically. Runs before airport/ // callsign expansion so the registration body isn't read as an ICAO code. out = out.replace(/\b([A-Z])-([A-Z]{1,5})\b/g, (_m, prefix: string, body: string) => spellIcaoLetters(prefix + body)); if (opts.sidSuffixIcao) { 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); }); } // 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) { // 5-letter waypoint names (SULUS, SUGOL, ANEKI…) are designed to be // pronounceable and are spoken AS WORDS in real radiotelephony — never // spelled letter-by-letter. Titlecase them so TTS reads them as words // instead of shouting all-caps or spelling them out. out = out.replace(/\b([A-Z]{5,6})\b/g, (match, wp: string) => { if (WAYPOINT_SKIP.has(wp)) return match; return wp.charAt(0) + wp.slice(1).toLowerCase(); }); } if (opts.expandAirports) { out = out.replace(/\b([A-Z]{4})\b/g, (_match, code: string) => icaoAirportSpeak(code)); } if (opts.expandCallsigns) { const airlineMap = opts.airlineMap ?? {}; out = out.replace(/\b([A-Z]{2,3}\d{1,4}[A-Z]{0,3})\b/g, (match: string) => callsignSpeak(match, airlineMap)); } out = applyTaxiRoutePhonetics(out); return out.replace(/\s+/g, ' ').trim(); }