Normalize taxi routes for clearer speech

This commit is contained in:
Remi
2025-10-18 21:29:56 +02:00
parent 5e3f189b7d
commit 663001a7af
4 changed files with 96 additions and 20 deletions

View File

@@ -511,7 +511,7 @@ function handleClassroomEntry() {
}
const voiceMode = ref<VoiceMode>('text')
const radioLevel = ref(3)
const radioLevel = ref(4)
const hasCompletedRadioCheck = ref(false)
const tourStarted = ref(false)
const stageIndex = ref(0)

View File

@@ -1282,6 +1282,7 @@ import type {BlankWidth, Frequency, Lesson, LessonField, ModuleDef, Scenario} fr
import {loadPizzicatoLite} from '~~/shared/utils/pizzicatoLite'
import type {PizzicatoLite} from '~~/shared/utils/pizzicatoLite'
import {createNoiseGenerators, getReadabilityProfile} from '~~/shared/utils/radioEffects'
import {DEFAULT_AIRLINE_TELEPHONY, normalizeRadioPhrase} from '~~/shared/utils/radioSpeech'
definePageMeta({middleware: ['require-auth', 'require-classroom-intro']})
@@ -4273,12 +4274,18 @@ async function say(text: string) {
const playbackToken = ++activePlaybackToken
const normalizedRate = computeSpeechRate()
const spokenPhrase = normalizeRadioPhrase(trimmed, {
airlineMap: DEFAULT_AIRLINE_TELEPHONY,
expandAirports: true,
expandCallsigns: true
})
const speakText = spokenPhrase || trimmed
const hasBrowserTts = cfg.value.tts && typeof window !== 'undefined' && 'speechSynthesis' in window
if (hasBrowserTts) {
const synth = window.speechSynthesis
const utterance = new SpeechSynthesisUtterance(trimmed)
const utterance = new SpeechSynthesisUtterance(speakText)
utterance.rate = normalizedRate
if (cfg.value.voice) {
const voiceName = cfg.value.voice.toLowerCase()
@@ -4312,7 +4319,7 @@ async function say(text: string) {
payload.voice = cfg.value.voice
}
const cacheKey = buildSayCacheKey(trimmed, normalizedRate)
const cacheKey = buildSayCacheKey(speakText, normalizedRate)
isSpeaking.value = true
ttsLoading.value = true

View File

@@ -1,7 +1,7 @@
// yarn add openai
import OpenAI from "openai";
import fs from "node:fs";
import { normalizeRadioPhrase } from "../../shared/utils/radioSpeech";
import { DEFAULT_AIRLINE_TELEPHONY, normalizeRadioPhrase } from "../../shared/utils/radioSpeech";
import { getServerRuntimeConfig } from "./runtimeConfig";
const { openaiKey, openaiProject, openaiBaseUrl, llmModel, ttsModel } = getServerRuntimeConfig();
@@ -144,22 +144,7 @@ export function atcReplyPrompt(userText: string, state?: {
========================= */
// Airline telephony (extensible)
export const CALLSIGN_MAP: Record<string,string> = {
DLH: "Lufthansa",
EWG: "Eurowings",
THY: "Turkish",
JBU: "JetBlue",
NAX: "Norwegian",
SWR: "Swiss",
BAW: "Speedbird",
AFR: "Air France",
KLM: "KLM",
AAL: "American",
UAL: "United",
DAL: "Delta",
RYR: "Ryanair",
EZY: "Easy",
};
export const CALLSIGN_MAP: Record<string,string> = { ...DEFAULT_AIRLINE_TELEPHONY };
// Public Normalizer
export function normalizeATC(

View File

@@ -42,6 +42,23 @@ export const ICAO_LETTERS: Record<string, string> = {
export type AirlineTelephonyMap = Record<string, string>;
export const DEFAULT_AIRLINE_TELEPHONY: AirlineTelephonyMap = {
DLH: "Lufthansa",
EWG: "Eurowings",
THY: "Turkish",
JBU: "JetBlue",
NAX: "Norwegian",
SWR: "Swiss",
BAW: "Speedbird",
AFR: "Air France",
KLM: "KLM",
AAL: "American",
UAL: "United",
DAL: "Delta",
RYR: "Ryanair",
EZY: "Easy",
};
export interface NormalizeRadioOptions {
airlineMap?: AirlineTelephonyMap;
expandCallsigns?: boolean;
@@ -113,6 +130,71 @@ function freqSpeak(raw: string): string {
return `${leftSpoken} decimal ${rightSpoken}`;
}
const VIA_TAXI_ROUTE_PATTERN = /\b((?:expect\s+taxi\s+)?via\s+)([A-Z0-9\s/\-]+?)(?=(?:,|\s+(?:hold short|cross|then|contact|monitor|with|for|to|left|right)\b|\.)|$)/gi;
const TAXI_ROUTE_LABEL_PATTERN = /\b(taxi(?:-?in)?\s+route[:\s]+)([A-Z0-9\s/\-]+?)(?=(?:,|\s+(?:hold short|cross|then|contact|monitor|with|for|to|left|right)\b|\.)|$)/gi;
const STAND_ROUTE_PATTERN = /\b(taxi\s+to\s+stand\s+[A-Z0-9]+\s+via\s+)([A-Z0-9\s/\-]+?)(?=(?:,|\s+(?:hold short|cross|then|contact|monitor|with|for|to|left|right)\b|\.)|$)/gi;
const TAXI_SEGMENT_SINGLE = /^[A-Z]{1,2}$/;
const TAXI_SEGMENT_WITH_DIGITS = /^[A-Z]{1,3}\d{1,3}$/;
const TAXI_ROUTE_SEPARATOR = /[-/]/g;
function shouldConvertTaxiSegment(value: string): boolean {
if (!value) return false;
const upper = value.toUpperCase();
if (!/[A-Z]/.test(upper)) return false;
if (TAXI_SEGMENT_SINGLE.test(upper)) return true;
if (TAXI_SEGMENT_WITH_DIGITS.test(upper)) return true;
return false;
}
function speakTaxiSegment(segment: string): string {
const trimmed = segment.trim();
if (!trimmed) return '';
const cleaned = trimmed.replace(/[^A-Za-z0-9\-/]/g, '');
if (!cleaned) return trimmed;
const expanded = cleaned.replace(TAXI_ROUTE_SEPARATOR, (match) => (match === '-' ? ' dash ' : ' slash '));
const tokens = expanded.split(/\s+/).filter(Boolean);
const spoken = tokens.map((token) => {
if (token === 'dash' || token === 'slash') return token;
const upper = token.toUpperCase();
if (!shouldConvertTaxiSegment(upper)) {
return token;
}
return toIcaoPhonetic(upper);
});
return spoken.join(' ');
}
function speakTaxiRoute(route: string): string {
const parts = route.trim().split(/(\s+)/);
if (!parts.length) return route.trim();
const spokenParts = parts.map((part) => {
if (!part.trim()) return part;
const spoken = speakTaxiSegment(part);
return spoken || part;
});
return spokenParts.join('').replace(/\s+/g, ' ').trim();
}
function applyTaxiRoutePhonetics(text: string): string {
const replacer = (_match: string, prefix: string, rawRoute: string) => {
const route = rawRoute.trim();
if (!route) return `${prefix}${rawRoute}`;
const spoken = speakTaxiRoute(route);
if (!spoken) return `${prefix}${rawRoute}`;
const needsSpace = /\s$/.test(prefix) ? '' : ' ';
return `${prefix}${needsSpace}${spoken}`.replace(/\s+/g, ' ');
};
let out = text.replace(VIA_TAXI_ROUTE_PATTERN, replacer);
out = out.replace(TAXI_ROUTE_LABEL_PATTERN, replacer);
out = out.replace(STAND_ROUTE_PATTERN, replacer);
return out;
}
const HUNDRED_WORDS: Record<number, string> = {
100: 'wun hundred',
200: 'too hundred',
@@ -197,5 +279,7 @@ export function normalizeRadioPhrase(text: string, options: NormalizeRadioOption
out = out.replace(/\b([A-Z]{2,3}\d{1,4}[A-Z]?)\b/g, (match: string) => callsignSpeak(match, airlineMap));
}
out = applyTaxiRoutePhonetics(out);
return out.replace(/\s+/g, ' ').trim();
}