# Audio

6 on-device blocks in the Audio category — each installs and runs on its own, entirely in the browser.

## Voice Notes

Record or upload audio and get a text transcript back. Save transcripts as notes, replay them word by word in sync with the audio, and search your notes by meaning. Runs entirely in your browser; nothing downloads until you transcribe, upload, or search.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/audio/voice-notes
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/audio/voice-notes.json

```tsx
'use client';

/**
 * @file voice-notes.tsx
 * @description Voice Notes — record or upload → Whisper/Moonshine transcription → saved notes with synced word replay and block-local semantic search; owns its STT selector.
 */

import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import {
  useModelLoad,
  useTranscribe,
  useVoiceRecorder,
  type UseModelLoadReturn,
} from '@localmode/react';
import type { EmbeddingModel, SpeechToTextModel, VectorDB } from '@localmode/core';
import { transcribe, createVectorDB, embed } from '@localmode/core';
import { transformers, isModelCached } from '@localmode/transformers';

import { VoiceButton, type VoiceButtonState } from '@/components/voice-button';
import { MicSelector } from '@/components/mic-selector';
import { WaveformActivityBars } from '@/components/waveform-activity-bars';
import { FileDropzone } from '@/components/file-dropzone';
import { TranscribedNoteCard } from '@/components/transcribed-note-card';
import { SyncedTranscriptViewer } from '@/components/synced-transcript-viewer';
import { ScoredResultBarList, type ScoredResult } from '@/components/scored-result-bar-list';
import { ModelSelector } from '@/components/model-selector';
import { ModelLoadingPanel } from '@/components/model-loading-panel';
import { ErrorAlert } from '@/components/error-alert';


export interface NoteSearchHit {
  noteId: string;
  score: number;
}

interface IndexableNote {
  id: string;
  text: string;
}

export function useNoteIndex() {
  const dbPromiseRef = useRef<Promise<VectorDB<{ text: string }>> | null>(null);
  const indexedIdsRef = useRef<Set<string>>(new Set());

  const getDb = (dimensions: number): Promise<VectorDB<{ text: string }>> => {
    if (!dbPromiseRef.current) {
      const promise = createVectorDB<{ text: string }>({
        name: 'voice-notes-search',
        dimensions,
        storage: 'memory',
      });
      promise.catch(() => {
        if (dbPromiseRef.current === promise) dbPromiseRef.current = null;
      });
      dbPromiseRef.current = promise;
    }
    return dbPromiseRef.current;
  };

  useEffect(() => {
    const indexedIds = indexedIdsRef.current;
    return () => {
      const pending = dbPromiseRef.current;
      dbPromiseRef.current = null;
      indexedIds.clear();
      if (pending) void pending.then((db) => db.close()).catch(() => {});
    };
  }, []);

  const search = async (
    model: EmbeddingModel,
    notes: readonly IndexableNote[],
    query: string,
    abortSignal?: AbortSignal,
  ): Promise<NoteSearchHit[]> => {
    abortSignal?.throwIfAborted();

    const { embedding: queryVector } = await embed({
      model,
      value: query,
      ...(abortSignal ? { abortSignal } : {}),
    });
    const db = await getDb(queryVector.length);
    const indexedIds = indexedIdsRef.current;

    const liveIds = new Set(notes.map((n) => n.id));
    for (const id of [...indexedIds]) {
      if (!liveIds.has(id)) {
        abortSignal?.throwIfAborted();
        await db.delete(id);
        indexedIds.delete(id);
      }
    }

    for (const note of notes) {
      if (indexedIds.has(note.id)) continue;
      abortSignal?.throwIfAborted();
      const { embedding } = await embed({
        model,
        value: note.text,
        ...(abortSignal ? { abortSignal } : {}),
      });
      await db.add({ id: note.id, vector: embedding, metadata: { text: note.text } });
      indexedIds.add(note.id);
    }

    abortSignal?.throwIfAborted();
    const results = await db.search(queryVector, { k: Math.max(notes.length, 1) });
    return results.map((r) => ({ noteId: r.id, score: r.score }));
  };

  return { search };
}


interface SttModelEntry {
  id: string;
  name: string;
  size: string;
  timestamps: boolean;
}

const STT_MODELS: readonly SttModelEntry[] = [
  { id: 'Xenova/whisper-tiny.en', name: 'Whisper Tiny EN', size: '~40MB', timestamps: true },
  { id: 'onnx-community/moonshine-tiny-ONNX', name: 'Moonshine Tiny', size: '~50MB', timestamps: false },
  { id: 'onnx-community/moonshine-base-ONNX', name: 'Moonshine Base', size: '~237MB', timestamps: false },
];

const DEFAULT_STT_MODEL_ID = STT_MODELS[0].id;

function sttSupportsTimestamps(modelId: string): boolean {
  return STT_MODELS.find((m) => m.id === modelId)?.timestamps ?? false;
}

const EMBEDDING_MODEL_ID = 'Xenova/bge-small-en-v1.5';
const EMBEDDING_MODEL_SIZE = '~34MB';

const NO_SPEECH_TEXT = '[No speech detected]';

const ACCEPTED_AUDIO_MIME_TYPES = [
  'audio/wav',
  'audio/x-wav',
  'audio/wave',
  'audio/mp3',
  'audio/mpeg',
  'audio/webm',
  'audio/ogg',
  'audio/mp4',
  'audio/x-m4a',
  'audio/m4a',
  'audio/aac',
  'video/mp4',
];

interface NoteWord {
  text: string;
  start: number;
  end: number;
}

interface SavedNote {
  id: string;
  text: string;
  audio: Blob;
  timestamp: Date;
  words: NoteWord[];
}


export function VoiceNotesBlock() {
  const [sttModelId, setSttModelId] = useState(DEFAULT_STT_MODEL_ID);
  const [notes, setNotes] = useState<SavedNote[]>([]);

  return (
    <div className="flex flex-col gap-4 p-4">
      <NotesSession
        key={sttModelId}
        sttModelId={sttModelId}
        onSttModelIdChange={setSttModelId}
        notes={notes}
        onNotesChange={setNotes}
      />
    </div>
  );
}


interface NotesSessionProps {
  sttModelId: string;
  onSttModelIdChange: (id: string) => void;
  notes: SavedNote[];
  onNotesChange: Dispatch<SetStateAction<SavedNote[]>>;
}

function NotesSession({ sttModelId, onSttModelIdChange, notes, onNotesChange }: NotesSessionProps) {
  const stt = useModelLoad<SpeechToTextModel>({
    key: `voice-notes-stt:${sttModelId}`,
    create: (onProgress) =>
      transformers.speechToText(sttModelId, {
        onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]),
      }),
    warmup: (model) => transcribe({ model, audio: new Float32Array(16_000) }),
    isCached: () => isModelCached(sttModelId),
  });

  const sttEntry = STT_MODELS.find((m) => m.id === sttModelId) ?? STT_MODELS[0];
  const supportsTimestamps = sttSupportsTimestamps(sttModelId);

  const addNote = (note: SavedNote) => onNotesChange((prev) => [note, ...prev]);
  const deleteNote = (id: string) => onNotesChange((prev) => prev.filter((n) => n.id !== id));

  return (
    <>
      {}
      <section className="flex flex-col gap-2">
        <div data-model-id={sttModelId}>
          <p className="mb-1 text-xs font-medium text-muted-foreground">
            Speech-to-text model - downloads only when you transcribe, upload audio, or press its
            download action.
          </p>
          <ModelSelector
            models={STT_MODELS.map((m) => ({
              id: m.id,
              name: m.name,
              backend: 'onnx' as const,
              category: m.timestamps ? 'STT · timestamps' : 'STT',
              size: m.size,
            }))}
            selectedId={sttModelId}
            busyIds={stt.status === 'loading' ? new Set([sttModelId]) : undefined}
            onSelect={onSttModelIdChange}
            onDownload={(id) => {
              if (id === sttModelId) void stt.load().catch(() => {});
              else onSttModelIdChange(id);
            }}
          />
        </div>
        {stt.status === 'loading' && (
          <ModelLoadingPanel
            name={sttEntry.name}
            size={sttEntry.size}
            category="Speech-to-text"
            progress={stt.progressValue}
            cached={stt.cached === true}
          />
        )}
      </section>

      {}
      {stt.model ? (
        <NotesSurface
          stt={stt as UseModelLoadReturn<SpeechToTextModel>}
          sttModel={stt.model}
          supportsTimestamps={supportsTimestamps}
          notes={notes}
          onAddNote={addNote}
          onDeleteNote={deleteNote}
        />
      ) : (
        <p className="p-4 text-sm text-muted-foreground">Preparing…</p>
      )}
    </>
  );
}


interface NotesSurfaceProps {
  stt: UseModelLoadReturn<SpeechToTextModel>;
  sttModel: SpeechToTextModel;
  supportsTimestamps: boolean;
  notes: SavedNote[];
  onAddNote: (note: SavedNote) => void;
  onDeleteNote: (id: string) => void;
}

function NotesSurface({
  stt,
  sttModel,
  supportsTimestamps,
  notes,
  onAddNote,
  onDeleteNote,
}: NotesSurfaceProps) {
  const [micDeviceId, setMicDeviceId] = useState<string | undefined>();
  const recorder = useVoiceRecorder({ deviceId: micDeviceId });
  const transcriber = useTranscribe({ model: sttModel, returnTimestamps: supportsTimestamps });

  const [voiceState, setVoiceState] = useState<VoiceButtonState>('idle');
  const [micVolume, setMicVolume] = useState(0);
  const [dismissedError, setDismissedError] = useState<string | null>(null);

  const noteIndex = useNoteIndex();
  const embedLoad = useModelLoad<EmbeddingModel>({
    key: `voice-notes-embedding:${EMBEDDING_MODEL_ID}`,
    create: (onProgress) =>
      transformers.embedding(EMBEDDING_MODEL_ID, {
        onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]),
      }),
    isCached: () => isModelCached(EMBEDDING_MODEL_ID),
  });
  const [searchQuery, setSearchQuery] = useState('');
  const [searchResults, setSearchResults] = useState<ScoredResult[] | null>(null);
  const [searching, setSearching] = useState(false);
  const [searchError, setSearchError] = useState<string | null>(null);

  const recordStartedAtRef = useRef(0);

  const buttonState: VoiceButtonState =
    voiceState === 'recording' && recorder.error ? 'error' : voiceState;

  useEffect(() => {
    if (buttonState !== 'recording') {
      setMicVolume(0);
      return;
    }
    const id = window.setInterval(() => setMicVolume(Math.min(1, recorder.getVolume() * 4)), 100);
    return () => window.clearInterval(id);
  }, [buttonState]);

  const startRecording = async () => {
    setVoiceState('recording');
    recordStartedAtRef.current = Date.now();
    await recorder.startRecording();
  };

  const transcribeToNote = async (blob: Blob) => {
    setVoiceState('processing');
    setDismissedError(null);
    try {
      await stt.load();
    } catch {
      setVoiceState('error');
      return;
    }
    const result = await transcriber.execute(blob);
    if (!result) {
      setVoiceState('idle');
      return;
    }
    onAddNote({
      id: crypto.randomUUID(),
      text: result.text.trim() || NO_SPEECH_TEXT,
      audio: blob,
      timestamp: new Date(),
      words: supportsTimestamps
        ? (result.segments ?? [])
            .filter((s) => Number.isFinite(s.start) && Number.isFinite(s.end))
            .map((s) => ({ text: s.text.trim(), start: s.start, end: s.end }))
        : [],
    });
    setVoiceState('success');
  };

  const stopAndTranscribe = async () => {
    if (buttonState !== 'recording') return;
    if (Date.now() - recordStartedAtRef.current < 500) return;

    const blob = await recorder.stopRecording();
    if (!blob) {
      setVoiceState('error');
      return;
    }
    await transcribeToNote(blob);
  };

  const runSearch = async () => {
    const query = searchQuery.trim();
    if (!query || searching || notes.length === 0) return;
    setSearching(true);
    setSearchError(null);
    try {
      await embedLoad.load();
      const model = embedLoad.model;
      if (!model) throw new Error('Embedding model unavailable');
      const hits = await noteIndex.search(model, notes, query);
      const byId = new Map(notes.map((n) => [n.id, n]));
      setSearchResults(
        hits
          .filter((h) => byId.has(h.noteId))
          .map((h) => ({ label: byId.get(h.noteId)!.text, score: h.score })),
      );
    } catch (err) {
      setSearchError(err instanceof Error ? err.message : String(err));
    } finally {
      setSearching(false);
    }
  };

  const latestTimestamped = notes.find((n) => n.words.length > 0);
  const latestText = notes[0]?.text ?? '';

  const rawErrorText =
    recorder.error?.message ??
    transcriber.error?.message ??
    searchError ??
    (buttonState === 'error' ? 'Recording failed' : null);
  const errorText = rawErrorText && rawErrorText !== dismissedError ? rawErrorText : null;

  const statusText =
    buttonState === 'recording'
      ? 'recording'
      : buttonState === 'processing'
        ? 'transcribing'
        : searching
          ? 'searching'
          : errorText
            ? 'error'
            : notes.length > 0
              ? 'ready'
              : 'idle';
  const statusLabel =
    statusText === 'recording'
      ? 'Recording…'
      : statusText === 'transcribing'
        ? 'Transcribing…'
        : statusText === 'searching'
          ? 'Searching…'
          : statusText === 'error'
            ? 'Error'
            : statusText === 'ready'
              ? 'Ready'
              : 'Idle';

  return (
    <div className="flex flex-col gap-4">
      <p
        data-status={statusText}
        role="status"
        aria-live="polite"
        aria-label="Status"
        className="text-xs text-muted-foreground"
      >
        {statusLabel}
      </p>
      {errorText && (
        <ErrorAlert
          message={errorText}
          onDismiss={() => {
            const startFailed = !!recorder.error && voiceState === 'recording';
            setDismissedError(rawErrorText);
            setSearchError(null);
            recorder.clearError();
            transcriber.reset();
            if (voiceState === 'error' || startFailed) setVoiceState('idle');
          }}
        />
      )}

      {}
      <section className="flex flex-col gap-3 rounded-lg border border-border p-4">
        <h2 className="text-sm font-medium">Record a note</h2>
        <div className="flex flex-wrap items-center gap-4">
          <span>
            <VoiceButton
              state={buttonState}
              onStart={() => void startRecording()}
              onStop={() => void stopAndTranscribe()}
              volume={micVolume}
            />
          </span>
          <button
            type="button"
            onClick={() => void stopAndTranscribe()}
            disabled={buttonState !== 'recording'}
            className="inline-flex h-8 items-center rounded-md border border-border px-3 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
          >
            Stop &amp; transcribe
          </button>
          <WaveformActivityBars
            active={buttonState === 'recording'}
            volume={micVolume}
            height={28}
            barCount={9}
          />
          <span>
            <MicSelector value={micDeviceId} onValueChange={setMicDeviceId} />
          </span>
        </div>

        {latestText && (
          <div
            role="region"
            aria-label="Latest transcript"
            className="min-h-6 rounded-md bg-muted/50 p-2 text-sm whitespace-pre-wrap"
          >
            {latestText}
          </div>
        )}

        {buttonState === 'processing' && (
          <div className="flex items-center gap-2">
            <TranscribedNoteCard transcribing className="flex-1" />
            <button
              type="button"
              onClick={() => {
                transcriber.cancel();
                setVoiceState('idle');
              }}
              className="inline-flex h-8 items-center rounded-md border border-border px-3 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
            >
              Cancel
            </button>
          </div>
        )}
      </section>

      {}
      <section aria-label="Or upload audio" className="flex flex-col gap-3 rounded-lg border border-border p-4">
        <h2 className="text-sm font-medium">Or upload audio</h2>
        <div>
          <FileDropzone
            onUpload={(files) => {
              const file = files[0];
              if (file) void transcribeToNote(file);
            }}
            accept={ACCEPTED_AUDIO_MIME_TYPES}
            multiple={false}
            disabled={buttonState === 'processing' || buttonState === 'recording'}
            processing={buttonState === 'processing'}
            processingLabel="Transcribing…"
            label="Drop an audio file or click to browse"
            hint=".wav, .mp3, .webm, .m4a, .ogg, .mp4"
          />
        </div>
      </section>

      {}
      {supportsTimestamps ? (
        latestTimestamped && (
          <section
            aria-label="Synced transcript"
            className="flex flex-col gap-2 rounded-lg border border-border p-4"
          >
            <p className="text-xs text-muted-foreground">
              Synced transcript - real Whisper SEGMENT timestamps (each highlighted span is one
              segment, not a single word). Click a span to seek.
            </p>
            <SyncedTranscriptViewer words={latestTimestamped.words} audio={latestTimestamped.audio} />
          </section>
        )
      ) : (
        <p className="rounded-lg border border-dashed border-border p-3 text-xs text-muted-foreground">
          Synced replay is disabled - segment timestamps are only validated on Whisper Tiny EN.
          Plain transcripts still work with the selected Moonshine model.
        </p>
      )}

      {}
      <section className="flex flex-col gap-3 rounded-lg border border-border p-4">
        <h2 className="text-sm font-medium">Search notes</h2>
        <div className="flex flex-wrap items-center gap-2">
          <input
            value={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === 'Enter') void runSearch();
            }}
            placeholder={notes.length === 0 ? 'Save a note first…' : 'Semantic search…'}
            disabled={notes.length === 0}
            aria-label="Search notes"
            className="h-8 min-w-56 flex-1 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
          />
          <button
            type="button"
            onClick={() => void runSearch()}
            disabled={!searchQuery.trim() || searching || notes.length === 0}
            className="inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
          >
            {searching ? 'Searching…' : 'Search'}
          </button>
        </div>
        {embedLoad.status === 'loading' && (
          <ModelLoadingPanel
            name="BGE Small EN v1.5"
            size={EMBEDDING_MODEL_SIZE}
            category="Embedding"
            progress={embedLoad.progressValue}
            cached={embedLoad.cached === true}
          />
        )}
        {searchResults && (
          <div
            data-count={searchResults.length}
            data-top-label={searchResults[0]?.label ?? ''}
            role="region"
            aria-label="Note search results"
          >
            <ScoredResultBarList
              results={searchResults}
              isLoading={searching}
              emptyState="No matching notes"
            />
          </div>
        )}
      </section>

      {}
      <section className="flex flex-col gap-3 rounded-lg border border-border p-4">
        <div className="flex items-center justify-between">
          <h2 className="text-sm font-medium">Saved notes</h2>
          <span className="text-xs tabular-nums text-muted-foreground">
            {notes.length} note{notes.length === 1 ? '' : 's'}
          </span>
        </div>
        {notes.length === 0 ? (
          <p className="text-sm text-muted-foreground">
            No notes yet - record or upload audio to create your first note.
          </p>
        ) : (
          <ul aria-label="Saved notes" className="flex flex-col gap-2">
            {notes.map((note) => (
              <li
                key={note.id}
                data-note-id={note.id}
                className="group/note flex flex-col gap-1"
              >
                <TranscribedNoteCard
                  text={note.text}
                  timestamp={note.timestamp}
                  audio={note.audio}
                  onDelete={() => onDeleteNote(note.id)}
                />
              </li>
            ))}
          </ul>
        )}
      </section>
    </div>
  );
}
```

