diff --git a/app/pages/classroom-introduction.vue b/app/pages/classroom-introduction.vue index d30673f..3d0f809 100644 --- a/app/pages/classroom-introduction.vue +++ b/app/pages/classroom-introduction.vue @@ -511,7 +511,7 @@ function handleClassroomEntry() { } const voiceMode = ref('text') -const radioLevel = ref(3) +const radioLevel = ref(4) const hasCompletedRadioCheck = ref(false) const tourStarted = ref(false) const stageIndex = ref(0) diff --git a/app/pages/classroom.vue b/app/pages/classroom.vue index 28f0ede..924b1b1 100644 --- a/app/pages/classroom.vue +++ b/app/pages/classroom.vue @@ -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 diff --git a/server/utils/normalize.ts b/server/utils/normalize.ts index 30ee7af..b3c3f30 100644 --- a/server/utils/normalize.ts +++ b/server/utils/normalize.ts @@ -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 = { - 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 = { ...DEFAULT_AIRLINE_TELEPHONY }; // Public Normalizer export function normalizeATC( diff --git a/shared/utils/radioSpeech.ts b/shared/utils/radioSpeech.ts index c418e73..0ed3cd4 100644 --- a/shared/utils/radioSpeech.ts +++ b/shared/utils/radioSpeech.ts @@ -42,6 +42,23 @@ export const ICAO_LETTERS: Record = { export type AirlineTelephonyMap = Record; +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 = { 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(); }