From efd1a7f1d8aae06ab08b569e31d5073e733f11b2 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Fri, 10 Jul 2026 09:40:46 +0200 Subject: [PATCH] refactor(live-atc): extract PTT/mic recording and pre-rec buffer to usePttRecording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves mic access, MediaRecorder handling, the pre-recording rolling PCM buffer, and Whisper transmission processing out of live-atc.vue. Keeps the internal AudioContext (prerecCtx) properly encapsulated — the two external call sites that used to reach into it directly (the lead-in-duration watcher and the SimBridge remote-PTT handler) now go through small exposed functions instead. Co-Authored-By: Claude Sonnet 5 --- app/composables/usePttRecording.ts | 445 +++++++++++++++++++++++++++++ app/pages/live-atc.vue | 419 ++------------------------- 2 files changed, 470 insertions(+), 394 deletions(-) create mode 100644 app/composables/usePttRecording.ts diff --git a/app/composables/usePttRecording.ts b/app/composables/usePttRecording.ts new file mode 100644 index 0000000..1a23032 --- /dev/null +++ b/app/composables/usePttRecording.ts @@ -0,0 +1,445 @@ +import { ref, type Ref } from 'vue' +import { useApi } from '~/composables/useApi' +import useCommunicationsEngine from '../../shared/utils/communicationsEngine' +import { encodeWav } from '../../shared/utils/wavEncoder' +import { pmLog } from '../../shared/utils/pmLog' + +export interface PttRecordingDeps { + stopCurrentSpeech: () => void + speakWithRadioEffects: (tpl: string, options?: Record) => void + radioEffectsEnabled: Ref + inputMode: Ref<'voice' | 'text'> + backendSessionId: Ref + backendExpectedPhrase: Ref + setLastTransmission: (text: string) => void + handlePilotTransmission: (message: string, source: 'text' | 'ptt') => Promise +} + +/** Maximum PTT hold time in ms. Auto-stops and submits the recording when exceeded. */ +export const PTT_MAX_DURATION_MS = 30_000 // 30 s — long enough for any realistic transmission + +export function usePttRecording( + engine: ReturnType, + deps: PttRecordingDeps, +) { + const { currentState, variables: vars } = engine + const api = useApi() + const { + stopCurrentSpeech, speakWithRadioEffects, radioEffectsEnabled, inputMode, + backendSessionId, backendExpectedPhrase, setLastTransmission, handlePilotTransmission, + } = deps + + const isRecording = ref(false) + const micPermission = ref(false) + const prerecEnabled = ref(true) + const prerecSeconds = ref(1.0) + + const mediaRecorder = ref(null) + const audioChunks = ref([]) + let pttMaxDurationTimer: ReturnType | null = null + + const requestMicAccess = async () => { + try { + await navigator.mediaDevices.getUserMedia({ audio: true }) + micPermission.value = true + } catch (err) { + console.error('Microphone permission denied:', err) + micPermission.value = false + } + } + + // --------------------------------------------------------------------------- + // Pre-recording: continuously capture mic into a PCM ring buffer so the start + // of a PTT transmission isn't clipped. On PTT release we prepend the rolling + // buffer to the actively-captured frames and ship as WAV. + // --------------------------------------------------------------------------- + let prerecCtx: AudioContext | null = null + let prerecStream: MediaStream | null = null + let prerecSource: MediaStreamAudioSourceNode | null = null + let prerecNode: ScriptProcessorNode | null = null + let prerecSampleRate = 16000 + let prerecRing: Float32Array | null = null + let prerecRingWrite = 0 + let prerecRingFilled = false + let prerecLiveChunks: Float32Array[] = [] + let prerecLiveCapture = false + let prerecLiveIntercom = false + let prerecStarting = false + let prerecLivePrebuffer: Float32Array = new Float32Array(0) + + const snapshotPrerecPCM = (): Float32Array => { + if (!prerecRing) return new Float32Array(0) + const rb = prerecRing + if (!prerecRingFilled) return rb.slice(0, prerecRingWrite) + const out = new Float32Array(rb.length) + const tail = rb.length - prerecRingWrite + out.set(rb.subarray(prerecRingWrite), 0) + out.set(rb.subarray(0, prerecRingWrite), tail) + return out + } + + const stopPrerecCapture = () => { + prerecLiveCapture = false + prerecLiveChunks = [] + try { prerecNode?.disconnect() } catch {} + try { prerecSource?.disconnect() } catch {} + if (prerecCtx) { + try { void prerecCtx.close() } catch {} + } + if (prerecStream) { + prerecStream.getTracks().forEach(t => t.stop()) + } + prerecCtx = null + prerecStream = null + prerecSource = null + prerecNode = null + prerecRing = null + prerecRingWrite = 0 + prerecRingFilled = false + } + + const startPrerecCapture = async () => { + if (typeof window === 'undefined') return + if (prerecCtx || prerecStarting) return + if (!prerecEnabled.value) return + if (inputMode.value !== 'voice') return + if (!micPermission.value) return + + prerecStarting = true + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }) + const AC = window.AudioContext || (window as any).webkitAudioContext + if (!AC) { + stream.getTracks().forEach(t => t.stop()) + return + } + const ctx = new AC() + prerecCtx = ctx + prerecStream = stream + prerecSampleRate = ctx.sampleRate + prerecSource = ctx.createMediaStreamSource(stream) + const node = ctx.createScriptProcessor(2048, 1, 1) + prerecNode = node + const ringSize = Math.max(1, Math.ceil(prerecSampleRate * Math.max(0.2, prerecSeconds.value))) + prerecRing = new Float32Array(ringSize) + prerecRingWrite = 0 + prerecRingFilled = false + prerecLiveChunks = [] + prerecLiveCapture = false + + node.onaudioprocess = (e) => { + const input = e.inputBuffer.getChannelData(0) + const rb = prerecRing + if (!rb) return + const len = input.length + for (let i = 0; i < len; i++) { + rb[prerecRingWrite++] = input[i] + if (prerecRingWrite >= rb.length) { + prerecRingWrite = 0 + prerecRingFilled = true + } + } + if (prerecLiveCapture) { + prerecLiveChunks.push(new Float32Array(input)) + } + } + + prerecSource.connect(node) + // ScriptProcessor only emits events while connected to destination. + // Mute via a zero-gain node so we don't echo mic to speakers. + const muteGain = ctx.createGain() + muteGain.gain.value = 0 + node.connect(muteGain) + muteGain.connect(ctx.destination) + } catch (err) { + console.warn('[PM] prerec capture failed', err) + stopPrerecCapture() + } finally { + prerecStarting = false + } + } + + const restartPrerecCapture = async () => { + stopPrerecCapture() + await startPrerecCapture() + } + + /** Restart the pre-recording capture only if it's currently active — used + * when the lead-in duration setting changes so a new ring buffer size takes + * effect immediately, without starting capture when it wasn't running. */ + const restartPrerecCaptureIfActive = () => { + if (prerecCtx) { + void restartPrerecCapture() + } + } + + /** A backgrounded tab can suspend the prerec AudioContext; resume it so the + * ring buffer + live capture are running before a remote (SimBridge) PTT + * edge starts a recording. */ + const resumePrerecIfSuspended = async () => { + if (prerecCtx && prerecCtx.state === 'suspended') { + try { await prerecCtx.resume() } catch {} + } + } + + /** Clear any pending PTT auto-stop timer. */ + const clearPttTimer = () => { + if (pttMaxDurationTimer !== null) { + clearTimeout(pttMaxDurationTimer) + pttMaxDurationTimer = null + } + } + + const playPTTBeep = (start: boolean) => { + if (!radioEffectsEnabled.value) return + + try { + const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)() + const oscillator = audioContext.createOscillator() + const gainNode = audioContext.createGain() + + oscillator.connect(gainNode) + gainNode.connect(audioContext.destination) + + oscillator.frequency.setValueAtTime(start ? 800 : 600, audioContext.currentTime) + gainNode.gain.setValueAtTime(0.1, audioContext.currentTime) + gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1) + + oscillator.start() + oscillator.stop(audioContext.currentTime + 0.1) + } catch (err) { + // Audio context may not be available + } + } + + /** Start the PTT auto-stop safety timer. Fires stopRecording() if the user + * holds PTT longer than PTT_MAX_DURATION_MS. Without this, very long + * recordings produce blobs too large to base64-encode synchronously. */ + const startPttTimer = () => { + clearPttTimer() + pttMaxDurationTimer = setTimeout(() => { + pmLog.warn(`PTT auto-stop: exceeded ${PTT_MAX_DURATION_MS / 1000}s limit`) + stopRecording() + }, PTT_MAX_DURATION_MS) + } + + /** + * Convert an ArrayBuffer to a base64 string without blowing the call stack. + * + * The naive approach — btoa(String.fromCharCode(...new Uint8Array(buf))) — + * spreads every byte as a separate function argument. For recordings longer + * than a few seconds the argument count exceeds the JS engine limit (~65 k – + * 131 k args) and throws RangeError: Maximum call stack size exceeded, + * silently aborting the PTT request before it is ever sent. + * + * Processing in 8 KB chunks keeps argument count well within safe limits + * regardless of recording length. + */ + function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + const chunkSize = 8192 + let binary = '' + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length))) + } + return btoa(binary) + } + + // Build the Whisper-prompt seed for the current state: the expected pilot + // phrase plus the active variable values (callsign, SID, squawk, runway, + // frequencies…). The server expands these to spoken ICAO form and appends them + // to the bias prompt so Whisper is steered toward exactly what's expected. + function buildSttExpected(): { phrase?: string; tokens: string[] } { + const phrase = backendExpectedPhrase.value?.trim() || undefined + const dict = ((vars as any).value ?? {}) as Record + const tokens: string[] = [] + for (const val of Object.values(dict)) { + if (typeof val === 'number') { tokens.push(String(val)); continue } + if (typeof val === 'string') { + const t = val.trim() + if (t && t.length <= 24) tokens.push(t) + } + } + return { phrase, tokens } + } + + const processTransmission = async (audioBlob: Blob, isIntercom: boolean, format: 'wav' | 'webm' = 'webm') => { + const channel = isIntercom ? 'INTERCOM' : 'RADIO' + pmLog.info(`PTT ▶ ${channel} blob=${(audioBlob.size / 1024).toFixed(1)}KB fmt=${format} session=${backendSessionId.value?.slice(0, 8) ?? 'none'}`) + try { + const arrayBuffer = await audioBlob.arrayBuffer() + const base64Audio = arrayBufferToBase64(arrayBuffer) + + if (isIntercom) { + const result = await api.post('/api/atc/ptt', { + audio: base64Audio, + moduleId: 'pilot-monitoring-intercom', + lessonId: 'intercom', + format, + sessionId: backendSessionId.value || undefined, + }) + + if (result.success) { + pmLog.info('PTT ✓ INTERCOM transcription:', result.transcription) + setLastTransmission(`INTERCOM: ${result.transcription}`) + const transcription = result.transcription.toLowerCase() + if (transcription.includes('checklist') || transcription.includes('check list')) { + speakWithRadioEffects('Checklist functionality available in advanced mode', { + delayMs: 600, + updateLastTransmission: false, + tag: 'system-info', + useNormalizedForTTS: true, + }) + } + } + } else { + const result = await api.post('/api/atc/ptt', { + audio: base64Audio, + moduleId: 'pilot-monitoring', + lessonId: currentState.value?.id || 'general', + format, + sessionId: backendSessionId.value || undefined, + expected: buildSttExpected(), + }) + + if (result.success) { + pmLog.info('PTT ✓ RADIO transcription:', result.transcription) + await handlePilotTransmission(result.transcription, 'ptt') + } else { + pmLog.warn('PTT ✗ RADIO Whisper returned no transcription', result) + } + } + } catch (err) { + pmLog.error(`PTT ✗ ${channel} error:`, err) + console.error('Error processing transmission:', err) + setLastTransmission('Error processing audio') + } + } + + const startRecording = async (isIntercom = false) => { + if (!micPermission.value) { + await requestMicAccess() + return + } + + // Barge-in: keying the mic cuts any ATC speech still playing, mirroring a real + // half-duplex radio where transmitting overrides the controller's output. + stopCurrentSpeech() + + // Pre-recording path: ring buffer + active capture, encoded to WAV on release + if (prerecEnabled.value) { + if (!prerecCtx) { + await startPrerecCapture() + } + if (prerecCtx && prerecNode) { + prerecLivePrebuffer = snapshotPrerecPCM() + prerecLiveChunks = [] + prerecLiveIntercom = isIntercom + prerecLiveCapture = true + isRecording.value = true + startPttTimer() + if (radioEffectsEnabled.value) { + playPTTBeep(true) + } + return + } + // Fall through to MediaRecorder if prerec init failed + } + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + sampleRate: 16000, + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + }, + }) + + mediaRecorder.value = new MediaRecorder(stream) + audioChunks.value = [] + + mediaRecorder.value.ondataavailable = (event) => { + if (event.data.size > 0) { + audioChunks.value.push(event.data) + } + } + + mediaRecorder.value.onstop = () => { + const audioBlob = new Blob(audioChunks.value, { type: 'audio/wav' }) + processTransmission(audioBlob, isIntercom, 'webm') + stream.getTracks().forEach(track => track.stop()) + } + + mediaRecorder.value.start() + isRecording.value = true + startPttTimer() + + if (radioEffectsEnabled.value) { + playPTTBeep(true) + } + } catch (err) { + console.error('Failed to start recording:', err) + } + } + + const stopRecording = () => { + // Always clear the safety timer, regardless of which recording path is active. + clearPttTimer() + + if (prerecLiveCapture) { + prerecLiveCapture = false + isRecording.value = false + if (radioEffectsEnabled.value) { + playPTTBeep(false) + } + const prebuffer = prerecLivePrebuffer + const liveLen = prerecLiveChunks.reduce((sum, c) => sum + c.length, 0) + const combined = new Float32Array(prebuffer.length + liveLen) + combined.set(prebuffer, 0) + let off = prebuffer.length + for (const c of prerecLiveChunks) { + combined.set(c, off) + off += c.length + } + prerecLiveChunks = [] + prerecLivePrebuffer = new Float32Array(0) + if (combined.length === 0) return + const blob = encodeWav(combined, prerecSampleRate) + processTransmission(blob, prerecLiveIntercom, 'wav') + return + } + + if (mediaRecorder.value && isRecording.value) { + mediaRecorder.value.stop() + isRecording.value = false + + if (radioEffectsEnabled.value) { + playPTTBeep(false) + } + } + } + + return { + isRecording, + micPermission, + prerecEnabled, + prerecSeconds, + requestMicAccess, + stopPrerecCapture, + startPrerecCapture, + restartPrerecCapture, + restartPrerecCaptureIfActive, + resumePrerecIfSuspended, + startRecording, + stopRecording, + processTransmission, + } +} diff --git a/app/pages/live-atc.vue b/app/pages/live-atc.vue index f8dfb9b..2f15210 100644 --- a/app/pages/live-atc.vue +++ b/app/pages/live-atc.vue @@ -1534,6 +1534,7 @@ import { type AirportFrequencyEntry, } from '~/composables/useFrequencyPresets' import { useAtisPlayback } from '~/composables/useAtisPlayback' +import { usePttRecording } from '~/composables/usePttRecording' import type { CandidateTraceElimination, CandidateTraceEntry, @@ -2279,10 +2280,6 @@ const currentScreen = ref<'login' | 'flightselect' | 'scenario' | 'monitor' | 'c const loading = ref(false) const error = ref('') const pilotInput = ref('') -const isRecording = ref(false) -const micPermission = ref(false) -/** Maximum PTT hold time in ms. Auto-stops and submits the recording when exceeded. */ -const PTT_MAX_DURATION_MS = 30_000 // 30 s — long enough for any realistic transmission const signalStrength = ref(5) const speechSpeed = ref(0.95) const radioCheckLoading = ref(false) @@ -2547,10 +2544,6 @@ async function restoreBugReportState(restoreId: string) { } // ──────────────────────────────────────────────────────────────────────────── -// Pre-recording (rolling mic buffer) so the first ~1s of PTT speech isn't clipped -const prerecEnabled = ref(true) -const prerecSeconds = ref(1.0) - // Layout / view state const activeTab = ref<'funk' | 'log'>('funk') const experienceMenu = ref(false) @@ -2680,12 +2673,6 @@ watch(vatsimId, (id) => { } }) -// Audio -const mediaRecorder = ref(null) -const audioChunks = ref([]) -/** Timer that auto-stops a PTT recording when PTT_MAX_DURATION_MS is exceeded. */ -let pttMaxDurationTimer: ReturnType | null = null - // Computed Properties const radioQuality = computed(() => { const strength = signalStrength.value @@ -3740,356 +3727,28 @@ const backToSetup = () => { frequencySources.value = { vatsim: false, openaip: false } } -// Audio/PTT Functions -const requestMicAccess = async () => { - try { - await navigator.mediaDevices.getUserMedia({ audio: true }) - micPermission.value = true - } catch (err) { - console.error('Microphone permission denied:', err) - micPermission.value = false - } -} - -// --------------------------------------------------------------------------- -// Pre-recording: continuously capture mic into a PCM ring buffer so the start -// of a PTT transmission isn't clipped. On PTT release we prepend the rolling -// buffer to the actively-captured frames and ship as WAV. -// --------------------------------------------------------------------------- -let prerecCtx: AudioContext | null = null -let prerecStream: MediaStream | null = null -let prerecSource: MediaStreamAudioSourceNode | null = null -let prerecNode: ScriptProcessorNode | null = null -let prerecSampleRate = 16000 -let prerecRing: Float32Array | null = null -let prerecRingWrite = 0 -let prerecRingFilled = false -let prerecLiveChunks: Float32Array[] = [] -let prerecLiveCapture = false -let prerecLiveIntercom = false -let prerecStarting = false -let prerecLivePrebuffer: Float32Array = new Float32Array(0) - -const snapshotPrerecPCM = (): Float32Array => { - if (!prerecRing) return new Float32Array(0) - const rb = prerecRing - if (!prerecRingFilled) return rb.slice(0, prerecRingWrite) - const out = new Float32Array(rb.length) - const tail = rb.length - prerecRingWrite - out.set(rb.subarray(prerecRingWrite), 0) - out.set(rb.subarray(0, prerecRingWrite), tail) - return out -} - -const stopPrerecCapture = () => { - prerecLiveCapture = false - prerecLiveChunks = [] - try { prerecNode?.disconnect() } catch {} - try { prerecSource?.disconnect() } catch {} - if (prerecCtx) { - try { void prerecCtx.close() } catch {} - } - if (prerecStream) { - prerecStream.getTracks().forEach(t => t.stop()) - } - prerecCtx = null - prerecStream = null - prerecSource = null - prerecNode = null - prerecRing = null - prerecRingWrite = 0 - prerecRingFilled = false -} - -const startPrerecCapture = async () => { - if (typeof window === 'undefined') return - if (prerecCtx || prerecStarting) return - if (!prerecEnabled.value) return - if (inputMode.value !== 'voice') return - if (!micPermission.value) return - - prerecStarting = true - try { - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - channelCount: 1, - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, - } - }) - const AC = window.AudioContext || (window as any).webkitAudioContext - if (!AC) { - stream.getTracks().forEach(t => t.stop()) - return - } - const ctx = new AC() - prerecCtx = ctx - prerecStream = stream - prerecSampleRate = ctx.sampleRate - prerecSource = ctx.createMediaStreamSource(stream) - const node = ctx.createScriptProcessor(2048, 1, 1) - prerecNode = node - const ringSize = Math.max(1, Math.ceil(prerecSampleRate * Math.max(0.2, prerecSeconds.value))) - prerecRing = new Float32Array(ringSize) - prerecRingWrite = 0 - prerecRingFilled = false - prerecLiveChunks = [] - prerecLiveCapture = false - - node.onaudioprocess = (e) => { - const input = e.inputBuffer.getChannelData(0) - const rb = prerecRing - if (!rb) return - const len = input.length - for (let i = 0; i < len; i++) { - rb[prerecRingWrite++] = input[i] - if (prerecRingWrite >= rb.length) { - prerecRingWrite = 0 - prerecRingFilled = true - } - } - if (prerecLiveCapture) { - prerecLiveChunks.push(new Float32Array(input)) - } - } - - prerecSource.connect(node) - // ScriptProcessor only emits events while connected to destination. - // Mute via a zero-gain node so we don't echo mic to speakers. - const muteGain = ctx.createGain() - muteGain.gain.value = 0 - node.connect(muteGain) - muteGain.connect(ctx.destination) - } catch (err) { - console.warn('[PM] prerec capture failed', err) - stopPrerecCapture() - } finally { - prerecStarting = false - } -} - -const restartPrerecCapture = async () => { - stopPrerecCapture() - await startPrerecCapture() -} - -/** Clear any pending PTT auto-stop timer. */ -const clearPttTimer = () => { - if (pttMaxDurationTimer !== null) { - clearTimeout(pttMaxDurationTimer) - pttMaxDurationTimer = null - } -} - -/** Start the PTT auto-stop safety timer. Fires stopRecording() if the user - * holds PTT longer than PTT_MAX_DURATION_MS. Without this, very long - * recordings produce blobs too large to base64-encode synchronously. */ -const startPttTimer = () => { - clearPttTimer() - pttMaxDurationTimer = setTimeout(() => { - pmLog.warn(`PTT auto-stop: exceeded ${PTT_MAX_DURATION_MS / 1000}s limit`) - stopRecording() - }, PTT_MAX_DURATION_MS) -} - -const startRecording = async (isIntercom = false) => { - if (!micPermission.value) { - await requestMicAccess() - return - } - - // Barge-in: keying the mic cuts any ATC speech still playing, mirroring a real - // half-duplex radio where transmitting overrides the controller's output. - stopCurrentSpeech() - - // Pre-recording path: ring buffer + active capture, encoded to WAV on release - if (prerecEnabled.value) { - if (!prerecCtx) { - await startPrerecCapture() - } - if (prerecCtx && prerecNode) { - prerecLivePrebuffer = snapshotPrerecPCM() - prerecLiveChunks = [] - prerecLiveIntercom = isIntercom - prerecLiveCapture = true - isRecording.value = true - startPttTimer() - if (radioEffectsEnabled.value) { - playPTTBeep(true) - } - return - } - // Fall through to MediaRecorder if prerec init failed - } - - try { - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - sampleRate: 16000, - channelCount: 1, - echoCancellation: true, - noiseSuppression: true - } - }) - - mediaRecorder.value = new MediaRecorder(stream) - audioChunks.value = [] - - mediaRecorder.value.ondataavailable = (event) => { - if (event.data.size > 0) { - audioChunks.value.push(event.data) - } - } - - mediaRecorder.value.onstop = () => { - const audioBlob = new Blob(audioChunks.value, { type: 'audio/wav' }) - processTransmission(audioBlob, isIntercom, 'webm') - stream.getTracks().forEach(track => track.stop()) - } - - mediaRecorder.value.start() - isRecording.value = true - startPttTimer() - - if (radioEffectsEnabled.value) { - playPTTBeep(true) - } - - } catch (err) { - console.error('Failed to start recording:', err) - } -} - -const stopRecording = () => { - // Always clear the safety timer, regardless of which recording path is active. - clearPttTimer() - - if (prerecLiveCapture) { - prerecLiveCapture = false - isRecording.value = false - if (radioEffectsEnabled.value) { - playPTTBeep(false) - } - const prebuffer = prerecLivePrebuffer - const liveLen = prerecLiveChunks.reduce((sum, c) => sum + c.length, 0) - const combined = new Float32Array(prebuffer.length + liveLen) - combined.set(prebuffer, 0) - let off = prebuffer.length - for (const c of prerecLiveChunks) { - combined.set(c, off) - off += c.length - } - prerecLiveChunks = [] - prerecLivePrebuffer = new Float32Array(0) - if (combined.length === 0) return - const blob = encodeWav(combined, prerecSampleRate) - processTransmission(blob, prerecLiveIntercom, 'wav') - return - } - - if (mediaRecorder.value && isRecording.value) { - mediaRecorder.value.stop() - isRecording.value = false - - if (radioEffectsEnabled.value) { - playPTTBeep(false) - } - } -} - -/** - * Convert an ArrayBuffer to a base64 string without blowing the call stack. - * - * The naive approach — btoa(String.fromCharCode(...new Uint8Array(buf))) — - * spreads every byte as a separate function argument. For recordings longer - * than a few seconds the argument count exceeds the JS engine limit (~65 k – - * 131 k args) and throws RangeError: Maximum call stack size exceeded, - * silently aborting the PTT request before it is ever sent. - * - * Processing in 8 KB chunks keeps argument count well within safe limits - * regardless of recording length. - */ -function arrayBufferToBase64(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer) - const chunkSize = 8192 - let binary = '' - for (let i = 0; i < bytes.length; i += chunkSize) { - binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length))) - } - return btoa(binary) -} - -// Build the Whisper-prompt seed for the current state: the expected pilot -// phrase plus the active variable values (callsign, SID, squawk, runway, -// frequencies…). The server expands these to spoken ICAO form and appends them -// to the bias prompt so Whisper is steered toward exactly what's expected. -function buildSttExpected(): { phrase?: string; tokens: string[] } { - const phrase = backendExpectedPhrase.value?.trim() || undefined - const dict = ((vars as any).value ?? {}) as Record - const tokens: string[] = [] - for (const val of Object.values(dict)) { - if (typeof val === 'number') { tokens.push(String(val)); continue } - if (typeof val === 'string') { - const t = val.trim() - if (t && t.length <= 24) tokens.push(t) - } - } - return { phrase, tokens } -} - -const processTransmission = async (audioBlob: Blob, isIntercom: boolean, format: 'wav' | 'webm' = 'webm') => { - const channel = isIntercom ? 'INTERCOM' : 'RADIO' - pmLog.info(`PTT ▶ ${channel} blob=${(audioBlob.size / 1024).toFixed(1)}KB fmt=${format} session=${backendSessionId.value?.slice(0,8) ?? 'none'}`) - try { - const arrayBuffer = await audioBlob.arrayBuffer() - const base64Audio = arrayBufferToBase64(arrayBuffer) - - if (isIntercom) { - const result = await api.post('/api/atc/ptt', { - audio: base64Audio, - moduleId: 'pilot-monitoring-intercom', - lessonId: 'intercom', - format, - sessionId: backendSessionId.value || undefined, - }) - - if (result.success) { - pmLog.info('PTT ✓ INTERCOM transcription:', result.transcription) - setLastTransmission(`INTERCOM: ${result.transcription}`) - const transcription = result.transcription.toLowerCase() - if (transcription.includes('checklist') || transcription.includes('check list')) { - speakWithRadioEffects('Checklist functionality available in advanced mode', { - delayMs: 600, - updateLastTransmission: false, - tag: 'system-info', - useNormalizedForTTS: true - }) - } - } - } else { - const result = await api.post('/api/atc/ptt', { - audio: base64Audio, - moduleId: 'pilot-monitoring', - lessonId: currentState.value?.id || 'general', - format, - sessionId: backendSessionId.value || undefined, - expected: buildSttExpected(), - }) - - if (result.success) { - pmLog.info('PTT ✓ RADIO transcription:', result.transcription) - await handlePilotTransmission(result.transcription, 'ptt') - } else { - pmLog.warn('PTT ✗ RADIO Whisper returned no transcription', result) - } - } - } catch (err) { - pmLog.error(`PTT ✗ ${channel} error:`, err) - console.error('Error processing transmission:', err) - setLastTransmission('Error processing audio') - } -} +const { + isRecording, + micPermission, + prerecEnabled, + prerecSeconds, + requestMicAccess, + stopPrerecCapture, + startPrerecCapture, + restartPrerecCaptureIfActive, + resumePrerecIfSuspended, + startRecording, + stopRecording, +} = usePttRecording(engine, { + stopCurrentSpeech, + speakWithRadioEffects, + radioEffectsEnabled, + inputMode, + backendSessionId, + backendExpectedPhrase, + setLastTransmission, + handlePilotTransmission, +}) const sendPilotText = async () => { const text = pilotInput.value.trim() @@ -4203,28 +3862,6 @@ const formatTime = (date: Date): string => { }) } -const playPTTBeep = (start: boolean) => { - if (!radioEffectsEnabled.value) return - - try { - const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)() - const oscillator = audioContext.createOscillator() - const gainNode = audioContext.createGain() - - oscillator.connect(gainNode) - gainNode.connect(audioContext.destination) - - oscillator.frequency.setValueAtTime(start ? 800 : 600, audioContext.currentTime) - gainNode.gain.setValueAtTime(0.1, audioContext.currentTime) - gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1) - - oscillator.start() - oscillator.stop(audioContext.currentTime + 0.1) - } catch (err) { - // Audio context may not be available - } -} - const formatTracePayload = (payload: any): string => { if (payload === null || payload === undefined) return '' if (typeof payload === 'string') return payload @@ -4451,9 +4088,7 @@ watch(prerecSeconds, (val) => { if (typeof window !== 'undefined') { window.localStorage.setItem(STORAGE_KEYS.prerecSeconds, String(val)) } - if (prerecCtx) { - void restartPrerecCapture() - } + restartPrerecCaptureIfActive() }) watch(prerecEnabled, (val) => { @@ -4601,11 +4236,7 @@ const bridgePttConnected = ref(false) async function handleRemotePtt(state: 'down' | 'up') { if (state === 'down') { - // A backgrounded tab can suspend the prerec AudioContext; resume it so the - // ring buffer + live capture are running when the edge arrives from the sim. - if (prerecCtx && prerecCtx.state === 'suspended') { - try { await prerecCtx.resume() } catch {} - } + await resumePrerecIfSuspended() void startRecording(false) } else { stopRecording()