mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 08:06:26 +08:00
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>
72 lines
2.6 KiB
TypeScript
72 lines
2.6 KiB
TypeScript
/**
|
|
* 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,
|
|
}
|
|
}
|