mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-14 17:55:36 +08:00
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>
169 lines
7.0 KiB
TypeScript
169 lines
7.0 KiB
TypeScript
import { describe, it } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
|
|
import {
|
|
normalizeAtisForSpeech,
|
|
normalizeMetarPhrase,
|
|
normalizeRadioPhrase,
|
|
spellIcaoDigits,
|
|
spellIcaoLetters,
|
|
toIcaoPhonetic,
|
|
} from '~~/shared/utils/radioSpeech'
|
|
|
|
describe('radioSpeech', () => {
|
|
it('spells ICAO digits and letters', () => {
|
|
assert.equal(spellIcaoDigits('120'), 'wun too zero')
|
|
assert.equal(spellIcaoLetters('eddf'), 'Echo Delta Delta Foxtrot')
|
|
assert.equal(toIcaoPhonetic('A3'), 'Alfa tree')
|
|
})
|
|
|
|
it('normalizes a combined phrase with callsign, runway, frequency and taxi route', () => {
|
|
const normalized = normalizeRadioPhrase('DLH359 contact 121.800, RWY 25R via N3 U4', {
|
|
expandCallsigns: true,
|
|
airlineMap: { DLH: 'Lufthansa' },
|
|
})
|
|
|
|
assert.match(normalized, /Lufthansa tree fife niner/)
|
|
assert.match(normalized, /wun too wun decimal eight zero zero/)
|
|
assert.match(normalized, /runway too fife right/)
|
|
assert.match(normalized, /November tree,\s+Uniform four/)
|
|
})
|
|
|
|
it('normalizes SID suffix and METAR data', () => {
|
|
const sid = normalizeRadioPhrase('MARUN 7F')
|
|
const metar = normalizeMetarPhrase('EDDF 171450Z 28015G25KT 9999 -RA SCT025 BKN040 15/08 Q1013')
|
|
|
|
// SID prefix is spelled phonetically so TTS pronounces unfamiliar waypoint names correctly
|
|
assert.equal(sid, 'Mike Alfa Romeo Uniform November seven Foxtrot')
|
|
assert.match(metar, /wind too eight zero degrees/)
|
|
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/)
|
|
})
|
|
|
|
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/)
|
|
})
|
|
})
|