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