{
	"$schema": "https://shadcn-svelte.com/schema/registry-item.json",
	"name": "transcriber-01",
	"title": "Transcriber 01",
	"type": "registry:block",
	"description": "A record-and-transcribe card with live waveform, elapsed timing, copy-to-clipboard, and a provider-agnostic transcription backend (Web Speech API demo).",
	"dependencies": [
		"@lucide/svelte"
	],
	"devDependencies": [
		"@lucide/svelte@^1.7.0",
		"tailwind-variants@^1.0.0"
	],
	"registryDependencies": [
		"button",
		"card",
		"https://sv11.ui.twango.dev/r/live-waveform.json",
		"https://sv11.ui.twango.dev/r/response.json",
		"scroll-area",
		"separator"
	],
	"files": [
		{
			"content": "<script lang=\"ts\" module>\n\timport { tv } from \"tailwind-variants\";\n\n\texport const transcriber01Variants = tv({\n\t\tslots: {\n\t\t\troot: \"mx-auto w-full max-w-xl\",\n\t\t\tcontent: \"flex flex-col gap-4\",\n\t\t\tstage:\n\t\t\t\t\"bg-muted/40 relative flex h-32 items-center justify-center overflow-hidden rounded-lg border\",\n\t\t\tscroll: \"h-full w-full\",\n\t\t\tresult: \"p-4 text-sm\",\n\t\t\terrorText: \"text-destructive\",\n\t\t\temptyText: \"text-muted-foreground\",\n\t\t\tcopyButton: \"absolute top-2 right-2 size-7\",\n\t\t\twaveform: \"h-full w-full\",\n\t\t\ttoolbar: \"flex items-center justify-between gap-3\",\n\t\t\ttimer: \"text-muted-foreground font-mono text-xs tabular-nums\",\n\t\t\tkbd: \"bg-muted text-muted-foreground ms-1 hidden rounded px-1.5 py-0.5 font-mono text-[10px] sm:inline\",\n\t\t\tnote: \"text-muted-foreground text-xs\",\n\t\t\tcode: \"bg-muted rounded px-1 py-0.5\",\n\t\t},\n\t});\n\n\texport type Transcriber01Props = {\n\t\t/**\n\t\t * Provider-agnostic transcription backend. Receives the recorded audio\n\t\t * and resolves with the transcript text. When omitted, the block uses the\n\t\t * browser Web Speech API as a zero-backend demo (Chromium only).\n\t\t */\n\t\ttranscribe?: (audio: Blob) => Promise<string>;\n\t\tclass?: string;\n\t};\n</script>\n\n<script lang=\"ts\">\n\timport CopyIcon from \"@lucide/svelte/icons/copy\";\n\timport CheckIcon from \"@lucide/svelte/icons/check\";\n\timport MicIcon from \"@lucide/svelte/icons/mic\";\n\timport SquareIcon from \"@lucide/svelte/icons/square\";\n\timport { onDestroy, onMount } from \"svelte\";\n\timport { cn } from \"$UTILS$.js\";\n\timport { Button } from \"$UI$/button/index.js\";\n\timport {\n\t\tCard,\n\t\tCardContent,\n\t\tCardHeader,\n\t\tCardTitle,\n\t\tCardDescription,\n\t} from \"$UI$/card/index.js\";\n\timport { ScrollArea } from \"$UI$/scroll-area/index.js\";\n\timport { Separator } from \"$UI$/separator/index.js\";\n\timport { LiveWaveform } from \"$UI$/live-waveform/index.js\";\n\timport { Response } from \"$UI$/response/index.js\";\n\timport { SpeechSession, isSpeechRecognitionSupported } from \"./speech.js\";\n\n\tlet { transcribe, class: className }: Transcriber01Props = $props();\n\n\tconst ui = transcriber01Variants();\n\n\ttype Status = \"idle\" | \"recording\" | \"processing\" | \"done\" | \"error\";\n\n\tlet status = $state<Status>(\"idle\");\n\tlet transcript = $state(\"\");\n\tlet error = $state(\"\");\n\tlet elapsed = $state<number | null>(null);\n\tlet copied = $state(false);\n\n\t// Optimistic until mounted so prerendered HTML doesn't flash the \"unsupported\"\n\t// note before the client can feature-detect.\n\tlet mounted = $state(false);\n\tonMount(() => (mounted = true));\n\tconst supported = $derived(\n\t\t!mounted || isSpeechRecognitionSupported() || typeof transcribe === \"function\"\n\t);\n\n\tconst session = new SpeechSession();\n\tlet mediaRecorder: MediaRecorder | null = null;\n\tlet chunks: Blob[] = [];\n\tlet stream: MediaStream | null = null;\n\tlet startedAt = 0;\n\t// Bumped on every start/stop so a stop issued while getUserMedia() is still\n\t// resolving invalidates that startup and the late stream is released instead\n\t// of recording in the background with the UI already marked done.\n\tlet recordToken = 0;\n\n\tconst isRecording = $derived(status === \"recording\");\n\tconst isProcessing = $derived(status === \"processing\");\n\n\tfunction teardownStream() {\n\t\tstream?.getTracks().forEach((t) => t.stop());\n\t\tstream = null;\n\t\tmediaRecorder = null;\n\t\tchunks = [];\n\t}\n\n\tasync function start() {\n\t\terror = \"\";\n\t\ttranscript = \"\";\n\t\telapsed = null;\n\t\tstartedAt = Date.now();\n\t\tstatus = \"recording\";\n\t\tconst token = ++recordToken;\n\n\t\ttry {\n\t\t\tif (transcribe) {\n\t\t\t\t// Real backend: capture audio for the adapter to transcribe.\n\t\t\t\tconst captured = await navigator.mediaDevices.getUserMedia({ audio: true });\n\t\t\t\tif (token !== recordToken) {\n\t\t\t\t\t// Stopped before the recorder was ready — release the late stream.\n\t\t\t\t\tcaptured.getTracks().forEach((t) => t.stop());\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tstream = captured;\n\t\t\t\tconst mimeType = MediaRecorder.isTypeSupported(\"audio/webm;codecs=opus\")\n\t\t\t\t\t? \"audio/webm;codecs=opus\"\n\t\t\t\t\t: \"audio/webm\";\n\t\t\t\tchunks = [];\n\t\t\t\tmediaRecorder = new MediaRecorder(stream, { mimeType });\n\t\t\t\tmediaRecorder.ondataavailable = (e) => e.data.size > 0 && chunks.push(e.data);\n\t\t\t\tmediaRecorder.start();\n\t\t\t} else {\n\t\t\t\t// Demo: browser Web Speech API, no audio upload needed.\n\t\t\t\tsession.start(\"en-US\", {\n\t\t\t\t\tonError: (err) => fail(err.message),\n\t\t\t\t});\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tif (token === recordToken) {\n\t\t\t\tfail(err instanceof Error ? err.message : \"Could not access the microphone.\");\n\t\t\t}\n\t\t}\n\t}\n\n\tasync function stop() {\n\t\t// Invalidate any in-flight startup so a not-yet-ready recorder bails out.\n\t\trecordToken++;\n\t\telapsed = (Date.now() - startedAt) / 1000;\n\t\tstatus = \"processing\";\n\t\ttry {\n\t\t\tif (transcribe) {\n\t\t\t\tif (mediaRecorder) {\n\t\t\t\t\tconst blob = await new Promise<Blob>((resolve) => {\n\t\t\t\t\t\tmediaRecorder!.onstop = () =>\n\t\t\t\t\t\t\tresolve(new Blob(chunks, { type: mediaRecorder!.mimeType }));\n\t\t\t\t\t\tmediaRecorder!.stop();\n\t\t\t\t\t});\n\t\t\t\t\tteardownStream();\n\t\t\t\t\ttranscript = (await transcribe(blob)).trim();\n\t\t\t\t} else {\n\t\t\t\t\t// Stopped before getUserMedia()/MediaRecorder were ready — nothing captured.\n\t\t\t\t\tteardownStream();\n\t\t\t\t\ttranscript = \"\";\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tawait session.stop();\n\t\t\t\ttranscript = session.committed.trim();\n\t\t\t}\n\t\t\tstatus = \"done\";\n\t\t} catch (err) {\n\t\t\tfail(err instanceof Error ? err.message : \"Transcription failed.\");\n\t\t}\n\t}\n\n\tfunction fail(message: string) {\n\t\terror = message;\n\t\tstatus = \"error\";\n\t\tsession.abort();\n\t\tteardownStream();\n\t}\n\n\tfunction toggle() {\n\t\tif (!supported) return;\n\t\tif (isRecording) void stop();\n\t\telse if (status !== \"processing\") void start();\n\t}\n\n\tfunction onKeydown(event: KeyboardEvent) {\n\t\t// ⌥Space (Alt+Space) toggles recording — but not while typing in a field.\n\t\tconst target = event.target as HTMLElement | null;\n\t\tif (target?.tagName === \"INPUT\" || target?.tagName === \"TEXTAREA\" || target?.isContentEditable)\n\t\t\treturn;\n\t\tif (event.altKey && event.code === \"Space\") {\n\t\t\tevent.preventDefault();\n\t\t\ttoggle();\n\t\t}\n\t}\n\n\tasync function copy() {\n\t\tif (!transcript) return;\n\t\ttry {\n\t\t\tawait navigator.clipboard.writeText(transcript);\n\t\t} catch {\n\t\t\treturn; // clipboard blocked (insecure context / denied) — leave UI untouched\n\t\t}\n\t\tcopied = true;\n\t\tsetTimeout(() => (copied = false), 1500);\n\t}\n\n\tonDestroy(() => {\n\t\tsession.abort();\n\t\tteardownStream();\n\t});\n</script>\n\n<svelte:window onkeydown={onKeydown} />\n\n<Card class={cn(ui.root(), className)}>\n\t<CardHeader>\n\t\t<CardTitle>Transcriber</CardTitle>\n\t\t<CardDescription>Record a clip and transcribe it to text.</CardDescription>\n\t</CardHeader>\n\t<CardContent class={ui.content()}>\n\t\t<div class={ui.stage()}>\n\t\t\t{#if status === \"done\" || status === \"error\"}\n\t\t\t\t<ScrollArea class={ui.scroll()}>\n\t\t\t\t\t<div class={ui.result()}>\n\t\t\t\t\t\t{#if error}\n\t\t\t\t\t\t\t<p class={ui.errorText()}>{error}</p>\n\t\t\t\t\t\t{:else if transcript}\n\t\t\t\t\t\t\t<Response content={transcript} />\n\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t<p class={ui.emptyText()}>No speech detected. Try again.</p>\n\t\t\t\t\t\t{/if}\n\t\t\t\t\t</div>\n\t\t\t\t</ScrollArea>\n\t\t\t\t{#if transcript && !error}\n\t\t\t\t\t<Button\n\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\tsize=\"icon\"\n\t\t\t\t\t\tclass={ui.copyButton()}\n\t\t\t\t\t\tonclick={copy}\n\t\t\t\t\t\taria-label=\"Copy transcript\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{#if copied}<CheckIcon class=\"size-3.5\" />{:else}<CopyIcon class=\"size-3.5\" />{/if}\n\t\t\t\t\t</Button>\n\t\t\t\t{/if}\n\t\t\t{:else}\n\t\t\t\t<LiveWaveform\n\t\t\t\t\tactive={isRecording}\n\t\t\t\t\tprocessing={isProcessing}\n\t\t\t\t\tbarColor=\"#71717a\"\n\t\t\t\t\tfadeEdges\n\t\t\t\t\tsensitivity={0.8}\n\t\t\t\t\tclass={cn(ui.waveform(), isProcessing && \"opacity-60\")}\n\t\t\t\t/>\n\t\t\t{/if}\n\t\t</div>\n\n\t\t<Separator />\n\n\t\t<div class={ui.toolbar()}>\n\t\t\t<span class={ui.timer()}>\n\t\t\t\t{#if status === \"error\"}\n\t\t\t\t\tError\n\t\t\t\t{:else if elapsed !== null}\n\t\t\t\t\t{elapsed.toFixed(2)}s\n\t\t\t\t{:else}\n\t\t\t\t\t&nbsp;\n\t\t\t\t{/if}\n\t\t\t</span>\n\t\t\t<Button\n\t\t\t\tonclick={toggle}\n\t\t\t\tdisabled={!supported || isProcessing}\n\t\t\t\tvariant={isRecording ? \"secondary\" : \"default\"}\n\t\t\t>\n\t\t\t\t{#if isRecording}\n\t\t\t\t\t<SquareIcon class=\"size-4\" /> Stop\n\t\t\t\t{:else}\n\t\t\t\t\t<MicIcon class=\"size-4\" /> {isProcessing ? \"Transcribing…\" : \"Record\"}\n\t\t\t\t{/if}\n\t\t\t\t<kbd class={ui.kbd()}>⌥Space</kbd>\n\t\t\t</Button>\n\t\t</div>\n\n\t\t{#if !supported}\n\t\t\t<p class={ui.note()}>\n\t\t\t\tThis demo uses the browser Web Speech API (Chromium-based browsers). Pass a\n\t\t\t\t<code class={ui.code()}>transcribe</code> function to wire any provider.\n\t\t\t</p>\n\t\t{/if}\n\t</CardContent>\n</Card>\n",
			"type": "registry:block",
			"target": "components/blocks/transcriber-01/transcriber-01.svelte"
		},
		{
			"content": "// Minimal Web Speech API surface. These types are not part of the default\n// TypeScript DOM lib, so we declare just what this block uses. The block uses\n// the browser's SpeechRecognition as a zero-backend demo transcriber; swap in a\n// real provider by passing the `transcribe` prop on <Transcriber01>.\n\ntype SpeechRecognitionAlternative = { transcript: string };\ntype SpeechRecognitionResult = { 0: SpeechRecognitionAlternative; isFinal: boolean };\ntype SpeechRecognitionResultList = {\n\tlength: number;\n\t[index: number]: SpeechRecognitionResult;\n};\ntype SpeechRecognitionEvent = {\n\tresultIndex: number;\n\tresults: SpeechRecognitionResultList;\n};\n\nexport interface SpeechRecognitionLike {\n\tcontinuous: boolean;\n\tinterimResults: boolean;\n\tlang: string;\n\tstart(): void;\n\tstop(): void;\n\tabort(): void;\n\tonresult: ((event: SpeechRecognitionEvent) => void) | null;\n\tonerror: ((event: { error: string }) => void) | null;\n\tonend: (() => void) | null;\n}\n\ntype SpeechRecognitionCtor = new () => SpeechRecognitionLike;\n\nexport function getSpeechRecognition(): SpeechRecognitionCtor | null {\n\tif (typeof window === \"undefined\") return null;\n\tconst w = window as unknown as {\n\t\tSpeechRecognition?: SpeechRecognitionCtor;\n\t\twebkitSpeechRecognition?: SpeechRecognitionCtor;\n\t};\n\treturn w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;\n}\n\nexport const isSpeechRecognitionSupported = () => getSpeechRecognition() !== null;\n\nexport interface SpeechSessionCallbacks {\n\t/** Fired with the running interim (not-yet-final) transcript. */\n\tonPartial?: (text: string) => void;\n\t/** Fired each time a phrase is finalized, with the full committed text. */\n\tonFinal?: (text: string) => void;\n\tonError?: (error: Error) => void;\n}\n\n/**\n * Thin wrapper over the browser SpeechRecognition that accumulates committed\n * text and surfaces interim results. Used as the demo STT for the transcriber\n * blocks — no API key, runs entirely in the browser (Chromium-based).\n */\nexport class SpeechSession {\n\t#recognition: SpeechRecognitionLike | null = null;\n\t#committed = \"\";\n\n\tget committed() {\n\t\treturn this.#committed;\n\t}\n\n\tstart(lang: string, callbacks: SpeechSessionCallbacks): void {\n\t\tconst Ctor = getSpeechRecognition();\n\t\tif (!Ctor) {\n\t\t\tcallbacks.onError?.(new Error(\"SpeechRecognition is not supported in this browser.\"));\n\t\t\treturn;\n\t\t}\n\t\tthis.#committed = \"\";\n\t\tconst recognition = new Ctor();\n\t\trecognition.continuous = true;\n\t\trecognition.interimResults = true;\n\t\trecognition.lang = lang;\n\n\t\trecognition.onresult = (event) => {\n\t\t\tlet interim = \"\";\n\t\t\tfor (let i = event.resultIndex; i < event.results.length; i++) {\n\t\t\t\tconst result = event.results[i];\n\t\t\t\tconst text = result[0].transcript;\n\t\t\t\tif (result.isFinal) {\n\t\t\t\t\tthis.#committed = (this.#committed + \" \" + text).trim();\n\t\t\t\t\tcallbacks.onFinal?.(this.#committed);\n\t\t\t\t} else {\n\t\t\t\t\tinterim += text;\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (interim) callbacks.onPartial?.((this.#committed + \" \" + interim).trim());\n\t\t};\n\t\trecognition.onerror = (event) => {\n\t\t\t// \"no-speech\" / \"aborted\" are benign stop conditions, not failures.\n\t\t\tif (event.error !== \"no-speech\" && event.error !== \"aborted\") {\n\t\t\t\tcallbacks.onError?.(new Error(`Speech recognition error: ${event.error}`));\n\t\t\t}\n\t\t};\n\n\t\tthis.#recognition = recognition;\n\t\trecognition.start();\n\t}\n\n\t/**\n\t * Stop recognition and resolve once it has fully ended, so the caller can read\n\t * `committed` only after the final `onresult` has landed. Falls back to a short\n\t * timeout in case the browser never fires `onend`.\n\t */\n\tstop(): Promise<void> {\n\t\tconst recognition = this.#recognition;\n\t\tthis.#recognition = null;\n\t\tif (!recognition) return Promise.resolve();\n\t\treturn new Promise<void>((resolve) => {\n\t\t\tlet settled = false;\n\t\t\tconst finish = () => {\n\t\t\t\tif (settled) return;\n\t\t\t\tsettled = true;\n\t\t\t\tresolve();\n\t\t\t};\n\t\t\trecognition.onend = finish;\n\t\t\tsetTimeout(finish, 600);\n\t\t\trecognition.stop();\n\t\t});\n\t}\n\n\tabort(): void {\n\t\tthis.#recognition?.abort();\n\t\tthis.#recognition = null;\n\t}\n}\n",
			"type": "registry:block",
			"target": "components/blocks/transcriber-01/speech.ts"
		},
		{
			"content": "import Transcriber01 from \"./transcriber-01.svelte\";\n\nexport { Transcriber01, Transcriber01 as default };\nexport type { Transcriber01Props } from \"./transcriber-01.svelte\";\nexport { SpeechSession, getSpeechRecognition, isSpeechRecognitionSupported } from \"./speech.js\";\nexport type { SpeechSessionCallbacks, SpeechRecognitionLike } from \"./speech.js\";\n",
			"type": "registry:block",
			"target": "components/blocks/transcriber-01/index.ts"
		}
	]
}