fix(tts): keep a SID in one piece and speak the approach variant letter

Two of the items from the combined phraseology report were real.

The taxi-route rule treated the "via" of a departure clearance as the start of
a taxi route and comma-joined every token after it, so "cleared via MARUN7F
departure, climb 5000 feet" was read as "Marun, seven, Foxtrot, departure,
climb, fife, thousand, feet" — the SID's own name, number and suffix split into
three separate items, with the break in exactly the wrong place. The rule now
converts a run only when it is actually made of taxiway designators, which
leaves real taxi routes ("via N3, U4, A") reading as before.

"ILS Z" was read as a bare letter. The rule that should have expanded it
required the runway in the same match, and by the time it ran the runway had
already been rewritten to words, so it never fired on the phrasing the flows
use. Reading the variant is now independent of what follows it — where two
approaches serve the same runway, that letter is the only thing telling them
apart.

Checked the rest of the report and left it alone: taxiway sequences already
paused correctly, parallel runways already spoke their side in full, and no
approach clearance names a runway literally. Tests now pin all of it, including
two structural invariants over every flow — an approach clearance takes its
runway from the variable rather than a literal, and a readback quotes the
clearance items back in the order they were given.
This commit is contained in:
itsrubberduck
2026-07-27 01:37:19 +02:00
parent 91e124f54d
commit 00a31f564f
2 changed files with 80 additions and 6 deletions

View File

@@ -264,6 +264,19 @@ function speakTaxiSegment(segment: string): string {
return spoken.join(' ');
}
/** True when every token is a taxiway designator (A, V, N3, U4, A-V). */
function looksLikeTaxiRoute(route: string): boolean {
const tokens = route.trim().split(/\s+/).filter(Boolean);
if (!tokens.length) return false;
return tokens.every(token =>
token
.replace(/[^A-Za-z0-9\-/]/g, '')
.split(TAXI_ROUTE_SEPARATOR)
.filter(Boolean)
.every(part => shouldConvertTaxiSegment(part.toUpperCase())),
);
}
function speakTaxiRoute(route: string): string {
const tokens = route.trim().split(/\s+/).filter(Boolean);
if (!tokens.length) return route.trim();
@@ -275,6 +288,12 @@ function applyTaxiRoutePhonetics(text: string): string {
const replacer = (_match: string, prefix: string, rawRoute: string) => {
const route = rawRoute.trim();
if (!route) return `${prefix}${rawRoute}`;
// "via" also introduces the SID of a departure clearance. Comma-joining
// every token after it turned "via Marun seven Foxtrot departure, climb
// five thousand feet" into a list, splitting the SID's own name, number
// and suffix into three separate items. Only convert a run that is
// actually made of taxiway designators.
if (!looksLikeTaxiRoute(route)) return `${prefix}${rawRoute}`;
const spoken = speakTaxiRoute(route);
if (!spoken) return `${prefix}${rawRoute}`;
const needsSpace = /\s$/.test(prefix) ? '' : ' ';
@@ -706,6 +725,19 @@ export function normalizeRadioPhrase(text: string, options: NormalizeRadioOption
const opts = { ...DEFAULT_OPTIONS, ...options };
let out = text;
// Approach variant letter: "ILS Z" → "ILS Zulu". Where two approaches serve
// the same runway they are told apart by this letter alone, so reading it
// as a bare "Z" loses the distinction. Runs first, and independently of
// what follows: the runway rule below rewrites "runway 25C" to words, and
// an earlier attempt that required the runway in the same match therefore
// never fired on the phrasing the flows actually use ("ILS Z approach
// runway 25C").
out = out.replace(
/\b(ILS|VOR|RNAV|LOC|RNP)\s+([A-Z])(?![A-Za-z0-9])/g,
(_match, type: string, variant: string) =>
`${type} ${ICAO_LETTERS[variant] ?? variant}`,
);
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));
@@ -754,12 +786,6 @@ export function normalizeRadioPhrase(text: string, options: NormalizeRadioOption
});
}
// ILS/VOR variant letter before runway: "ILS Z 25C" → "ILS Zulu runway two five center"
out = out.replace(
/\b(ILS|VOR|RNAV|LOC|RNP)\s+([A-Z])\s+(\d{2}[LCR]?)\b/gi,
(_match, type: string, variant: string, runway: string) =>
`${type.toUpperCase()} ${ICAO_LETTERS[variant.toUpperCase()] ?? variant} ${runwaySpeak(runway)}`
);
// ILS/VOR suffix after runway: "ILS 25C Z" → legacy format
out = out.replace(
/\b(ILS|VOR|RNAV|LOC|RNP)\s+(\d{2}[LCR]?)\s+([A-Z])\b/gi,

View File

@@ -36,6 +36,54 @@ describe('normalizeRadioPhrase — PM radio pronunciation', () => {
assert.equal(normalizeRadioPhrase('maintain 250 knots', opts), 'maintain 250 knots')
})
// A departure clearance also says "via". The taxi-route rule comma-joined
// everything after it, so the SID's own name, number and suffix came out as
// three separate items — "Marun, seven, Foxtrot, departure, climb, …".
it('keeps a SID in a departure clearance in one piece', () => {
assert.equal(
normalizeRadioPhrase('cleared to Munich via BIBAX1N departure', opts),
'cleared to Munich via Bibax wun November departure',
)
assert.equal(
normalizeRadioPhrase('cleared via MARUN7F departure, climb 5000 feet', opts),
'cleared via Marun seven Foxtrot departure, climb fife thousand feet',
)
})
it('still breaks a real taxi route into separate designators', () => {
assert.match(
normalizeRadioPhrase('taxi to holding point runway 25L via N3, U4, A', opts),
/via November tree, Uniform four, Alfa$/,
)
assert.match(
normalizeRadioPhrase('taxi via A, V, hold short runway 07', opts),
/via Alfa, Victor, hold short/,
)
})
// Where two approaches serve the same runway the variant letter is the only
// thing telling them apart, so reading it as a bare "Z" loses the distinction.
it('speaks an approach variant letter phonetically', () => {
assert.match(
normalizeRadioPhrase('cleared ILS Z approach runway 25C', opts),
/ILS Zulu approach runway too fife center/,
)
assert.match(
normalizeRadioPhrase('cleared RNAV Y approach runway 08L', opts),
/RNAV Yankee approach runway zero eight left/,
)
})
it('speaks the parallel-runway side in full', () => {
for (const [written, spoken] of [
['25L', 'too fife left'],
['25C', 'too fife center'],
['07R', 'zero seven right'],
]) {
assert.match(normalizeRadioPhrase(`runway ${written}`, opts), new RegExp(spoken))
}
})
// ICAO five-letter name-codes are built to be pronounceable and are spoken as
// words on the radio. Spelling them out would both be wrong phraseology and
// train the pilot to read back a spelling the matcher then has to undo.