Transcription
Time-synced transcript: the active segment is highlighted, past is muted, future is dimmed, click a segment to seek.
import { Transcription, TranscriptionSegment,} from "@/components/ai/transcription";import { useEffect, useRef, useState } from "react";
// Segments as the AI SDK `transcribe()` returns them.const words = [ "So the keeper checks drift", "every hour,", "and when NVDAx is more than two percent", "over its weight", "it asks you to approve a rebalance.",];const segments = words.map((text, i) => ({ text, startSecond: i * 2.4, endSecond: (i + 1) * 2.4,}));
export default function Demo() { const [time, setTime] = useState(4); const [playing, setPlaying] = useState(false); const raf = useRef(0);
// Stand-in for an <audio> element's `timeupdate`: advance the clock while playing. useEffect(() => { if (!playing) return; let last = performance.now(); const tick = (now: number) => { setTime((t) => (t + (now - last) / 1000) % 12); last = now; raf.current = requestAnimationFrame(tick); }; raf.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf.current); }, [playing]);
return ( <div className="flex w-full max-w-xl flex-col gap-3"> <button className="self-start rounded-md border border-border px-3 py-1 text-sm hover:bg-accent" onClick={() => setPlaying((p) => !p)} type="button" > {playing ? "Pause" : "Play"} ·{" "} <span className="font-mono">{time.toFixed(1)}s</span> </button> <Transcription currentTime={time} onSeek={setTime} segments={segments}> {(segment, index) => ( <TranscriptionSegment index={index} key={index} segment={segment} /> )} </Transcription> </div> );}<script setup lang="ts">import { Transcription, TranscriptionSegment } from "@/components/ai/transcription";import { onBeforeUnmount, ref, watch } from "vue";
// Segments as the AI SDK `transcribe()` returns them.const lines = [ "So the keeper checks drift", "every hour,", "and when NVDAx is more than two percent", "over its weight", "it asks you to approve a rebalance.",];const segments = lines.map((text, i) => ({ text, startSecond: i * 2.4, endSecond: (i + 1) * 2.4 }));
const time = ref(4);const playing = ref(false);let raf = 0;
// Stand-in for an <audio> element's `timeupdate`: advance the clock while playing.watch(playing, (on) => { cancelAnimationFrame(raf); if (!on) return; let last = performance.now(); const tick = (now: number) => { time.value = (time.value + (now - last) / 1000) % 12; last = now; raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick);});onBeforeUnmount(() => cancelAnimationFrame(raf));</script>
<template> <div class="flex w-full max-w-xl flex-col gap-3"> <button type="button" class="self-start rounded-md border border-border px-3 py-1 text-sm hover:bg-accent" @click="playing = !playing" > {{ playing ? "Pause" : "Play" }} · <span class="font-mono">{{ time.toFixed(1) }}s</span> </button> <Transcription v-model:current-time="time" :segments="segments"> <template #default="{ segment, index }"> <TranscriptionSegment :segment="segment" :index="index" /> </template> </Transcription> </div></template><script lang="ts"> import { Transcription, TranscriptionSegment } from "$lib/components/ai/transcription";
// Segments as the AI SDK `transcribe()` returns them. const lines = [ "So the keeper checks drift", "every hour,", "and when NVDAx is more than two percent", "over its weight", "it asks you to approve a rebalance.", ]; const segments = lines.map((text, i) => ({ text, startSecond: i * 2.4, endSecond: (i + 1) * 2.4 }));
let time = $state(4); let playing = $state(false); let raf = 0;
// Stand-in for an <audio> element's `timeupdate`: advance the clock while playing. $effect(() => { if (!playing) return; let last = performance.now(); const tick = (now: number) => { time = (time + (now - last) / 1000) % 12; last = now; raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); });</script>
<div class="flex w-full max-w-xl flex-col gap-3"> <button type="button" class="self-start rounded-md border border-border px-3 py-1 text-sm hover:bg-accent" onclick={() => (playing = !playing)} > {playing ? "Pause" : "Play"} · <span class="font-mono">{time.toFixed(1)}s</span> </button> <Transcription {segments} bind:currentTime={time} onSeek={() => {}}> {#snippet children(segment, index)} <TranscriptionSegment {segment} {index} /> {/snippet} </Transcription></div>Takes the segments of the AI SDK transcribe() result.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-transcriptionpnpm dlx shadcn@latest add @edmi-ui/ai-transcriptionyarn dlx shadcn@latest add @edmi-ui/ai-transcriptionbunx shadcn@latest add @edmi-ui/ai-transcriptionPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install aiTerminal window pnpm add aiTerminal window yarn add aiTerminal window bun add aiCopy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/ai-transcriptionpnpm dlx shadcn-vue@latest add @edmi-ui/ai-transcriptionyarn dlx shadcn-vue@latest add @edmi-ui/ai-transcriptionbunx shadcn-vue@latest add @edmi-ui/ai-transcriptionPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @vueuse/core aiTerminal window pnpm add @vueuse/core aiTerminal window yarn add @vueuse/core aiTerminal window bun add @vueuse/core aiCopy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-transcription.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-transcription.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-transcription.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-transcription.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Copy the code from Component source into your project and update the import paths to match your aliases.
import { Transcription, TranscriptionSegment,} from "@/components/ai/transcription";
<div className="flex w-full max-w-xl flex-col gap-3"> <button className="self-start rounded-md border border-border px-3 py-1 text-sm hover:bg-accent" type="button" > ·{" "} <span className="font-mono">s</span> </button> <Transcription> {(segment, index) => ( <TranscriptionSegment index={index} key={index} segment={segment} /> )} </Transcription></div><script setup lang="ts">import { Transcription, TranscriptionSegment,} from "@/components/ai/transcription";</script>
<template> <div class="flex w-full max-w-xl flex-col gap-3"> <button type="button" class="self-start rounded-md border border-border px-3 py-1 text-sm hover:bg-accent" > · <span class="font-mono">s</span> </button> <Transcription> <template #default="{ segment, index }"> <TranscriptionSegment :segment="segment" :index="index" /> </template> </Transcription> </div></template><script lang="ts"> import { Transcription, TranscriptionSegment, } from "$lib/components/ai/transcription";</script>
<div class="flex w-full max-w-xl flex-col gap-3"> <button type="button" class="self-start rounded-md border border-border px-3 py-1 text-sm hover:bg-accent" > · <span class="font-mono">s</span> </button> <Transcription onSeek={() => {}}> {#snippet children(segment, index)} <TranscriptionSegment {segment} {index} /> {/snippet} </Transcription></div>Component source
Section titled “Component source”"use client";
import type { Experimental_TranscriptionResult as TranscriptionResult } from "ai";import { cn } from "cn";import type { ComponentProps, ReactNode } from "react";import { createContext, useCallback, useContext, useMemo } from "react";import { useControllableState } from "@/registry/edmi/hooks/ai/use-controllable-state";
type TranscriptionSegment = TranscriptionResult["segments"][number];
interface TranscriptionContextValue { segments: TranscriptionSegment[]; currentTime: number; onTimeUpdate: (time: number) => void; onSeek?: (time: number) => void;}
const TranscriptionContext = createContext<TranscriptionContextValue | null>( null,);
const useTranscription = () => { const context = useContext(TranscriptionContext); if (!context) { throw new Error( "Transcription components must be used within Transcription", ); } return context;};
export type TranscriptionProps = Omit<ComponentProps<"div">, "children"> & { segments: TranscriptionSegment[]; currentTime?: number; onSeek?: (time: number) => void; children: (segment: TranscriptionSegment, index: number) => ReactNode;};
export const Transcription = ({ segments, currentTime: externalCurrentTime, onSeek, className, children, ...props}: TranscriptionProps) => { const [currentTime, setCurrentTime] = useControllableState({ defaultProp: 0, onChange: onSeek, prop: externalCurrentTime, });
const contextValue = useMemo( () => ({ currentTime, onSeek, onTimeUpdate: setCurrentTime, segments }), [currentTime, onSeek, setCurrentTime, segments], );
return ( <TranscriptionContext.Provider value={contextValue}> <div className={cn( "flex flex-wrap gap-x-1 gap-y-0.5 text-[15px] leading-[1.9]", className, )} data-slot="ai-transcription" {...props} > {segments .filter((segment) => segment.text.trim()) .map((segment, index) => children(segment, index))} </div> </TranscriptionContext.Provider> );};
export type TranscriptionSegmentProps = ComponentProps<"button"> & { segment: TranscriptionSegment; index: number;};
/** States: past (`--muted-foreground`), active (`--primary` fill), future (`--muted-foreground-2`). */export const TranscriptionSegment = ({ segment, index, className, onClick, ...props}: TranscriptionSegmentProps) => { const { currentTime, onSeek } = useTranscription();
const isActive = currentTime >= segment.startSecond && currentTime < segment.endSecond; const isPast = currentTime >= segment.endSecond;
const handleClick = useCallback( (event: React.MouseEvent<HTMLButtonElement>) => { if (onSeek) { onSeek(segment.startSecond); } onClick?.(event); }, [onSeek, segment.startSecond, onClick], );
return ( <button className={cn( "inline rounded px-[3px] py-0.5 text-left transition-colors", isActive && "bg-primary text-primary-foreground", isPast && "text-muted-foreground", !(isActive || isPast) && "text-muted-foreground-2", onSeek && !isActive && "cursor-pointer hover:text-foreground", !onSeek && "cursor-default", className, )} data-active={isActive} data-index={index} data-slot="ai-transcription-segment" onClick={handleClick} type="button" {...props} > {segment.text} </button> );};<script setup lang="ts">import type { HTMLAttributes } from "vue"import type { TranscriptionSegment } from "./context"import { useVModel } from "@vueuse/core"import { computed, getCurrentInstance, provide } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { TranscriptionKey } from "./context"
const props = withDefaults(defineProps<{ segments: TranscriptionSegment[] currentTime?: number class?: HTMLAttributes["class"]}>(), { currentTime: undefined })
const emit = defineEmits<{ (e: "update:currentTime", time: number): void (e: "seek", time: number): void}>()
const model = useVModel(props, "currentTime", emit, { defaultValue: 0, passive: true })const currentTime = computed({ get: () => model.value ?? 0, set: (time: number) => { model.value = time },})
// Segments are clickable only when somebody listens for `seek` or `update:currentTime`.const vnodeProps = getCurrentInstance()?.vnode.props ?? {}const seekable = "onSeek" in vnodeProps || "onUpdate:currentTime" in vnodeProps
provide(TranscriptionKey, { segments: computed(() => props.segments), currentTime, onSeek: seekable ? (time: number) => { currentTime.value = time emit("seek", time) } : undefined,})</script>
<template> <div data-slot="ai-transcription" :class="cn('flex flex-wrap gap-x-1 gap-y-0.5 text-[15px] leading-[1.9]', props.class)" > <template v-for="(segment, index) in props.segments" :key="`${segment.startSecond}-${segment.endSecond}`"> <slot v-if="segment.text.trim()" :segment="segment" :index="index" /> </template> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import type { TranscriptionSegment } from "./context"import { computed } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { useTranscriptionContext } from "./context"
// States: past (--muted-foreground), active (--primary fill), future (--muted-foreground-2).const props = defineProps<{ segment: TranscriptionSegment index: number class?: HTMLAttributes["class"]}>()
const { currentTime, onSeek } = useTranscriptionContext()
const isActive = computed(() => currentTime.value >= props.segment.startSecond && currentTime.value < props.segment.endSecond)const isPast = computed(() => currentTime.value >= props.segment.endSecond)
function handleClick() { onSeek?.(props.segment.startSecond)}</script>
<template> <button type="button" data-slot="ai-transcription-segment" :data-active="isActive" :data-index="index" :class="cn( 'inline rounded px-[3px] py-0.5 text-left transition-colors', isActive && 'bg-primary text-primary-foreground', isPast && 'text-muted-foreground', !(isActive || isPast) && 'text-muted-foreground-2', onSeek && !isActive && 'cursor-pointer hover:text-foreground', !onSeek && 'cursor-default', props.class, )" @click="handleClick" > {{ segment.text }} </button></template>import type { Experimental_TranscriptionResult as TranscriptionResult } from "ai";import type { ComputedRef, InjectionKey, Ref } from "vue";import { inject } from "vue";
export type TranscriptionSegment = NonNullable< TranscriptionResult["segments"]>[number];
export interface TranscriptionContextValue { segments: ComputedRef<TranscriptionSegment[]>; currentTime: Ref<number>; /** Present only when the consumer listens for `seek`; segments are clickable then. */ onSeek?: (time: number) => void;}
export const TranscriptionKey: InjectionKey<TranscriptionContextValue> = Symbol("Transcription");
export function useTranscriptionContext(): TranscriptionContextValue { const context = inject(TranscriptionKey); if (!context) { throw new Error( "Transcription components must be used within Transcription", ); } return context;}export * from "./context";export { default as Transcription } from "./Transcription.vue";export { default as TranscriptionSegment } from "./TranscriptionSegment.vue";<script lang="ts"> import { cn } from "$lib/utils.js"; import type { HTMLButtonAttributes } from "svelte/elements"; import { type TranscriptionSegment, useTranscription } from "./use-transcription.svelte.js";
// States: past (--muted-foreground), active (--primary fill), future (--muted-foreground-2). let { segment, index, class: className, onclick, ...restProps }: Omit<HTMLButtonAttributes, "children"> & { segment: TranscriptionSegment; index: number; } = $props();
const ctx = useTranscription(); const isActive = $derived( ctx.currentTime >= segment.startSecond && ctx.currentTime < segment.endSecond ); const isPast = $derived(ctx.currentTime >= segment.endSecond);</script>
<button type="button" data-slot="ai-transcription-segment" data-active={isActive} data-index={index} class={cn( "inline rounded px-[3px] py-0.5 text-left transition-colors", isActive && "bg-primary text-primary-foreground", isPast && "text-muted-foreground", !(isActive || isPast) && "text-muted-foreground-2", ctx.onSeek && !isActive && "cursor-pointer hover:text-foreground", !ctx.onSeek && "cursor-default", className )} onclick={(event) => { ctx.onSeek?.(segment.startSecond); onclick?.(event); }} {...restProps}> {segment.text}</button><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements"; import { setTranscriptionContext, type TranscriptionSegment } from "./use-transcription.svelte.js";
let { segments, currentTime = $bindable(0), onSeek, class: className, children, ...restProps }: Omit<HTMLAttributes<HTMLDivElement>, "children"> & { segments: TranscriptionSegment[]; /** Seconds; `bind:currentTime` to follow an audio element. */ currentTime?: number; onSeek?: (time: number) => void; /** Receives each segment and its index: `{#snippet children(segment, index)}`. */ children: Snippet<[TranscriptionSegment, number]>; } = $props();
setTranscriptionContext({ get segments() { return segments; }, get currentTime() { return currentTime; }, get onSeek() { return onSeek ? (time: number) => { currentTime = time; onSeek(time); } : undefined; }, });</script>
<div data-slot="ai-transcription" class={cn("flex flex-wrap gap-x-1 gap-y-0.5 text-[15px] leading-[1.9]", className)} {...restProps}> {#each segments as segment, index (`${segment.startSecond}-${segment.endSecond}-${index}`)} {#if segment.text.trim()} {@render children(segment, index)} {/if} {/each}</div>import type { Experimental_TranscriptionResult as TranscriptionResult } from "ai";import { getContext, setContext } from "svelte";
export type TranscriptionSegment = NonNullable< TranscriptionResult["segments"]>[number];
/** Getter object so consumers stay reactive when the props change. */export interface TranscriptionContext { readonly segments: TranscriptionSegment[]; readonly currentTime: number; /** Present only when the consumer passes `onSeek`; segments are clickable then. */ readonly onSeek: ((time: number) => void) | undefined;}
const KEY = Symbol("ai-transcription");
export function setTranscriptionContext(value: TranscriptionContext) { return setContext(KEY, value);}
export function useTranscription(): TranscriptionContext { const context = getContext<TranscriptionContext | undefined>(KEY); if (!context) { throw new Error( "Transcription components must be used within Transcription", ); } return context;}import Root from "./transcription.svelte";import Segment from "./transcription-segment.svelte";
export type { TranscriptionSegment as TranscriptionSegmentData } from "./use-transcription.svelte.js";export { Root, // Root as Transcription, Segment, Segment as TranscriptionSegment,};| Part | Prop | Type | Default |
|---|---|---|---|
| Transcription | segments |
AI SDK transcription segments | |
| Transcription | currentTime, onSeek |
number (seconds), (time) => void |
|
| Transcription | children |
(segment, index) => ReactNode |
|
| TranscriptionSegment | segment, index |
✦ Edmi additions
Section titled “✦ Edmi additions”- States use tokens: active =
--primaryfill with--primary-foreground, past =--muted-foreground, future =--muted-foreground-2.
Framework notes
Section titled “Framework notes”- Vue:
v-model:current-time,@seek, default slot{ segment, index }; segments are clickable only when a listener is attached. Svelte:bind:currentTime,onSeek,childrensnippet(segment, index).