Built for people who think better out loud.
at main 336 lines 8.9 kB view raw
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 smoothBarsForStop(values: number[], passes = 2): number[] { 75 if (values.length < 3 || passes <= 0) return values; 76 77 let smoothed = [...values]; 78 for (let pass = 0; pass < passes; pass += 1) { 79 const next = [...smoothed]; 80 for (let i = 1; i < smoothed.length - 1; i += 1) { 81 const prev = smoothed[i - 1]; 82 const current = smoothed[i]; 83 const upcoming = smoothed[i + 1]; 84 // Weighted moving average to mimic the visual easing while bars are in motion. 85 next[i] = Math.max(0.08, prev * 0.25 + current * 0.5 + upcoming * 0.25); 86 } 87 smoothed = next; 88 } 89 90 return smoothed; 91 } 92 93 function getRecorderOptions(): MediaRecorderOptions | undefined { 94 if (typeof MediaRecorder === "undefined" || !MediaRecorder.isTypeSupported) return undefined; 95 96 const candidates = [ 97 "audio/mp4;codecs=mp4a.40.2", 98 "audio/mp4", 99 "audio/webm;codecs=opus", 100 "audio/webm", 101 ]; 102 103 for (const mimeType of candidates) { 104 if (MediaRecorder.isTypeSupported(mimeType)) return { mimeType }; 105 } 106 107 return undefined; 108 } 109 110 function finalizeRecording() { 111 if (!mediaRecorder || isFinalizing) return; 112 isFinalizing = true; 113 114 const type = mediaRecorder.mimeType || "audio/webm"; 115 if (chunks.length === 0) { 116 errorMessage = "No audio data was captured. Please try recording again."; 117 cleanupAudioNodes(); 118 cleanupStream(); 119 mediaRecorder = null; 120 chunks = []; 121 isFinalizing = false; 122 return; 123 } 124 125 const blob = new Blob(chunks, { type }); 126 const durationMs = Math.max(0, Date.now() - recordingStartedAt); 127 128 const endedAt = new Date(); 129 const metadata: RecordingMetadata = { 130 mimeType: blob.type || type, 131 sizeBytes: blob.size, 132 durationMs, 133 startedAt: recordingStartedAtIso, 134 endedAt: endedAt.toISOString(), 135 }; 136 137 recording = blob; 138 onrecordingcomplete?.({ blob, metadata }); 139 140 cleanupAudioNodes(); 141 cleanupStream(); 142 mediaRecorder = null; 143 chunks = []; 144 isFinalizing = false; 145 } 146 147 function tick() { 148 if (!analyser) return; 149 150 const buffer = new Uint8Array(analyser.fftSize); 151 analyser.getByteTimeDomainData(buffer); 152 153 let sum = 0; 154 for (const value of buffer) { 155 const normalized = (value - 128) / 128; 156 sum += normalized * normalized; 157 } 158 159 const rms = Math.sqrt(sum / buffer.length); 160 const boosted = Math.min(1, rms * 14); 161 const logScaled = Math.log1p(boosted * 9) / Math.log(10); 162 const scaled = Math.min(1, Math.max(0.08, logScaled)); 163 164 frameStep += 1; 165 if (frameStep % 3 === 0) { 166 bars = [...bars.slice(1), scaled]; 167 } 168 169 frameId = window.requestAnimationFrame(tick); 170 } 171 172 async function startRecording() { 173 errorMessage = ""; 174 recording = null; 175 recordingStartedAt = Date.now(); 176 recordingStartedAtIso = new Date(recordingStartedAt).toISOString(); 177 isFinalizing = false; 178 179 try { 180 stream = await navigator.mediaDevices.getUserMedia({ audio: true }); 181 chunks = []; 182 frameStep = 0; 183 resetBars(); 184 185 mediaRecorder = new MediaRecorder(stream, getRecorderOptions()); 186 mediaRecorder.ondataavailable = (event) => { 187 if (event.data.size > 0) chunks.push(event.data); 188 }; 189 mediaRecorder.onstop = () => { 190 finalizeRecording(); 191 }; 192 mediaRecorder.onerror = () => { 193 errorMessage = "Recording failed. Please try again."; 194 cleanupAudioNodes(); 195 cleanupStream(); 196 mediaRecorder = null; 197 chunks = []; 198 }; 199 mediaRecorder.start(); 200 201 audioContext = new AudioContext(); 202 analyser = audioContext.createAnalyser(); 203 analyser.fftSize = 1024; 204 analyser.smoothingTimeConstant = 0.8; 205 206 sourceNode = audioContext.createMediaStreamSource(stream); 207 sourceNode.connect(analyser); 208 209 isRecording = true; 210 tick(); 211 } catch (error) { 212 errorMessage = error instanceof Error ? error.message : "Microphone access failed"; 213 cleanupAudioNodes(); 214 cleanupStream(); 215 isRecording = false; 216 } 217 } 218 219 function stopRecording() { 220 if (!isRecording) return; 221 isRecording = false; 222 bars = smoothBarsForStop(bars); 223 224 if (frameId) { 225 window.cancelAnimationFrame(frameId); 226 frameId = 0; 227 } 228 frameStep = 0; 229 230 if (mediaRecorder && mediaRecorder.state !== "inactive") { 231 try { 232 mediaRecorder.requestData(); 233 } catch { 234 // Some engines can throw here during stop transitions. 235 } 236 237 try { 238 mediaRecorder.stop(); 239 } catch { 240 errorMessage = "Unable to stop recording cleanly."; 241 cleanupAudioNodes(); 242 cleanupStream(); 243 mediaRecorder = null; 244 chunks = []; 245 return; 246 } 247 return; 248 } 249 250 cleanupAudioNodes(); 251 cleanupStream(); 252 } 253 254 async function toggleRecording() { 255 if (isRecording) { 256 stopRecording(); 257 return; 258 } 259 await startRecording(); 260 } 261 262 function cleanupAudioNodes() { 263 sourceNode?.disconnect(); 264 sourceNode = null; 265 analyser = null; 266 267 if (audioContext) { 268 void audioContext.close(); 269 audioContext = null; 270 } 271 } 272 273 function cleanupStream() { 274 stream?.getTracks().forEach((track) => track.stop()); 275 stream = null; 276 } 277 278 onDestroy(() => { 279 stopRecording(); 280 resizeObserver?.disconnect(); 281 resizeObserver = null; 282 }); 283 284 onMount(() => { 285 if (!visualizerEl) return; 286 287 effectiveBarCount = Math.max(1, barCount); 288 resetBars(); 289 updateBarCount(visualizerEl.clientWidth); 290 resizeObserver = new ResizeObserver((entries) => { 291 const entry = entries[0]; 292 if (!entry) return; 293 updateBarCount(entry.contentRect.width); 294 }); 295 resizeObserver.observe(visualizerEl); 296 }); 297</script> 298 299<div class={`w-full ${className}`}> 300 <div class="flex items-end gap-3"> 301 <div 302 bind:this={visualizerEl} 303 class=" 304 grid h-11 flex-1 items-end gap-[3px] overflow-hidden rounded-md px-2 py-1 305 border-s-slate-950 bg-[#4a5527] 306 border-l-4 border-t-4 border-r border-b 307 shadow-[inset_-1px_-1px_3px_rgba(34,40,18,0.85)] 308 " 309 style={`grid-template-columns: repeat(${bars.length}, minmax(0, 1fr));`} 310 aria-hidden="true" 311 > 312 {#each bars as bar, index (index)} 313 <span 314 class="w-full rounded-sm bg-[#ffe600] shadow-[0_0_6px_rgba(255,230,0,0.55)] transition-[height] duration-200" 315 style={`height: ${Math.max(8, bar * 100)}%`} 316 ></span> 317 {/each} 318 </div> 319 320 <IconButton 321 type="button" 322 aria-label={isRecording ? "Stop recording" : "Start recording"} 323 onclick={toggleRecording} 324 > 325 {#if isRecording} 326 <Square size={18} /> 327 {:else} 328 <Mic size={18} /> 329 {/if} 330 </IconButton> 331 </div> 332 333 {#if errorMessage} 334 <p class="mt-2 text-sm text-red-700">{errorMessage}</p> 335 {/if} 336</div>