diff --git a/app/pages/pm.vue b/app/pages/pm.vue index cb17524..4797470 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -780,6 +780,40 @@ hide-details /> + +
+
+

Voice input

+

+ Pre-recording keeps the mic listening in the background so the start of your transmission isn't clipped. +

+
+ +
+
+ + {{ prerecSeconds.toFixed(1) }}s +
+ +
+
@@ -1097,7 +1131,9 @@ const config = useRuntimeConfig() const STORAGE_KEYS = { selectedPlan: 'pm_selected_plan', - vatsimId: 'pm_vatsim_id' + vatsimId: 'pm_vatsim_id', + prerecEnabled: 'pm_prerec_enabled', + prerecSeconds: 'pm_prerec_seconds', } as const let restoringFromStorage = true @@ -1548,6 +1584,10 @@ const radioEffectsEnabled = ref(true) const readbackEnabled = ref(false) const debugMode = ref(true) +// 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' | 'freq' | 'log' | 'flug' | 'more' | 'debug'>('funk') const experienceMenu = ref(false) @@ -1623,6 +1663,18 @@ onMounted(async () => { vatsimId.value = storedVatsimId } + const storedPrerecEnabled = window.localStorage.getItem(STORAGE_KEYS.prerecEnabled) + if (storedPrerecEnabled !== null) { + prerecEnabled.value = storedPrerecEnabled === '1' + } + const storedPrerecSeconds = window.localStorage.getItem(STORAGE_KEYS.prerecSeconds) + if (storedPrerecSeconds !== null) { + const parsed = Number.parseFloat(storedPrerecSeconds) + if (Number.isFinite(parsed) && parsed >= 0.2 && parsed <= 3) { + prerecSeconds.value = parsed + } + } + const storedPlanRaw = window.localStorage.getItem(STORAGE_KEYS.selectedPlan) if (storedPlanRaw) { try { @@ -2423,12 +2475,183 @@ const requestMicAccess = async () => { } } +// --------------------------------------------------------------------------- +// 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 encodeWav = (pcm: Float32Array, sampleRate: number): Blob => { + const length = pcm.length + const buffer = new ArrayBuffer(44 + length * 2) + const view = new DataView(buffer) + const writeStr = (off: number, s: string) => { + for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)) + } + writeStr(0, 'RIFF') + view.setUint32(4, 36 + length * 2, true) + writeStr(8, 'WAVE') + writeStr(12, 'fmt ') + view.setUint32(16, 16, true) + view.setUint16(20, 1, true) + view.setUint16(22, 1, true) + view.setUint32(24, sampleRate, true) + view.setUint32(28, sampleRate * 2, true) + view.setUint16(32, 2, true) + view.setUint16(34, 16, true) + writeStr(36, 'data') + view.setUint32(40, length * 2, true) + let off = 44 + for (let i = 0; i < length; i++) { + const s = Math.max(-1, Math.min(1, pcm[i])) + view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true) + off += 2 + } + return new Blob([buffer], { type: 'audio/wav' }) +} + +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() +} + const startRecording = async (isIntercom = false) => { if (!micPermission.value) { await requestMicAccess() return } + // 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 + if (radioEffectsEnabled.value) { + playPTTBeep(true) + } + return + } + // Fall through to MediaRecorder if prerec init failed + } + try { const stream = await navigator.mediaDevices.getUserMedia({ audio: { @@ -2450,7 +2673,7 @@ const startRecording = async (isIntercom = false) => { mediaRecorder.value.onstop = () => { const audioBlob = new Blob(audioChunks.value, { type: 'audio/wav' }) - processTransmission(audioBlob, isIntercom) + processTransmission(audioBlob, isIntercom, 'webm') stream.getTracks().forEach(track => track.stop()) } @@ -2467,6 +2690,29 @@ const startRecording = async (isIntercom = false) => { } const stopRecording = () => { + 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 @@ -2477,9 +2723,9 @@ const stopRecording = () => { } } -const processTransmission = async (audioBlob: Blob, isIntercom: boolean) => { +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 session=${backendSessionId.value?.slice(0,8) ?? 'none'}`) + 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 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))) @@ -2489,7 +2735,7 @@ const processTransmission = async (audioBlob: Blob, isIntercom: boolean) => { audio: base64Audio, moduleId: 'pilot-monitoring-intercom', lessonId: 'intercom', - format: 'webm', + format, sessionId: backendSessionId.value || undefined, }) @@ -2511,7 +2757,7 @@ const processTransmission = async (audioBlob: Blob, isIntercom: boolean) => { audio: base64Audio, moduleId: 'pilot-monitoring', lessonId: currentState.value?.id || 'general', - format: 'webm', + format, sessionId: backendSessionId.value || undefined, }) @@ -3206,8 +3452,33 @@ watch( { immediate: true } ) +// Pre-recording: manage continuous mic capture lifecycle +watch([inputMode, micPermission, prerecEnabled], ([mode, mic, enabled]) => { + if (mode === 'voice' && mic && enabled) { + void startPrerecCapture() + } else { + stopPrerecCapture() + } +}, { immediate: true }) + +watch(prerecSeconds, (val) => { + if (typeof window !== 'undefined') { + window.localStorage.setItem(STORAGE_KEYS.prerecSeconds, String(val)) + } + if (prerecCtx) { + void restartPrerecCapture() + } +}) + +watch(prerecEnabled, (val) => { + if (typeof window !== 'undefined') { + window.localStorage.setItem(STORAGE_KEYS.prerecEnabled, val ? '1' : '0') + } +}) + onUnmounted(() => { stopAtisLoop() + stopPrerecCapture() })