mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 17:05:53 +08:00
refactor: centralize radio speech normalization
This commit is contained in:
@@ -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<string, string> = {
|
||||
"0": "zero", "1": "wun", "2": "too", "3": "tree", "4": "fower",
|
||||
"5": "fife", "6": "six", "7": "seven", "8": "eight", "9": "niner",
|
||||
};
|
||||
|
||||
const NATO: Record<string, string> = {
|
||||
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<string,string> = {
|
||||
DLH: "Lufthansa",
|
||||
@@ -169,89 +158,16 @@ export const CALLSIGN_MAP: Record<string,string> = {
|
||||
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<string,string>) => {
|
||||
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<string,string>; }
|
||||
) {
|
||||
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)
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
'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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
'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, any>): 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, any>): string {
|
||||
|
||||
201
shared/utils/radioSpeech.ts
Normal file
201
shared/utils/radioSpeech.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
export const ICAO_DIGITS: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
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<string, string>;
|
||||
|
||||
export interface NormalizeRadioOptions {
|
||||
airlineMap?: AirlineTelephonyMap;
|
||||
expandCallsigns?: boolean;
|
||||
expandAirports?: boolean;
|
||||
sidSuffixIcao?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<Omit<NormalizeRadioOptions, 'airlineMap'>> = {
|
||||
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<number, string> = {
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user