## Live Transcription

Turn on your microphone and watch your speech become text in real time. Includes a hands-free assistant that listens, thinks, and speaks back, with barge-in so you can interrupt. All runs on-device; stopping releases the microphone, and nothing downloads until you start a session.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/audio/live-transcription
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/audio/live-transcription.json

```tsx
'use client';

/**
 * @file live-transcription.tsx
 * @description Live Transcription — open-mic streaming STT (useLiveTranscribe) + a listen→plan→speak turn-taking assistant (useTurnTaker) with an energy/Silero VAD picker; owns its STT selector, its adapter-backed device probe, and a block-local Silero provider.
 */

import { useCallback, useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import {
  useLiveTranscribe,
  useModelLoad,
  useTurnTaker,
  type UseModelLoadReturn,
} from '@localmode/react';
import {
  createLiveTranscriber,
  isWebGPUSupported,
  synthesizeSpeech,
  transcribe,
} from '@localmode/core';
import type {
  LanguageModel,
  LiveTranscriber,
  SpeechToTextModel,
  TextToSpeechModel,
} from '@localmode/core';
import { transformers, isModelCached, KOKORO_DEFAULT_VOICE } from '@localmode/transformers';

import { VoiceOrb, type VoiceOrbState } from '@/components/voice-orb';
import { WaveformActivityBars } from '@/components/waveform-activity-bars';
import { SegmentedModePicker } from '@/components/segmented-mode-picker';
import { ModelSelector } from '@/components/model-selector';
import { ModelLoadingPanel } from '@/components/model-loading-panel';
import { ErrorAlert } from '@/components/error-alert';
import { cn } from '@/lib/utils';


interface SttModelEntry {
  id: string;
  name: string;
  size: string;
  timestamps: boolean;
}

const STT_MODELS: readonly SttModelEntry[] = [
  { id: 'Xenova/whisper-tiny.en', name: 'Whisper Tiny EN', size: '~40MB', timestamps: true },
  { id: 'onnx-community/moonshine-tiny-ONNX', name: 'Moonshine Tiny', size: '~50MB', timestamps: false },
  { id: 'onnx-community/moonshine-base-ONNX', name: 'Moonshine Base', size: '~237MB', timestamps: false },
];

const DEFAULT_STT_MODEL_ID = STT_MODELS[0].id;

const PLANNER_MODEL_ID = 'onnx-community/granite-4.0-350m-ONNX-web';
const PLANNER_MODEL_SIZE = '~120MB';

const TTS_MODEL_ID = 'onnx-community/Kokoro-82M-v1.0-ONNX';
const TTS_MODEL_SIZE = '~86MB';

const SILERO_VAD_MODEL_ID = 'onnx-community/silero-vad';
const SILERO_VAD_MODEL_SIZE = '~2MB';

type SileroVad = ReturnType<typeof transformers.vad>;

type LiveMode = 'transcribe' | 'turn';
type VadChoice = 'energy' | 'silero';

const MAX_UTTERANCE_SEC = 15;

const TURN_SYSTEM_PROMPT =
  'You are a concise voice assistant running entirely in the browser. Reply in one or two short sentences.';


export function LiveTranscriptionBlock() {
  const [sttModelId, setSttModelId] = useState(DEFAULT_STT_MODEL_ID);

  const [device, setDevice] = useState<'webgpu' | 'wasm' | null>(null);
  useEffect(() => {
    let alive = true;
    void isWebGPUSupported().then((ok) => {
      if (alive) setDevice(ok ? 'webgpu' : 'wasm');
    });
    return () => {
      alive = false;
    };
  }, []);

  return (
    <div className="flex flex-col gap-4 p-4">
      {device ? (
        <LiveSession
          key={sttModelId}
          sttModelId={sttModelId}
          onSttModelIdChange={setSttModelId}
          device={device}
        />
      ) : (
        <p className="p-4 text-sm text-muted-foreground">Preparing…</p>
      )}
    </div>
  );
}


interface LiveSessionProps {
  sttModelId: string;
  onSttModelIdChange: (id: string) => void;
  device: 'webgpu' | 'wasm';
}

function LiveSession({ sttModelId, onSttModelIdChange, device }: LiveSessionProps) {
  const stt = useModelLoad<SpeechToTextModel>({
    key: `live-transcription-stt:${sttModelId}`,
    create: (onProgress) =>
      transformers.speechToText(sttModelId, {
        onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]),
      }),
    warmup: (model) => transcribe({ model, audio: new Float32Array(16_000) }),
    isCached: () => isModelCached(sttModelId),
  });

  const sttEntry = STT_MODELS.find((m) => m.id === sttModelId) ?? STT_MODELS[0];

  const sileroVadRef = useRef<SileroVad | null>(null);
  const sileroProgressListenersRef = useRef(new Set<(p: unknown) => void>());

  const getSileroVad = useCallback((): SileroVad => {
    if (!sileroVadRef.current) {
      sileroVadRef.current = transformers.vad(SILERO_VAD_MODEL_ID, {
        onProgress: (p) => {
          for (const listener of sileroProgressListenersRef.current) listener(p);
        },
      });
    }
    return sileroVadRef.current;
  }, []);

  const onSileroProgress = useCallback((listener: (p: unknown) => void) => {
    sileroProgressListenersRef.current.add(listener);
  }, []);

  return (
    <>
      {}
      <section className="flex flex-col gap-2">
        <div data-model-id={sttModelId}>
          <p className="mb-1 text-xs font-medium text-muted-foreground">
            Speech-to-text model - downloads only when you start a live session or press its
            download action.
          </p>
          <ModelSelector
            models={STT_MODELS.map((m) => ({
              id: m.id,
              name: m.name,
              backend: 'onnx' as const,
              category: m.timestamps ? 'STT · timestamps' : 'STT',
              size: m.size,
            }))}
            selectedId={sttModelId}
            busyIds={stt.status === 'loading' ? new Set([sttModelId]) : undefined}
            onSelect={onSttModelIdChange}
            onDownload={(id) => {
              if (id === sttModelId) void stt.load().catch(() => {});
              else onSttModelIdChange(id);
            }}
          />
        </div>
        {stt.status === 'loading' && (
          <ModelLoadingPanel
            name={sttEntry.name}
            size={sttEntry.size}
            category="Speech-to-text"
            progress={stt.progressValue}
            cached={stt.cached === true}
          />
        )}
      </section>

      {}
      {stt.model ? (
        <LiveSurface
          stt={stt}
          sttModel={stt.model}
          sttModelId={sttModelId}
          device={device}
          getSileroVad={getSileroVad}
          onSileroProgress={onSileroProgress}
        />
      ) : (
        <p className="p-4 text-sm text-muted-foreground">Preparing…</p>
      )}
    </>
  );
}


interface LiveSurfaceProps {
  stt: UseModelLoadReturn<SpeechToTextModel>;
  sttModel: SpeechToTextModel;
  sttModelId: string;
  device: 'webgpu' | 'wasm';
  getSileroVad: () => SileroVad;
  onSileroProgress: (listener: (p: unknown) => void) => void;
}

function LiveSurface({
  stt,
  sttModel,
  sttModelId,
  device,
  getSileroVad,
  onSileroProgress,
}: LiveSurfaceProps) {
  const [mode, setMode] = useState<LiveMode>('transcribe');
  const [vadChoice, setVadChoice] = useState<VadChoice>('energy');
  const [actionError, setActionError] = useState<string | null>(null);
  const [dismissedError, setDismissedError] = useState<string | null>(null);

  const sileroLoad = useModelLoad<SileroVad>({
    key: 'live-transcription-vad:silero',
    create: (onProgress) => {
      onSileroProgress((p) => onProgress(p as Parameters<typeof onProgress>[0]));
      return getSileroVad();
    },
    warmup: async (vad) => {
      await vad.start({ onSpeechStart: () => {}, onSpeechEnd: () => {} });
      await vad.stop();
    },
  });
  const plannerLoad = useModelLoad<LanguageModel>({
    key: `live-transcription-planner:${PLANNER_MODEL_ID}`,
    create: (onProgress) =>
      transformers.languageModel(PLANNER_MODEL_ID, {
        device,
        onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]),
      }),
    isCached: () => isModelCached(PLANNER_MODEL_ID),
  });
  const ttsLoad = useModelLoad<TextToSpeechModel>({
    key: `live-transcription-tts:${TTS_MODEL_ID}`,
    create: (onProgress) =>
      transformers.textToSpeech(TTS_MODEL_ID, {
        onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]),
      }),
    warmup: (model) => synthesizeSpeech({ model, text: 'Ready.', voice: KOKORO_DEFAULT_VOICE }),
    isCached: () => isModelCached(TTS_MODEL_ID),
  });

  const live = useLiveTranscribe({
    model: sttModel,
    mode: 'open-mic',
    vad: vadChoice === 'silero' ? getSileroVad() : 'energy',
    maxUtteranceSec: MAX_UTTERANCE_SEC,
  });

  const liveDisposeRef = useRef(live.dispose);
  useEffect(() => {
    liveDisposeRef.current = live.dispose;
  });
  useEffect(() => {
    return () => {
      void liveDisposeRef.current();
    };
  }, [vadChoice]);

  const [turnTranscriber, setTurnTranscriber] = useState<LiveTranscriber | null>(null);
  const turn = useTurnTaker({
    transcriber: turnTranscriber as unknown as LiveTranscriber,
    planner: plannerLoad.model as unknown as LanguageModel,
    voice: ttsLoad.model as unknown as TextToSpeechModel,
    systemPrompt: TURN_SYSTEM_PROMPT,
  });

  const liveActive = live.state === 'listening' || live.state === 'transcribing';
  const turnActive = turn.state !== 'idle' && turn.state !== 'error';

  const startLive = async () => {
    setActionError(null);
    setDismissedError(null);
    try {
      await stt.load();
      if (vadChoice === 'silero') await sileroLoad.load();
      await live.start();
    } catch (err) {
      setActionError(err instanceof Error ? err.message : String(err));
    }
  };

  const stopLive = async () => {
    await live.stop();
    await live.dispose();
  };

  const startTurn = async () => {
    setActionError(null);
    setDismissedError(null);
    try {
      await stt.load();
      if (vadChoice === 'silero') await sileroLoad.load();
      await Promise.all([plannerLoad.load(), ttsLoad.load()]);
      let transcriber = turnTranscriber;
      if (!transcriber) {
        transcriber = await createLiveTranscriber({
          model: sttModel,
          mode: 'open-mic',
          vad: vadChoice === 'silero' ? getSileroVad() : 'energy',
          maxUtteranceSec: MAX_UTTERANCE_SEC,
        });
        flushSync(() => setTurnTranscriber(transcriber));
      }
      await turn.start();
    } catch (err) {
      setActionError(err instanceof Error ? err.message : String(err));
    }
  };

  const stopTurn = async () => {
    await turn.stop();
    await turn.dispose();
    setTurnTranscriber(null);
  };

  const selectMode = (next: LiveMode) => {
    if (next === mode) return;
    if (mode === 'transcribe') void stopLive();
    else void stopTurn();
    setMode(next);
  };

  const selectVad = (next: VadChoice) => {
    if (next === vadChoice || liveActive || turnActive) return;
    setVadChoice(next);
  };

  const orbState: VoiceOrbState =
    mode === 'turn'
      ? turn.state === 'listening'
        ? 'listening'
        : turn.state === 'planning'
          ? 'thinking'
          : turn.state === 'speaking'
            ? 'speaking'
            : 'idle'
      : live.state === 'listening'
        ? 'listening'
        : live.state === 'transcribing'
          ? 'thinking'
          : 'idle';

  const rawErrorText =
    actionError ??
    (mode === 'transcribe' ? live.error?.message : turn.error?.message) ??
    plannerLoad.error?.message ??
    ttsLoad.error?.message ??
    sileroLoad.error?.message ??
    null;
  const errorText = rawErrorText && rawErrorText !== dismissedError ? rawErrorText : null;

  const statusText =
    mode === 'turn'
      ? turnActive
        ? turn.state
        : errorText
          ? 'error'
          : turn.turns.length > 0
            ? 'ready'
            : 'idle'
      : liveActive
        ? live.state
        : errorText
          ? 'error'
          : live.utterances.length > 0
            ? 'ready'
            : 'idle';
  const statusLabel =
    ({
      idle: 'Idle',
      ready: 'Ready',
      error: 'Error',
      listening: 'Listening…',
      transcribing: 'Transcribing…',
      planning: 'Planning…',
      speaking: 'Speaking…',
    } as Record<string, string>)[statusText] ?? statusText;

  return (
    <div className="flex flex-col gap-4">
      <p
        data-status={statusText}
        role="status"
        aria-live="polite"
        aria-label="Status"
        className="text-xs text-muted-foreground"
      >
        {statusLabel}
      </p>
      {errorText && (
        <ErrorAlert
          message={errorText}
          onDismiss={() => {
            setDismissedError(rawErrorText);
            setActionError(null);
          }}
        />
      )}

      {}
      <div className="flex flex-wrap items-center gap-4">
        <SegmentedModePicker<LiveMode>
          items={[
            { id: 'transcribe', label: 'Transcribe' },
            { id: 'turn', label: 'Turn-taking' },
          ]}
          selectedId={mode}
          onSelect={selectMode}
          aria-label="Live mode"
        />
        <SegmentedModePicker<VadChoice>
          items={[
            { id: 'energy', label: 'Energy VAD' },
            { id: 'silero', label: `Silero VAD · ${SILERO_VAD_MODEL_SIZE}` },
          ]}
          selectedId={vadChoice}
          onSelect={selectVad}
          aria-label="Voice activity detector"
        />
      </div>
      <p className="text-xs text-muted-foreground">
        Energy VAD needs no download; Silero VAD downloads a small model for sharper voice-activity
        detection.
      </p>

      {vadChoice === 'silero' && sileroLoad.status === 'loading' && (
        <ModelLoadingPanel
          name="Silero VAD"
          size={SILERO_VAD_MODEL_SIZE}
          category="VAD"
          progress={sileroLoad.progressValue}
          cached={sileroLoad.cached === true}
        />
      )}

      {}
      <section className="flex flex-col gap-3 rounded-lg border border-border p-4">
        <div className="flex flex-wrap items-center gap-4">
          <div>
            <VoiceOrb state={orbState} size={96} />
          </div>
          <WaveformActivityBars active={orbState === 'listening' || orbState === 'speaking'} height={28} barCount={9} />
          {mode === 'transcribe' ? (
            <>
              <button
                type="button"
                onClick={() => void startLive()}
                disabled={liveActive}
                className="inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
              >
                Start live session
              </button>
              <button
                type="button"
                onClick={() => void stopLive()}
                disabled={!liveActive}
                className="inline-flex h-8 items-center rounded-md border border-border px-3 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
              >
                Stop
              </button>
            </>
          ) : (
            <>
              <button
                type="button"
                onClick={() => void startTurn()}
                disabled={turnActive}
                className="inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
              >
                Start turn-taking
              </button>
              <button
                type="button"
                onClick={() => void stopTurn()}
                disabled={!turnActive}
                className="inline-flex h-8 items-center rounded-md border border-border px-3 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
              >
                Stop
              </button>
              <button
                type="button"
                onClick={() => turn.interrupt()}
                disabled={!turnActive}
                className="inline-flex h-8 items-center rounded-md border border-border px-3 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
              >
                Interrupt
              </button>
            </>
          )}
        </div>
        <p className="text-xs text-muted-foreground">
          {mode === 'transcribe'
            ? `Open-mic streaming transcription on ${sttModelId}: speak, pause, and finalized utterances appear below.`
            : 'Speak, then wait: the assistant plans a short reply and speaks it back. Speaking over it barges in.'}
        </p>
      </section>

      {mode === 'transcribe' ? (
        <section className="flex flex-col gap-3 rounded-lg border border-border p-4">
          <div className="flex items-center justify-between">
            <h2 className="text-sm font-medium">Utterances</h2>
            <button
              type="button"
              onClick={() => live.clearUtterances()}
              disabled={live.utterances.length === 0}
              className="inline-flex h-7 items-center rounded-md px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
            >
              Clear
            </button>
          </div>
          <div
            className={cn(
              'min-h-6 rounded-md bg-muted/50 p-2 text-sm italic',
              !live.currentUtterance && 'text-muted-foreground',
            )}
          >
            {live.currentUtterance || (liveActive ? 'Listening…' : 'Partial text appears here while you speak.')}
          </div>
          {live.utterances.length === 0 ? (
            <p className="text-sm text-muted-foreground">No utterances yet.</p>
          ) : (
            <ul aria-label="Utterances" className="flex flex-col gap-1.5">
              {live.utterances.map((u) => (
                <li
                  key={u.utteranceId}
                  className="rounded-md border border-border bg-card p-2 text-sm"
                >
                  <span className="mr-2 text-[11px] tabular-nums text-muted-foreground">
                    {u.durationSec.toFixed(1)}s{u.truncated ? ' · truncated' : ''}
                  </span>
                  {u.text}
                </li>
              ))}
            </ul>
          )}
        </section>
      ) : (
        <section className="flex flex-col gap-3 rounded-lg border border-border p-4">
          <div className="flex flex-wrap items-center gap-3">
            <span
              data-state={turn.state}
              className="rounded-md bg-muted px-2 py-0.5 text-xs font-medium capitalize"
            >
              {turn.state}
            </span>
            {turn.lastBargeIn && (
              <span
                className="rounded-md bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-400"
              >
                Barge-in at {turn.lastBargeIn.toLocaleTimeString()}
              </span>
            )}
          </div>
          {(plannerLoad.status === 'loading' || ttsLoad.status === 'loading') && (
            <div className="flex flex-col gap-2">
              {plannerLoad.status === 'loading' && (
                <ModelLoadingPanel
                  name="Granite 4.0 350M"
                  size={PLANNER_MODEL_SIZE}
                  category="Planner LM"
                  progress={plannerLoad.progressValue}
                  cached={plannerLoad.cached === true}
                />
              )}
              {ttsLoad.status === 'loading' && (
                <ModelLoadingPanel
                  name="Kokoro 82M"
                  size={TTS_MODEL_SIZE}
                  category="Text-to-speech"
                  progress={ttsLoad.progressValue}
                  cached={ttsLoad.cached === true}
                />
              )}
            </div>
          )}
          {turn.turns.length === 0 ? (
            <p className="text-sm text-muted-foreground">No turns yet - start and say something.</p>
          ) : (
            <ul className="flex flex-col gap-1.5">
              {turn.turns.map((t, i) => (
                <li
                  key={`${t.timestamp.getTime()}-${i}`}
                  data-role={t.role}
                  className={cn(
                    'rounded-md border p-2 text-sm',
                    t.role === 'agent' ? 'border-primary/30 bg-primary/5' : 'border-border bg-card',
                  )}
                >
                  <span className="mr-2 text-[11px] font-medium uppercase text-muted-foreground">
                    {t.role}
                  </span>
                  {t.text}
                </li>
              ))}
            </ul>
          )}
        </section>
      )}
    </div>
  );
}
```

