From db984c22bb6641517166c85c31b7e1ad194fc8ed Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Wed, 22 Jul 2026 20:30:24 +0200 Subject: [PATCH] feat(live-atc): route STT through local bridge with cloud fallback --- app/composables/useLocalSpeechBridge.ts | 26 ++++++++++++++++++ .../usePttRecording.localRouting.test.ts | 27 +++++++++++++++++++ app/composables/usePttRecording.ts | 20 +++++++++++--- 3 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 app/composables/usePttRecording.localRouting.test.ts diff --git a/app/composables/useLocalSpeechBridge.ts b/app/composables/useLocalSpeechBridge.ts index bd307f4..6a89ba4 100644 --- a/app/composables/useLocalSpeechBridge.ts +++ b/app/composables/useLocalSpeechBridge.ts @@ -3,6 +3,32 @@ import { ref } from 'vue' const PORTS = [8765, 8766, 8767, 8768, 8769, 8770] const HEALTH_TIMEOUT_MS = 1200 const RECHECK_MS = 15_000 +const LOCAL_TIMEOUT_MS = 8000 + +export async function postWithLocalFallback( + localUrl: string | null, + body: any, + cloudPost: () => Promise, +): Promise { + if (localUrl) { + const ctrl = new AbortController() + const timeout = setTimeout(() => ctrl.abort(), LOCAL_TIMEOUT_MS) + try { + const response = await fetch(localUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: ctrl.signal, + }) + if (response.ok) return await response.json() + } catch { + // Local Bridge is unavailable: continue with the existing cloud path. + } finally { + clearTimeout(timeout) + } + } + return cloudPost() +} export async function probeLocalSpeech(ports: number[] = PORTS): Promise { for (const port of ports) { diff --git a/app/composables/usePttRecording.localRouting.test.ts b/app/composables/usePttRecording.localRouting.test.ts new file mode 100644 index 0000000..bdb26b6 --- /dev/null +++ b/app/composables/usePttRecording.localRouting.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, vi } from 'vitest' +import { postWithLocalFallback } from './useLocalSpeechBridge' + +describe('postWithLocalFallback', () => { + it('uses local when it returns 2xx', async () => { + const local = vi.fn(async () => ({ ok: true, json: async () => ({ success: true, transcription: 'roger' }) })) + vi.stubGlobal('fetch', local as any) + const cloud = vi.fn() + const response = await postWithLocalFallback('http://127.0.0.1:8765/api/atc/ptt', { a: 1 }, cloud as any) + expect(response.transcription).toBe('roger') + expect(cloud).not.toHaveBeenCalled() + }) + + it('falls back to cloud when local throws', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('refused') }) as any) + const cloud = vi.fn(async () => ({ success: true, transcription: 'cloud' })) + const response = await postWithLocalFallback('http://127.0.0.1:8765/api/atc/ptt', { a: 1 }, cloud as any) + expect(response.transcription).toBe('cloud') + expect(cloud).toHaveBeenCalledOnce() + }) + + it('falls back to cloud when localUrl is null', async () => { + const cloud = vi.fn(async () => ({ success: true, transcription: 'cloud' })) + const response = await postWithLocalFallback(null, { a: 1 }, cloud as any) + expect(response.transcription).toBe('cloud') + }) +}) diff --git a/app/composables/usePttRecording.ts b/app/composables/usePttRecording.ts index 1a23032..a7dbca0 100644 --- a/app/composables/usePttRecording.ts +++ b/app/composables/usePttRecording.ts @@ -3,6 +3,7 @@ import { useApi } from '~/composables/useApi' import useCommunicationsEngine from '../../shared/utils/communicationsEngine' import { encodeWav } from '../../shared/utils/wavEncoder' import { pmLog } from '../../shared/utils/pmLog' +import { postWithLocalFallback, useLocalSpeechBridge } from './useLocalSpeechBridge' export interface PttRecordingDeps { stopCurrentSpeech: () => void @@ -24,6 +25,7 @@ export function usePttRecording( ) { const { currentState, variables: vars } = engine const api = useApi() + const { localUrl } = useLocalSpeechBridge() const { stopCurrentSpeech, speakWithRadioEffects, radioEffectsEnabled, inputMode, backendSessionId, backendExpectedPhrase, setLastTransmission, handlePilotTransmission, @@ -278,13 +280,18 @@ export function usePttRecording( const base64Audio = arrayBufferToBase64(arrayBuffer) if (isIntercom) { - const result = await api.post('/api/atc/ptt', { + const payload = { audio: base64Audio, moduleId: 'pilot-monitoring-intercom', lessonId: 'intercom', format, sessionId: backendSessionId.value || undefined, - }) + } + const result = await postWithLocalFallback( + localUrl('/api/atc/ptt'), + payload, + () => api.post('/api/atc/ptt', payload), + ) if (result.success) { pmLog.info('PTT ✓ INTERCOM transcription:', result.transcription) @@ -300,14 +307,19 @@ export function usePttRecording( } } } else { - const result = await api.post('/api/atc/ptt', { + const payload = { audio: base64Audio, moduleId: 'pilot-monitoring', lessonId: currentState.value?.id || 'general', format, sessionId: backendSessionId.value || undefined, expected: buildSttExpected(), - }) + } + const result = await postWithLocalFallback( + localUrl('/api/atc/ptt'), + payload, + () => api.post('/api/atc/ptt', payload), + ) if (result.success) { pmLog.info('PTT ✓ RADIO transcription:', result.transcription)