mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-11 20:15:32 +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 { useAuthStore } from '~/stores/auth'
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import { usePmTheme } from '~/composables/usePmTheme'
|
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 { normalizeManualFreq } from '../../shared/utils/frequency'
|
||||||
import { normalizeSimFreq, normalizeBridgeTelemetry, telemetrySignature } from '../../shared/utils/bridgeTelemetry'
|
import { normalizeSimFreq, normalizeBridgeTelemetry, telemetrySignature } from '../../shared/utils/bridgeTelemetry'
|
||||||
import { encodeWav } from '../../shared/utils/wavEncoder'
|
import { encodeWav } from '../../shared/utils/wavEncoder'
|
||||||
import { pmLog } from '../../shared/utils/pmLog'
|
import { pmLog } from '../../shared/utils/pmLog'
|
||||||
import {
|
import {
|
||||||
useFrequencyPresets,
|
useFrequencyPresets,
|
||||||
FREQUENCY_PLACEHOLDER,
|
|
||||||
FREQ_ROLE_LABEL,
|
|
||||||
normalizedFrequencyValue,
|
normalizedFrequencyValue,
|
||||||
type AirportFrequencyEntry,
|
|
||||||
} from '~/composables/useFrequencyPresets'
|
} from '~/composables/useFrequencyPresets'
|
||||||
import { useAtisPlayback } from '~/composables/useAtisPlayback'
|
import { useAtisPlayback } from '~/composables/useAtisPlayback'
|
||||||
import { usePttRecording } from '~/composables/usePttRecording'
|
import { usePttRecording } from '~/composables/usePttRecording'
|
||||||
|
import { useSpeechInterrupt } from '~/composables/useSpeechInterrupt'
|
||||||
|
import { useRadioSpeech } from '~/composables/useRadioSpeech'
|
||||||
import type {
|
import type {
|
||||||
CandidateTraceElimination,
|
CandidateTraceElimination,
|
||||||
CandidateTraceEntry,
|
CandidateTraceEntry,
|
||||||
@@ -2674,36 +2670,8 @@ watch(vatsimId, (id) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Computed Properties
|
// 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)
|
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 = {
|
type SimulationDecisionTemplate = {
|
||||||
next: string
|
next: string
|
||||||
controllerSayState?: string
|
controllerSayState?: string
|
||||||
@@ -2817,39 +2785,8 @@ const simulationStepCount = simulationPilotSteps.length
|
|||||||
|
|
||||||
const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
let audioContext: AudioContext | null = null
|
const speechInterrupt = useSpeechInterrupt()
|
||||||
let pizzicatoLite: PizzicatoLite | null = null
|
const { stopCurrentSpeech } = speechInterrupt
|
||||||
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 freq = useFrequencyPresets(engine, stopCurrentSpeech)
|
const freq = useFrequencyPresets(engine, stopCurrentSpeech)
|
||||||
const {
|
const {
|
||||||
@@ -2890,25 +2827,6 @@ const {
|
|||||||
prefetchAtisAudio,
|
prefetchAtisAudio,
|
||||||
} = useAtisPlayback(engine, freq, currentScreen, signalStrength, setLastTransmission)
|
} = 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(() => {
|
const debugState = computed(() => {
|
||||||
if (!currentState.value) return null
|
if (!currentState.value) return null
|
||||||
const sayTpl = currentState.value.say_tpl
|
const sayTpl = currentState.value.say_tpl
|
||||||
@@ -2947,248 +2865,6 @@ const normalizeExpectedText = (text: string): string => {
|
|||||||
return normalizeATCText(text, { ...vars.value, ...flags.value })
|
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 ----------------------------------------------------
|
// --- Silence auto-advance ----------------------------------------------------
|
||||||
// Some pilot states let ATC continue on its own when the pilot stays quiet
|
// 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
|
// (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 }
|
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 {
|
const {
|
||||||
isRecording,
|
isRecording,
|
||||||
micPermission,
|
micPermission,
|
||||||
@@ -3758,101 +3459,10 @@ const sendPilotText = async () => {
|
|||||||
await handlePilotTransmission(text, 'text')
|
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
|
// Monotonic counter so a newer pilot transmission supersedes an older one still
|
||||||
// awaiting its backend response — only the latest result is applied (#16).
|
// awaiting its backend response — only the latest result is applied (#16).
|
||||||
let transmitGeneration = 0
|
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 => {
|
const formatTime = (date: Date): string => {
|
||||||
return date.toLocaleTimeString('en-US', {
|
return date.toLocaleTimeString('en-US', {
|
||||||
hour12: false,
|
hour12: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user