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 transcription demo
isaaccorbrey.com
1 month ago
88938c84
052dc3d1
verified
This commit was signed with the committer's
known signature
.
isaaccorbrey.com
SSH Key Fingerprint:
SHA256:mwogCTZEXIXrYk4l7PaavTNPxe1Xqjf5jMIBe0LvAHU=
+143
-1
3 changed files
expand all
collapse all
unified
split
src
components
MicRecorder.stories.svelte
MicRecorderWhisperDemo.svelte
StorySlot.svelte
+7
-1
src/components/MicRecorder.stories.svelte
reviewed
···
1
1
<script module>
2
2
import { defineMeta } from "@storybook/addon-svelte-csf";
3
3
import MicRecorderDemo from "./MicRecorderDemo.svelte";
4
4
+
import MicRecorderWhisperDemo from "./MicRecorderWhisperDemo.svelte";
5
5
+
import StorySlot from "./StorySlot.svelte";
4
6
5
7
const { Story } = defineMeta({
6
8
title: "Components/MicRecorder",
7
7
-
component: MicRecorderDemo,
9
9
+
component: StorySlot,
8
10
});
9
11
</script>
10
12
11
13
<Story name="MicRecorder">
12
14
<MicRecorderDemo />
13
15
</Story>
16
16
+
17
17
+
<Story name="MicRecorder Whisper">
18
18
+
<MicRecorderWhisperDemo />
19
19
+
</Story>
+131
src/components/MicRecorderWhisperDemo.svelte
reviewed
···
1
1
+
<script lang="ts">
2
2
+
import Input from "./Input.svelte";
3
3
+
import MicRecorder from "./MicRecorder.svelte";
4
4
+
import Textarea from "./Textarea.svelte";
5
5
+
6
6
+
type RecordingCompletePayload = {
7
7
+
blob: Blob;
8
8
+
};
9
9
+
10
10
+
let apiKey = $state("");
11
11
+
let transcript = $state("");
12
12
+
let isTranscribing = $state(false);
13
13
+
let errorMessage = $state("");
14
14
+
let statusMessage = $state("Record audio to create a transcript.");
15
15
+
let activeRequestId = 0;
16
16
+
17
17
+
function extensionFromMimeType(mimeType: string) {
18
18
+
const match = mimeType.match(/audio\/([^;]+)/);
19
19
+
return match?.[1] || "webm";
20
20
+
}
21
21
+
22
22
+
async function transcribeAudio(blob: Blob) {
23
23
+
const trimmedApiKey = apiKey.trim();
24
24
+
if (!trimmedApiKey) {
25
25
+
errorMessage = "Enter an API key before recording.";
26
26
+
statusMessage = "Missing API key.";
27
27
+
return;
28
28
+
}
29
29
+
30
30
+
const requestId = ++activeRequestId;
31
31
+
isTranscribing = true;
32
32
+
errorMessage = "";
33
33
+
statusMessage = "Transcribing with Whisper...";
34
34
+
35
35
+
try {
36
36
+
const extension = extensionFromMimeType(blob.type || "audio/webm");
37
37
+
const file = new File([blob], `recording.${extension}`, {
38
38
+
type: blob.type || "audio/webm",
39
39
+
});
40
40
+
41
41
+
const formData = new FormData();
42
42
+
formData.append("file", file);
43
43
+
formData.append("model", "whisper-1");
44
44
+
45
45
+
const response = await fetch(
46
46
+
"https://api.openai.com/v1/audio/transcriptions",
47
47
+
{
48
48
+
method: "POST",
49
49
+
headers: {
50
50
+
Authorization: `Bearer ${trimmedApiKey}`,
51
51
+
},
52
52
+
body: formData,
53
53
+
},
54
54
+
);
55
55
+
56
56
+
if (!response.ok) {
57
57
+
const payload = await response.json().catch(() => ({}));
58
58
+
const message =
59
59
+
payload && typeof payload === "object" && "error" in payload
60
60
+
? String(
61
61
+
(payload as { error?: { message?: string } }).error?.message ||
62
62
+
"",
63
63
+
)
64
64
+
: "";
65
65
+
throw new Error(
66
66
+
message || `Transcription request failed (${response.status}).`,
67
67
+
);
68
68
+
}
69
69
+
70
70
+
const payload = (await response.json()) as { text?: string };
71
71
+
if (requestId !== activeRequestId) return;
72
72
+
73
73
+
transcript = payload.text?.trim() || "";
74
74
+
statusMessage = transcript
75
75
+
? "Transcription complete."
76
76
+
: "No text detected.";
77
77
+
} catch (error) {
78
78
+
if (requestId !== activeRequestId) return;
79
79
+
errorMessage =
80
80
+
error instanceof Error
81
81
+
? error.message
82
82
+
: "Unable to transcribe recording.";
83
83
+
statusMessage = "Transcription failed.";
84
84
+
} finally {
85
85
+
if (requestId === activeRequestId) {
86
86
+
isTranscribing = false;
87
87
+
}
88
88
+
}
89
89
+
}
90
90
+
91
91
+
async function handleRecordingComplete({ blob }: RecordingCompletePayload) {
92
92
+
await transcribeAudio(blob);
93
93
+
}
94
94
+
</script>
95
95
+
96
96
+
<div class="w-full max-w-3xl space-y-3">
97
97
+
<label class="block text-sm font-base text-slate-900">
98
98
+
Whisper API key
99
99
+
<Input
100
100
+
type="password"
101
101
+
autocomplete="off"
102
102
+
spellcheck={false}
103
103
+
value={apiKey}
104
104
+
oninput={(event) => {
105
105
+
apiKey = (event.currentTarget as HTMLInputElement).value;
106
106
+
}}
107
107
+
placeholder="sk-..."
108
108
+
class="mt-1 w-full"
109
109
+
/>
110
110
+
</label>
111
111
+
112
112
+
<Textarea
113
113
+
rows={6}
114
114
+
value={transcript}
115
115
+
oninput={(event) => {
116
116
+
transcript = (event.currentTarget as HTMLTextAreaElement).value;
117
117
+
}}
118
118
+
placeholder="Transcription appears here after recording."
119
119
+
class="w-full"
120
120
+
/>
121
121
+
122
122
+
<MicRecorder barCount={48} onrecordingcomplete={handleRecordingComplete} />
123
123
+
124
124
+
<p class="text-sm font-base text-slate-700">
125
125
+
{isTranscribing ? "Transcribing..." : statusMessage}
126
126
+
</p>
127
127
+
128
128
+
{#if errorMessage}
129
129
+
<p class="text-sm font-base text-red-700">{errorMessage}</p>
130
130
+
{/if}
131
131
+
</div>
+5
src/components/StorySlot.svelte
reviewed
···
1
1
+
<script lang="ts">
2
2
+
let { children } = $props();
3
3
+
</script>
4
4
+
5
5
+
{@render children?.()}