Built for people who think better out loud.

mic recorder: Add transcription demo

isaaccorbrey.com 88938c84 052dc3d1

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