# Transcribed Note Card

Transcribed Note Card [#transcribed-note-card]

**Transcribed Note Card** is a list item that pairs transcribed text with inline audio playback. It has two variants from one component:

* **Placeholder** (`transcribing`) — a waveform row + a "Transcribing…" label, the canonical loading state for a `useOperationList`-backed STT list where items stream in.
* **Populated** — a relative timestamp (hover → absolute), the transcript body, a native `<audio>` footer, and a hover-revealed delete.

Drive it from [`useTranscribe`](https://localmode.dev/docs/react) + `useOperationList`.

**When to use it:** render a growing list of voice notes / meeting snippets where each item starts as a placeholder and morphs into the finished transcription.

Preview [#preview]

```tsx
'use client';

import { useState } from 'react';
import { TranscribedNoteCard } from '@/components/transcribed-note-card';

/**
 * Demo for {@link TranscribedNoteCard}. Shows the placeholder → populated morph
 * (driven here by a timer) alongside an existing populated note with inline
 * audio and a delete control. The real app flips `transcribing` off when
 * `useTranscribe` resolves and supplies the recorded Blob.
 */
function makeSilentWav(): Blob {
  const sampleRate = 8000;
  const samples = sampleRate; // 1s
  const buffer = new ArrayBuffer(44 + samples * 2);
  const view = new DataView(buffer);
  const writeStr = (offset: number, s: string) => {
    for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i));
  };
  writeStr(0, 'RIFF');
  view.setUint32(4, 36 + samples * 2, true);
  writeStr(8, 'WAVE');
  writeStr(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);
  writeStr(36, 'data');
  view.setUint32(40, samples * 2, true);
  return new Blob([buffer], { type: 'audio/wav' });
}

export default function TranscribedNoteCardDemo() {
  const [pending, setPending] = useState(true);
  const [notes, setNotes] = useState(() => [
    {
      id: '1',
      text: 'Remember to download the model before going offline.',
      timestamp: new Date(Date.now() - 1000 * 60 * 4),
      audio: makeSilentWav(),
    },
  ]);

  const finish = () => {
    setPending(false);
    setNotes((prev) => [
      {
        id: crypto.randomUUID(),
        text: 'This note finished transcribing.',
        timestamp: new Date(),
        audio: makeSilentWav(),
      },
      ...prev,
    ]);
  };

  return (
    <div className="flex flex-col gap-3">
      <button
        type="button"
        onClick={() => {
          setPending(true);
          setTimeout(finish, 1500);
        }}
        className="inline-flex h-9 w-fit items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90"
      >
        Simulate transcription
      </button>

      {pending && <TranscribedNoteCard transcribing />}

      {notes.map((note) => (
        <TranscribedNoteCard
          key={note.id}
          text={note.text}
          timestamp={note.timestamp}
          audio={note.audio}
          onDelete={() => setNotes((prev) => prev.filter((n) => n.id !== note.id))}
        />
      ))}
    </div>
  );
}
```

Installation [#installation]

```bash
npx shadcn@latest add @localmode/ui/audio/transcribed-note-card
```

Data source & dependencies [#data-source--dependencies]

**Data source:** renders the transcript text + audio Blob you pass — works with any backend. Recommended producer: `useTranscribe` + `useOperationList` (with the Whisper STT model from `@localmode/transformers`) from `@localmode/react` (on-device, optional).

* `@localmode/ui/lib/browser-utils` — `useObjectUrl` for Blob lifecycle (installed automatically as a registry dependency)
* `clsx` + `tailwind-merge` — via the shared `cn()` util

Files installed [#files-installed]

* `transcribed-note-card.tsx` — the component
* `waveform-activity-bars.tsx` — the placeholder indicator (registry dependency)
* `lib/browser-utils.ts` — generic browser helpers (`useObjectUrl`)
* `lib/utils.ts` — the `cn()` helper (if not already present)

Props [#props]

**TranscribedNoteCard**

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `transcribing` | `boolean` | — | When true, render the "transcribing…" placeholder (waveform + label) instead of the populated card. Flip to false once `useTranscribe` resolves. |
| `text` | `string` | — | The transcribed text (shown once `transcribing` is false). |
| `timestamp` | `object` | — | When the note was created — shown as relative time, absolute on hover. |
| `audio` | `object \| string \| null` | — | Local audio for inline playback (a `Blob` or object URL). |
| `onDelete` | `function` | — | Fired when the hover-revealed delete control is clicked. Omit to hide it. |

Examples [#examples]

A streaming transcription list [#a-streaming-transcription-list]

```tsx
import { TranscribedNoteCard } from '@/components/transcribed-note-card';
import { useOperationList, useVoiceRecorder } from '@localmode/react';
import { transcribe } from '@localmode/core';
import { transformers } from '@localmode/transformers';

export function VoiceNotes() {
  const recorder = useVoiceRecorder();
  const { items, isLoading, execute, removeItem } = useOperationList({
    fn: (audio: Blob, signal) =>
      transcribe({ model: transformers.speechToText('onnx-community/whisper-base'), audio, abortSignal: signal }),
    transform: (result, audio) => ({ id: crypto.randomUUID(), text: result.text, audio, timestamp: new Date() }),
  });

  return (
    <div className="flex flex-col gap-3">
      {isLoading && <TranscribedNoteCard transcribing />}
      {items.map((note) => (
        <TranscribedNoteCard
          key={note.id}
          text={note.text}
          audio={note.audio}
          timestamp={note.timestamp}
          onDelete={() => removeItem((n) => n.id === note.id)}
        />
      ))}
    </div>
  );
}
```

Customization [#customization]

The relative-time formatter lives in the copied file — swap it for your own (or a library) if you need finer granularity. The delete control is hover-revealed via `group-hover`; remove the `opacity-0` classes to keep it always visible. Pass either a `Blob` or an object-URL string for `audio`.