diff --git a/server/utils/normalize.ts b/server/utils/normalize.ts index d6b3331..d04a05e 100644 --- a/server/utils/normalize.ts +++ b/server/utils/normalize.ts @@ -1,6 +1,7 @@ // yarn add openai import OpenAI from "openai"; import fs from "node:fs"; +import { normalizeRadioPhrase } from "../../shared/utils/radioSpeech"; import { getServerRuntimeConfig } from "./runtimeConfig"; const { openaiKey, openaiProject, llmModel, ttsModel } = getServerRuntimeConfig(); @@ -139,18 +140,6 @@ export function atcReplyPrompt(userText: string, state?: { Normalizer → TTS (wie zuvor) ========================= */ -const DIGIT: Record = { - "0": "zero", "1": "wun", "2": "too", "3": "tree", "4": "fower", - "5": "fife", "6": "six", "7": "seven", "8": "eight", "9": "niner", -}; - -const NATO: 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" -}; - // Airline-Telephony (erweiterbar) export const CALLSIGN_MAP: Record = { DLH: "Lufthansa", @@ -169,89 +158,16 @@ export const CALLSIGN_MAP: Record = { EZY: "Easy", }; -const spellDigits = (s: string) => - s.split("").map(ch => DIGIT[ch] ?? ch).join(" "); - -const toNato = (s: string) => - s.toUpperCase().split("").map(ch => NATO[ch] ?? ch).join("-"); - -const runwaySpeak = (rw: string) => { - const m = rw.match(/^(\d{2})([LCR])?$/i); - if (!m) return rw; - const num = spellDigits(m[1]); - const side = m[2]?.toUpperCase() === "L" ? "left" - : m[2]?.toUpperCase() === "C" ? "center" - : m[2]?.toUpperCase() === "R" ? "right" : ""; - return `runway ${num}${side ? " " + side : ""}`; -}; - -const headingSpeak = (hdg: string) => `heading ${spellDigits(hdg.padStart(3, "0"))}`; -const squawkSpeak = (code: string) => `squawk ${spellDigits(code)}`; - -const freqSpeak = (f: string) => { - const [a,b] = f.split("."); - const left = spellDigits(a); - const right = b ? spellDigits(b) : ""; - return `${left}${b ? " decimal " + right : ""}`; -}; - -const altitudeSpeak = (ft: number) => { - if (!Number.isFinite(ft)) return `${ft} feet`; - const thousands = Math.floor(ft/1000); - const hundreds = Math.round((ft % 1000)/100)*100; - const parts: string[] = []; - if (thousands) parts.push(`${spellDigits(String(thousands))} thousand`); - if (hundreds) { - const h = hundreds === 900 ? "nine hundred" - : hundreds === 800 ? "eight hundred" - : hundreds === 700 ? "seven hundred" - : hundreds === 600 ? "six hundred" - : hundreds === 500 ? "five hundred" - : hundreds === 400 ? "fower hundred" - : hundreds === 300 ? "tree hundred" - : hundreds === 200 ? "too hundred" - : hundreds === 100 ? "wun hundred" - : spellDigits(String(hundreds)); - parts.push(h); - } - return `${parts.join(" ")} feet`.trim(); -}; - -const flightLevelSpeak = (fl: string) => - `flight level ${spellDigits(fl.replace(/^0+/, ""))}`; - -const qnhSpeak = (q: string) => `QNH ${spellDigits(q)}`; - -const callsignSpeak = (raw: string, map: Record) => { - const up = raw.toUpperCase(); - const m = up.match(/^([A-Z]{2,3})(\d{1,4}[A-Z]?)$/); - if (!m) return raw; - const telephony = map[m[1]] ?? toNato(m[1]).replace(/-/g," "); - const suffix = spellDigits(m[2].replace(/[A-Z]$/, (l) => " " + (NATO[l] ?? l))); - return `${telephony} ${suffix}`; -}; - -const icaoAirportSpeak = (code: string) => - /^[A-Z]{4}$/.test(code) ? toNato(code) : code; - // Public Normalizer export function normalizeATC( text: string, opts?: { airlineMap?: Record; } ) { - let out = text; - - out = out.replace(/\b(\d{3})\.(\d{3})\b/g, (_,a,b)=> `${freqSpeak(`${a}.${b}`)}`); - out = out.replace(/\b(?:HDG|heading)\s*(\d{2,3})\b/gi, (_,h)=> headingSpeak(h)); - out = out.replace(/\b(?:RWY|runway)\s*(\d{2}[LCR]?)\b/gi, (_,rw)=> runwaySpeak(rw)); - out = out.replace(/\b(?:squawk|code)\s*(\d{4})\b/gi, (_,c)=> squawkSpeak(c)); - out = out.replace(/\bFL\s*(\d{2,3})\b/gi, (_,fl)=> flightLevelSpeak(fl)); - out = out.replace(/\b(\d{3,5})\s*(?:ft|feet)\b/gi, (_,ft)=> altitudeSpeak(Number(ft))); - out = out.replace(/\bQNH\s*(\d{3,4})\b/gi, (_,q)=> qnhSpeak(q)); - out = out.replace(/\b([A-Z]{4})\b/g, (_,code)=> icaoAirportSpeak(code)); - out = out.replace(/\b([A-Z]{2,3}\d{1,4}[A-Z]?)\b/g, (m)=> callsignSpeak(m, opts?.airlineMap ?? CALLSIGN_MAP)); - - return out.replace(/\s+/g," ").trim(); + return normalizeRadioPhrase(text, { + airlineMap: opts?.airlineMap ?? CALLSIGN_MAP, + expandAirports: true, + expandCallsigns: true, + }); } // TTS Wrapper (mp3) diff --git a/server/utils/openai.ts b/server/utils/openai.ts index d69e237..23c5d2d 100644 --- a/server/utils/openai.ts +++ b/server/utils/openai.ts @@ -1,5 +1,6 @@ // server/utils/openai.ts import OpenAI from 'openai' +import { spellIcaoDigits, toIcaoPhonetic } from '../../shared/utils/radioSpeech' import { getServerRuntimeConfig } from './runtimeConfig' let openaiClient: OpenAI | null = null @@ -130,27 +131,6 @@ function sanitizeForQuickMatch(text: string): string { return text.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() } -const ICAO_LETTERS: Record = { - A: 'Alpha', 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' -} - -const ICAO_DIGITS: Record = { - '0': 'zero', '1': 'wun', '2': 'too', '3': 'tree', '4': 'fower', - '5': 'fife', '6': 'six', '7': 'seven', '8': 'eight', '9': 'niner' -} - -function toPhonetic(value: string): string { - return value - .toUpperCase() - .split('') - .map((ch) => ICAO_LETTERS[ch] || ICAO_DIGITS[ch] || ch) - .join(' ') -} - function buildSpokenVariants(key: string, value: string): string[] { const normalized = String(value ?? '').trim() if (!normalized) return [] @@ -173,20 +153,17 @@ function buildSpokenVariants(key: string, value: string): string[] { } if (/^[A-Z]{3,4}$/.test(normalized.toUpperCase())) { - variants.add(toPhonetic(normalized)) + variants.add(toIcaoPhonetic(normalized)) } if (/^\d{4}$/.test(normalized)) { variants.add(normalized.split('').join(' ')) - variants.add(normalized.split('').map((d) => ICAO_DIGITS[d] || d).join(' ')) + variants.add(spellIcaoDigits(normalized)) } if (/^\d{1,2}[LCR]?$/i.test(normalized)) { const digits = normalized.match(/\d+/)?.[0] ?? '' - const spelledDigits = digits - .split('') - .map((d) => ICAO_DIGITS[d] || d) - .join(' ') + const spelledDigits = spellIcaoDigits(digits) const suffix = normalized.replace(/\d+/g, '').toUpperCase() const suffixWord = suffix === 'L' ? 'left' : suffix === 'R' ? 'right' : suffix === 'C' ? 'center' : '' @@ -202,7 +179,7 @@ function buildSpokenVariants(key: string, value: string): string[] { const spaced = digits.split('').join(' ') variants.add(spaced) variants.add(digits) - variants.add(digits.split('').map((d) => ICAO_DIGITS[d] || d).join(' ')) + variants.add(spellIcaoDigits(digits)) } } diff --git a/shared/utils/communicationsEngine.ts b/shared/utils/communicationsEngine.ts index 2234135..9a800cb 100644 --- a/shared/utils/communicationsEngine.ts +++ b/shared/utils/communicationsEngine.ts @@ -1,6 +1,7 @@ // communicationsEngine composable import { ref, computed, readonly } from 'vue' import atcDecisionTree from "../data/atcDecisionTree"; +import { normalizeRadioPhrase } from './radioSpeech' // --- DecisionTree types (derived from ~/data/atcDecisionTree.json) --- type Role = 'pilot' | 'atc' | 'system' @@ -140,50 +141,9 @@ export interface EngineLog { offSchema?: boolean } -// NATO/ICAO normalizer trimmed for better performance -const NATO_PHONETIC: Record = { - A: 'Alpha', 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' -} - -const ICAO_NUMBERS: Record = { - '0': 'zero', '1': 'wun', '2': 'too', '3': 'tree', '4': 'fower', - '5': 'fife', '6': 'six', '7': 'seven', '8': 'eight', '9': 'niner' -} - export function normalizeATCText(text: string, context: Record): string { - let normalized = renderTpl(text, context) - - // Runway normalization - normalized = normalized.replace(/runway\s+(\d{1,2})([LRC]?)/gi, (_, num: string, suffix: string) => { - const n = num.split('').map(d => ICAO_NUMBERS[d] || d).join(' ') - const s = suffix === 'L' ? ' left' : suffix === 'R' ? ' right' : suffix === 'C' ? ' center' : '' - return `runway ${n}${s}` - }) - - // Flight Level - normalized = normalized.replace(/FL(\d{3})/gi, (_, lvl: string) => { - const n = lvl.split('').map(d => ICAO_NUMBERS[d] || d).join(' ') - return `flight level ${n}` - }) - - // Frequency normalization - normalized = normalized.replace(/(\d{3})\.(\d{1,3})/g, (_, a: string, b: string) => { - const A = a.split('').map(d => ICAO_NUMBERS[d] || d).join(' ') - const B = b.split('').map(d => ICAO_NUMBERS[d] || d).join(' ') - return `${A} decimal ${B}` - }) - - // Squawk codes - normalized = normalized.replace(/squawk\s+(\d{4})/gi, (_, code: string) => { - const n = code.split('').map(d => ICAO_NUMBERS[d] || d).join(' ') - return `squawk ${n}` - }) - - return normalized + const rendered = renderTpl(text, context) + return normalizeRadioPhrase(rendered) } function renderTpl(tpl: string, ctx: Record): string { diff --git a/shared/utils/radioSpeech.ts b/shared/utils/radioSpeech.ts new file mode 100644 index 0000000..344236b --- /dev/null +++ b/shared/utils/radioSpeech.ts @@ -0,0 +1,201 @@ +export const ICAO_DIGITS: Record = { + '0': 'zero', + '1': 'wun', + '2': 'too', + '3': 'tree', + '4': 'fower', + '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 interface NormalizeRadioOptions { + airlineMap?: AirlineTelephonyMap; + expandCallsigns?: boolean; + expandAirports?: boolean; + sidSuffixIcao?: boolean; +} + +const DEFAULT_OPTIONS: Required> = { + expandAirports: false, + expandCallsigns: false, + sidSuffixIcao: 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(); +} + +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 HUNDRED_WORDS: Record = { + 100: 'wun hundred', + 200: 'too hundred', + 300: 'tree hundred', + 400: 'fower 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(); + const match = upper.match(/^([A-Z]{2,3})(\d{1,4})([A-Z])?$/); + if (!match) return raw; + const [, prefix, digitsPart, suffixLetter] = match; + const telephony = map[prefix] ?? spellIcaoLetters(prefix); + const digitsSpoken = spellIcaoDigits(digitsPart); + const suffix = suffixLetter ? ` ${spellIcaoLetters(suffixLetter)}` : ''; + return `${telephony} ${digitsSpoken}${suffix}`.trim(); +} + +function icaoAirportSpeak(raw: string): string { + return /^[A-Z]{4}$/.test(raw) ? spellIcaoLetters(raw) : raw; +} + +function sidSuffixSpeak(prefix: string, digit: string, letter: string): string { + return `${prefix} ${spellIcaoDigits(digit)} ${spellIcaoLetters(letter)}`; +} + +export function normalizeRadioPhrase(text: string, options: NormalizeRadioOptions = {}): string { + const opts = { ...DEFAULT_OPTIONS, ...options }; + let out = text; + + 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)); + + 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) => { + return sidSuffixSpeak(prefix, digit, letter); + }); + } + + 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]?)\b/g, (match: string) => callsignSpeak(match, airlineMap)); + } + + return out.replace(/\s+/g, ' ').trim(); +}