## Meeting Assistant

Upload meeting audio or paste a transcript, then get a short summary and a checklist of action items with priorities. Tick items off as you go, track progress, and export everything to a text file. Runs entirely in your browser; nothing downloads until you process a meeting.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/audio/meeting-assistant
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/audio/meeting-assistant.json

```tsx
'use client';

/**
 * @file meeting-assistant.tsx
 * @description Meeting Assistant — audio upload OR pasted transcript → Transcribe → Summarize (DistilBART) → Extract action items (Granite 4.0 350M) with a three-step indicator, cancel-preserves-completed-steps, priority/toggle/progress, and a dated .txt export; owns its STT selector + adapter-backed device probe.
 */

import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import {
  downloadBlob,
  useGenerateObject,
  useModelLoad,
  useSummarize,
  useTranscribe,
  type UseModelLoadReturn,
} from '@localmode/react';
import { summarize, transcribe, isWebGPUSupported } from '@localmode/core';
import type {
  LanguageModel,
  ObjectSchema,
  SpeechToTextModel,
  SummarizationModel,
} from '@localmode/core';
import { transformers, isModelCached } from '@localmode/transformers';

import { FileDropzone } from '@/components/file-dropzone';
import { AudioScrubPlayer } from '@/components/audio-scrub-player';
import { ModelSelector } from '@/components/model-selector';
import { ModelLoadingPanel } from '@/components/model-loading-panel';
import { ErrorAlert } from '@/components/error-alert';
import { cn } from '@/lib/utils';


interface SttModelEntry {
  id: string;
  name: string;
  size: string;
  timestamps: boolean;
}

const STT_MODELS: readonly SttModelEntry[] = [
  { id: 'Xenova/whisper-tiny.en', name: 'Whisper Tiny EN', size: '~40MB', timestamps: true },
  { id: 'onnx-community/moonshine-tiny-ONNX', name: 'Moonshine Tiny', size: '~50MB', timestamps: false },
  { id: 'onnx-community/moonshine-base-ONNX', name: 'Moonshine Base', size: '~237MB', timestamps: false },
];

const DEFAULT_STT_MODEL_ID = STT_MODELS[0].id;

function sttSupportsTimestamps(modelId: string): boolean {
  return STT_MODELS.find((m) => m.id === modelId)?.timestamps ?? false;
}

const SUMMARIZER_MODEL_ID = 'Xenova/distilbart-cnn-6-6';
const SUMMARIZER_MODEL_SIZE = '~200MB';

const SUMMARY_MAX_LENGTH = 200;
const SUMMARY_MIN_LENGTH = 60;

const PLANNER_MODEL_ID = 'onnx-community/granite-4.0-350m-ONNX-web';
const PLANNER_MODEL_SIZE = '~120MB';

const NO_SPEECH_TEXT = '[No speech detected]';

const ACCEPTED_AUDIO_MIME_TYPES = [
  'audio/wav',
  'audio/x-wav',
  'audio/wave',
  'audio/mp3',
  'audio/mpeg',
  'audio/webm',
  'audio/ogg',
  'audio/mp4',
  'audio/x-m4a',
  'audio/m4a',
  'audio/aac',
  'video/mp4',
];

interface ExtractedActionItem {
  text: string;
  priority: 'high' | 'medium' | 'low';
}

const PRIORITIES = new Set(['high', 'medium', 'low']);

const ACTION_ITEMS_SCHEMA: ObjectSchema<{ items: ExtractedActionItem[] }> = {
  description: 'Action items extracted from a meeting transcript',
  jsonSchema: {
    type: 'object',
    properties: {
      items: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            text: { type: 'string', description: 'The task or commitment' },
            priority: { type: 'string', enum: ['high', 'medium', 'low'] },
          },
          required: ['text', 'priority'],
        },
      },
    },
    required: ['items'],
  },
  parse: (value: unknown) => {
    if (typeof value !== 'object' || value === null || !Array.isArray((value as { items?: unknown }).items)) {
      throw new Error('Expected an object with an "items" array');
    }
    const items: ExtractedActionItem[] = [];
    for (const raw of (value as { items: unknown[] }).items) {
      if (typeof raw !== 'object' || raw === null) continue;
      const text = (raw as { text?: unknown }).text;
      if (typeof text !== 'string' || text.trim().length === 0) continue;
      const rawPriority = String((raw as { priority?: unknown }).priority ?? '').toLowerCase();
      items.push({
        text: text.trim(),
        priority: (PRIORITIES.has(rawPriority) ? rawPriority : 'medium') as ExtractedActionItem['priority'],
      });
    }
    return { items: items.slice(0, 12) };
  },
};

function buildActionItemsPrompt(transcript: string): string {
  return [
    'Extract the action items from this meeting transcript. An action item is a specific task, commitment, or follow-up that someone agreed to do.',
    'For each item provide the task text and a priority: "high" for urgent or deadline-bound items, "medium" for important items, "low" for nice-to-haves.',
    'Return at most 8 items. If there are none, return an empty items list.',
    '',
    'Transcript:',
    '"""',
    transcript,
    '"""',
  ].join('\n');
}

interface MeetingActionItem extends ExtractedActionItem {
  id: string;
  completed: boolean;
}

interface MeetingData {
  sourceLabel: string;
  audio: Blob | null;
  transcript: string;
  summary: string | null;
  actionItems: MeetingActionItem[] | null;
  noSpeech: boolean;
}

function countWords(text: string): number {
  return text.trim().split(/\s+/).filter(Boolean).length;
}

function buildMeetingExport(
  transcript: string,
  summary: string | null,
  actionItems: Array<{ text: string; completed: boolean; priority: string }>,
): string {
  const lines: string[] = ['MEETING TRANSCRIPT', '==================', '', transcript];

  if (summary) {
    lines.push('', 'SUMMARY', '-------', summary);
  }

  if (actionItems.length > 0) {
    lines.push('', 'ACTION ITEMS', '------------');
    actionItems.forEach((item, i) => {
      const status = item.completed ? '[x]' : '[ ]';
      lines.push(`${status} ${i + 1}. ${item.text} (${item.priority})`);
    });
  }

  return lines.join('\n');
}

function meetingExportFilename(now = new Date()): string {
  return `meeting-transcript-${now.toISOString().slice(0, 10)}.txt`;
}


type MeetingStep = 'transcribe' | 'summarize' | 'extract';
type StepState = 'pending' | 'active' | 'done' | 'skipped';

const STEPS: ReadonlyArray<{ id: MeetingStep; label: string }> = [
  { id: 'transcribe', label: 'Transcribe' },
  { id: 'summarize', label: 'Summarize' },
  { id: 'extract', label: 'Extract' },
];

const PRIORITY_BADGE: Record<ExtractedActionItem['priority'], string> = {
  high: 'bg-destructive/10 text-destructive ring-1 ring-inset ring-destructive/20',
  medium: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 ring-1 ring-inset ring-amber-500/20',
  low: 'bg-muted text-muted-foreground ring-1 ring-inset ring-border',
};

export function MeetingAssistantBlock() {
  const [sttModelId, setSttModelId] = useState(DEFAULT_STT_MODEL_ID);
  const [meeting, setMeeting] = useState<MeetingData | null>(null);

  const [device, setDevice] = useState<'webgpu' | 'wasm' | null>(null);
  useEffect(() => {
    let alive = true;
    void isWebGPUSupported().then((ok) => {
      if (alive) setDevice(ok ? 'webgpu' : 'wasm');
    });
    return () => {
      alive = false;
    };
  }, []);

  return (
    <div className="flex flex-col gap-4 p-4">
      {device ? (
        <MeetingSession
          key={sttModelId}
          sttModelId={sttModelId}
          onSttModelIdChange={setSttModelId}
          device={device}
          meeting={meeting}
          onMeetingChange={setMeeting}
        />
      ) : (
        <p className="p-4 text-sm text-muted-foreground">Preparing…</p>
      )}
    </div>
  );
}

interface MeetingSessionProps {
  sttModelId: string;
  onSttModelIdChange: (id: string) => void;
  device: 'webgpu' | 'wasm';
  meeting: MeetingData | null;
  onMeetingChange: Dispatch<SetStateAction<MeetingData | null>>;
}

function MeetingSession({
  sttModelId,
  onSttModelIdChange,
  device,
  meeting,
  onMeetingChange,
}: MeetingSessionProps) {
  const stt = useModelLoad<SpeechToTextModel>({
    key: `meeting-assistant-stt:${sttModelId}`,
    create: (onProgress) =>
      transformers.speechToText(sttModelId, {
        onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]),
      }),
    warmup: (model) => transcribe({ model, audio: new Float32Array(16_000) }),
    isCached: () => isModelCached(sttModelId),
  });

  const sttEntry = STT_MODELS.find((m) => m.id === sttModelId) ?? STT_MODELS[0];

  return (
    <div className="flex flex-col gap-4">
      {}
      <section className="flex flex-col gap-2">
        <div data-model-id={sttModelId}>
          <p className="mb-1 text-xs font-medium text-muted-foreground">
            Speech-to-text model - downloads only when you transcribe a meeting or press its download
            action.
          </p>
          <ModelSelector
            models={STT_MODELS.map((m) => ({
              id: m.id,
              name: m.name,
              backend: 'onnx' as const,
              category: m.timestamps ? 'STT · timestamps' : 'STT',
              size: m.size,
            }))}
            selectedId={sttModelId}
            busyIds={stt.status === 'loading' ? new Set([sttModelId]) : undefined}
            onSelect={onSttModelIdChange}
            onDownload={(id) => {
              if (id === sttModelId) void stt.load().catch(() => {});
              else onSttModelIdChange(id);
            }}
          />
        </div>
        {stt.status === 'loading' && (
          <ModelLoadingPanel
            name={sttEntry.name}
            size={sttEntry.size}
            category="Speech-to-text"
            progress={stt.progressValue}
            cached={stt.cached === true}
          />
        )}
      </section>

      {}
      {stt.model ? (
        <MeetingPipeline
          stt={stt as UseModelLoadReturn<SpeechToTextModel>}
          sttModel={stt.model}
          sttModelId={sttModelId}
          device={device}
          meeting={meeting}
          onMeetingChange={onMeetingChange}
        />
      ) : (
        <p className="p-4 text-sm text-muted-foreground">Preparing…</p>
      )}
    </div>
  );
}

interface MeetingPipelineProps {
  stt: UseModelLoadReturn<SpeechToTextModel>;
  sttModel: SpeechToTextModel;
  sttModelId: string;
  device: 'webgpu' | 'wasm';
  meeting: MeetingData | null;
  onMeetingChange: Dispatch<SetStateAction<MeetingData | null>>;
}

function MeetingPipeline({
  stt,
  sttModel,
  sttModelId,
  device,
  meeting,
  onMeetingChange,
}: MeetingPipelineProps) {
  const transcriber = useTranscribe({ model: sttModel });
  const summarizerLoad = useModelLoad<SummarizationModel>({
    key: `meeting-assistant-summarizer:${SUMMARIZER_MODEL_ID}`,
    create: (onProgress) =>
      transformers.summarizer(SUMMARIZER_MODEL_ID, {
        onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]),
      }),
    warmup: (model) =>
      summarize({
        model,
        text: 'The team met to plan the release. The release ships next week after testing completes.',
        maxLength: 20,
        minLength: 5,
      }),
    isCached: () => isModelCached(SUMMARIZER_MODEL_ID),
  });
  const plannerLoad = useModelLoad<LanguageModel>({
    key: `meeting-assistant-planner:${PLANNER_MODEL_ID}`,
    create: (onProgress) =>
      transformers.languageModel(PLANNER_MODEL_ID, {
        device,
        onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]),
      }),
    isCached: () => isModelCached(PLANNER_MODEL_ID),
  });

  const summarizerModel = summarizerLoad.model;
  const plannerModel = plannerLoad.model;
  if (!summarizerModel || !plannerModel) {
    return <p className="p-4 text-sm text-muted-foreground">Preparing…</p>;
  }
  return (
    <MeetingSurface
      stt={stt}
      transcriber={transcriber}
      summarizerLoad={summarizerLoad}
      summarizerModel={summarizerModel}
      plannerLoad={plannerLoad}
      plannerModel={plannerModel}
      sttModelId={sttModelId}
      meeting={meeting}
      onMeetingChange={onMeetingChange}
    />
  );
}

interface MeetingSurfaceProps {
  stt: UseModelLoadReturn<SpeechToTextModel>;
  transcriber: ReturnType<typeof useTranscribe>;
  summarizerLoad: UseModelLoadReturn<SummarizationModel>;
  summarizerModel: SummarizationModel;
  plannerLoad: UseModelLoadReturn<LanguageModel>;
  plannerModel: LanguageModel;
  sttModelId: string;
  meeting: MeetingData | null;
  onMeetingChange: Dispatch<SetStateAction<MeetingData | null>>;
}

function MeetingSurface({
  stt,
  transcriber,
  summarizerLoad,
  summarizerModel,
  plannerLoad,
  plannerModel,
  sttModelId,
  meeting,
  onMeetingChange,
}: MeetingSurfaceProps) {
  const summarizer = useSummarize({ model: summarizerModel });
  const extractor = useGenerateObject({
    model: plannerModel,
    schema: ACTION_ITEMS_SCHEMA,
    temperature: 0,
    maxTokens: 512,
  });

  const [pastedText, setPastedText] = useState('');
  const [activeStep, setActiveStep] = useState<MeetingStep | null>(null);
  const [doneSteps, setDoneSteps] = useState<ReadonlySet<MeetingStep>>(new Set());
  const [transcribeSkipped, setTranscribeSkipped] = useState(false);
  const [actionError, setActionError] = useState<string | null>(null);
  const [dismissedError, setDismissedError] = useState<string | null>(null);

  const runIdRef = useRef(0);

  const running = activeStep !== null;

  const markDone = (step: MeetingStep) =>
    setDoneSteps((prev) => {
      const next = new Set(prev);
      next.add(step);
      return next;
    });

  const runPipeline = async (source: { file?: File; text?: string }) => {
    const runId = ++runIdRef.current;
    const cancelled = () => runIdRef.current !== runId;

    setActionError(null);
    setDismissedError(null);
    setDoneSteps(new Set());
    setTranscribeSkipped(!!source.text);
    transcriber.reset();
    summarizer.reset();
    extractor.reset();

    const base: MeetingData = {
      sourceLabel: source.file ? source.file.name : 'Pasted transcript',
      audio: source.file ?? null,
      transcript: '',
      summary: null,
      actionItems: null,
      noSpeech: false,
    };
    onMeetingChange(base);

    try {
      let transcript = source.text?.trim() ?? '';
      if (source.file) {
        setActiveStep('transcribe');
        await stt.load();
        if (cancelled()) return;
        const result = await transcriber.execute(source.file);
        if (!result || cancelled()) return;
        transcript = result.text.trim();
        if (!transcript) {
          onMeetingChange({ ...base, transcript: NO_SPEECH_TEXT, noSpeech: true });
          markDone('transcribe');
          return;
        }
        markDone('transcribe');
      }
      onMeetingChange((prev) => (prev ? { ...prev, transcript } : prev));

      setActiveStep('summarize');
      await summarizerLoad.load();
      if (cancelled()) return;
      const summaryResult = await summarizer.execute({
        text: transcript,
        maxLength: SUMMARY_MAX_LENGTH,
        minLength: SUMMARY_MIN_LENGTH,
      });
      if (!summaryResult || cancelled()) return;
      onMeetingChange((prev) => (prev ? { ...prev, summary: summaryResult.summary } : prev));
      markDone('summarize');

      setActiveStep('extract');
      await plannerLoad.load();
      if (cancelled()) return;
      const extraction = await extractor.execute(buildActionItemsPrompt(transcript));
      if (!extraction || cancelled()) return;
      onMeetingChange((prev) =>
        prev
          ? {
              ...prev,
              actionItems: extraction.object.items.map((item) => ({
                ...item,
                id: crypto.randomUUID(),
                completed: false,
              })),
            }
          : prev,
      );
      markDone('extract');
    } catch (err) {
      if (!cancelled()) setActionError(err instanceof Error ? err.message : String(err));
    } finally {
      if (runIdRef.current === runId) setActiveStep(null);
    }
  };

  const cancelPipeline = () => {
    runIdRef.current++;
    transcriber.cancel();
    summarizer.cancel();
    extractor.cancel();
    setActiveStep(null);
  };

  const resetMeeting = () => {
    cancelPipeline();
    onMeetingChange(null);
    setPastedText('');
    setDoneSteps(new Set());
    setTranscribeSkipped(false);
    setActionError(null);
  };

  const toggleActionItem = (id: string) => {
    onMeetingChange((prev) =>
      prev && prev.actionItems
        ? {
            ...prev,
            actionItems: prev.actionItems.map((item) =>
              item.id === id ? { ...item, completed: !item.completed } : item,
            ),
          }
        : prev,
    );
  };

  const exportMeeting = () => {
    if (!meeting) return;
    downloadBlob(
      buildMeetingExport(meeting.transcript, meeting.summary, meeting.actionItems ?? []),
      meetingExportFilename(),
    );
  };

  const stepState = (step: MeetingStep): StepState => {
    if (step === 'transcribe' && transcribeSkipped) return 'skipped';
    if (doneSteps.has(step)) return 'done';
    if (activeStep === step) return 'active';
    return 'pending';
  };

  const rawErrorText =
    actionError ??
    transcriber.error?.message ??
    summarizer.error?.message ??
    extractor.error?.message ??
    null;
  const errorText = rawErrorText && rawErrorText !== dismissedError ? rawErrorText : null;

  const statusText = running
    ? activeStep === 'transcribe'
      ? 'transcribing'
      : activeStep === 'summarize'
        ? 'summarizing'
        : 'extracting'
    : errorText
      ? 'error'
      : meeting
        ? 'ready'
        : 'idle';
  const statusLabel =
    statusText === 'transcribing'
      ? 'Transcribing…'
      : statusText === 'summarizing'
        ? 'Summarizing…'
        : statusText === 'extracting'
          ? 'Extracting…'
          : statusText === 'error'
            ? 'Error'
            : statusText === 'ready'
              ? 'Ready'
              : 'Idle';

  const doneCount = meeting?.actionItems?.filter((i) => i.completed).length ?? 0;
  const totalCount = meeting?.actionItems?.length ?? 0;

  return (
    <div className="flex flex-col gap-4">
      <p
        data-status={statusText}
        role="status"
        aria-live="polite"
        aria-label="Status"
        className="text-xs text-muted-foreground"
      >
        {statusLabel}
      </p>
      {errorText && (
        <ErrorAlert
          message={errorText}
          onDismiss={() => {
            setDismissedError(rawErrorText);
            setActionError(null);
          }}
        />
      )}

      {}
      {!meeting && !running && (
        <div className="grid gap-4 sm:grid-cols-2">
          <section aria-label="Upload meeting audio" className="flex flex-col gap-2 rounded-lg border border-border p-4">
            <h2 className="text-sm font-medium">Upload meeting audio</h2>
            <div>
              <FileDropzone
                onUpload={(files) => {
                  const file = files[0];
                  if (file) void runPipeline({ file });
                }}
                accept={ACCEPTED_AUDIO_MIME_TYPES}
                multiple={false}
                label="Drop a recording or click to browse"
                hint=".mp3, .wav, .webm, .m4a, .mp4"
              />
            </div>
            {sttModelId !== 'onnx-community/moonshine-base-ONNX' && (
              <p className="text-xs text-muted-foreground">
                Tip: Moonshine Base (~237MB) gives the best meeting transcription - pick it in the
                speech-to-text selector above.
              </p>
            )}
          </section>
          <section className="flex flex-col gap-2 rounded-lg border border-border p-4">
            <h2 className="text-sm font-medium">Or paste a transcript</h2>
            <textarea
              value={pastedText}
              onChange={(e) => setPastedText(e.target.value)}
              rows={6}
              placeholder="Paste a meeting transcript - skips the transcription step."
              aria-label="Meeting transcript"
              className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
            />
            <button
              type="button"
              onClick={() => void runPipeline({ text: pastedText })}
              disabled={!pastedText.trim()}
              className="inline-flex h-8 w-fit items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
            >
              Process transcript
            </button>
          </section>
        </div>
      )}

      {}
      {(running || meeting) && (
        <section className="flex flex-col gap-3 rounded-lg border border-border p-4">
          {}
          <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
            <ol aria-label="Meeting pipeline" className="flex flex-wrap items-center gap-2">
              {STEPS.map((step, i) => {
                const state = stepState(step.id);
                return (
                  <li
                    key={step.id}
                    data-step={step.id}
                    data-state={state}
                    aria-label={step.label}
                    className="flex items-center gap-2"
                  >
                    {i > 0 && (
                      <span className="text-muted-foreground" aria-hidden="true">
                        →
                      </span>
                    )}
                    <span
                      className={cn(
                        'inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium',
                        state === 'done' && 'bg-primary/10 text-primary',
                        state === 'active' && 'bg-primary text-primary-foreground',
                        state === 'pending' && 'bg-muted text-muted-foreground',
                        state === 'skipped' && 'bg-muted text-muted-foreground line-through',
                      )}
                    >
                      {state === 'active' && (
                        <span
                          className="size-3 animate-spin rounded-full border-2 border-primary-foreground/40 border-t-primary-foreground motion-reduce:animate-none"
                          aria-hidden="true"
                        />
                      )}
                      {state === 'done' && <span aria-hidden="true">✓</span>}
                      {step.label}
                    </span>
                  </li>
                );
              })}
            </ol>
            <div className="flex flex-wrap gap-2">
              {running && (
                <button
                  type="button"
                  onClick={cancelPipeline}
                  className="inline-flex h-7 items-center rounded-md border border-border px-2.5 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                >
                  Cancel
                </button>
              )}
              <button
                type="button"
                onClick={exportMeeting}
                disabled={!meeting || !meeting.transcript}
                className="inline-flex h-7 items-center rounded-md border border-border px-2.5 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
              >
                Export .txt
              </button>
              <button
                type="button"
                onClick={resetMeeting}
                className="inline-flex h-7 items-center rounded-md border border-border px-2.5 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
              >
                New meeting
              </button>
            </div>
          </div>

          {activeStep === 'summarize' && summarizerLoad.status === 'loading' && (
            <ModelLoadingPanel
              name="DistilBART CNN 6-6"
              size={SUMMARIZER_MODEL_SIZE}
              category="Summarization"
              progress={summarizerLoad.progressValue}
              cached={summarizerLoad.cached === true}
            />
          )}
          {activeStep === 'extract' && plannerLoad.status === 'loading' && (
            <ModelLoadingPanel
              name="Granite 4.0 350M"
              size={PLANNER_MODEL_SIZE}
              category="Action-item LM"
              progress={plannerLoad.progressValue}
              cached={plannerLoad.cached === true}
            />
          )}
        </section>
      )}

      {}
      {meeting && (
        <div className="flex flex-col gap-4">
          {meeting.audio && (
            <section className="flex flex-col gap-2 rounded-lg border border-border p-4">
              <h2 className="text-sm font-medium">{meeting.sourceLabel}</h2>
              <AudioScrubPlayer audio={meeting.audio} />
            </section>
          )}

          <section className="flex flex-col gap-2 rounded-lg border border-border p-4">
            <div className="flex items-center gap-2">
              <h2 className="text-sm font-medium">Transcript</h2>
              {meeting.transcript && !meeting.noSpeech && (
                <span className="rounded-md bg-muted px-1.5 py-0.5 text-xs tabular-nums text-foreground/70">
                  {countWords(meeting.transcript)} words
                </span>
              )}
            </div>
            <p className="whitespace-pre-wrap text-sm">
              {meeting.transcript || (activeStep === 'transcribe' ? 'Transcribing…' : '')}
            </p>
          </section>

          {meeting.summary !== null && (
            <section aria-label="Summary" className="flex flex-col gap-2 rounded-lg border border-border p-4">
              <div className="flex items-center gap-2">
                <h2 className="text-sm font-medium">Summary</h2>
                <span className="rounded-md bg-muted px-1.5 py-0.5 text-xs tabular-nums text-foreground/70">
                  {countWords(meeting.summary)} words
                </span>
              </div>
              <p className="whitespace-pre-wrap text-sm">
                {meeting.summary}
              </p>
            </section>
          )}

          {meeting.actionItems !== null && (
            <section className="flex flex-col gap-2 rounded-lg border border-border p-4">
              <div className="flex items-center gap-2">
                <h2 className="text-sm font-medium">Action items</h2>
                <span
                  data-done={doneCount}
                  data-total={totalCount}
                  className="rounded-md bg-muted px-1.5 py-0.5 text-xs tabular-nums text-foreground/70"
                >
                  {doneCount}/{totalCount} done
                </span>
              </div>
              {meeting.actionItems.length === 0 ? (
                <p className="text-sm text-muted-foreground">No action items found.</p>
              ) : (
                <ul aria-label="Action items" className="flex flex-col gap-1.5">
                  {meeting.actionItems.map((item) => (
                    <li
                      key={item.id}
                      data-priority={item.priority}
                      className="flex items-start gap-2 rounded-md border border-border bg-card p-2"
                    >
                      {}
                      <label className="-m-1 flex size-11 shrink-0 cursor-pointer items-center justify-center">
                        <input
                          type="checkbox"
                          checked={item.completed}
                          onChange={() => toggleActionItem(item.id)}
                          aria-label={`Mark "${item.text}" ${item.completed ? 'incomplete' : 'complete'}`}
                          className="size-4 accent-primary focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
                        />
                      </label>
                      <span
                        className={cn(
                          'mt-1.5 flex-1 text-sm',
                          item.completed && 'text-muted-foreground line-through',
                        )}
                      >
                        {item.text}
                      </span>
                      <span
                        className={cn(
                          'rounded-md px-1.5 py-0.5 text-[11px] font-medium capitalize',
                          PRIORITY_BADGE[item.priority],
                        )}
                      >
                        {item.priority}
                      </span>
                    </li>
                  ))}
                </ul>
              )}
            </section>
          )}
        </div>
      )}
    </div>
  );
}
```

