From bbb3984826db8803d70db0a2c219ee5f4b764ece Mon Sep 17 00:00:00 2001 From: Nikhil Vishwakarma <63867254+vishwakarmanikhil@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:00:23 +0530 Subject: [PATCH] Assemble full recording alongside chunked streaming When timeslice + onChunkAvailable are used to stream audio chunks in real time, buffer the chunks and combine them into the final blob once recording stops, so recordedBlob/audioSrc/playback still work exactly as they do without timeslice. Also pass each chunk's index and isLast flag to onChunkAvailable so callers can order and finalize uploads, and document the timeslice/ onChunkAvailable feature in the README. --- README.md | 21 ++++++++++++ src/hooks/useVoiceVisualizer.tsx | 56 ++++++++++++++++++++++++++++---- src/types/types.ts | 7 +++- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9d87c6e..5cab184 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,25 @@ const App = () => { export default App; ``` +### Uploading audio while recording (chunked streaming) + +If you need to upload audio as it's being recorded (e.g. for live transcription) rather than waiting for the full recording to finish, pass `timeslice` together with `onChunkAvailable`. The full recording is still assembled as normal once `stopRecording` is called, so `recordedBlob`, playback, etc. keep working exactly as before. + +```typescript jsx +const recorderControls = useVoiceVisualizer({ + timeslice: 1000, // emit a chunk every 1000ms + onChunkAvailable: (chunk, meta) => { + // e.g. upload this chunk to your server + uploadChunk(chunk, meta.index); + + if (meta.isLast) { + // this was the final chunk of the recording + finalizeUpload(); + } + }, +}); +``` + ## Getting started 1. Import the required components and hooks from the library. @@ -127,6 +146,8 @@ const recorderControls = useVoiceVisualizer(); | `onErrorPlayingAudio` | `(error: Error) => void` | Callback function is invoked when an error occurs during the execution of `audio.play()`. It provides an opportunity to handle and respond to such error. | | `shouldHandleBeforeUnload` | `boolean` | Determines whether the `beforeunload` event handler should be added to the window, preventing page unload if necessary (`true` by default). | | `mediaRecorderOptions` | `MediaRecorderOptions` | Configuration options for the MediaRecorder instance. | +| `timeslice` | `number` | Interval in milliseconds at which recorded audio chunks are emitted via `onChunkAvailable` (e.g. for uploading audio while it's still being recorded). Must be used together with `onChunkAvailable`; the full recording (`recordedBlob`, playback, etc.) is still produced as normal once `stopRecording` is called. | +| `onChunkAvailable` | `(chunk: Blob, meta: ChunkMeta) => void` | Callback invoked with each audio chunk as soon as it's available, when `timeslice` is set. `meta.index` is the zero-based order of the chunk and `meta.isLast` is `true` for the final chunk of the recording, useful for reassembling/finalizing an upload. | ##### Returns diff --git a/src/hooks/useVoiceVisualizer.tsx b/src/hooks/useVoiceVisualizer.tsx index b4196d4..cf953ac 100644 --- a/src/hooks/useVoiceVisualizer.tsx +++ b/src/hooks/useVoiceVisualizer.tsx @@ -54,6 +54,9 @@ function useVoiceVisualizer({ const rafRecordingRef = useRef(null); const rafCurrentTimeUpdateRef = useRef(null); const audioRef = useRef(null); + const recordedChunksRef = useRef([]); + const chunkIndexRef = useRef(0); + const isStoppingRef = useRef(false); const isAvailableRecordedAudio = Boolean( bufferFromRecordedBlob && !isProcessingAudioOnComplete, @@ -153,6 +156,9 @@ function useVoiceVisualizer({ const getUserMedia = () => { setIsProcessingStartRecording(true); + recordedChunksRef.current = []; + chunkIndexRef.current = 0; + isStoppingRef.current = false; navigator.mediaDevices .getUserMedia({ audio: true }) @@ -180,6 +186,9 @@ function useVoiceVisualizer({ "dataavailable", handleDataAvailable, ); + if (timeslice && onChunkAvailable) { + mediaRecorderRef.current.addEventListener("stop", handleRecordingStop); + } // Start recording with timeslice if provided, otherwise normal recording if (timeslice) { mediaRecorderRef.current.start(timeslice); @@ -207,9 +216,16 @@ function useVoiceVisualizer({ }; const handleDataAvailable = (event: BlobEvent) => { - // If timeslice is set, only emit chunks - don't store locally + if (!event.data || event.data.size === 0) return; + + // If timeslice is set, emit each chunk for upload and also keep it + // so the full recording can still be assembled once stopped. if (timeslice && onChunkAvailable) { - onChunkAvailable(event.data); + recordedChunksRef.current.push(event.data); + onChunkAvailable(event.data, { + index: chunkIndexRef.current++, + isLast: isStoppingRef.current, + }); return; } @@ -222,6 +238,23 @@ function useVoiceVisualizer({ void processBlob(event.data); }; + // Fires after all "dataavailable" chunks have been emitted, so the + // buffered chunks can be combined into the final playable recording. + const handleRecordingStop = () => { + const chunks = recordedChunksRef.current; + recordedChunksRef.current = []; + + if (!mediaRecorderRef.current || chunks.length === 0) return; + + const finalBlob = new Blob(chunks, { + type: mediaRecorderRef.current.mimeType, + }); + mediaRecorderRef.current = null; + audioRef.current = new Audio(); + setRecordedBlob(finalBlob); + void processBlob(finalBlob); + }; + const handleTimeUpdate = () => { if (!audioRef.current) return; @@ -242,11 +275,18 @@ function useVoiceVisualizer({ setIsRecordingInProgress(false); if (mediaRecorderRef.current) { + isStoppingRef.current = true; mediaRecorderRef.current.stop(); mediaRecorderRef.current.removeEventListener( "dataavailable", handleDataAvailable, ); + if (timeslice && onChunkAvailable) { + mediaRecorderRef.current.removeEventListener( + "stop", + handleRecordingStop, + ); + } } audioStream?.getTracks().forEach((track) => track.stop()); if (rafRecordingRef.current) cancelAnimationFrame(rafRecordingRef.current); @@ -255,10 +295,7 @@ function useVoiceVisualizer({ void audioContextRef.current.close(); } - // Only process blob for playback if not in timeslice mode - if (!timeslice) { - _setIsProcessingAudioOnComplete(true); - } + _setIsProcessingAudioOnComplete(true); setRecordingTime(0); setIsPausedRecording(false); if (onStopRecording) onStopRecording(); @@ -278,9 +315,16 @@ function useVoiceVisualizer({ "dataavailable", handleDataAvailable, ); + if (timeslice && onChunkAvailable) { + mediaRecorderRef.current.removeEventListener( + "stop", + handleRecordingStop, + ); + } mediaRecorderRef.current.stop(); mediaRecorderRef.current = null; } + recordedChunksRef.current = []; audioStream?.getTracks().forEach((track) => track.stop()); if (audioRef?.current) { diff --git a/src/types/types.ts b/src/types/types.ts index 96d7f35..538acfe 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -122,7 +122,12 @@ export interface useVoiceVisualizerParams { shouldHandleBeforeUnload?: boolean; mediaRecorderOptions?: MediaRecorderOptions; timeslice?: number; - onChunkAvailable?: (chunk: Blob) => void; + onChunkAvailable?: (chunk: Blob, meta: ChunkMeta) => void; +} + +export interface ChunkMeta { + index: number; + isLast: boolean; } export interface UseWebWorkerParams {