# Audiobook Reader Block

> Long-text streaming synthesis (useStreamSpeech) with early playback that begins before synthesis completes, a live clause counter + current-clause display, speed control (0.5x–2.0x), pause/resume/stop transport, a WAV download (audio-{voiceId}-{timestamp}.wav), a one-shot single-take synthesis path, a 10,000-character limit with pre-flight validation, and one-click sample texts. Kokoro downloads only on the first synthesize/stream action. Fully on-device. Split from ui/blocks/audio-studio (Audiobook tab).

A shadcn/ui block from the [@localmode](https://localmode.ai/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/localmode/ui/blocks/audio/audiobook-reader).

- Page, with a live preview: https://shadcn.exchange/r/localmode/ui/blocks/audio/audiobook-reader
- Screenshot: https://shadcn.exchange/shot/localmode/ui/blocks/audio/audiobook-reader/light.png
- Registry item: https://localmode.ai/r/ui/blocks/audio/audiobook-reader.json
- Categories: blocks, audio
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/localmode/ui/blocks/audio/audiobook-reader.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@localmode/core`, `@localmode/react`, `@localmode/transformers`
- Registry items: `https://localmode.ai/r/ui/audio/voice-picker.json`, `https://localmode.ai/r/ui/audio/streaming-speech-panel.json`, `https://localmode.ai/r/ui/audio/waveform-activity-bars.json`, `https://localmode.ai/r/ui/audio/audio-scrub-player.json`, `https://localmode.ai/r/ui/input-controls/parameter-slider.json`, `https://localmode.ai/r/ui/input-controls/char-limit-indicator.json`, `https://localmode.ai/r/ui/local-first/model-loading-panel.json`, `https://localmode.ai/r/ui/results/error-alert.json`

## Files

### `src/app/blocks/audio/audiobook-reader/audiobook-reader.tsx`

```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>
  );
}
```