## Voice Explorer

Browse and preview 29 text-to-speech voices grouped by language. Type any text, hear each voice read it, and play two side by side to compare. Everything runs on-device; the voice model (~86MB) downloads only on the first preview or comparison.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/audio/voice-explorer
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/audio/voice-explorer.json

```tsx
'use client';

/**
 * @file voice-explorer.tsx
 * @description Voice Explorer — browse, search, preview, and A/B-compare all 29 Kokoro voices; Kokoro downloads only on the first preview or comparison.
 */

import { useState } from 'react';
import { useModelLoad, useSynthesizeSpeech, type UseModelLoadReturn } from '@localmode/react';
import { synthesizeSpeech } from '@localmode/core';
import type { TextToSpeechModel } from '@localmode/core';
import {
  transformers,
  isModelCached,
  KOKORO_VOICES,
  KOKORO_DEFAULT_VOICE,
} from '@localmode/transformers';

import { VoiceCard, type VoiceOption } from '@/components/voice-picker';
import { VoiceComparisonPanel } from '@/components/voice-comparison-panel';
import { AudioScrubPlayer } from '@/components/audio-scrub-player';
import { ModelLoadingPanel } from '@/components/model-loading-panel';
import { ErrorAlert } from '@/components/error-alert';

const TTS_MODEL_ID = 'onnx-community/Kokoro-82M-v1.0-ONNX';
const TTS_MODEL_SIZE = '~86MB';

const PREVIEW_TEXT = 'Hello! This is a preview of this voice.';

const VOICE_OPTIONS: VoiceOption[] = KOKORO_VOICES.map((v) => ({
  id: v.id,
  name: v.name,
  gender: v.gender,
  languageLabel: v.languageLabel,
}));

const DEFAULT_COMPARE_TEXT = 'The quick brown fox jumps over the lazy dog.';

export function VoiceExplorerBlock() {
  const ttsLoad = useModelLoad<TextToSpeechModel>({
    key: `voice-explorer-tts:${TTS_MODEL_ID}`,
    create: (onProgress) =>
      transformers.textToSpeech(TTS_MODEL_ID, {
        onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]),
      }),
    warmup: (model) => synthesizeSpeech({ model, text: 'Ready.', voice: KOKORO_DEFAULT_VOICE }),
    isCached: () => isModelCached(TTS_MODEL_ID),
  });

  const ttsModel = ttsLoad.model;
  if (!ttsModel) return <p className="p-4 text-sm text-muted-foreground">Preparing…</p>;
  return <VoiceExplorerSurface ttsLoad={ttsLoad} ttsModel={ttsModel} />;
}

interface VoiceExplorerSurfaceProps {
  ttsLoad: UseModelLoadReturn<TextToSpeechModel>;
  ttsModel: TextToSpeechModel;
}

function VoiceExplorerSurface({ ttsLoad, ttsModel }: VoiceExplorerSurfaceProps) {
  const previewTts = useSynthesizeSpeech({ model: ttsModel });
  const compareTts = useSynthesizeSpeech({ model: ttsModel });

  const [search, setSearch] = useState('');
  const [selectedVoiceId, setSelectedVoiceId] = useState<string | undefined>();
  const [pendingPreviewId, setPendingPreviewId] = useState<string | null>(null);
  const [preview, setPreview] = useState<{ voiceId: string; audio: Blob } | null>(null);
  const [dismissedError, setDismissedError] = useState<string | null>(null);

  const [voiceA, setVoiceA] = useState('af_heart');
  const [voiceB, setVoiceB] = useState('am_michael');
  const [compareText, setCompareText] = useState(DEFAULT_COMPARE_TEXT);
  const [audioA, setAudioA] = useState<Blob | null>(null);
  const [audioB, setAudioB] = useState<Blob | null>(null);
  const [comparing, setComparing] = useState(false);

  const query = search.trim().toLowerCase();
  const filtered = query
    ? VOICE_OPTIONS.filter(
        (v) =>
          v.name.toLowerCase().includes(query) ||
          v.id.toLowerCase().includes(query) ||
          v.gender.includes(query),
      )
    : VOICE_OPTIONS;
  const groups = [...new Set(filtered.map((v) => v.languageLabel))].map((label) => ({
    label,
    voices: filtered.filter((v) => v.languageLabel === label),
  }));

  const previewVoice = async (voiceId: string) => {
    if (preview?.voiceId === voiceId) {
      setPreview(null);
      return;
    }
    setPendingPreviewId(voiceId);
    setPreview(null);
    setDismissedError(null);
    try {
      await ttsLoad.load();
      const result = await previewTts.execute(PREVIEW_TEXT, { voice: voiceId });
      if (result) setPreview({ voiceId, audio: result.audio });
    } catch {
    } finally {
      setPendingPreviewId(null);
    }
  };

  const runCompare = async () => {
    if (comparing || !compareText.trim()) return;
    setComparing(true);
    setDismissedError(null);
    setAudioA(null);
    setAudioB(null);
    try {
      await ttsLoad.load();
      const a = await compareTts.execute(compareText, { voice: voiceA });
      if (!a) return;
      setAudioA(a.audio);
      const b = await compareTts.execute(compareText, { voice: voiceB });
      if (!b) return;
      setAudioB(b.audio);
    } catch {
    } finally {
      setComparing(false);
    }
  };

  const cancelCompare = () => {
    compareTts.cancel();
    setComparing(false);
  };

  const rawErrorText =
    previewTts.error?.message ?? compareTts.error?.message ?? ttsLoad.error?.message ?? null;
  const errorText = rawErrorText && rawErrorText !== dismissedError ? rawErrorText : null;

  const busy = previewTts.isLoading || comparing;
  const statusText = busy
    ? 'synthesizing'
    : errorText
      ? 'error'
      : preview || audioA || audioB
        ? 'ready'
        : 'idle';
  const statusLabel =
    statusText === 'synthesizing'
      ? 'Synthesizing…'
      : statusText === 'error'
        ? 'Error'
        : statusText === 'ready'
          ? 'Ready'
          : 'Idle';

  return (
    <div className="flex flex-col gap-4 p-4">
      <p
        data-status={statusText}
        role="status"
        aria-live="polite"
        aria-label="Status"
        className="text-xs text-muted-foreground"
      >
        {statusLabel}
      </p>
      {errorText && (
        <ErrorAlert
          message={errorText}
          onDismiss={() => {
            setDismissedError(rawErrorText);
            previewTts.reset();
            compareTts.reset();
          }}
        />
      )}

      {ttsLoad.status === 'loading' && (
        <ModelLoadingPanel
          name="Kokoro 82M"
          size={TTS_MODEL_SIZE}
          category="Text-to-speech"
          progress={ttsLoad.progressValue}
          cached={ttsLoad.cached === true}
        />
      )}

      {}
      <section className="flex flex-col gap-3 rounded-lg border border-border p-4">
        <div className="flex flex-wrap items-center justify-between gap-2">
          <h2 className="text-sm font-medium">
            Voices <span className="text-xs text-muted-foreground">({VOICE_OPTIONS.length} Kokoro voices)</span>
          </h2>
          <input
            value={search}
            onChange={(e) => setSearch(e.target.value)}
            placeholder="Search name, id, or gender…"
            aria-label="Search voices"
            className="h-8 w-full min-w-0 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 sm:w-56"
          />
        </div>

        <div className="flex flex-col gap-4">
          {groups.length === 0 && (
            <p className="text-sm text-muted-foreground">No voices match “{search}”.</p>
          )}
          {groups.map((group) => (
            <div key={group.label} className="flex flex-col gap-2">
              <h3 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
                {group.label} <span className="normal-case">({group.voices.length})</span>
              </h3>
              <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
                {group.voices.map((voice) => (
                  <div key={voice.id} data-voice-id={voice.id}>
                    <VoiceCard
                      voice={voice}
                      selected={selectedVoiceId === voice.id}
                      onSelect={setSelectedVoiceId}
                      onPreview={(id) => void previewVoice(id)}
                      loading={pendingPreviewId === voice.id}
                      playing={preview?.voiceId === voice.id}
                    />
                  </div>
                ))}
              </div>
            </div>
          ))}
        </div>

        {preview && (
          <div
            data-voice-id={preview.voiceId}
            role="region"
            aria-label="Voice preview"
            className="flex flex-col gap-1"
          >
            <p className="text-xs text-muted-foreground">
              Previewing <span className="font-medium">{preview.voiceId}</span>: “{PREVIEW_TEXT}”
            </p>
            <AudioScrubPlayer audio={preview.audio} autoPlay />
          </div>
        )}
      </section>

      {}
      <section className="flex flex-col gap-3 rounded-lg border border-border p-4">
        <div className="flex flex-wrap items-center gap-3">
          <h2 className="text-sm font-medium">Compare two voices</h2>
          <span
            data-ready={audioA ? 'true' : 'false'}
            data-voice={voiceA}
            className="rounded-md bg-muted px-1.5 py-0.5 text-xs tabular-nums text-foreground/70"
          >
            A: {voiceA} · {audioA ? 'Ready' : 'Pending'}
          </span>
          <span
            data-ready={audioB ? 'true' : 'false'}
            data-voice={voiceB}
            className="rounded-md bg-muted px-1.5 py-0.5 text-xs tabular-nums text-foreground/70"
          >
            B: {voiceB} · {audioB ? 'Ready' : 'Pending'}
          </span>
          {comparing && (
            <button
              type="button"
              onClick={cancelCompare}
              className="inline-flex h-7 items-center rounded-md border border-border px-2.5 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
            >
              Cancel
            </button>
          )}
        </div>
        <div role="group" aria-label="Voice comparison">
          <VoiceComparisonPanel
            voices={VOICE_OPTIONS}
            columnA={{ voiceId: voiceA, audio: audioA }}
            columnB={{ voiceId: voiceB, audio: audioB }}
            onVoiceAChange={setVoiceA}
            onVoiceBChange={setVoiceB}
            text={compareText}
            onTextChange={setCompareText}
            onCompare={() => void runCompare()}
            loading={comparing}
          />
        </div>
      </section>
    </div>
  );
}
```

