Files
OpenSquawk/app/composables/useRadioSpeech.ts
itsrubberduck 3fe431ea47 fix(live-atc): stop ATC from repeating/looping and hanging on silent frequencies
- Neutralize the local engine's autonomous auto-advance (evaluateAutoTransitions/
  evaluateSimpleAutoFlow) — the Python backend now drives state exclusively via
  moveToSilent; the old walker could self-answer pilot states and race the
  backend, producing loops.
- Dedupe applyBackendDecision's ATC log entry + TTS: moveToSilent no longer logs
  say_tpl for backend-driven auto-advanced states (suppressSay), and a
  lastAppliedSay guard drops a repeated decision from overlapping sources
  (transmit reply, telemetry tick, silence timeout).
- Cap consecutive silence-timeout re-fires on the same state at 2 instead of
  re-arming forever.
- Pause telemetry forwarding while a pilot transmission is in flight, and guard
  against overlapping telemetry POSTs.
- Add request timeouts to TTS (20s) and backend transmit/telemetry/timeout
  (30s)/createSession (60s) calls so a hung request can no longer freeze the
  whole session.

Verified live against the Python backend: a full clearance→taxi chain now logs
each ATC line exactly once instead of 2-3x.
2026-07-16 23:47:52 +02:00

471 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
/**
* Re-checked inside the queued task, immediately before playback. Enqueue time
* and play time can be seconds apart, so a caller whose right to the frequency
* can lapse in between (today: useAiTraffic's gating chain) passes this and the
* task drops itself instead of talking over whoever now owns it.
*/
gate?: () => boolean
/** Called when `gate` returned false — the caller can re-queue its event. */
onGateClosed?: () => void
/**
* Called once the task has committed to this transmission and run it. The
* counterpart to `onGateClosed`: exactly one of the two fires, so a caller can
* apply state changes only for what was actually said.
*/
onSpoken?: () => void
}
export interface RadioSpeechDeps {
setLastTransmission: (text: string) => void
handlePilotTransmission: (message: string, source: 'text' | 'ptt') => Promise<void>
lastControllerSay: Ref<string | null>
/**
* Stamped by scheduleControllerSpeech — opens the readback window that keeps
* simulated traffic silent (useAiTraffic). Only the user-facing ATC reply goes
* through that function, so traffic never opens a window against itself.
*/
lastControllerSpeechAtMs: Ref<number | 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, lastControllerSpeechAtMs,
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()
}
}
/** A hung /api/atc/say request would otherwise block the whole speech queue
* forever (enqueueSpeech serializes playback on one promise chain) — the
* session would read as "frozen" with a dead frequency. */
const TTS_FETCH_TIMEOUT_MS = 20_000
/** 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)
const watchdog = setTimeout(() => abort.abort(), TTS_FETCH_TIMEOUT_MS)
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 or timeout)')
return null
}
pmLog.error('TTS FAILED', err)
console.error('TTS failed:', err)
return null
} finally {
clearTimeout(watchdog)
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
if (options.gate && !options.gate()) {
options.onGateClosed?.()
return // the frequency stopped being ours while we sat in the queue
}
options.onSpoken?.()
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)
// Opens the readback window: from here until the pilot answers (and for at
// least readbackProtectionMs), background traffic stays off the frequency.
lastControllerSpeechAtMs.value = Date.now()
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 (15). */
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,
}
}