feat(live-atc): route TTS through local bridge with cloud fallback

This commit is contained in:
itsrubberduck
2026-07-22 20:32:04 +02:00
parent 714366019a
commit af59c2a1cf
2 changed files with 40 additions and 4 deletions

View File

@@ -0,0 +1,19 @@
import { describe, it, expect, vi } from 'vitest'
import { postWithLocalFallback } from './useLocalSpeechBridge'
describe('TTS local routing', () => {
it('uses local audio response when available', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
json: async () => ({ success: true, audio: { base64: 'AAA', mime: 'audio/wav', size: 3, ext: 'wav' } }),
})) as any)
const cloud = vi.fn()
const response = await postWithLocalFallback(
'http://127.0.0.1:8765/api/atc/say',
{ text: 'x', preNormalized: true },
cloud as any,
)
expect(response.audio.base64).toBe('AAA')
expect(cloud).not.toHaveBeenCalled()
})
})

View File

@@ -7,6 +7,7 @@ import { createNoiseGenerators, createSquelchTransients, getReadabilityProfile,
import { controllerPersonaFor, transmissionSpeed } from '../../shared/utils/voicePool'
import { pmLog } from '../../shared/utils/pmLog'
import { useSpeechInterrupt } from '~/composables/useSpeechInterrupt'
import { postWithLocalFallback, useLocalSpeechBridge } from '~/composables/useLocalSpeechBridge'
import {
FREQUENCY_PLACEHOLDER,
FREQ_ROLE_LABEL,
@@ -82,6 +83,7 @@ export function useRadioSpeech(
signalStrength, speechSpeed, radioCheckLoading, radioEffectsEnabled, readbackEnabled,
} = deps
const api = useApi()
const { localUrl } = useLocalSpeechBridge()
let audioContext: AudioContext | null = null
let pizzicatoLite: PizzicatoLite | null = null
@@ -256,7 +258,7 @@ export function useRadioSpeech(
const persona = options.voice ? null : activeControllerPersona()
const speed = options.speed ?? (persona ? controllerSpeed(persona.baseSpeed) : speechSpeed.value)
const usesNormalized = options.useNormalizedForTTS !== false
const response = await api.post('/api/atc/say', {
const body = {
text: usesNormalized ? prepared.normalized : prepared.plain,
// The normalized variant already went through the client-side
// radiotelephony normalizer — the server must not normalize again.
@@ -268,7 +270,12 @@ export function useRadioSpeech(
lessonId: currentState.value?.id || 'general',
tag: options.tag || 'controller-reply',
sessionId: engineSessionId.value || flags.value.session_id || undefined,
}, { signal: abort.signal })
}
const response = await postWithLocalFallback(
localUrl('/api/atc/say'),
body,
() => api.post('/api/atc/say', body, { signal: abort.signal }),
)
if (abort.signal.aborted) return null // frequency changed while in flight
return response
} catch (err: any) {
@@ -340,7 +347,7 @@ export function useRadioSpeech(
try {
const persona = options.voice ? null : activeControllerPersona()
const speed = options.speed ?? (persona ? controllerSpeed(persona.baseSpeed) : speechSpeed.value)
const response = await api.post('/api/atc/say', {
const cloudBody = {
text: trimmed,
level: signalStrength.value,
voice: options.voice || persona!.voice,
@@ -349,7 +356,17 @@ export function useRadioSpeech(
lessonId,
tag: options.tag || 'announcement',
sessionId: engineSessionId.value || flags.value.session_id || undefined,
})
}
const localBody = {
...cloudBody,
text: normalizeATCText(trimmed, { ...vars.value, ...flags.value }),
preNormalized: true,
}
const response = await postWithLocalFallback(
localUrl('/api/atc/say'),
localBody,
() => api.post('/api/atc/say', cloudBody),
)
if (response.success && response.audio) {
if (options.updateLastTransmission !== false) {