Built for people who think better out loud.

mic recorder: Add component + demo for Storybook

+376
+13
src/components/MicRecorder.stories.svelte
··· 1 + <script module> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import MicRecorderDemo from "./MicRecorderDemo.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "Components/MicRecorder", 7 + component: MicRecorderDemo, 8 + }); 9 + </script> 10 + 11 + <Story name="MicRecorder"> 12 + <MicRecorderDemo /> 13 + </Story>
+317
src/components/MicRecorder.svelte
··· 1 + <script lang="ts"> 2 + import IconButton from "./IconButton.svelte"; 3 + import { Mic, Square } from "lucide-svelte"; 4 + import { onDestroy, onMount } from "svelte"; 5 + 6 + type MicRecorderProps = { 7 + class?: string; 8 + barCount?: number; 9 + recording?: Blob | null; 10 + onrecordingcomplete?: (payload: RecordingCompletePayload) => void; 11 + }; 12 + 13 + type RecordingMetadata = { 14 + mimeType: string; 15 + sizeBytes: number; 16 + durationMs: number; 17 + startedAt: string; 18 + endedAt: string; 19 + }; 20 + 21 + type RecordingCompletePayload = { 22 + blob: Blob; 23 + metadata: RecordingMetadata; 24 + }; 25 + 26 + let { 27 + class: className = "", 28 + barCount = 48, 29 + recording = $bindable<Blob | null>(null), 30 + onrecordingcomplete, 31 + }: MicRecorderProps = $props(); 32 + 33 + let isRecording = $state(false); 34 + let errorMessage = $state(""); 35 + let bars = $state<number[]>([]); 36 + let effectiveBarCount = $state(0); 37 + 38 + let stream: MediaStream | null = null; 39 + let mediaRecorder: MediaRecorder | null = null; 40 + let audioContext: AudioContext | null = null; 41 + let analyser: AnalyserNode | null = null; 42 + let sourceNode: MediaStreamAudioSourceNode | null = null; 43 + let visualizerEl: HTMLDivElement | null = null; 44 + let resizeObserver: ResizeObserver | null = null; 45 + let frameId = 0; 46 + let frameStep = 0; 47 + let recordingStartedAt = 0; 48 + let recordingStartedAtIso = ""; 49 + let isFinalizing = false; 50 + let chunks: BlobPart[] = []; 51 + 52 + function syncBarsLength(nextCount: number) { 53 + const currentCount = bars.length; 54 + if (nextCount === currentCount) return; 55 + 56 + if (nextCount > currentCount) { 57 + bars = [...Array.from({ length: nextCount - currentCount }, () => 0.08), ...bars]; 58 + return; 59 + } 60 + 61 + bars = bars.slice(currentCount - nextCount); 62 + } 63 + 64 + function updateBarCount(width: number) { 65 + const autoCount = Math.max(barCount, Math.floor(width / 8)); 66 + effectiveBarCount = Math.max(1, autoCount); 67 + syncBarsLength(effectiveBarCount); 68 + } 69 + 70 + function resetBars() { 71 + bars = Array.from({ length: effectiveBarCount }, () => 0.08); 72 + } 73 + 74 + function getRecorderOptions(): MediaRecorderOptions | undefined { 75 + if (typeof MediaRecorder === "undefined" || !MediaRecorder.isTypeSupported) return undefined; 76 + 77 + const candidates = [ 78 + "audio/mp4;codecs=mp4a.40.2", 79 + "audio/mp4", 80 + "audio/webm;codecs=opus", 81 + "audio/webm", 82 + ]; 83 + 84 + for (const mimeType of candidates) { 85 + if (MediaRecorder.isTypeSupported(mimeType)) return { mimeType }; 86 + } 87 + 88 + return undefined; 89 + } 90 + 91 + function finalizeRecording() { 92 + if (!mediaRecorder || isFinalizing) return; 93 + isFinalizing = true; 94 + 95 + const type = mediaRecorder.mimeType || "audio/webm"; 96 + if (chunks.length === 0) { 97 + errorMessage = "No audio data was captured. Please try recording again."; 98 + cleanupAudioNodes(); 99 + cleanupStream(); 100 + mediaRecorder = null; 101 + chunks = []; 102 + isFinalizing = false; 103 + return; 104 + } 105 + 106 + const blob = new Blob(chunks, { type }); 107 + const durationMs = Math.max(0, Date.now() - recordingStartedAt); 108 + 109 + const endedAt = new Date(); 110 + const metadata: RecordingMetadata = { 111 + mimeType: blob.type || type, 112 + sizeBytes: blob.size, 113 + durationMs, 114 + startedAt: recordingStartedAtIso, 115 + endedAt: endedAt.toISOString(), 116 + }; 117 + 118 + recording = blob; 119 + onrecordingcomplete?.({ blob, metadata }); 120 + 121 + cleanupAudioNodes(); 122 + cleanupStream(); 123 + mediaRecorder = null; 124 + chunks = []; 125 + isFinalizing = false; 126 + } 127 + 128 + function tick() { 129 + if (!analyser) return; 130 + 131 + const buffer = new Uint8Array(analyser.fftSize); 132 + analyser.getByteTimeDomainData(buffer); 133 + 134 + let sum = 0; 135 + for (const value of buffer) { 136 + const normalized = (value - 128) / 128; 137 + sum += normalized * normalized; 138 + } 139 + 140 + const rms = Math.sqrt(sum / buffer.length); 141 + const boosted = Math.min(1, rms * 14); 142 + const logScaled = Math.log1p(boosted * 9) / Math.log(10); 143 + const scaled = Math.min(1, Math.max(0.08, logScaled)); 144 + 145 + frameStep += 1; 146 + if (frameStep % 3 === 0) { 147 + bars = [...bars.slice(1), scaled]; 148 + } 149 + 150 + frameId = window.requestAnimationFrame(tick); 151 + } 152 + 153 + async function startRecording() { 154 + errorMessage = ""; 155 + recording = null; 156 + recordingStartedAt = Date.now(); 157 + recordingStartedAtIso = new Date(recordingStartedAt).toISOString(); 158 + isFinalizing = false; 159 + 160 + try { 161 + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); 162 + chunks = []; 163 + frameStep = 0; 164 + resetBars(); 165 + 166 + mediaRecorder = new MediaRecorder(stream, getRecorderOptions()); 167 + mediaRecorder.ondataavailable = (event) => { 168 + if (event.data.size > 0) chunks.push(event.data); 169 + }; 170 + mediaRecorder.onstop = () => { 171 + finalizeRecording(); 172 + }; 173 + mediaRecorder.onerror = () => { 174 + errorMessage = "Recording failed. Please try again."; 175 + cleanupAudioNodes(); 176 + cleanupStream(); 177 + mediaRecorder = null; 178 + chunks = []; 179 + }; 180 + mediaRecorder.start(); 181 + 182 + audioContext = new AudioContext(); 183 + analyser = audioContext.createAnalyser(); 184 + analyser.fftSize = 1024; 185 + analyser.smoothingTimeConstant = 0.8; 186 + 187 + sourceNode = audioContext.createMediaStreamSource(stream); 188 + sourceNode.connect(analyser); 189 + 190 + isRecording = true; 191 + tick(); 192 + } catch (error) { 193 + errorMessage = error instanceof Error ? error.message : "Microphone access failed"; 194 + cleanupAudioNodes(); 195 + cleanupStream(); 196 + isRecording = false; 197 + } 198 + } 199 + 200 + function stopRecording() { 201 + if (!isRecording) return; 202 + isRecording = false; 203 + bars = bars.map(() => 0.08); 204 + 205 + if (frameId) { 206 + window.cancelAnimationFrame(frameId); 207 + frameId = 0; 208 + } 209 + frameStep = 0; 210 + 211 + if (mediaRecorder && mediaRecorder.state !== "inactive") { 212 + try { 213 + mediaRecorder.requestData(); 214 + } catch { 215 + // Some engines can throw here during stop transitions. 216 + } 217 + 218 + try { 219 + mediaRecorder.stop(); 220 + } catch { 221 + errorMessage = "Unable to stop recording cleanly."; 222 + cleanupAudioNodes(); 223 + cleanupStream(); 224 + mediaRecorder = null; 225 + chunks = []; 226 + return; 227 + } 228 + return; 229 + } 230 + 231 + cleanupAudioNodes(); 232 + cleanupStream(); 233 + } 234 + 235 + async function toggleRecording() { 236 + if (isRecording) { 237 + stopRecording(); 238 + return; 239 + } 240 + await startRecording(); 241 + } 242 + 243 + function cleanupAudioNodes() { 244 + sourceNode?.disconnect(); 245 + sourceNode = null; 246 + analyser = null; 247 + 248 + if (audioContext) { 249 + void audioContext.close(); 250 + audioContext = null; 251 + } 252 + } 253 + 254 + function cleanupStream() { 255 + stream?.getTracks().forEach((track) => track.stop()); 256 + stream = null; 257 + } 258 + 259 + onDestroy(() => { 260 + stopRecording(); 261 + resizeObserver?.disconnect(); 262 + resizeObserver = null; 263 + }); 264 + 265 + onMount(() => { 266 + if (!visualizerEl) return; 267 + 268 + effectiveBarCount = Math.max(1, barCount); 269 + resetBars(); 270 + updateBarCount(visualizerEl.clientWidth); 271 + resizeObserver = new ResizeObserver((entries) => { 272 + const entry = entries[0]; 273 + if (!entry) return; 274 + updateBarCount(entry.contentRect.width); 275 + }); 276 + resizeObserver.observe(visualizerEl); 277 + }); 278 + </script> 279 + 280 + <div class={`w-full ${className}`}> 281 + <div class="flex items-end gap-3"> 282 + <div 283 + bind:this={visualizerEl} 284 + class=" 285 + grid h-11 flex-1 items-end gap-[3px] overflow-hidden rounded-md px-2 py-1 286 + border-s-slate-950 bg-[#4a5527] 287 + border-l-4 border-t-4 border-r border-b 288 + shadow-[inset_-1px_-1px_3px_rgba(34,40,18,0.85)] 289 + " 290 + style={`grid-template-columns: repeat(${bars.length}, minmax(0, 1fr));`} 291 + aria-hidden="true" 292 + > 293 + {#each bars as bar, index (index)} 294 + <span 295 + class="w-full rounded-sm bg-[#ffe600] shadow-[0_0_6px_rgba(255,230,0,0.55)] transition-[height] duration-200" 296 + style={`height: ${Math.max(8, bar * 100)}%`} 297 + ></span> 298 + {/each} 299 + </div> 300 + 301 + <IconButton 302 + type="button" 303 + aria-label={isRecording ? "Stop recording" : "Start recording"} 304 + onclick={toggleRecording} 305 + > 306 + {#if isRecording} 307 + <Square size={18} /> 308 + {:else} 309 + <Mic size={18} /> 310 + {/if} 311 + </IconButton> 312 + </div> 313 + 314 + {#if errorMessage} 315 + <p class="mt-2 text-sm text-red-700">{errorMessage}</p> 316 + {/if} 317 + </div>
+46
src/components/MicRecorderDemo.svelte
··· 1 + <script lang="ts"> 2 + import DownloadButton from "./DownloadButton.svelte"; 3 + import { onDestroy } from "svelte"; 4 + import MicRecorder from "./MicRecorder.svelte"; 5 + 6 + let recording = $state<Blob | null>(null); 7 + let audioUrl = $state(""); 8 + let audioExt = $state("webm"); 9 + let previousUrl = ""; 10 + 11 + $effect(() => { 12 + if (previousUrl) { 13 + URL.revokeObjectURL(previousUrl); 14 + previousUrl = ""; 15 + } 16 + 17 + if (!recording) { 18 + audioUrl = ""; 19 + audioExt = "webm"; 20 + return; 21 + } 22 + 23 + const type = recording.type || "audio/webm"; 24 + const match = type.match(/audio\/([^;]+)/); 25 + audioExt = match?.[1] || "webm"; 26 + previousUrl = URL.createObjectURL(recording); 27 + audioUrl = previousUrl; 28 + }); 29 + 30 + onDestroy(() => { 31 + if (previousUrl) URL.revokeObjectURL(previousUrl); 32 + }); 33 + </script> 34 + 35 + <div class="w-full max-w-3xl"> 36 + <MicRecorder barCount={48} bind:recording={recording} /> 37 + 38 + {#if audioUrl} 39 + <p class="mt-2 text-sm font-base text-slate-800">Recording captured.</p> 40 + <DownloadButton href={audioUrl} download={`recording.${audioExt}`}> 41 + Download recording 42 + </DownloadButton> 43 + {:else} 44 + <p class="mt-2 text-sm font-base text-slate-700">No recording yet.</p> 45 + {/if} 46 + </div>