mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-01 06:06:05 +08:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1524,6 +1524,7 @@ const frequencies = ref({
|
||||
})
|
||||
|
||||
const airportFrequencies = ref<AirportFrequencyEntry[]>([])
|
||||
const airportName = ref<string | undefined>(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
|
||||
|
||||
101
docs/plans/2026-05-28-atis-metar-inline-normalizer-design.md
Normal file
101
docs/plans/2026-05-28-atis-metar-inline-normalizer-design.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# ATIS/METAR Comprehensive Inline Normalizer
|
||||
|
||||
## Problem
|
||||
Die erste Pass-Implementation deckt nur die "expanded ATIS"-Form (z.B. EDDM mit `WIND 030 DEGREES 4 KNOTS`) ab. Real auftretende ATIS-Texte sind oft METAR-codiert (z.B. EDDF: `METAR EDDF 281050Z AUTO 02008KT 320V070 CAVOK 24/02 Q1025 NOSIG`). Diese werden vom TTS Buchstabe-für-Buchstabe gelesen. Zusätzlich liest die TTS-Engine Akronyme wie `ATIS`, `METAR`, `EDDF` als Einzelbuchstaben.
|
||||
|
||||
## Lösung
|
||||
Erweiterung des bestehenden `normalizeAtisForSpeech` um:
|
||||
1. Inline-METAR-Token-Expansion (alle gängigen WMO-Code-Form-FM-15-XV-Patterns)
|
||||
2. ICAO-Airport-Code → City-Name via OpenAIP (Endpoint liefert den Namen schon mit)
|
||||
3. Akronym-Lowercasing (`ATIS` → `atis`) damit TTS sie als Wort spricht statt zu spellen
|
||||
|
||||
## Backend — Airport-Name in FrequencyResponse
|
||||
|
||||
`server/api/airports/[icao]/frequencies.get.ts`:
|
||||
- Extrahiere `airport.name` (oder Fallback `airport.municipality`) aus OpenAIP-Response
|
||||
- Füge `airportName?: string` zu `FrequencyResponse`
|
||||
- pm.vue speichert das in einer neuen Ref `airportNameForFreq` analog zu `airportFrequencies`
|
||||
|
||||
## Frontend — Erweiterte Normalizer-Pipeline
|
||||
|
||||
Neue Reihenfolge in `normalizeAtisForSpeech(text, opts?: { airportIcao, airportName })`:
|
||||
|
||||
```
|
||||
1. ICAO-Airport-Code-Substitution
|
||||
- Wenn opts.airportIcao + opts.airportName gesetzt: \b{icao}\b → {name}
|
||||
2. METAR-Token-Expansion (inline, siehe Tabelle unten)
|
||||
3. ATIS-spezifische Regeln (bestehend — info-letter, expanded wind/temp/time)
|
||||
4. normalizeRadioPhrase (bestehend — QNH/RWY/FL/freq)
|
||||
5. Bare-Runway-Designators (bestehend)
|
||||
6. Akronym-Lowercase: ATIS/METAR/SPECI → atis/metar/speci
|
||||
7. Whitespace-Cleanup
|
||||
```
|
||||
|
||||
### METAR-Token-Regeln (Schritt 2)
|
||||
|
||||
| Pattern | Output | Notizen |
|
||||
|---|---|---|
|
||||
| `\d{6}Z` | "on the {day} at {hours} {minutes} zulu" | DDHHMMZ |
|
||||
| `\bAUTO\b` | "automatic observation" | |
|
||||
| `\bCOR\b` | "correction" | |
|
||||
| `(VRB\|\d{3})(\d{2,3})(G\d{2,3})?KT` | "wind {dir} degrees at {spd} knots[, gusting {gust} knots]" | VRB→"variable" |
|
||||
| `\b00000KT\b` | "wind calm" | Special case |
|
||||
| `(\d{3})V(\d{3})` | "variable between {a} and {b} degrees" | Wind-Variabilität |
|
||||
| `R(\d{2}[LCR]?)/[MP]?\d{4}[UDN]?` | "runway {rwy} visibility [less/more than] {dist} meters[, increasing/decreasing]" | RVR |
|
||||
| `\bCAVOK\b` | (bleibt — TTS spricht 'kavok' korrekt) | |
|
||||
| `\b9999\b` | "visibility wun zero kilometers or more" | Max-Vis-Marker |
|
||||
| `\bNSC\b` / `\bSKC\b` / `\bCLR\b` | "no significant cloud" / "sky clear" | |
|
||||
| `\bNCD\b` | "no cloud detected" | Auto-Stationen |
|
||||
| `\bVV(\d{3})\b` | "vertical visibility {alt}" | |
|
||||
| Weather codes | "[light/heavy/in the vicinity] [descriptor] [type]" | Tabelle in code |
|
||||
| `\bRE(\w+)\b` | "recent {phenomenon}" | |
|
||||
| `\bWS\s+R(\d{2}[LCR]?)\b` | "wind shear runway {rwy}" | |
|
||||
| `(M?\d{2})/(M?\d{2})` | "temperature {temp}, dewpoint {dew}" | M→"minus" |
|
||||
| `\bQ(\d{4})\b` | "QNH {digits}" | hPa, ICAO |
|
||||
| `\bA(\d{4})\b` | "altimeter {digits}" | inches Hg, US |
|
||||
| `\bNOSIG\b` | "no significant change" | |
|
||||
| `\bBECMG\b` | "becoming" | |
|
||||
| `\bTEMPO\b` | "temporary" | |
|
||||
| `\b(FM\|TL\|AT)(\d{4})\b` | "from/until/at {hhmm} zulu" | |
|
||||
|
||||
### Weather Codes (vollständige Tabelle)
|
||||
|
||||
**Intensität-Präfix:** `-` → "light", `+` → "heavy", `VC` → "in the vicinity"
|
||||
**Descriptor:** `MI` (shallow), `BC` (patches), `DR` (low drifting), `BL` (blowing), `SH` (shower), `TS` (thunderstorm), `FZ` (freezing), `PR` (partial)
|
||||
**Precipitation:** `DZ` (drizzle), `RA` (rain), `SN` (snow), `SG` (snow grains), `IC` (ice crystals), `PL` (ice pellets), `GR` (hail), `GS` (small hail), `UP` (unknown)
|
||||
**Obscuration:** `BR` (mist), `FG` (fog), `FU` (smoke), `VA` (volcanic ash), `DU` (dust), `SA` (sand), `HZ` (haze)
|
||||
**Other:** `PO` (dust devil), `SQ` (squall), `FC` (funnel cloud / tornado), `SS` (sandstorm), `DS` (duststorm)
|
||||
|
||||
### Akronym-Behandlung (Schritt 6)
|
||||
|
||||
**Lowercase (TTS liest als Wort):** `ATIS`, `METAR`, `SPECI`
|
||||
**Bleiben uppercase (Pilots SPELLEN):** `QNH`, `ILS`, `VOR`, `DME`, `LOC`, `NDB`, `GP`, `RNAV`, `RNP`
|
||||
**Begründung:** Wenn Pilots im echten Funk "ILS" sagen, dann spellen sie es ("I-L-S"). Bei ATIS und METAR sagen sie es als Wort.
|
||||
|
||||
## Wiring in pm.vue
|
||||
|
||||
```ts
|
||||
// startAtisLoop:
|
||||
const spokenAnnouncement = normalizeAtisForSpeech(announcement, {
|
||||
airportIcao: flightContext.value.dep,
|
||||
airportName: airportNameForFreq.value || undefined,
|
||||
})
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
Neue Unit-Tests in `tests/shared/radioSpeech.test.ts`:
|
||||
1. EDDF METAR-Form (User-Beispiel): `METAR EDDF 281050Z AUTO 02008KT 320V070 CAVOK 24/02 Q1025 NOSIG` mit airportName=Frankfurt
|
||||
2. Wind-Edge-Cases: VRB, calm, gusts
|
||||
3. Visibility-Edge-Cases: 9999, 0800, RVR
|
||||
4. Weather-Codes: -RA, +TSRA, FZRA, VCSH
|
||||
5. Akronym-Lowercase: ATIS, METAR
|
||||
6. Bestehendes EDDM-Test bleibt grün
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Cross-airport ICAO-Lookups (z.B. ATIS erwähnt Diversion-Flughafen)
|
||||
- Runway-State-Codes `R25/0/0/0/00` (komplex, selten in VATSIM-ATIS)
|
||||
- Sea state, military codes, NOTAM-Style-Codes
|
||||
- Statute-Miles Vis (`5SM`) — selten in europäischen ATIS
|
||||
- SSML-basierte TTS-Pronunciation (engine-spezifisch)
|
||||
@@ -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<FrequencyResponse> => {
|
||||
|
||||
const icao = icaoParam.toUpperCase()
|
||||
const frequencyMap = new Map<string, FrequencyEntry>()
|
||||
let airportName: string | undefined
|
||||
let vatsimSuccess = false
|
||||
let openaipSuccess = false
|
||||
|
||||
@@ -205,6 +207,12 @@ export default defineEventHandler(async (event): Promise<FrequencyResponse> => {
|
||||
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<FrequencyResponse> => {
|
||||
|
||||
return {
|
||||
icao,
|
||||
airportName,
|
||||
sources: {
|
||||
vatsim: vatsimSuccess,
|
||||
openaip: openaipSuccess
|
||||
|
||||
@@ -70,6 +70,28 @@ const METAR_WEATHER: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'-': 'light', '+': 'heavy', 'VC': 'in the vicinity',
|
||||
};
|
||||
const WX_DESCRIPTOR: Record<string, string> = {
|
||||
MI: 'shallow', BC: 'patches', DR: 'low drifting', BL: 'blowing',
|
||||
SH: 'shower', TS: 'thunderstorm', FZ: 'freezing', PR: 'partial',
|
||||
};
|
||||
const WX_PHENOMENON: Record<string, string> = {
|
||||
// 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<string, string> = {
|
||||
'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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user