Metadata-Version: 2.5
Name: quotable-mcp
Version: 0.1.0
Summary: Local-first MCP server and CLI that turns recordings, podcast feeds and media URLs into cite-ready transcripts
Project-URL: Repository, https://github.com/rfung4/quotable
Author: Ross Fung
License-Expression: MIT
License-File: LICENSE
Keywords: captions,diarization,mcp,podcast,transcription,whisper
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
Requires-Python: >=3.10
Requires-Dist: av<19,>=11
Requires-Dist: faster-whisper>=1.2.1
Requires-Dist: filelock>=3.10
Requires-Dist: mcp<3,>=2.3
Requires-Dist: pydantic>=2.12
Requires-Dist: yt-dlp[default]>=2026.8.19
Provides-Extra: diarize
Requires-Dist: pyannote-audio>=4; extra == 'diarize'
Description-Content-Type: text/markdown

<!-- mcp-name: io.github.rfung4/quotable -->

# quotable

Turn recordings, podcast feeds and media URLs into transcripts you can cite,
on your own machine. Every line carries a timestamp, and a speaker label when
you ask for one. Captions first, [faster-whisper](https://github.com/SYSTRAN/faster-whisper)
when there are none. Exposed as an MCP server over stdio and as a CLI.

![Claude Code transcribing an interview with speaker labels and quoting a speaker at a timestamp](https://raw.githubusercontent.com/rfung4/quotable/main/docs/demo.gif)

## Install

```bash
uvx quotable-mcp --help
claude mcp add quotable -- uvx quotable-mcp serve
```

With speaker labels (pulls in PyTorch and pyannote, about 2 GB):

```bash
claude mcp add quotable -e QUOTABLE_HF_TOKEN=hf_... -- uvx --from 'quotable-mcp[diarize]' quotable serve
```

Diarization uses pyannote's
[`speaker-diarization-community-1`](https://huggingface.co/pyannote/speaker-diarization-community-1),
a gated model: accept its terms on Hugging Face once, then pass a token.

MCP clients start the server with a near-empty environment, so put `QUOTABLE_*`
settings in the client's `env` block (`-e` above), not in your shell profile.

## Use

From an agent:

> Transcribe `~/meetings/2026-10-13-planning.m4a` with speaker labels and quote
> what SPEAKER_01 said about the deadline, with the timestamp.

From the shell:

```bash
quotable get ~/meetings/planning.m4a --diarize --format srt
quotable get https://feeds.example.com/podcast.xml --episode "pricing" --format txt
quotable info https://www.youtube.com/watch?v=...
quotable serve
```

`get` prints the transcript on stdout and streams segments to stderr as they
are produced. Failures are one stderr line with the fix, exit code 1.

## Tools and resources

| Name | Kind | What it does |
|------|------|--------------|
| `media_info(source, episode?)` | tool, read-only | Title, duration, caption languages, chapters; for a feed, its recent episodes |
| `transcribe(source, mode?, language?, diarize?, episode?, force?)` | tool | Transcribes and caches; returns an `id`, metadata, `cite_url` and the first page |
| `read_transcript(id, offset?, limit?, start_s?, end_s?, timestamps?)` | tool, read-only | Pages a cached transcript by character offset or by time window |
| `transcript://{id}` | resource, `text/plain` | The whole transcript, one `[hh:mm:ss] SPEAKER: text` line per segment |
| `transcript://{id}/meta` | resource, `application/json` | Method, language, chapters, speakers, segment count |

`mode` is `auto` (the source's own captions or feed transcript, else Whisper),
`captions` (never run Whisper) or `whisper` (always transcribe the audio).
When no `language` is given and a video carries captions in several languages
without naming its own (TED talks do), no track is guessed, since most would be
translations: `auto` transcribes the audio, `captions` fails with a hint to pass
`language`.
`episode` picks a feed episode by guid, 1-based index (1 = newest) or title words.

Tool results are typed structured output plus a short text block. Failures
arrive as tool errors (`isError: true`) that name the cause and the fix, never
as success-shaped text. Long Whisper runs report progress per segment with the
newest line; a cancel stops between segments and the next call resumes from
there.

`cite_url` is a template with `{t}` for whole seconds, so an agent can link the
exact moment: `https://youtu.be/{id}?t={t}` for YouTube, `…#t={t}` for other
URLs and enclosures, `file://{path}#t={t}` for local files.

Transcript text and titles are third-party content. The rendered text says so
in its first line, and an agent should treat it as data, never as instructions.

## Configuration

| Variable | Default | Meaning |
|----------|---------|---------|
| `QUOTABLE_OUTPUT_DIR` | `$XDG_DATA_HOME/quotable` (`~/.local/share/quotable`) | Cache of transcripts: `transcript.json`, `.txt`, `.srt` per variant |
| `QUOTABLE_DEVICE` | `auto` | `auto`, `cpu` or `cuda`; `auto` picks CUDA when ctranslate2 sees a GPU. Diarization follows it |
| `QUOTABLE_MODEL` | `turbo` on CUDA, `base` on CPU | Any faster-whisper model name |
| `QUOTABLE_COMPUTE_TYPE` | `float16` on CUDA, `int8` on CPU | ctranslate2 compute type |
| `QUOTABLE_KEEP_AUDIO` | off | Keep downloaded audio in its scratch directory (path logged to stderr) instead of deleting it; nothing reads it back |
| `QUOTABLE_HF_TOKEN` | `HF_TOKEN` | Hugging Face token for the diarization model |
| `QUOTABLE_VAD` | `auto` | Voice filter before Whisper: `on`, `off`, or `auto` (on, unless it keeps less than half of the audio's sound, as it does on some radio recordings: then off, with a warning on stderr) |

A transcript is cached per *variant*: source × method × language × model ×
diarized. Two concurrent requests for the same variant share one run; the
second waits and then reads the cache.

### GPU

On CUDA the model loads in `float16`. If CUDA or cuDNN is missing or broken,
the load falls back to CPU with a warning on stderr; the server keeps working.
faster-whisper's README covers installing cuBLAS and cuDNN 9 for your platform.
An 8 GB card fits `turbo`.

### YouTube and other sites

Downloads go through [yt-dlp](https://github.com/yt-dlp/yt-dlp). YouTube
requires a JavaScript runtime to solve its challenge: install
[Deno](https://deno.com) or Node.js 20+ and keep it on `PATH`. If it is
missing, the error says so.

## How it works

```mermaid
flowchart LR
    S[file · feed URL · media URL] --> R{resolve}
    R -->|file| A[audio]
    R -->|feed| F[episode: podcast:transcript?]
    R -->|URL| C[captions: json3 / VTT]
    F -->|transcript| T[Transcript]
    C -->|captions| T
    F -->|no transcript| A
    C -->|no captions| A
    A --> W[faster-whisper, streaming segments]
    W -->|optional| D[pyannote speakers]
    W --> T
    D --> T
    T --> K[(cache per variant)]
    K --> M[MCP tools + transcript:// resources · CLI]
```

The pipeline is captions-first: platform subtitles or a feed's
`<podcast:transcript>` cost seconds and keep names right, so they win when
they exist. Whisper runs otherwise, on the GPU when there is one. Segments are
written to disk as they arrive, so a crashed or cancelled run resumes from its
last segment. One model stays warm per process, and one lock per process
serialises inference so two runs in one server cannot fight for the card. Each
MCP client starts its own server, so two clients hold two models: two `turbo`
servers fit an 8 GB card, two diarizing ones may not.

The server runs the work in-process. yt-dlp spawns ffmpeg and a JS runtime on
its own, and a child that inherits the server's stdin or stdout would block or
corrupt the JSON-RPC pipe. The `mcp` SDK repoints fd 0 and fd 1 while serving,
quotable never overrides that, and a regression test pins the behaviour.
See [docs/notes/mcp-stdio-subprocess.md](docs/notes/mcp-stdio-subprocess.md).

## Benchmark

Measured with `python -m evals.run` (October 2026) on NVIDIA GeForce RTX 3070 (8 GB, driver 595.84) · AMD Ryzen 9 7950X3D 16-Core Processor (32 threads) · 30 GB RAM · faster-whisper 1.2.1.
Audio is the decoded length; load is the model load; wall is one `transcribe` call
after that, on the already downloaded audio; realtime is audio divided by wall; cached
is a second call for the same variant.

| Source | Model | Device | Audio | Load | Wall | Realtime | Cached |
|---|---|---|---|---|---|---|---|
| *Charles Ponzi's scheme (plus a new scam)* (podcast feed, 29.0 min) | base | cpu | 29.0 min | 0.9 s | 66.9 s | 26× | 0.02 s |
| same episode | turbo | cuda | 29.0 min | 1.4 s | 40.5 s | 43× | 0.02 s |
| *Lecture 1: Algorithmic Thinking, Peak Finding* (YouTube, 53.4 min) | captions | - | 53.4 min | - | 2.0 s | 1640× | 1.50 s |

Fetched live from the feed, the `turbo` row took 44.5 s including the download.

The full matrix (every input kind, four models, both devices, the long-run scenarios) is in [`evals/README.md`](evals/README.md).

## Why not one of the others

Each cell read from the project's own source on 2026-10-16; they move, so check before relying
on one.

| | quotable | [yueying](https://github.com/vsh5dvsch7-png/yueying) | [transcriptor-mcp](https://github.com/samson-art/transcriptor-mcp) | [hearsay](https://github.com/mudassar531/hearsay) | [mcp-video-analyzer](https://github.com/guimatheus92/mcp-video-analyzer) |
|---|---|---|---|---|---|
| Captions first, Whisper fallback | yes | yes | yes (Whisper opt-in, as a service) | yes | yes |
| Typed tool errors, never success-shaped text | yes | no (`"ERROR: …"` strings) | yes | yes | partly (no transcript is a success with a warning) |
| Structured output and transcript resources | yes | no | yes | no (markdown text) | no (JSON in a text block) |
| Speaker labels over MCP | yes (extra) | no | no | CLI only | from platform captions only |
| Podcast RSS as an input | yes | no | no | CLI only | no |
| Partial text, resume, cancel on long runs | yes | progress only | no (a timed-out job finishes in the background for a retry) | no | progress only |
| Keyframes, OCR, visual analysis | no, by design | yes | yes | no | yes |

If you need frames or OCR, use one of those; quotable aims at the transcript
half. The main trade-off against yueying's child-process-per-job design: one
warm model in-process saves a multi-second load per job and lets a cancel keep
the partial result, at the cost of hard-kill and VRAM release.

## Known issues

Found by the evaluation in `evals/README.md` (October 2026, one machine); not fixed in 0.1.0.

- **RAM grows with audio length**, about 1 GB per 16 minutes of audio (11 GB for 3 hours),
  because the whole file's spectrogram is built at once. Keep long recordings under what your
  RAM allows.
- **A feed's own transcript carries the publisher's timestamps.** Ads inserted into your
  download shift the audio (121 to 193 s on two Darknet Diaries episodes); `mode=whisper`
  stamps what you downloaded.
- **`turbo` starts words 0.15 to 0.26 s early** and splits some produced podcasts into
  very short lines. It shows in SRT/VTT, not in `[hh:mm:ss]` citations; `small` is tighter.
- **YouTube sometimes answers 403.** The call fails with a typed error; call again.
- **In Claude Code**, `@`-mentioning a transcript does not attach it, and a call longer than
  120 s moves to a background task: it keeps running, and the next call is a cache hit.

## Rights

quotable processes what you point it at. You need the rights to the
recordings, feeds and pages you transcribe, and the terms of the site you fetch
from apply to you: YouTube's terms, for one, restrict downloading and automated
access. quotable ships no cookie or anti-bot handling.

## Development

```bash
uv sync
uv run pytest -q                 # unit tests, collaborators faked
uv run pytest -m integration     # real tiny model on CPU, real stdio, no network
uv run ruff check . && uv run black --check . && uv run isort --check-only .
```

The test clip is the public-domain JFK inaugural excerpt from whisper.cpp,
re-encoded to 16 kHz mono FLAC; see `tests/fixtures/README.md`. Python 3.10 to
3.14, MIT licence.
