mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
Normalize taxi routes for clearer speech
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user