Built for people who think better out loud.
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>