ossicle
by PSNapier
README.md
# ossicle
Transcribe local media files and URLs with Deepgram, as an MCP server for Claude Code and as a
standalone CLI. Transcripts are written to disk as Markdown; nothing large is ever returned inline.
Every job is priced from its measured duration **before** anything is sent, and a job whose estimate
exceeds the configured per-job cap is refused outright. That guard is the point of the package.
## Requirements
- Node >= 20
- `ffprobe` and `ffmpeg` on `PATH` (duration measurement and the 16 kHz mono opus upload)
- `yt-dlp` on `PATH`, if you want URL input
- A Deepgram API key
## Install
```bash
npm install
npm run build
cp .env.example .env # then fill in DEEPGRAM_API_KEY
```
## Configuration
Configuration is read **only** from the `.env` file in the package root. Shell-exported variables and
`claude mcp add --env` flags are ignored on purpose, so the server behaves identically no matter which
project launched it.
| Variable | Required | Default | Meaning |
| --- | --- | --- | --- |
| `DEEPGRAM_API_KEY` | yes | | Deepgram API key |
| `DEEPGRAM_MODEL` | no | `nova-3` | Transcription model |
| `DEEPGRAM_USD_PER_MINUTE` | no | `0.0043` | Price per audio minute, used for the estimate |
| `MAX_COST_PER_JOB_USD` | no | `1.00` | Hard per-job ceiling. Over it is a refusal, never a prompt |
| `TRANSCRIPTION_OUTPUT_DIR` | no | `./output` | Where job folders are written. Relative paths resolve against the package root |
| `OPENROUTER_API_KEY` | only to format | | Key for the formatting pass. Transcription never needs it |
| `OPENROUTER_MODEL` | no | `openai/gpt-4o-mini` | Model the formatting pass asks for structure from |
| `FORMAT_HEADINGS_MIN_SENTENCES` | no | `120` | Below this many sentences, formatting adds paragraphs and tags but no sections |
## MCP server
```bash
claude mcp add ossicle -- node "<absolute path to this repo>/dist/index.js"
```
### `transcribe`
| Input | Type | Default | Notes |
| --- | --- | --- | --- |
| `source` | string | required | Local file path, or any URL yt-dlp can fetch |
| `diarize` | boolean | `false` | **Experimental.** Speaker-labelled `## Speaker N [mm:ss]` blocks |
| `model` | string | configured model | Deepgram model override |
| `language` | string | `en` | Spoken language code |
| `force` | boolean | `false` | Re-transcribe even on a cache hit. Costs money again |
Returns the transcript path, the job folder, duration, estimated and actually-spent USD, a `cached`
flag, and a preview capped at 500 characters. The full transcript stays on disk.
### Diarization
Diarization is experimental and off by default. On real recordings Deepgram misattributes turns often
enough that the speaker-labelled output reads worse than plain paragraphs, so the flag is kept for the
cases where speaker separation is worth that risk rather than recommended as a normal option. It stays
part of the cache key, so flipping it never returns a stale transcript.
### `format_transcript`
| Input | Type | Default | Notes |
| --- | --- | --- | --- |
| `target` | string | required | The `job_dir` from a transcribe result, or the original local file path |
| `force` | boolean | `false` | Re-ask the model for structure. Costs money again |
A second, optional pass over a transcript already on disk. See [Formatting](#formatting).
### `estimate_cost`
Takes the same `source` and returns duration, estimated USD, the cap, and whether the job would be
allowed. No Deepgram request is made. A URL is still downloaded, because duration is unknowable
otherwise, so this is free of Deepgram charges but not instant.
## CLI
```bash
transcribe ./interview.mp4 --diarize # experimental, labels are often wrong
transcribe ./lecture.mp3 --estimate
transcribe ./clip.mp4 --json | jq .transcript_path
```
| Flag | Default | Meaning |
| --- | --- | --- |
| `--diarize` | off | **Experimental.** Label speakers |
| `--model <name>` | `DEEPGRAM_MODEL`, else `nova-3` | Deepgram model |
| `--language <code>` | `en` | Spoken language |
| `--out <dir>` | `TRANSCRIPTION_OUTPUT_DIR`, else `./output` | Output directory |
| `--force` | off | Re-transcribe even on a cache hit |
| `--estimate` | off | Print duration and estimated USD, then exit |
| `--json` | off | Print one JSON object and nothing else on stdout |
| `--help` | | List every flag |
Exit codes: `0` success, `2` refused for exceeding the cost cap, `3` configuration or missing binary,
`1` everything else.
```bash
transcribe format ./output/interview-final-8a2c1d0b7e64
transcribe format ./interview.mp4 --force
```
The `format` subcommand takes a job folder or the local file that produced one, and accepts `--force`
and `--json`.
## Formatting
A raw transcript is accurate and close to unreadable: one wall of text, or paragraphs cut every four
sentences by a rule that cannot hear the speaker. The formatting pass fixes that without letting a
language model near the words.
The transcript is split into numbered sentences and sent to a cheap OpenRouter model, which replies
with **structure only**: the indices a paragraph break follows, optional `{ startIndex, title }`
section headings, and three to eight kebab-case topic tags. The Markdown is then rebuilt from the
stored sentence array. A dropped, reworded, or invented sentence is impossible by construction rather
than by review, because no text ever comes back from the model.
- **Opt in.** `transcribe` never formats for you. Run `format_transcript` or `transcribe format`.
- **Partial failure only.** Sentences are sent in windows. A window whose plan is invalid or whose
request keeps failing is retried, then left as plain paragraphs and reported as a skipped range. The
transcript is never left worse than the raw render.
- **Short transcripts get no sections.** Below `FORMAT_HEADINGS_MIN_SENTENCES` the model is asked for
paragraphs and tags only. A four-minute voice note does not need three invented sections.
- **Cached like transcription.** The plan is written to `format.json` in the job folder. A second call
re-renders from it and spends nothing; `force` re-calls the model. Re-running `transcribe` on a
formatted job reapplies the stored plan instead of clobbering it.
- **Same cost guard.** Formatting is priced before any request and refused over `MAX_COST_PER_JOB_USD`.
Each invocation is its own job for that purpose: it is never summed with what Deepgram already cost.
## Output layout
```
<TRANSCRIPTION_OUTPUT_DIR>/<slug>-<key12>/
URL sources: never-gonna-give-you-up-dQw4w9WgXcQ-1f3b9c2d4e5a/
Local files: interview-final-8a2c1d0b7e64/
audio.opus the 16 kHz mono upload
audio.<ext> the yt-dlp download, for URL sources, kept so re-runs never re-fetch
response.json Deepgram's raw response
format.json the structure plan, once the transcript has been formatted
transcript.md YAML front matter plus the rendered transcript
```
## Caching
The cache key is the source identity plus the options that change the transcript: `model`, `diarize`,
and `language`. Local files are identified by a SHA-256 of their bytes; URLs by the yt-dlp extractor id,
so tracking parameters and short-link variants never cause a second paid transcription.
The folder name is cosmetic: for a URL it is the video title followed by the video id, and for a local
file the filename. A job is found by the trailing `<key12>` alone, so a folder is reused whatever its
readable half says. A video renamed by its uploader, or a folder named by an older version of this
tool, still hits the cache rather than paying for the same transcript twice.
A hit re-renders `transcript.md` from the stored `response.json` rather than returning the old Markdown,
so improvements to the formatter reach old jobs at zero cost. Only `--force` / `force: true` re-calls
Deepgram.
## Development
```bash
npm test # vitest
npm run typecheck
npm run build
```
No test spawns a binary or touches the network: `ffprobe`, `ffmpeg`, and `yt-dlp` go through an
injectable command runner, and Deepgram goes through an injectable `fetch`.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues