From d913a9ae2a33a2e2e4be5badced0bd2e86ec560a Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Mon, 26 Jan 2026 20:41:18 -0800 Subject: [PATCH] Implement audio format conversion and enhance recording completion handling - Added a new utility function to convert audio blobs to WAV format, ensuring compatibility without requiring ffmpeg on the backend. - Updated the useAudioRecording hook to convert recorded audio from WebM to WAV upon completion, with error handling for conversion failures. - Improved the organization of imports in useAudioRecording for better readability. --- app/src/lib/hooks/useAudioRecording.ts | 30 ++++++-- app/src/lib/utils/audio.ts | 101 +++++++++++++++++++++++++ tauri/src-tauri/gen/Assets.car | Bin 3847048 -> 3847048 bytes 3 files changed, 123 insertions(+), 8 deletions(-) diff --git a/app/src/lib/hooks/useAudioRecording.ts b/app/src/lib/hooks/useAudioRecording.ts index cf6c2c79..23c6e680 100644 --- a/app/src/lib/hooks/useAudioRecording.ts +++ b/app/src/lib/hooks/useAudioRecording.ts @@ -1,5 +1,6 @@ -import { useState, useRef, useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { isTauri } from '@/lib/tauri'; +import { convertToWav } from '@/lib/utils/audio'; interface UseAudioRecordingOptions { maxDurationSeconds?: number; @@ -85,13 +86,26 @@ export function useAudioRecording({ } }; - mediaRecorder.onstop = () => { - const blob = new Blob(chunksRef.current, { type: 'audio/webm' }); - // Pass the actual recorded duration - const recordedDuration = startTimeRef.current - ? (Date.now() - startTimeRef.current) / 1000 - : undefined; - onRecordingComplete?.(blob, recordedDuration); + mediaRecorder.onstop = async () => { + const webmBlob = new Blob(chunksRef.current, { type: 'audio/webm' }); + + // Convert to WAV format to avoid needing ffmpeg on backend + try { + const wavBlob = await convertToWav(webmBlob); + + // Pass the actual recorded duration + const recordedDuration = startTimeRef.current + ? (Date.now() - startTimeRef.current) / 1000 + : undefined; + onRecordingComplete?.(wavBlob, recordedDuration); + } catch (err) { + console.error('Error converting audio to WAV:', err); + // Fallback to original blob if conversion fails + const recordedDuration = startTimeRef.current + ? (Date.now() - startTimeRef.current) / 1000 + : undefined; + onRecordingComplete?.(webmBlob, recordedDuration); + } // Stop all tracks streamRef.current?.getTracks().forEach((track) => { diff --git a/app/src/lib/utils/audio.ts b/app/src/lib/utils/audio.ts index fe10c675..c4cb5d9f 100644 --- a/app/src/lib/utils/audio.ts +++ b/app/src/lib/utils/audio.ts @@ -16,3 +16,104 @@ export function formatAudioDuration(seconds: number): string { const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; } + +/** + * Convert any audio blob to WAV format using Web Audio API. + * This ensures compatibility without requiring ffmpeg on the backend. + */ +export async function convertToWav(audioBlob: Blob): Promise { + // Create audio context + const audioContext = new AudioContext(); + + // Read blob as array buffer + const arrayBuffer = await audioBlob.arrayBuffer(); + + // Decode audio data + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + + // Convert to WAV + const wavBlob = audioBufferToWav(audioBuffer); + + // Close audio context to free resources + await audioContext.close(); + + return wavBlob; +} + +/** + * Convert AudioBuffer to WAV blob. + */ +function audioBufferToWav(buffer: AudioBuffer): Blob { + const numberOfChannels = buffer.numberOfChannels; + const sampleRate = buffer.sampleRate; + const format = 1; // PCM + const bitDepth = 16; + + const bytesPerSample = bitDepth / 8; + const blockAlign = numberOfChannels * bytesPerSample; + + // Interleave channels + const interleaved = interleaveChannels(buffer); + + // Create WAV file + const dataLength = interleaved.length * bytesPerSample; + const buffer2 = new ArrayBuffer(44 + dataLength); + const view = new DataView(buffer2); + + // Write WAV header + writeString(view, 0, 'RIFF'); + view.setUint32(4, 36 + dataLength, true); + writeString(view, 8, 'WAVE'); + writeString(view, 12, 'fmt '); + view.setUint32(16, 16, true); // fmt chunk size + view.setUint16(20, format, true); // audio format (PCM) + view.setUint16(22, numberOfChannels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * blockAlign, true); // byte rate + view.setUint16(32, blockAlign, true); + view.setUint16(34, bitDepth, true); + writeString(view, 36, 'data'); + view.setUint32(40, dataLength, true); + + // Write audio data + floatTo16BitPCM(view, 44, interleaved); + + return new Blob([buffer2], { type: 'audio/wav' }); +} + +/** + * Interleave multiple channels into a single array. + */ +function interleaveChannels(buffer: AudioBuffer): Float32Array { + const numberOfChannels = buffer.numberOfChannels; + const length = buffer.length; + const interleaved = new Float32Array(length * numberOfChannels); + + for (let channel = 0; channel < numberOfChannels; channel++) { + const channelData = buffer.getChannelData(channel); + for (let i = 0; i < length; i++) { + interleaved[i * numberOfChannels + channel] = channelData[i]; + } + } + + return interleaved; +} + +/** + * Write string to DataView. + */ +function writeString(view: DataView, offset: number, string: string): void { + for (let i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } +} + +/** + * Convert float32 audio data to 16-bit PCM. + */ +function floatTo16BitPCM(view: DataView, offset: number, input: Float32Array): void { + for (let i = 0; i < input.length; i++, offset += 2) { + const s = Math.max(-1, Math.min(1, input[i])); + view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); + } +} diff --git a/tauri/src-tauri/gen/Assets.car b/tauri/src-tauri/gen/Assets.car index 269230ac8902e9a2e29f47de2c208df0cbe4c5b4..f47e56d5addf933f193c68e0dc9858883a0a9d2b 100644 GIT binary patch delta 751 zcmZwFxo%TI6b4{9CUDsaOCTXYLfGNNv(Fp~Zf3j6BjgR}5G8#IyQQFmJ|z;x^<(e= z^jH)WG->%Q;>wKlb)*02ER&zV1{3)D*$sYt`{Mqrv^DCWy(z>&R5CU)u}p!I)r#^V_eU_}FV)FiV(6y2z(d%DNAMV)z*Bez&*4RXc<}P%q!lSv7crP>FcXXk2O_f~rMddj zSuf>sQ^i>u!|7R!kM~~z!5)l2fCL2^4A}m7-}S#+TT}2ASD{O=SZfJbu<+z;aVh5% bWQj{tEDBqVi-OaUA5TXCxSx(*e?R&Qgk;p@ delta 751 zcmZY7xo#9e5C&ka7sGJh1~29?=7#ci_w-4?xcZVG!EZoj5t2LDO$o>#cZftVehl7% z6#)Sfa>~D2oOoOMf=>SGuBz$JUxO)p`(g)wzkjuVR=Vhf@ojZvwP4aQFhyrG$>Q3` zGG);+8EJ(fF+WC|m}_BW%=U-<_n$UL2eUAlg#oO<5YE6VoP~4!EHq`ct2RfGouiVu zkX9v7XmNEesw7fSuGr-|krn$zosE9lHJ^u_P2T&Q9Yt9pY3Ty_YBEL6C2B6RO@28j zM=RAy(8tuQ4c>Y7%IB8Y*-DzDCC{RgX$@y1tFj)K(;v@9_;ivp%gy?*V`I@jRVbxo zN$Zj*C=;nfp*a+7Wm@WBk;ZGwi=excz1;@R!v(kqm*6s7fva#0uEPzu3Af-jtic_a zXRN~r?!gA!hX?Qw9>FF&hAnskPvIFnhZpb?UiEvsuaA$rXgFw7@o+9;KCVb1R*j}J zT3##m!JO_+5!#rxE+MfAEU|9*nx-8AC2GsIQR$k{ncUs