## Audiobook Reader

Paste long text and have it read aloud, with playback starting before the whole thing finishes. Adjust the reading speed, pause, resume, or stop anytime, and download the result as an audio file. All runs on-device (up to 10,000 characters); the voice model downloads only on the first play.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/audio/audiobook-reader
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/audio/audiobook-reader.json

```tsx
'use client';

/**
 * @file audiobook-reader.tsx
 * @description Audiobook Reader — long-text streaming synthesis (useStreamSpeech) with early playback, speed, pause/resume/stop, WAV download, a one-shot path, and a 10,000-char limit; Kokoro downloads on first synthesize/stream.
 */

import { useState } from 'react';
import {
  downloadBlob,
  useModelLoad,
  useStreamSpeech,
  useSynthesizeSpeech,
  type UseModelLoadReturn,
} from '@localmode/react';
import { synthesizeSpeech } from '@localmode/core';
import type { SynthesizedClause, TextToSpeechModel } from '@localmode/core';
import {
  transformers,
  isModelCached,
  KOKORO_VOICES,
  KOKORO_DEFAULT_VOICE,
} from '@localmode/transformers';

import { VoicePicker, type VoiceOption } from '@/components/voice-picker';
import { ParameterSlider } from '@/components/parameter-slider';
import { CharLimitIndicator } from '@/components/char-limit-indicator';
import { StreamingSpeechPanel } from '@/components/streaming-speech-panel';
import { WaveformActivityBars } from '@/components/waveform-activity-bars';
import { AudioScrubPlayer } from '@/components/audio-scrub-player';
import { ModelLoadingPanel } from '@/components/model-loading-panel';
import { ErrorAlert } from '@/components/error-alert';


const TTS_MODEL_ID = 'onnx-community/Kokoro-82M-v1.0-ONNX';
const TTS_MODEL_SIZE = '~86MB';

const AUDIOBOOK_MAX_TEXT_LENGTH = 10_000;

const SPEED_MIN = 0.5;
const SPEED_MAX = 2.0;
const SPEED_STEP = 0.1;
const SPEED_DEFAULT = 1.0;

const AUDIOBOOK_DEFAULT_TEXT =
  'Welcome to the LocalMode Audio Studio. This application converts your text into natural-sounding speech, entirely in your browser. No servers, no API keys. Your text never leaves your device.';

const SAMPLE_TEXTS: readonly string[] = [
  'The quick brown fox jumps over the lazy dog. A wonderful journey through the countryside begins with a single step.',
  'In the beginning, there was silence. Then came the sound of waves crashing against ancient shores, a rhythm as old as time itself.',
  'Technology has transformed the way we communicate. Today, artificial intelligence runs entirely in your browser, no servers needed.',
  'Once upon a time, in a land far away, there lived a wise old owl who knew the secrets of the forest.',
];

function encodeWavFromClauses(clauses: readonly SynthesizedClause[]): Blob | null {
  if (clauses.length === 0) return null;

  const totalSamples = clauses.reduce((acc, c) => acc + c.audio.length, 0);
  const merged = new Float32Array(totalSamples);
  let offset = 0;
  for (const clause of clauses) {
    merged.set(clause.audio, offset);
    offset += clause.audio.length;
  }

  const sampleRate = clauses[0].sampleRate;
  const dataSize = merged.length * 2;
  const buffer = new ArrayBuffer(44 + dataSize);
  const view = new DataView(buffer);
  const writeString = (o: number, s: string) => {
    for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i));
  };
  writeString(0, 'RIFF');
  view.setUint32(4, 36 + dataSize, true);
  writeString(8, 'WAVE');
  writeString(12, 'fmt ');
  view.setUint32(16, 16, true);
  view.setUint16(20, 1, true);
  view.setUint16(22, 1, true);
  view.setUint32(24, sampleRate, true);
  view.setUint32(28, sampleRate * 2, true);
  view.setUint16(32, 2, true);
  view.setUint16(34, 16, true);
  writeString(36, 'data');
  view.setUint32(40, dataSize, true);
  for (let i = 0; i < merged.length; i++) {
    const s = Math.max(-1, Math.min(1, merged[i]));
    view.setInt16(44 + i * 2, s * 0x7fff, true);
  }
  return new Blob([buffer], { type: 'audio/wav' });
}

const VOICE_OPTIONS: VoiceOption[] = KOKORO_VOICES.map((v) => ({
  id: v.id,
  name: v.name,
  gender: v.gender,
  languageLabel: v.languageLabel,
}));

export function AudiobookReaderBlock() {
  const [text, setText] = useState(AUDIOBOOK_DEFAULT_TEXT);

  const ttsLoad = useModelLoad<TextToSpeechModel>({
    key: `audiobook-reader-tts:${TTS_MODEL_ID}`,
    create: (onProgress) =>
      transformers.textToSpeech(TTS_MODEL_ID, {
        onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]),
      }),
    warmup: (model) => synthesizeSpeech({ model, text: 'Ready.', voice: KOKORO_DEFAULT_VOICE }),
    isCached: () => isModelCached(TTS_MODEL_ID),
  });

  const ttsModel = ttsLoad.model;
  if (!ttsModel) return <p className="p-4 text-sm text-muted-foreground">Preparing…</p>;
  return <AudiobookSurface ttsLoad={ttsLoad} ttsModel={ttsModel} text={text} onTextChange={setText} />;
}

interface AudiobookSurfaceProps {
  ttsLoad: UseModelLoadReturn<TextToSpeechModel>;
  ttsModel: TextToSpeechModel;
  text: string;
  onTextChange: (text: string) => void;
}

function AudiobookSurface({ ttsLoad, ttsModel, text, onTextChange }: AudiobookSurfaceProps) {
  const [voiceId, setVoiceId] = useState(KOKORO_DEFAULT_VOICE);
  const [speed, setSpeed] = useState(SPEED_DEFAULT);
  const [validationError, setValidationError] = useState<string | null>(null);
  const [dismissedError, setDismissedError] = useState<string | null>(null);
  const [lastAction, setLastAction] = useState<'synthesize' | 'stream' | null>(null);

  const tts = useSynthesizeSpeech({ model: ttsModel, voice: voiceId, speed });
  const stream = useStreamSpeech({ model: ttsModel, voice: voiceId, speed });

  const streamActive = stream.isSynthesizing || stream.isPlaying;
  const streamFinished = !streamActive && stream.clauses.length > 0;
  const streamStarted = streamActive || stream.clauses.length > 0;
  const runActive = streamActive || tts.isLoading;
  const overLimit = text.length > AUDIOBOOK_MAX_TEXT_LENGTH;

  const validate = (): boolean => {
    if (!text.trim()) {
      setValidationError('Please enter some text to convert to speech.');
      return false;
    }
    if (overLimit) {
      setValidationError(
        `Text is too long. Maximum ${AUDIOBOOK_MAX_TEXT_LENGTH.toLocaleString()} characters allowed.`,
      );
      return false;
    }
    setValidationError(null);
    return true;
  };

  const synthesizeOnce = async () => {
    if (runActive || !validate()) return;
    setLastAction('synthesize');
    setDismissedError(null);
    try {
      await ttsLoad.load();
      await tts.execute(text);
    } catch {
    }
  };

  const streamSpeak = async () => {
    if (runActive || !validate()) return;
    setLastAction('stream');
    setDismissedError(null);
    try {
      await ttsLoad.load();
      await stream.speak(text);
    } catch {
    }
  };

  const downloadWav = () => {
    const wav = encodeWavFromClauses(stream.clauses);
    if (!wav) return;
    downloadBlob(wav, `audio-${voiceId}-${Date.now()}.wav`, 'audio/wav');
  };

  const resetAll = () => {
    stream.stop();
    stream.reset();
    tts.reset();
    onTextChange(AUDIOBOOK_DEFAULT_TEXT);
    setVoiceId(KOKORO_DEFAULT_VOICE);
    setSpeed(SPEED_DEFAULT);
    setValidationError(null);
    setDismissedError(null);
    setLastAction(null);
  };

  const retry = () => {
    setDismissedError(rawErrorText);
    tts.reset();
    stream.reset();
    if (lastAction === 'synthesize') void synthesizeOnce();
    else if (lastAction === 'stream') void streamSpeak();
  };

  const rawErrorText =
    validationError ?? tts.error?.message ?? stream.error?.message ?? ttsLoad.error?.message ?? null;
  const errorText = rawErrorText && rawErrorText !== dismissedError ? rawErrorText : null;

  const ttsBlob = tts.data?.audio ?? null;

  const statusText = tts.isLoading
    ? 'synthesizing'
    : stream.isSynthesizing || stream.isPlaying
      ? 'streaming-speech'
      : errorText
        ? 'error'
        : ttsBlob || stream.clauses.length > 0
          ? 'ready'
          : 'idle';
  const statusLabel =
    statusText === 'synthesizing'
      ? 'Synthesizing…'
      : statusText === 'streaming-speech'
        ? 'Streaming speech…'
        : statusText === 'error'
          ? 'Error'
          : statusText === 'ready'
            ? 'Ready'
            : 'Idle';

  return (
    <div className="flex flex-col gap-4 p-4">
      <p
        data-status={statusText}
        role="status"
        aria-live="polite"
        aria-label="Status"
        className="text-xs text-muted-foreground"
      >
        {statusLabel}
      </p>
      {errorText && (
        <ErrorAlert
          message={errorText}
          onRetry={validationError ? undefined : retry}
          onDismiss={() => {
            setDismissedError(rawErrorText);
            setValidationError(null);
          }}
        />
      )}

      {ttsLoad.status === 'loading' && (
        <ModelLoadingPanel
          name="Kokoro 82M"
          size={TTS_MODEL_SIZE}
          category="Text-to-speech"
          progress={ttsLoad.progressValue}
          cached={ttsLoad.cached === true}
        />
      )}

      {}
      <section className="flex flex-col gap-3 rounded-lg border border-border p-4">
        <div className="flex items-center justify-between">
          <h2 className="text-sm font-medium">Text</h2>
          <CharLimitIndicator charCount={text.length} maxLength={AUDIOBOOK_MAX_TEXT_LENGTH} />
        </div>
        <textarea
          value={text}
          onChange={(e) => onTextChange(e.target.value)}
          rows={8}
          disabled={runActive}
          aria-label="Audiobook text"
          className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm disabled:opacity-50"
        />
        <div className="flex flex-wrap items-center gap-2">
          <span className="text-xs text-muted-foreground">Samples:</span>
          {SAMPLE_TEXTS.map((sample, i) => (
            <button
              key={i}
              type="button"
              onClick={() => onTextChange(sample)}
              disabled={runActive}
              title={sample}
              aria-label={`Sample ${i + 1}: ${sample.slice(0, 50)}…`}
              className="inline-flex h-7 items-center rounded-md border border-border px-2.5 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
            >
              Sample {i + 1}
            </button>
          ))}
        </div>
      </section>

      {}
      <section className="flex flex-wrap items-end gap-6 rounded-lg border border-border p-4">
        <VoicePicker
          voices={VOICE_OPTIONS}
          value={voiceId}
          onValueChange={setVoiceId}
          disabled={runActive}
          label="Voice"
        />
        <div className="min-w-56 flex-1">
          <ParameterSlider
            label="Speed"
            value={speed}
            onChange={setSpeed}
            min={SPEED_MIN}
            max={SPEED_MAX}
            step={SPEED_STEP}
            precision={1}
            unit="×"
            disabled={runActive}
          />
        </div>
      </section>

      {}
      <div className="flex flex-wrap items-center gap-2">
        <button
          type="button"
          onClick={() => void streamSpeak()}
          disabled={runActive || !text.trim()}
          className="inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
        >
          {stream.isSynthesizing ? 'Generating…' : 'Generate audiobook (stream & play)'}
        </button>
        <button
          type="button"
          onClick={() => void synthesizeOnce()}
          disabled={runActive || !text.trim()}
          className="inline-flex h-8 items-center rounded-md border border-border px-3 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
        >
          {tts.isLoading ? 'Synthesizing…' : 'Synthesize (single take)'}
        </button>
        <button
          type="button"
          onClick={resetAll}
          className="inline-flex h-8 items-center rounded-md border border-border px-3 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
        >
          Reset
        </button>
      </div>

      {}
      <section
        data-synthesizing={stream.isSynthesizing ? 'true' : 'false'}
        data-playing={stream.isPlaying ? 'true' : 'false'}
        role="region"
        aria-label="Streaming playback"
        className="flex flex-col gap-3 rounded-lg border border-border p-4"
      >
        {streamStarted && (
          <>
            {}
            <div className="flex flex-wrap items-center gap-2">
              <WaveformActivityBars active={stream.isPlaying} height={24} barCount={7} />
              <button
                type="button"
                onClick={stream.pause}
                disabled={!stream.isPlaying}
                className="inline-flex h-7 items-center rounded-md border border-border px-2.5 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
              >
                Pause
              </button>
              <button
                type="button"
                onClick={stream.resume}
                disabled={stream.isPlaying || (!streamActive && stream.clauses.length === 0)}
                className="inline-flex h-7 items-center rounded-md border border-border px-2.5 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
              >
                Resume
              </button>
              <button
                type="button"
                onClick={stream.stop}
                disabled={!streamActive}
                className="inline-flex h-7 items-center rounded-md border border-border px-2.5 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
              >
                Stop
              </button>
            </div>

            {}
            <div className="flex flex-wrap items-center justify-between gap-2">
              <span
                data-count={stream.clauses.length}
                className="text-xs tabular-nums text-muted-foreground"
              >
                Streamed {stream.clauses.length} clause{stream.clauses.length === 1 ? '' : 's'}
              </span>
              <button
                type="button"
                onClick={downloadWav}
                disabled={!streamFinished}
                className="inline-flex h-7 items-center rounded-md border border-border px-2.5 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
              >
                Download WAV
              </button>
            </div>

            <p className="min-h-5 text-sm">
              {stream.currentClause ? (
                <>
                  <span className="mr-2 text-[11px] font-medium tracking-wide text-primary uppercase">
                    Now playing
                  </span>
                  {stream.currentClause.text}
                </>
              ) : (
                <span className="text-muted-foreground text-xs">
                  {streamActive ? 'Buffering next clause…' : 'The playing clause appears here during a run.'}
                </span>
              )}
            </p>
          </>
        )}

        <StreamingSpeechPanel
          isSynthesizing={stream.isSynthesizing}
          isPlaying={stream.isPlaying}
          currentClause={stream.currentClause}
          clauses={stream.clauses}
        />
      </section>

      {}
      {ttsBlob && !tts.isLoading && (
        <div
          role="region"
          aria-label="Single-take result"
          className="flex flex-col gap-2 rounded-lg border border-border p-4"
        >
          <p className="text-xs text-muted-foreground">Single-take synthesis: {voiceId}</p>
          <AudioScrubPlayer audio={ttsBlob} />
        </div>
      )}
    </div>
  );
}
```

