fix(flightlab): prevent overlapping takeoff voices on rapid phase changes

This commit is contained in:
itsrubberduck
2026-02-15 18:05:11 +01:00
parent bb73b43a89
commit 36f5a72c66
2 changed files with 46 additions and 10 deletions

View File

@@ -371,6 +371,7 @@ const sidebarOpen = ref(true)
// --- Direct Bridge Telemetry Polling (solo mode, no WS session) ---
let telemetryPollInterval: ReturnType<typeof setInterval> | null = null
let initialSpeechTimeout: ReturnType<typeof setTimeout> | 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()

View File

@@ -11,6 +11,7 @@ export function useFlightLabAudio() {
const masterGain = ref<GainNode | null>(null)
const soundBuffers = ref<Map<string, AudioBuffer>>(new Map())
let speechQueue: Promise<void> = 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<void> {
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<void> {
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) {