mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-01 22:26:04 +08:00
refactor(live-atc): extract TTS/radio-effects speech to useRadioSpeech
Moves the audio-context/Pizzicato setup, TTS fetch+playback pipeline, controller/readback speech scheduling, and the radio-check handling (RADIO_CHECK_RE/SAY_AGAIN_RE/FAREWELL_RE, answerRadioCheck, performRadioCheck, readability quick-select) out of live-atc.vue. The interrupt primitives (stopCurrentSpeech/enqueueSpeech and the audio/abort bookkeeping they tear down) live in a separate useSpeechInterrupt because useFrequencyPresets needs stopCurrentSpeech while useRadioSpeech needs frequency state — one composable owning both would be circular. useSpeechInterrupt is constructed first and threaded into both. signalStrength/speechSpeed/radioCheckLoading/radioEffectsEnabled/ readbackEnabled stay page-level refs and are passed in, because useAtisPlayback already consumes signalStrength before useRadioSpeech can be constructed (it needs handlePilotTransmission). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
434
app/composables/useRadioSpeech.ts
Normal file
434
app/composables/useRadioSpeech.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import useCommunicationsEngine from '../../shared/utils/communicationsEngine'
|
||||
import { loadPizzicatoLite } from '../../shared/utils/pizzicatoLite'
|
||||
import type { PizzicatoLite } from '../../shared/utils/pizzicatoLite'
|
||||
import { createNoiseGenerators, getReadabilityProfile } from '../../shared/utils/radioEffects'
|
||||
import { pmLog } from '../../shared/utils/pmLog'
|
||||
import { useSpeechInterrupt } from '~/composables/useSpeechInterrupt'
|
||||
import {
|
||||
FREQUENCY_PLACEHOLDER,
|
||||
FREQ_ROLE_LABEL,
|
||||
normalizedFrequencyValue,
|
||||
type useFrequencyPresets,
|
||||
} from '~/composables/useFrequencyPresets'
|
||||
|
||||
export type PreparedSpeech = {
|
||||
template: string
|
||||
plain: string
|
||||
normalized: string
|
||||
}
|
||||
|
||||
export type SpeechOptions = {
|
||||
voice?: string
|
||||
tag?: string
|
||||
updateLastTransmission?: boolean
|
||||
lastTransmissionLabel?: string
|
||||
delayMs?: number
|
||||
useNormalizedForTTS?: boolean
|
||||
speed?: number
|
||||
lessonId?: string
|
||||
}
|
||||
|
||||
export interface RadioSpeechDeps {
|
||||
setLastTransmission: (text: string) => void
|
||||
handlePilotTransmission: (message: string, source: 'text' | 'ptt') => Promise<void>
|
||||
lastControllerSay: Ref<string | null>
|
||||
signalStrength: Ref<number>
|
||||
speechSpeed: Ref<number>
|
||||
radioCheckLoading: Ref<boolean>
|
||||
radioEffectsEnabled: Ref<boolean>
|
||||
readbackEnabled: Ref<boolean>
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||
|
||||
export function useRadioSpeech(
|
||||
engine: ReturnType<typeof useCommunicationsEngine>,
|
||||
freq: ReturnType<typeof useFrequencyPresets>,
|
||||
speechInterrupt: ReturnType<typeof useSpeechInterrupt>,
|
||||
deps: RadioSpeechDeps,
|
||||
) {
|
||||
const { variables: vars, flags, flightContext, currentState, sessionId: engineSessionId, appendLogEntry, renderATCMessage, normalizeATCText } = engine
|
||||
const { frequencies, airportFrequencies, airportName } = freq
|
||||
const {
|
||||
stopCurrentSpeech, enqueueSpeech, getSpeechGeneration,
|
||||
setCurrentAudio, setCurrentPizzicatoSound, addPendingAbort, deletePendingAbort,
|
||||
} = speechInterrupt
|
||||
const {
|
||||
setLastTransmission, handlePilotTransmission, lastControllerSay,
|
||||
signalStrength, speechSpeed, radioCheckLoading, radioEffectsEnabled, readbackEnabled,
|
||||
} = deps
|
||||
const api = useApi()
|
||||
|
||||
let audioContext: AudioContext | null = null
|
||||
let pizzicatoLite: PizzicatoLite | null = null
|
||||
|
||||
const radioQuality = computed(() => {
|
||||
const strength = signalStrength.value
|
||||
if (strength >= 5) return { color: 'success', text: 'EXCELLENT' }
|
||||
if (strength >= 4) return { color: 'success', text: 'GOOD' }
|
||||
if (strength >= 3) return { color: 'warning', text: 'FAIR' }
|
||||
if (strength >= 2) return { color: 'orange', text: 'POOR' }
|
||||
return { color: 'error', text: 'WEAK' }
|
||||
})
|
||||
|
||||
const speechSpeedLabel = computed(() => `${speechSpeed.value.toFixed(2)}x`)
|
||||
|
||||
const prepareSpeech = (tpl: string): PreparedSpeech => {
|
||||
const plain = renderATCMessage(tpl)
|
||||
const normalized = normalizeATCText(tpl, { ...vars.value, ...flags.value })
|
||||
return { template: tpl, plain, normalized }
|
||||
}
|
||||
|
||||
const ensureAudioContext = async (): Promise<AudioContext | null> => {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
if (!audioContext) {
|
||||
const Ctx = window.AudioContext || (window as any).webkitAudioContext
|
||||
if (!Ctx) return null
|
||||
audioContext = new Ctx()
|
||||
}
|
||||
|
||||
if (audioContext.state === 'suspended') {
|
||||
try {
|
||||
await audioContext.resume()
|
||||
} catch (err) {
|
||||
console.warn('AudioContext resume failed', err)
|
||||
}
|
||||
}
|
||||
|
||||
return audioContext
|
||||
}
|
||||
|
||||
const ensurePizzicato = async (ctx: AudioContext | null): Promise<PizzicatoLite | null> => {
|
||||
if (!ctx) return null
|
||||
if (!pizzicatoLite) {
|
||||
pizzicatoLite = await loadPizzicatoLite()
|
||||
}
|
||||
return pizzicatoLite
|
||||
}
|
||||
|
||||
const playAudioWithEffects = async (base64: string, mime = 'audio/wav') => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const dataUrl = `data:${mime || 'audio/wav'};base64,${base64}`
|
||||
|
||||
const playWithoutEffects = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
const audio = new Audio(dataUrl)
|
||||
setCurrentAudio(audio)
|
||||
audio.onended = () => { setCurrentAudio(null); resolve() }
|
||||
audio.onerror = () => { setCurrentAudio(null); resolve() }
|
||||
audio.play().catch(() => { setCurrentAudio(null); resolve() })
|
||||
})
|
||||
|
||||
if (!radioEffectsEnabled.value) {
|
||||
await playWithoutEffects()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const ctx = await ensureAudioContext()
|
||||
const pizzicato = await ensurePizzicato(ctx)
|
||||
if (!ctx || !pizzicato) throw new Error('Audio engine unavailable')
|
||||
|
||||
const sound = await pizzicato.createSoundFromBase64(ctx, base64)
|
||||
const readability = Math.max(1, Math.min(5, signalStrength.value))
|
||||
const profile = getReadabilityProfile(readability)
|
||||
|
||||
const { Effects } = pizzicato
|
||||
|
||||
const highpass = new Effects.HighPassFilter(ctx, {
|
||||
frequency: profile.eq.highpass,
|
||||
q: profile.eq.highpassQ,
|
||||
})
|
||||
const lowpass = new Effects.LowPassFilter(ctx, {
|
||||
frequency: profile.eq.lowpass,
|
||||
q: profile.eq.lowpassQ,
|
||||
})
|
||||
sound.addEffect(highpass)
|
||||
sound.addEffect(lowpass)
|
||||
|
||||
if (profile.eq.bandpass) {
|
||||
sound.addEffect(
|
||||
new Effects.BandPassFilter(ctx, {
|
||||
frequency: profile.eq.bandpass.frequency,
|
||||
q: profile.eq.bandpass.q,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (profile.presence) {
|
||||
sound.addEffect(new Effects.PeakingFilter(ctx, profile.presence))
|
||||
}
|
||||
|
||||
profile.distortions.forEach((amount) => {
|
||||
sound.addEffect(new Effects.Distortion(ctx, { amount }))
|
||||
})
|
||||
|
||||
sound.addEffect(new Effects.Compressor(ctx, profile.compressor))
|
||||
|
||||
if (profile.tremolos) {
|
||||
profile.tremolos.forEach((tremolo) => {
|
||||
sound.addEffect(new Effects.Tremolo(ctx, tremolo))
|
||||
})
|
||||
}
|
||||
|
||||
sound.setVolume(profile.gain)
|
||||
|
||||
const stopNoiseGenerators = createNoiseGenerators(ctx, sound.duration, profile, readability)
|
||||
|
||||
setCurrentPizzicatoSound(sound)
|
||||
try {
|
||||
await sound.play()
|
||||
} finally {
|
||||
setCurrentPizzicatoSound(null)
|
||||
stopNoiseGenerators.forEach((stop) => stop())
|
||||
sound.clearEffects()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to apply radio effect', err)
|
||||
await playWithoutEffects()
|
||||
}
|
||||
}
|
||||
|
||||
/** Kick off TTS generation immediately (parallel to whatever is playing). */
|
||||
const fetchSpeechAudio = (prepared: PreparedSpeech, options: SpeechOptions = {}): Promise<any | null> => {
|
||||
const abort = new AbortController()
|
||||
addPendingAbort(abort)
|
||||
return (async () => {
|
||||
try {
|
||||
const speed = options.speed ?? speechSpeed.value
|
||||
const usesNormalized = options.useNormalizedForTTS !== false
|
||||
const response = await api.post('/api/atc/say', {
|
||||
text: usesNormalized ? prepared.normalized : prepared.plain,
|
||||
// The normalized variant already went through the client-side
|
||||
// radiotelephony normalizer — the server must not normalize again.
|
||||
preNormalized: usesNormalized,
|
||||
level: signalStrength.value,
|
||||
voice: options.voice || 'alloy',
|
||||
speed,
|
||||
moduleId: 'pilot-monitoring',
|
||||
lessonId: currentState.value?.id || 'general',
|
||||
tag: options.tag || 'controller-reply',
|
||||
sessionId: engineSessionId.value || flags.value.session_id || undefined,
|
||||
}, { signal: abort.signal })
|
||||
if (abort.signal.aborted) return null // frequency changed while in flight
|
||||
return response
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'AbortError' || abort.signal.aborted) {
|
||||
pmLog.info('TTS cancelled (frequency change)')
|
||||
return null
|
||||
}
|
||||
pmLog.error('TTS FAILED', err)
|
||||
console.error('TTS failed:', err)
|
||||
return null
|
||||
} finally {
|
||||
deletePendingAbort(abort)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const speakPrepared = async (
|
||||
prepared: PreparedSpeech,
|
||||
options: SpeechOptions = {},
|
||||
audioPromise?: Promise<any | null>,
|
||||
) => {
|
||||
pmLog.info('TTS ▶', prepared.plain.slice(0, 100))
|
||||
const response = await (audioPromise ?? fetchSpeechAudio(prepared, options))
|
||||
if (!response) return
|
||||
|
||||
if (response.success && response.audio) {
|
||||
pmLog.debug('TTS ✓ provider=%s size=%dB', response.meta?.ttsProvider, response.audio.size)
|
||||
if (options.updateLastTransmission !== false) {
|
||||
setLastTransmission(options.lastTransmissionLabel || `ATC: ${prepared.plain}`)
|
||||
}
|
||||
await playAudioWithEffects(response.audio.base64, response.audio.mime)
|
||||
} else {
|
||||
pmLog.warn('TTS ✗ unexpected response', response)
|
||||
}
|
||||
}
|
||||
|
||||
const speakWithRadioEffects = (tpl: string, options: SpeechOptions = {}) => {
|
||||
const prepared = prepareSpeech(tpl)
|
||||
const delay = options.delayMs ?? 0
|
||||
// Start TTS generation NOW — it overlaps queued speech and the artificial
|
||||
// controller delay instead of adding to them, so the audible latency is
|
||||
// max(generation, delay) rather than the sum of everything in the queue.
|
||||
const audioPromise = fetchSpeechAudio(prepared, options)
|
||||
enqueueSpeech(async () => {
|
||||
const generation = getSpeechGeneration()
|
||||
if (delay > 0) {
|
||||
await wait(delay)
|
||||
}
|
||||
if (generation !== getSpeechGeneration()) return // stopped while waiting
|
||||
await speakPrepared(prepared, options, audioPromise)
|
||||
})
|
||||
}
|
||||
|
||||
const speakPlainText = (text: string, options: SpeechOptions = {}) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const speed = options.speed ?? speechSpeed.value
|
||||
const lessonId = options.lessonId || currentState.value?.id || 'general'
|
||||
|
||||
return enqueueSpeech(async () => {
|
||||
try {
|
||||
const response = await api.post('/api/atc/say', {
|
||||
text: trimmed,
|
||||
level: signalStrength.value,
|
||||
voice: options.voice || 'alloy',
|
||||
speed,
|
||||
moduleId: 'pilot-monitoring',
|
||||
lessonId,
|
||||
tag: options.tag || 'announcement',
|
||||
sessionId: engineSessionId.value || flags.value.session_id || undefined,
|
||||
})
|
||||
|
||||
if (response.success && response.audio) {
|
||||
if (options.updateLastTransmission !== false) {
|
||||
setLastTransmission(options.lastTransmissionLabel || trimmed)
|
||||
}
|
||||
await playAudioWithEffects(response.audio.base64, response.audio.mime)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('TTS failed:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const scheduleControllerSpeech = (tpl: string) => {
|
||||
const plain = renderATCMessage(tpl)
|
||||
speakWithRadioEffects(tpl, {
|
||||
delayMs: 800 + Math.random() * 2000,
|
||||
tag: 'controller-reply',
|
||||
updateLastTransmission: true,
|
||||
useNormalizedForTTS: true,
|
||||
lastTransmissionLabel: `ATC: ${plain}`,
|
||||
})
|
||||
}
|
||||
|
||||
const speakPilotReadback = (text: string) => {
|
||||
speakWithRadioEffects(text, {
|
||||
delayMs: 400,
|
||||
voice: 'verse',
|
||||
tag: 'pilot-readback',
|
||||
updateLastTransmission: false,
|
||||
useNormalizedForTTS: true,
|
||||
})
|
||||
}
|
||||
|
||||
const RADIO_CHECK_RE = /\b(radio|signal)\s*check\b|how do you read/i
|
||||
// Pilot asking ATC to repeat its last transmission.
|
||||
const SAY_AGAIN_RE = /\b(say again|come again|repeat( that| your last)?)\b|please\s+say\s+again|pls\s+say\s+again/i
|
||||
// Bare sign-off / pleasantry (no substantive content alongside).
|
||||
const FAREWELL_RE = /\b(good\s?bye|bye[\s-]?bye|tsch(ü|ue)ss|servus|good day|cheerio|auf\s?wieder|thank you|thanks|danke)\b/i
|
||||
|
||||
/** Spoken readability per signal-strength level (1–5). */
|
||||
const READABILITY_PHRASE: Record<number, string> = {
|
||||
5: 'read you 5, loud and clear',
|
||||
4: 'read you 4',
|
||||
3: 'read you 3, readable with difficulty',
|
||||
2: 'read you 2, say again',
|
||||
1: 'read you 1, unreadable',
|
||||
}
|
||||
|
||||
/** Whichever station owns the currently tuned frequency, if any. */
|
||||
const tunedStationEntry = () => {
|
||||
const active = normalizedFrequencyValue(frequencies.value.active)
|
||||
if (!active) return null
|
||||
return airportFrequencies.value.find(entry =>
|
||||
entry.frequency
|
||||
&& entry.frequency !== FREQUENCY_PLACEHOLDER
|
||||
&& normalizedFrequencyValue(entry.frequency) === active,
|
||||
) ?? null
|
||||
}
|
||||
|
||||
const answerRadioCheck = (transcript: string) => {
|
||||
const callsign = (vars as any).value?.callsign || flightContext.value.callsign || ''
|
||||
const entry = tunedStationEntry()
|
||||
|
||||
// ATIS is a broadcast, and an unknown frequency is unstaffed — no reply.
|
||||
if (!entry || entry.type === 'ATIS') {
|
||||
pmLog.info('RADIO CHECK — no station on', frequencies.value.active)
|
||||
setLastTransmission(`Pilot: ${transcript} — no reply on ${frequencies.value.active}`)
|
||||
appendLogEntry('system', `No reply on ${frequencies.value.active}`, currentState.value?.id ?? '', {
|
||||
radioCheck: true,
|
||||
frequency: frequencies.value.active,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const role = FREQ_ROLE_LABEL[entry.type] || entry.type
|
||||
const station = airportName.value ? `${airportName.value} ${role}` : role
|
||||
const readability = READABILITY_PHRASE[signalStrength.value] || `read you ${signalStrength.value}`
|
||||
const reply = callsign
|
||||
? `${callsign}, ${station}, ${readability}`
|
||||
: `Station calling, ${station}, ${readability}`
|
||||
|
||||
pmLog.info('RADIO CHECK', { station, freq: frequencies.value.active, readability: signalStrength.value })
|
||||
lastControllerSay.value = reply
|
||||
scheduleControllerSpeech(reply)
|
||||
appendLogEntry('atc', reply, currentState.value?.id ?? '', {
|
||||
radioCheck: true,
|
||||
frequency: frequencies.value.active,
|
||||
})
|
||||
}
|
||||
|
||||
const performRadioCheck = async () => {
|
||||
if (!flightContext.value.callsign) return
|
||||
|
||||
radioCheckLoading.value = true
|
||||
|
||||
const station = tunedStationEntry()
|
||||
const stationName = station && station.type !== 'ATIS'
|
||||
? (airportName.value ? `${airportName.value} ${FREQ_ROLE_LABEL[station.type] || station.type}` : FREQ_ROLE_LABEL[station.type] || station.type)
|
||||
: frequencies.value.active
|
||||
const message = `${stationName}, ${flightContext.value.callsign}, radio check`
|
||||
|
||||
try {
|
||||
await handlePilotTransmission(message, 'text')
|
||||
} catch (err) {
|
||||
console.error('Radio check failed:', err)
|
||||
} finally {
|
||||
radioCheckLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Readability / signal strength quick-select (top bar)
|
||||
const readabilityOptions = [
|
||||
{ value: 5, label: 'Excellent', sublabel: 'Readability 5', color: '#22c55e' },
|
||||
{ value: 4, label: 'Good', sublabel: 'Readability 4', color: '#22c55e' },
|
||||
{ value: 3, label: 'Fair', sublabel: 'Readability 3', color: '#eab308' },
|
||||
{ value: 2, label: 'Poor', sublabel: 'Readability 2', color: '#f97316' },
|
||||
{ value: 1, label: 'Weak', sublabel: 'Readability 1', color: '#ef4444' },
|
||||
]
|
||||
const onReadabilitySelect = (opt: { value: string | number }) => {
|
||||
signalStrength.value = Number(opt.value)
|
||||
}
|
||||
|
||||
return {
|
||||
signalStrength,
|
||||
speechSpeed,
|
||||
radioCheckLoading,
|
||||
radioEffectsEnabled,
|
||||
readbackEnabled,
|
||||
radioQuality,
|
||||
speechSpeedLabel,
|
||||
RADIO_CHECK_RE,
|
||||
SAY_AGAIN_RE,
|
||||
FAREWELL_RE,
|
||||
speakWithRadioEffects,
|
||||
speakPlainText,
|
||||
scheduleControllerSpeech,
|
||||
speakPilotReadback,
|
||||
answerRadioCheck,
|
||||
performRadioCheck,
|
||||
readabilityOptions,
|
||||
onReadabilitySelect,
|
||||
}
|
||||
}
|
||||
71
app/composables/useSpeechInterrupt.ts
Normal file
71
app/composables/useSpeechInterrupt.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Low-level speech interruption/queue primitives, split out from
|
||||
* useRadioSpeech so it has zero dependency on frequency/airport state.
|
||||
* useFrequencyPresets needs stopCurrentSpeech (tuning away cuts in-flight
|
||||
* ATC speech) while useRadioSpeech needs frequency state — instantiating
|
||||
* both from one composable would be circular, so this piece is
|
||||
* constructed first and threaded into both.
|
||||
*/
|
||||
export function useSpeechInterrupt() {
|
||||
let speechQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
// Tracks objects that must be torn down when frequency changes mid-speech.
|
||||
let _currentAudio: HTMLAudioElement | null = null
|
||||
let _currentPizzicatoSound: { stop: () => void } | null = null
|
||||
// All in-flight TTS requests (playback is serialized, but generation runs in
|
||||
// parallel so a reply's TTS doesn't wait for earlier speech to finish playing).
|
||||
const _pendingSpeechAborts = new Set<AbortController>()
|
||||
// Incremented on each stopCurrentSpeech() call so enqueued tasks can detect
|
||||
// they belong to a cancelled generation and exit early.
|
||||
let _speechGeneration = 0
|
||||
|
||||
const getSpeechGeneration = () => _speechGeneration
|
||||
|
||||
/** Immediately stop any in-flight TTS request and audio playback. */
|
||||
const stopCurrentSpeech = () => {
|
||||
_speechGeneration++
|
||||
for (const abort of _pendingSpeechAborts) {
|
||||
abort.abort()
|
||||
}
|
||||
_pendingSpeechAborts.clear()
|
||||
if (_currentPizzicatoSound) {
|
||||
try { _currentPizzicatoSound.stop() } catch (_) { /* ignore */ }
|
||||
_currentPizzicatoSound = null
|
||||
}
|
||||
if (_currentAudio) {
|
||||
_currentAudio.pause()
|
||||
_currentAudio.src = ''
|
||||
_currentAudio = null
|
||||
}
|
||||
// Drop queued tasks by resolving the queue immediately.
|
||||
speechQueue = Promise.resolve()
|
||||
}
|
||||
|
||||
const enqueueSpeech = (task: () => Promise<void>) => {
|
||||
const generation = _speechGeneration
|
||||
speechQueue = speechQueue
|
||||
.then(() => {
|
||||
if (generation !== _speechGeneration) return // cancelled — skip
|
||||
return task()
|
||||
})
|
||||
.catch(err => {
|
||||
if (err?.name !== 'AbortError') console.error('Speech queue error:', err)
|
||||
})
|
||||
return speechQueue
|
||||
}
|
||||
|
||||
const setCurrentAudio = (audio: HTMLAudioElement | null) => { _currentAudio = audio }
|
||||
const setCurrentPizzicatoSound = (sound: { stop: () => void } | null) => { _currentPizzicatoSound = sound }
|
||||
const addPendingAbort = (abort: AbortController) => { _pendingSpeechAborts.add(abort) }
|
||||
const deletePendingAbort = (abort: AbortController) => { _pendingSpeechAborts.delete(abort) }
|
||||
|
||||
return {
|
||||
stopCurrentSpeech,
|
||||
enqueueSpeech,
|
||||
getSpeechGeneration,
|
||||
setCurrentAudio,
|
||||
setCurrentPizzicatoSound,
|
||||
addPendingAbort,
|
||||
deletePendingAbort,
|
||||
}
|
||||
}
|
||||
@@ -1519,22 +1519,18 @@ import { normalizeRadioPhrase, DEFAULT_AIRLINE_TELEPHONY } from '../../shared/ut
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { usePmTheme } from '~/composables/usePmTheme'
|
||||
import { loadPizzicatoLite } from '../../shared/utils/pizzicatoLite'
|
||||
import type { PizzicatoLite } from '../../shared/utils/pizzicatoLite'
|
||||
import { createNoiseGenerators, getReadabilityProfile } from '../../shared/utils/radioEffects'
|
||||
import { normalizeManualFreq } from '../../shared/utils/frequency'
|
||||
import { normalizeSimFreq, normalizeBridgeTelemetry, telemetrySignature } from '../../shared/utils/bridgeTelemetry'
|
||||
import { encodeWav } from '../../shared/utils/wavEncoder'
|
||||
import { pmLog } from '../../shared/utils/pmLog'
|
||||
import {
|
||||
useFrequencyPresets,
|
||||
FREQUENCY_PLACEHOLDER,
|
||||
FREQ_ROLE_LABEL,
|
||||
normalizedFrequencyValue,
|
||||
type AirportFrequencyEntry,
|
||||
} from '~/composables/useFrequencyPresets'
|
||||
import { useAtisPlayback } from '~/composables/useAtisPlayback'
|
||||
import { usePttRecording } from '~/composables/usePttRecording'
|
||||
import { useSpeechInterrupt } from '~/composables/useSpeechInterrupt'
|
||||
import { useRadioSpeech } from '~/composables/useRadioSpeech'
|
||||
import type {
|
||||
CandidateTraceElimination,
|
||||
CandidateTraceEntry,
|
||||
@@ -2674,36 +2670,8 @@ watch(vatsimId, (id) => {
|
||||
})
|
||||
|
||||
// Computed Properties
|
||||
const radioQuality = computed(() => {
|
||||
const strength = signalStrength.value
|
||||
if (strength >= 5) return { color: 'success', text: 'EXCELLENT' }
|
||||
if (strength >= 4) return { color: 'success', text: 'GOOD' }
|
||||
if (strength >= 3) return { color: 'warning', text: 'FAIR' }
|
||||
if (strength >= 2) return { color: 'orange', text: 'POOR' }
|
||||
return { color: 'error', text: 'WEAK' }
|
||||
})
|
||||
|
||||
const speechSpeedLabel = computed(() => `${speechSpeed.value.toFixed(2)}x`)
|
||||
|
||||
const completedPilotSteps = computed(() => simulationTrace.value.filter(entry => entry.kind === 'pilot').length)
|
||||
|
||||
type PreparedSpeech = {
|
||||
template: string
|
||||
plain: string
|
||||
normalized: string
|
||||
}
|
||||
|
||||
type SpeechOptions = {
|
||||
voice?: string
|
||||
tag?: string
|
||||
updateLastTransmission?: boolean
|
||||
lastTransmissionLabel?: string
|
||||
delayMs?: number
|
||||
useNormalizedForTTS?: boolean
|
||||
speed?: number
|
||||
lessonId?: string
|
||||
}
|
||||
|
||||
type SimulationDecisionTemplate = {
|
||||
next: string
|
||||
controllerSayState?: string
|
||||
@@ -2817,39 +2785,8 @@ const simulationStepCount = simulationPilotSteps.length
|
||||
|
||||
const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||
|
||||
let audioContext: AudioContext | null = null
|
||||
let pizzicatoLite: PizzicatoLite | null = null
|
||||
let speechQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
// Tracks objects that must be torn down when frequency changes mid-speech.
|
||||
let _currentAudio: HTMLAudioElement | null = null
|
||||
let _currentPizzicatoSound: { stop: () => void } | null = null
|
||||
// All in-flight TTS requests (playback is serialized, but generation runs in
|
||||
// parallel so a reply's TTS doesn't wait for earlier speech to finish playing).
|
||||
const _pendingSpeechAborts = new Set<AbortController>()
|
||||
// Incremented on each stopCurrentSpeech() call so enqueued tasks can detect
|
||||
// they belong to a cancelled generation and exit early.
|
||||
let _speechGeneration = 0
|
||||
|
||||
/** Immediately stop any in-flight TTS request and audio playback. */
|
||||
const stopCurrentSpeech = () => {
|
||||
_speechGeneration++
|
||||
for (const abort of _pendingSpeechAborts) {
|
||||
abort.abort()
|
||||
}
|
||||
_pendingSpeechAborts.clear()
|
||||
if (_currentPizzicatoSound) {
|
||||
try { _currentPizzicatoSound.stop() } catch (_) { /* ignore */ }
|
||||
_currentPizzicatoSound = null
|
||||
}
|
||||
if (_currentAudio) {
|
||||
_currentAudio.pause()
|
||||
_currentAudio.src = ''
|
||||
_currentAudio = null
|
||||
}
|
||||
// Drop queued tasks by resolving the queue immediately.
|
||||
speechQueue = Promise.resolve()
|
||||
}
|
||||
const speechInterrupt = useSpeechInterrupt()
|
||||
const { stopCurrentSpeech } = speechInterrupt
|
||||
|
||||
const freq = useFrequencyPresets(engine, stopCurrentSpeech)
|
||||
const {
|
||||
@@ -2890,25 +2827,6 @@ const {
|
||||
prefetchAtisAudio,
|
||||
} = useAtisPlayback(engine, freq, currentScreen, signalStrength, setLastTransmission)
|
||||
|
||||
const enqueueSpeech = (task: () => Promise<void>) => {
|
||||
const generation = _speechGeneration
|
||||
speechQueue = speechQueue
|
||||
.then(() => {
|
||||
if (generation !== _speechGeneration) return // cancelled — skip
|
||||
return task()
|
||||
})
|
||||
.catch(err => {
|
||||
if (err?.name !== 'AbortError') console.error('Speech queue error:', err)
|
||||
})
|
||||
return speechQueue
|
||||
}
|
||||
|
||||
const prepareSpeech = (tpl: string): PreparedSpeech => {
|
||||
const plain = renderATCMessage(tpl)
|
||||
const normalized = normalizeATCText(tpl, { ...vars.value, ...flags.value })
|
||||
return { template: tpl, plain, normalized }
|
||||
}
|
||||
|
||||
const debugState = computed(() => {
|
||||
if (!currentState.value) return null
|
||||
const sayTpl = currentState.value.say_tpl
|
||||
@@ -2947,248 +2865,6 @@ const normalizeExpectedText = (text: string): string => {
|
||||
return normalizeATCText(text, { ...vars.value, ...flags.value })
|
||||
}
|
||||
|
||||
const ensureAudioContext = async (): Promise<AudioContext | null> => {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
if (!audioContext) {
|
||||
const Ctx = window.AudioContext || (window as any).webkitAudioContext
|
||||
if (!Ctx) return null
|
||||
audioContext = new Ctx()
|
||||
}
|
||||
|
||||
if (audioContext.state === 'suspended') {
|
||||
try {
|
||||
await audioContext.resume()
|
||||
} catch (err) {
|
||||
console.warn('AudioContext resume failed', err)
|
||||
}
|
||||
}
|
||||
|
||||
return audioContext
|
||||
}
|
||||
|
||||
const ensurePizzicato = async (ctx: AudioContext | null): Promise<PizzicatoLite | null> => {
|
||||
if (!ctx) return null
|
||||
if (!pizzicatoLite) {
|
||||
pizzicatoLite = await loadPizzicatoLite()
|
||||
}
|
||||
return pizzicatoLite
|
||||
}
|
||||
|
||||
|
||||
const playAudioWithEffects = async (base64: string, mime = 'audio/wav') => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const dataUrl = `data:${mime || 'audio/wav'};base64,${base64}`
|
||||
|
||||
const playWithoutEffects = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
const audio = new Audio(dataUrl)
|
||||
_currentAudio = audio
|
||||
audio.onended = () => { _currentAudio = null; resolve() }
|
||||
audio.onerror = () => { _currentAudio = null; resolve() }
|
||||
audio.play().catch(() => { _currentAudio = null; resolve() })
|
||||
})
|
||||
|
||||
if (!radioEffectsEnabled.value) {
|
||||
await playWithoutEffects()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const ctx = await ensureAudioContext()
|
||||
const pizzicato = await ensurePizzicato(ctx)
|
||||
if (!ctx || !pizzicato) throw new Error('Audio engine unavailable')
|
||||
|
||||
const sound = await pizzicato.createSoundFromBase64(ctx, base64)
|
||||
const readability = Math.max(1, Math.min(5, signalStrength.value))
|
||||
const profile = getReadabilityProfile(readability)
|
||||
|
||||
const { Effects } = pizzicato
|
||||
|
||||
const highpass = new Effects.HighPassFilter(ctx, {
|
||||
frequency: profile.eq.highpass,
|
||||
q: profile.eq.highpassQ
|
||||
})
|
||||
const lowpass = new Effects.LowPassFilter(ctx, {
|
||||
frequency: profile.eq.lowpass,
|
||||
q: profile.eq.lowpassQ
|
||||
})
|
||||
sound.addEffect(highpass)
|
||||
sound.addEffect(lowpass)
|
||||
|
||||
if (profile.eq.bandpass) {
|
||||
sound.addEffect(
|
||||
new Effects.BandPassFilter(ctx, {
|
||||
frequency: profile.eq.bandpass.frequency,
|
||||
q: profile.eq.bandpass.q
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (profile.presence) {
|
||||
sound.addEffect(new Effects.PeakingFilter(ctx, profile.presence))
|
||||
}
|
||||
|
||||
profile.distortions.forEach((amount) => {
|
||||
sound.addEffect(new Effects.Distortion(ctx, { amount }))
|
||||
})
|
||||
|
||||
sound.addEffect(new Effects.Compressor(ctx, profile.compressor))
|
||||
|
||||
if (profile.tremolos) {
|
||||
profile.tremolos.forEach((tremolo) => {
|
||||
sound.addEffect(new Effects.Tremolo(ctx, tremolo))
|
||||
})
|
||||
}
|
||||
|
||||
sound.setVolume(profile.gain)
|
||||
|
||||
const stopNoiseGenerators = createNoiseGenerators(ctx, sound.duration, profile, readability)
|
||||
|
||||
_currentPizzicatoSound = sound
|
||||
try {
|
||||
await sound.play()
|
||||
} finally {
|
||||
_currentPizzicatoSound = null
|
||||
stopNoiseGenerators.forEach((stop) => stop())
|
||||
sound.clearEffects()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to apply radio effect', err)
|
||||
await playWithoutEffects()
|
||||
}
|
||||
}
|
||||
|
||||
/** Kick off TTS generation immediately (parallel to whatever is playing). */
|
||||
const fetchSpeechAudio = (prepared: PreparedSpeech, options: SpeechOptions = {}): Promise<any | null> => {
|
||||
const abort = new AbortController()
|
||||
_pendingSpeechAborts.add(abort)
|
||||
return (async () => {
|
||||
try {
|
||||
const speed = options.speed ?? speechSpeed.value
|
||||
const usesNormalized = options.useNormalizedForTTS !== false
|
||||
const response = await api.post('/api/atc/say', {
|
||||
text: usesNormalized ? prepared.normalized : prepared.plain,
|
||||
// The normalized variant already went through the client-side
|
||||
// radiotelephony normalizer — the server must not normalize again.
|
||||
preNormalized: usesNormalized,
|
||||
level: signalStrength.value,
|
||||
voice: options.voice || 'alloy',
|
||||
speed,
|
||||
moduleId: 'pilot-monitoring',
|
||||
lessonId: currentState.value?.id || 'general',
|
||||
tag: options.tag || 'controller-reply',
|
||||
sessionId: engineSessionId.value || flags.value.session_id || undefined,
|
||||
}, { signal: abort.signal })
|
||||
if (abort.signal.aborted) return null // frequency changed while in flight
|
||||
return response
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'AbortError' || abort.signal.aborted) {
|
||||
pmLog.info('TTS cancelled (frequency change)')
|
||||
return null
|
||||
}
|
||||
pmLog.error('TTS FAILED', err)
|
||||
console.error('TTS failed:', err)
|
||||
return null
|
||||
} finally {
|
||||
_pendingSpeechAborts.delete(abort)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const speakPrepared = async (
|
||||
prepared: PreparedSpeech,
|
||||
options: SpeechOptions = {},
|
||||
audioPromise?: Promise<any | null>,
|
||||
) => {
|
||||
pmLog.info('TTS ▶', prepared.plain.slice(0, 100))
|
||||
const response = await (audioPromise ?? fetchSpeechAudio(prepared, options))
|
||||
if (!response) return
|
||||
|
||||
if (response.success && response.audio) {
|
||||
pmLog.debug('TTS ✓ provider=%s size=%dB', response.meta?.ttsProvider, response.audio.size)
|
||||
if (options.updateLastTransmission !== false) {
|
||||
setLastTransmission(options.lastTransmissionLabel || `ATC: ${prepared.plain}`)
|
||||
}
|
||||
await playAudioWithEffects(response.audio.base64, response.audio.mime)
|
||||
} else {
|
||||
pmLog.warn('TTS ✗ unexpected response', response)
|
||||
}
|
||||
}
|
||||
|
||||
const speakWithRadioEffects = (tpl: string, options: SpeechOptions = {}) => {
|
||||
const prepared = prepareSpeech(tpl)
|
||||
const delay = options.delayMs ?? 0
|
||||
// Start TTS generation NOW — it overlaps queued speech and the artificial
|
||||
// controller delay instead of adding to them, so the audible latency is
|
||||
// max(generation, delay) rather than the sum of everything in the queue.
|
||||
const audioPromise = fetchSpeechAudio(prepared, options)
|
||||
enqueueSpeech(async () => {
|
||||
const generation = _speechGeneration
|
||||
if (delay > 0) {
|
||||
await wait(delay)
|
||||
}
|
||||
if (generation !== _speechGeneration) return // stopped while waiting
|
||||
await speakPrepared(prepared, options, audioPromise)
|
||||
})
|
||||
}
|
||||
|
||||
const speakPlainText = (text: string, options: SpeechOptions = {}) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const speed = options.speed ?? speechSpeed.value
|
||||
const lessonId = options.lessonId || currentState.value?.id || 'general'
|
||||
|
||||
return enqueueSpeech(async () => {
|
||||
try {
|
||||
const response = await api.post('/api/atc/say', {
|
||||
text: trimmed,
|
||||
level: signalStrength.value,
|
||||
voice: options.voice || 'alloy',
|
||||
speed,
|
||||
moduleId: 'pilot-monitoring',
|
||||
lessonId,
|
||||
tag: options.tag || 'announcement',
|
||||
sessionId: engineSessionId.value || flags.value.session_id || undefined,
|
||||
})
|
||||
|
||||
if (response.success && response.audio) {
|
||||
if (options.updateLastTransmission !== false) {
|
||||
setLastTransmission(options.lastTransmissionLabel || trimmed)
|
||||
}
|
||||
await playAudioWithEffects(response.audio.base64, response.audio.mime)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('TTS failed:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const scheduleControllerSpeech = (tpl: string) => {
|
||||
const plain = renderATCMessage(tpl)
|
||||
speakWithRadioEffects(tpl, {
|
||||
delayMs: 800 + Math.random() * 2000,
|
||||
tag: 'controller-reply',
|
||||
updateLastTransmission: true,
|
||||
useNormalizedForTTS: true,
|
||||
lastTransmissionLabel: `ATC: ${plain}`
|
||||
})
|
||||
}
|
||||
|
||||
const speakPilotReadback = (text: string) => {
|
||||
speakWithRadioEffects(text, {
|
||||
delayMs: 400,
|
||||
voice: 'verse',
|
||||
tag: 'pilot-readback',
|
||||
updateLastTransmission: false,
|
||||
useNormalizedForTTS: true
|
||||
})
|
||||
}
|
||||
|
||||
// --- Silence auto-advance ----------------------------------------------------
|
||||
// Some pilot states let ATC continue on its own when the pilot stays quiet
|
||||
// (e.g. the takeoff roll in tower-v1: no report needed, Tower hands off after a
|
||||
@@ -3727,6 +3403,31 @@ const backToSetup = () => {
|
||||
frequencySources.value = { vatsim: false, openaip: false }
|
||||
}
|
||||
|
||||
// Constructed here (not earlier) because it needs handlePilotTransmission, and
|
||||
// before usePttRecording because that needs speakWithRadioEffects.
|
||||
const {
|
||||
speechSpeedLabel,
|
||||
RADIO_CHECK_RE,
|
||||
SAY_AGAIN_RE,
|
||||
FAREWELL_RE,
|
||||
speakWithRadioEffects,
|
||||
scheduleControllerSpeech,
|
||||
speakPilotReadback,
|
||||
answerRadioCheck,
|
||||
performRadioCheck,
|
||||
readabilityOptions,
|
||||
onReadabilitySelect,
|
||||
} = useRadioSpeech(engine, freq, speechInterrupt, {
|
||||
setLastTransmission,
|
||||
handlePilotTransmission,
|
||||
lastControllerSay,
|
||||
signalStrength,
|
||||
speechSpeed,
|
||||
radioCheckLoading,
|
||||
radioEffectsEnabled,
|
||||
readbackEnabled,
|
||||
})
|
||||
|
||||
const {
|
||||
isRecording,
|
||||
micPermission,
|
||||
@@ -3758,101 +3459,10 @@ const sendPilotText = async () => {
|
||||
await handlePilotTransmission(text, 'text')
|
||||
}
|
||||
|
||||
|
||||
const RADIO_CHECK_RE = /\b(radio|signal)\s*check\b|how do you read/i
|
||||
|
||||
// Pilot asking ATC to repeat its last transmission.
|
||||
const SAY_AGAIN_RE = /\b(say again|come again|repeat( that| your last)?)\b|please\s+say\s+again|pls\s+say\s+again/i
|
||||
// Bare sign-off / pleasantry (no substantive content alongside).
|
||||
const FAREWELL_RE = /\b(good\s?bye|bye[\s-]?bye|tsch(ü|ue)ss|servus|good day|cheerio|auf\s?wieder|thank you|thanks|danke)\b/i
|
||||
|
||||
// Monotonic counter so a newer pilot transmission supersedes an older one still
|
||||
// awaiting its backend response — only the latest result is applied (#16).
|
||||
let transmitGeneration = 0
|
||||
|
||||
/** Spoken readability per signal-strength level (1–5). */
|
||||
const READABILITY_PHRASE: Record<number, string> = {
|
||||
5: 'read you 5, loud and clear',
|
||||
4: 'read you 4',
|
||||
3: 'read you 3, readable with difficulty',
|
||||
2: 'read you 2, say again',
|
||||
1: 'read you 1, unreadable',
|
||||
}
|
||||
|
||||
/** Whichever station owns the currently tuned frequency, if any. */
|
||||
const tunedStationEntry = (): AirportFrequencyEntry | null => {
|
||||
const active = normalizedFrequencyValue(frequencies.value.active)
|
||||
if (!active) return null
|
||||
return airportFrequencies.value.find(entry =>
|
||||
entry.frequency
|
||||
&& entry.frequency !== FREQUENCY_PLACEHOLDER
|
||||
&& normalizedFrequencyValue(entry.frequency) === active,
|
||||
) ?? null
|
||||
}
|
||||
|
||||
const answerRadioCheck = (transcript: string) => {
|
||||
const callsign = (vars as any).value?.callsign || flightContext.value.callsign || ''
|
||||
const entry = tunedStationEntry()
|
||||
|
||||
// ATIS is a broadcast, and an unknown frequency is unstaffed — no reply.
|
||||
if (!entry || entry.type === 'ATIS') {
|
||||
pmLog.info('RADIO CHECK — no station on', frequencies.value.active)
|
||||
setLastTransmission(`Pilot: ${transcript} — no reply on ${frequencies.value.active}`)
|
||||
appendLogEntry('system', `No reply on ${frequencies.value.active}`, currentState.value?.id ?? '', {
|
||||
radioCheck: true,
|
||||
frequency: frequencies.value.active,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const role = FREQ_ROLE_LABEL[entry.type] || entry.type
|
||||
const station = airportName.value ? `${airportName.value} ${role}` : role
|
||||
const readability = READABILITY_PHRASE[signalStrength.value] || `read you ${signalStrength.value}`
|
||||
const reply = callsign
|
||||
? `${callsign}, ${station}, ${readability}`
|
||||
: `Station calling, ${station}, ${readability}`
|
||||
|
||||
pmLog.info('RADIO CHECK', { station, freq: frequencies.value.active, readability: signalStrength.value })
|
||||
lastControllerSay.value = reply
|
||||
scheduleControllerSpeech(reply)
|
||||
appendLogEntry('atc', reply, currentState.value?.id ?? '', {
|
||||
radioCheck: true,
|
||||
frequency: frequencies.value.active,
|
||||
})
|
||||
}
|
||||
|
||||
const performRadioCheck = async () => {
|
||||
if (!flightContext.value.callsign) return
|
||||
|
||||
radioCheckLoading.value = true
|
||||
|
||||
const station = tunedStationEntry()
|
||||
const stationName = station && station.type !== 'ATIS'
|
||||
? (airportName.value ? `${airportName.value} ${FREQ_ROLE_LABEL[station.type] || station.type}` : FREQ_ROLE_LABEL[station.type] || station.type)
|
||||
: frequencies.value.active
|
||||
const message = `${stationName}, ${flightContext.value.callsign}, radio check`
|
||||
|
||||
try {
|
||||
await handlePilotTransmission(message, 'text')
|
||||
} catch (err) {
|
||||
console.error('Radio check failed:', err)
|
||||
} finally {
|
||||
radioCheckLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Readability / signal strength quick-select (top bar)
|
||||
const readabilityOptions = [
|
||||
{ value: 5, label: 'Excellent', sublabel: 'Readability 5', color: '#22c55e' },
|
||||
{ value: 4, label: 'Good', sublabel: 'Readability 4', color: '#22c55e' },
|
||||
{ value: 3, label: 'Fair', sublabel: 'Readability 3', color: '#eab308' },
|
||||
{ value: 2, label: 'Poor', sublabel: 'Readability 2', color: '#f97316' },
|
||||
{ value: 1, label: 'Weak', sublabel: 'Readability 1', color: '#ef4444' },
|
||||
]
|
||||
const onReadabilitySelect = (opt: { value: string | number }) => {
|
||||
signalStrength.value = Number(opt.value)
|
||||
}
|
||||
|
||||
const formatTime = (date: Date): string => {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
|
||||
Reference in New Issue
Block a user