## Audio Classifier

Record a sound or upload an audio file and see what it is, from music and speech to everyday noises. Results come back as a ranked list of the most likely sounds, with the top guess highlighted. Runs entirely in your browser; the model downloads only when you record or choose a file.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/audio/audio-classifier
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/audio/audio-classifier.json

```tsx
'use client';

/**
 * @file audio-classifier.tsx
 * @description Audio — Audio Classifier: MediaPipe YAMNet (521 categories) top-8 sound classification from a microphone recording or an uploaded audio file, fully on-device.
 */

import { useEffect, useRef, useState } from 'react';
import { Mic, Square, Upload } from 'lucide-react';
import {
  classifyAudio,
  type AudioClassificationModel,
  type AudioClassificationResultItem,
} from '@localmode/core';
import { mediapipe } from '@localmode/mediapipe';

import { CapabilityGate } from '@/components/capability-gate';
import { ErrorAlert } from '@/components/error-alert';
import { ScoredResultBarList } from '@/components/scored-result-bar-list';
import { WaveformActivityBars } from '@/components/waveform-activity-bars';

let audioModel: AudioClassificationModel | null = null;
const getAudioClassifier = () => (audioModel ??= mediapipe.audioClassifier());

export type AudioClassifierStatus = 'idle' | 'recording' | 'classifying' | 'done' | 'error';

export interface AudioClassifierError {
  kind: 'permission' | 'classify';
  message: string;
}

export function useAudioClassifier() {
  const [predictions, setPredictions] = useState<AudioClassificationResultItem[]>([]);
  const [status, setStatus] = useState<AudioClassifierStatus>('idle');
  const [error, setError] = useState<AudioClassifierError | null>(null);

  const recorderRef = useRef<MediaRecorder | null>(null);
  const chunksRef = useRef<Blob[]>([]);
  const abortRef = useRef<AbortController | null>(null);
  const lastAudioRef = useRef<Blob | null>(null);

  useEffect(() => {
    return () => {
      abortRef.current?.abort();
      const recorder = recorderRef.current;
      if (recorder && recorder.state !== 'inactive') recorder.stop();
      recorder?.stream.getTracks().forEach((t) => t.stop());
      recorderRef.current = null;
    };
  }, []);

  const classify = async (audio: Blob) => {
    lastAudioRef.current = audio;
    abortRef.current?.abort();
    const controller = new AbortController();
    abortRef.current = controller;
    setStatus('classifying');
    setError(null);
    try {
      const result = await classifyAudio({
        model: getAudioClassifier(),
        audio,
        topK: 8,
        abortSignal: controller.signal,
      });
      setPredictions(result.predictions);
      setStatus('done');
    } catch (err) {
      if (controller.signal.aborted) {
        setStatus('idle');
        return;
      }
      setError({
        kind: 'classify',
        message: err instanceof Error ? err.message : String(err),
      });
      setStatus('error');
    }
  };

  const startRecording = async () => {
    if (recorderRef.current) return;
    setError(null);
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const recorder = new MediaRecorder(stream);
      chunksRef.current = [];
      recorder.ondataavailable = (event) => {
        if (event.data.size > 0) chunksRef.current.push(event.data);
      };
      recorder.onstop = () => {
        stream.getTracks().forEach((t) => t.stop());
        const blob = new Blob(chunksRef.current, { type: recorder.mimeType });
        recorderRef.current = null;
        void classify(blob);
      };
      recorder.start();
      recorderRef.current = recorder;
      setStatus('recording');
    } catch {
      setError({
        kind: 'permission',
        message: 'Microphone access was denied. Grant microphone permission and try again.',
      });
      setStatus('error');
    }
  };

  const stopRecording = () => {
    const recorder = recorderRef.current;
    if (!recorder) return;
    recorder.stop();
  };

  const cancel = () => {
    abortRef.current?.abort();
  };

  const retry = () => {
    if (lastAudioRef.current) void classify(lastAudioRef.current);
  };

  const clearError = () => {
    setError(null);
    if (status === 'error') setStatus(predictions.length > 0 ? 'done' : 'idle');
  };

  return {
    predictions,
    status,
    error,
    isRecording: status === 'recording',
    classify,
    startRecording,
    stopRecording,
    cancel,
    retry,
    clearError,
  };
}

export function AudioClassifierBlock() {
  const audio = useAudioClassifier();
  const fileInputRef = useRef<HTMLInputElement>(null);

  const statusText =
    audio.status === 'recording'
      ? 'Recording…'
      : audio.status === 'classifying'
        ? 'Classifying…'
        : audio.status === 'done'
          ? `Done - top: ${audio.predictions[0]?.label ?? ''}`
          : audio.status === 'error'
            ? 'Error'
            : 'Idle - record or upload audio to classify';

  return (
    <div className="flex flex-col gap-3 p-4">
      <p
        data-status={audio.status}
        role="status"
        aria-live="polite"
        aria-label="Status"
        className="text-xs text-muted-foreground"
      >
        {statusText}
      </p>

      {audio.error && (
        <ErrorAlert
          message={audio.error.message}
          onRetry={() => {
            if (audio.error?.kind === 'permission') {
              audio.clearError();
              void audio.startRecording();
            } else {
              audio.retry();
            }
          }}
          onDismiss={audio.clearError}
        />
      )}

      <CapabilityGate requires="wasm">
        <div className="grid gap-3 lg:grid-cols-[1fr_1fr]">
          {}
          <section
            aria-label="Classify a sound"
            className="flex flex-col gap-3 rounded-lg border border-border p-4"
          >
            <div>
              <p className="text-sm font-medium">Classify a sound</p>
              <p className="text-xs text-muted-foreground">
                MediaPipe YAMNet - 521 environmental sound categories, fully on-device. The model
                downloads on your first classification.
              </p>
            </div>

            <div className="flex flex-wrap items-center gap-2">
              <CapabilityGate requires="microphone">
                <button
                  type="button"
                  data-recording={audio.isRecording}
                  onClick={() =>
                    audio.isRecording ? audio.stopRecording() : void audio.startRecording()
                  }
                  disabled={audio.status === 'classifying'}
                  className="inline-flex h-8 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
                >
                  {audio.isRecording ? (
                    <Square className="h-3.5 w-3.5" aria-hidden />
                  ) : (
                    <Mic className="h-3.5 w-3.5" aria-hidden />
                  )}
                  {audio.isRecording ? 'Stop & classify' : 'Record'}
                </button>
              </CapabilityGate>

              <button
                type="button"
                onClick={() => fileInputRef.current?.click()}
                disabled={audio.isRecording || audio.status === 'classifying'}
                className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
              >
                <Upload className="h-3.5 w-3.5" aria-hidden />
                Upload audio
              </button>
              <input
                ref={fileInputRef}
                aria-label="Upload audio file"
                type="file"
                accept="audio/*"
                className="hidden"
                onChange={(e) => {
                  const file = e.target.files?.[0];
                  if (file) void audio.classify(file);
                  e.target.value = '';
                }}
              />
            </div>

            {audio.isRecording && (
              <div className="flex items-center gap-3">
                <WaveformActivityBars state="record" label="recording activity" />
                <span className="text-xs font-medium text-destructive">Recording…</span>
              </div>
            )}
          </section>

          {}
          <section
            aria-label="Top predictions"
            className="flex flex-col gap-3 rounded-lg border border-border p-4"
          >
            <p className="text-sm font-medium">Top predictions</p>
            <ScoredResultBarList
              results={audio.predictions.map((p) => ({ label: p.label, score: p.score }))}
              isLoading={audio.status === 'classifying'}
              limit={8}
              emptyState="No classification yet - record or upload a sound."
            />
          </section>
        </div>
      </CapabilityGate>
    </div>
  );
}
```
