From 36f5a72c667d7735a605e915b1b2a8066b7c391e Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Sun, 15 Feb 2026 18:05:11 +0100 Subject: [PATCH] fix(flightlab): prevent overlapping takeoff voices on rapid phase changes --- app/pages/flightlab/takeoff.vue | 13 +++++- .../flightlab/useFlightLabAudio.ts | 43 +++++++++++++++---- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/app/pages/flightlab/takeoff.vue b/app/pages/flightlab/takeoff.vue index ebbf536..fe28895 100644 --- a/app/pages/flightlab/takeoff.vue +++ b/app/pages/flightlab/takeoff.vue @@ -371,6 +371,7 @@ const sidebarOpen = ref(true) // --- Direct Bridge Telemetry Polling (solo mode, no WS session) --- let telemetryPollInterval: ReturnType | null = null +let initialSpeechTimeout: ReturnType | null = null function startTelemetryPolling() { stopTelemetryPolling() @@ -613,6 +614,11 @@ engine.setOnHelpMessage(async (text: string) => { // Watch phase changes to trigger TTS + sounds watch(() => engine.currentPhaseId.value, async (newId, oldId) => { if (!newId || newId === oldId) return + if (initialSpeechTimeout) { + clearTimeout(initialSpeechTimeout) + initialSpeechTimeout = null + } + audio.skipSpeech() const phase = engine.currentPhase.value if (!phase) return audio.handlePhaseSounds(phase.sounds ?? []) @@ -627,13 +633,18 @@ onMounted(async () => { // Speak initial welcome const phase = engine.currentPhase.value if (phase?.atcMessage) { - setTimeout(() => { + initialSpeechTimeout = setTimeout(() => { audio.speakAtcMessage(phase.atcMessage, { speed: 0.9, readability: 5 }) + initialSpeechTimeout = null }, 500) } }) onBeforeUnmount(() => { + if (initialSpeechTimeout) { + clearTimeout(initialSpeechTimeout) + initialSpeechTimeout = null + } stopTelemetryPolling() engine.cleanup() audio.dispose() diff --git a/shared/composables/flightlab/useFlightLabAudio.ts b/shared/composables/flightlab/useFlightLabAudio.ts index 129b771..c67eade 100644 --- a/shared/composables/flightlab/useFlightLabAudio.ts +++ b/shared/composables/flightlab/useFlightLabAudio.ts @@ -11,6 +11,7 @@ export function useFlightLabAudio() { const masterGain = ref(null) const soundBuffers = ref>(new Map()) let speechQueue: Promise = Promise.resolve() + let speechQueueVersion = 0 let pizzicato: PizzicatoLiteType | null = null let currentSpeechReject: (() => void) | null = null @@ -124,8 +125,13 @@ export function useFlightLabAudio() { } async function speakAtcMessage(text: string, options?: { speed?: number; readability?: number }): Promise { + const callVersion = speechQueueVersion return new Promise((resolve) => { speechQueue = speechQueue.then(async () => { + if (callVersion !== speechQueueVersion) { + resolve() + return + } isSpeaking.value = true try { // Call the existing TTS API @@ -139,6 +145,8 @@ export function useFlightLabAudio() { }, }) + if (callVersion !== speechQueueVersion) return + if (res.success && res.audio?.base64) { // Cache for replay lastSpokenAudio.value = { @@ -152,11 +160,15 @@ export function useFlightLabAudio() { } catch (e) { console.error('[FlightLabAudio] TTS error:', e) } finally { - isSpeaking.value = false + if (callVersion === speechQueueVersion) { + isSpeaking.value = false + } resolve() } }).catch(() => { - isSpeaking.value = false + if (callVersion === speechQueueVersion) { + isSpeaking.value = false + } resolve() }) }) @@ -208,9 +220,7 @@ export function useFlightLabAudio() { currentNoiseStoppers = [] } - /** Skip currently playing TTS speech immediately */ - function skipSpeech() { - if (!isSpeaking.value) return + function stopCurrentSpeechPlayback() { // Stop the pizzicato sound if (currentPizzicatoSound) { try { currentPizzicatoSound.stop() } catch {} @@ -219,27 +229,42 @@ export function useFlightLabAudio() { // Stop noise generators currentNoiseStoppers.forEach((fn) => { try { fn() } catch {} }) currentNoiseStoppers = [] - isSpeaking.value = false + } + + /** Skip currently playing TTS speech and drop queued speech jobs */ + function skipSpeech() { + speechQueueVersion += 1 + stopCurrentSpeechPlayback() // Reset the speech queue so next speech can start fresh speechQueue = Promise.resolve() + isSpeaking.value = false } async function replayLastMessage(): Promise { if (!lastSpokenAudio.value || isSpeaking.value) return const { base64, mime, readability } = lastSpokenAudio.value + const callVersion = speechQueueVersion return new Promise((resolve) => { speechQueue = speechQueue.then(async () => { + if (callVersion !== speechQueueVersion) { + resolve() + return + } isSpeaking.value = true try { await playWithRadioEffects(base64, mime, readability) } catch (e) { console.error('[FlightLabAudio] Replay error:', e) } finally { - isSpeaking.value = false + if (callVersion === speechQueueVersion) { + isSpeaking.value = false + } resolve() } }).catch(() => { - isSpeaking.value = false + if (callVersion === speechQueueVersion) { + isSpeaking.value = false + } resolve() }) }) @@ -250,10 +275,10 @@ export function useFlightLabAudio() { } function stopAllSounds() { + skipSpeech() for (const [id] of activeSounds.value) { stopAmbientSound(id) } - isSpeaking.value = false } function setMasterVolume(vol: number) {