mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-08 18:45:33 +08:00
feat(atis): ICAO phraseology normalizer for spoken ATIS
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
61
docs/plans/2026-05-28-atis-phraseology-normalizer-design.md
Normal file
61
docs/plans/2026-05-28-atis-phraseology-normalizer-design.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# ATIS-Phraseology Normalizer
|
||||
|
||||
## Problem
|
||||
Mein ATIS-Loop sendet die rohe VATSIM-ATIS-Zeichenkette an TTS. Die TTS-Engine spricht Aviation-Abkürzungen wie `QNH`/`ILS` Buchstabe-für-Buchstabe aus und liest `WIND 030`, `TEMPERATURE 18`, `TIME 0620` wie englische Zahlwörter ("zero thirty", "eighteen", "six hundred twenty") statt ICAO-konform Ziffer-für-Ziffer ("zero three zero", "one eight", "zero six two zero").
|
||||
|
||||
Real-World VATSIM-ATIS (EDDM, 2026-05-28):
|
||||
```
|
||||
MUENCHEN INFORMATION A AUTOMATIC MET REPORT TIME 0620
|
||||
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
|
||||
```
|
||||
|
||||
## Lösung
|
||||
Neue Funktion `normalizeAtisForSpeech(text)` in `shared/utils/radioSpeech.ts`, die ATIS-spezifische Regex-Transformationen auf bestehenden Helpers (`spellIcaoDigits`, `ICAO_LETTERS`, `METAR_CLOUD`) aufbaut. Output behält den ICAO-Phonetik-Stil (`wun zero too four`), der schon im Rest der App (`speakWithRadioEffects` mit `useNormalizedForTTS: true`) verwendet wird.
|
||||
|
||||
## Verworfene Alternativen
|
||||
- **LLM (OpenAI) für die Normalisierung** — flexibler bei ungewöhnlichen Formaten, aber +500ms beim Cold-Cache, nondeterministisch, kostet pro unique ATIS. ICAO-Format ist standardisiert genug für Regeln.
|
||||
- **Hybrid (Regel + LLM-Fallback)** — doppelte Komplexität ohne klaren Nutzen.
|
||||
|
||||
## Transformations-Regeln (in Reihenfolge anwenden — spezifisch → generisch)
|
||||
|
||||
| Eingabe | Ausgabe | Anmerkung |
|
||||
|---|---|---|
|
||||
| `INFORMATION A` | `INFORMATION Alfa` | NATO-Phonetic für Info-Letter |
|
||||
| `TIME 0620` | `time zero six two zero` | 4-stellige Zulu-Zeit |
|
||||
| `WIND 030 DEGREES` | `wind zero three zero degrees` | 3-stellige Windrichtung |
|
||||
| `BETWEEN 340 AND 060 DEGREES` | `between three four zero and zero six zero degrees` | Wind-Variabilität |
|
||||
| `4 KNOTS` / `15 KNOTS` | `four knots` / `one five knots` | Wind/Gust-Speed ziffernweise |
|
||||
| `TEMPERATURE -5` | `temperature minus five` | Negative Temperaturen |
|
||||
| `TEMPERATURE 18` | `temperature one eight` | Zweistellig ziffernweise |
|
||||
| `DEW POINT 7` | `dew point seven` | Einstellig wortgenau |
|
||||
| `TRL 60` / `TL 60` | `transition level six zero` | Transition-Level |
|
||||
| `NOSIG` | `no significant change` | TREND-Code |
|
||||
| `BKN030` | `broken three thousand` | Cloud-Layer, Höhe ×100 ft, ohne "feet" |
|
||||
| `FEW005 CB` | `few five hundred cumulonimbus` | Cloud-Layer + Typ |
|
||||
| `VIS 10 KM` | `visibility one zero kilometers` | Sicht in km |
|
||||
| `VIS 5000 M` | `visibility five thousand meters` | Sicht in m |
|
||||
| Bare `08L` / `08R` | `zero eight left` / `zero eight right` | Runway-Designator ohne `RUNWAY`-Prefix |
|
||||
| Final: `normalizeRadioPhrase` über Ergebnis | — | Fängt `QNH 1024`, expliziter `RUNWAY 08L`, `FL250`, Frequenzen |
|
||||
|
||||
## Call-Site
|
||||
In `pm.vue` → `startAtisLoop`, direkt vor dem TTS-Request:
|
||||
|
||||
```ts
|
||||
const announcement = buildAtisAnnouncement({ ...entry, atisText: content })
|
||||
const spoken = normalizeAtisForSpeech(announcement)
|
||||
// then: text: spoken in api.post body
|
||||
```
|
||||
|
||||
Der Disk-Cache in `say.post.ts` hasht den normalisierten Input — gleicher ATIS-Text → gleicher Cache-Key → unverändert effizient.
|
||||
|
||||
## Tests
|
||||
Unit-Tests in `shared/utils/radioSpeech.test.ts` (falls vorhanden) mit den oben gelisteten Input/Output-Paaren als Fixtures.
|
||||
|
||||
## Out of Scope
|
||||
- METAR-Wetter-Codes (`+TSRA`, `-FZRA`) — in VATSIM-ATIS meist ausgeschrieben
|
||||
- Runway-Conditions (`WET`, `BRAKING ACTION POOR`) — wenn Feld auftaucht, erweitern
|
||||
- Deutsche ATIS-Texte (selten in VATSIM)
|
||||
- Spezielle Runway-Routing-Hinweise (`EXPECT VECTORS FOR INDEPENDENT PARALLEL ILS APPROACH`) — durchgereicht, TTS liest's leidlich
|
||||
@@ -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(/(?<!flight\s+level\s+|FL\s*)\b(\d{2})([LCR])\b/gi,
|
||||
(_m, digits: string, side: string) => {
|
||||
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;
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user