diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..b8722fb
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,35 @@
+name: ci
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+ python: ["3.11", "3.12", "3.13"]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python }}
+ - run: python -m pip install -e '.[dev]'
+ - run: ruff check
+ - run: ruff format --check
+ - run: mypy
+ - run: pytest -q
+
+ install-script:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - run: sh install.sh
+ - run: test -f agent-voice.env && .venv/bin/agent-voice --help
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e0600cb
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,20 @@
+# Changelog
+
+## Unreleased
+
+## 0.1.0 — 2026-08-27
+
+First public release.
+
+- `agent-voice speak | pipe | serve | voices | health | doctor`.
+- Spoken-line cascade: `...` tag (tags in code spans ignored) →
+ writer LLM with the voice's `persona.md` → direct text, markup stripped.
+- OmniVoice `/health` + `/synthesize` client, compatible with scorbo2's
+ `server_omnivoice.py` and TalkWithMe.
+- Voice library: `voices//{ref.wav, ref.txt, language.txt, persona.md}`.
+- Standard-library HTTP server (`POST /say`, `GET /health`), utterances
+ serialized.
+- Playback via afplay / winsound / paplay / aplay / ffplay.
+- Config from `AGENT_VOICE_*` environment variables or `agent-voice.env`.
+- `install.sh`, `INSTALL.md`, `agent-voice doctor` with a fix for every failure.
+- Example integration: Claude Code hooks.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..f15d7fe
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,41 @@
+# Contributing
+
+Thanks for looking. This is a small tool and it wants to stay small: one
+dependency, standard-library HTTP, no framework. Things that fit: new
+integrations under `integrations/`, more playback backends, better voice
+tooling, doc fixes, bug fixes with a test.
+
+## Setup
+
+```sh
+sh install.sh --dev
+.venv/bin/pytest
+.venv/bin/ruff check && .venv/bin/ruff format --check
+.venv/bin/mypy
+```
+
+All four must pass; CI runs them on Linux and macOS, Python 3.11–3.13.
+
+## Ground rules
+
+- **Tests use fake transports, never a real server.** See `tests/conftest.py`
+ (`fake_tts`, `fake_llm`). A test that needs a running OmniVoice server will
+ not be merged.
+- **Don't add dependencies** without a reason the README can state in one
+ line. `httpx` is the only one.
+- **Type everything.** `mypy --strict` is on for `src/`.
+- **Keep the TTS contract compatible** with scorbo2's `server_omnivoice.py`
+ (`/health`, `/synthesize`); that is why existing servers work unchanged.
+- **Don't commit voices.** `voices/` is git-ignored; reference clips are
+ usually somebody's copyrighted audio. Examples generate their own.
+
+## Pull requests
+
+One change per PR, with a test when behaviour changes, and a line in
+`CHANGELOG.md` under *Unreleased*. Say what you ran; "tests pass" means you
+ran them.
+
+## Reporting a problem
+
+Paste the output of `agent-voice doctor` and the exact command. If it is about
+synthesis quality, say which server (and GPU/MPS) and how long `ref.wav` is.
diff --git a/INSTALL.md b/INSTALL.md
new file mode 100644
index 0000000..367422f
--- /dev/null
+++ b/INSTALL.md
@@ -0,0 +1,226 @@
+# Installing agent-voice
+
+Five steps. Steps 1 and 3 are two minutes each; step 2 is the one that takes a
+while the first time (downloading the OmniVoice model). After every step,
+`agent-voice doctor` tells you what is still missing.
+
+```
+1. Install agent-voice sh install.sh
+2. Run an OmniVoice server (GPU or Apple Silicon box; can be another machine)
+3. Make a voice voices//ref.wav + ref.txt
+4. (optional) Pick a writer model any OpenAI-compatible endpoint
+5. Check and speak agent-voice doctor && agent-voice speak "hi"
+```
+
+---
+
+## 1. Install agent-voice
+
+**Requirements:** Python 3.11 or newer, and something that can play a WAV
+(`afplay` on macOS is built in; Linux needs `paplay`, `aplay`, or `ffplay`;
+Windows uses the built-in `winsound`).
+
+```sh
+git clone https://github.com/rodaddy/agent-voice.git
+cd agent-voice
+sh install.sh
+```
+
+`install.sh` finds a suitable Python, creates `.venv/`, installs the package
+into it (using [uv](https://docs.astral.sh/uv/) if you have it, plain `venv` +
+`pip` otherwise), writes `agent-voice.env` from the example if you don't have
+one, and finishes by running `agent-voice doctor`.
+
+Flags: `--demo` also makes a demo voice from the macOS `say` command
+(macOS + ffmpeg); `--dev` also installs pytest/ruff/mypy.
+
+**Without the script:**
+
+```sh
+python3 -m venv .venv
+.venv/bin/pip install -e .
+cp agent-voice.env.example agent-voice.env
+```
+
+**Using it from anywhere:** either call `.venv/bin/agent-voice` by full path,
+or `pipx install /path/to/agent-voice`, or add `.venv/bin` to your `PATH`.
+Config is read from `agent-voice.env` in the *current directory* and from the
+environment, so set `AGENT_VOICE_VOICES_DIR` to an absolute path if you run it
+from elsewhere.
+
+---
+
+## 2. Run an OmniVoice server
+
+agent-voice does not do the speech synthesis itself. It talks to a small REST
+server around [OmniVoice](https://github.com/k2-fsa/OmniVoice), the
+open-source zero-shot voice-cloning model (Apache-2.0). Any server exposing
+`GET /health` and `POST /synthesize` with the same JSON works; the one people
+actually use is scorbo2's `server_omnivoice.py`.
+
+**Where to run it:** a machine with an NVIDIA GPU (8 GB is plenty; a 16 GB
+RTX 5060 Ti synthesizes a sentence in about half a second) or an Apple Silicon
+Mac (works on MPS in float32; roughly real time on an M-series Air). It can be
+a different machine from the one that plays the audio — set
+`AGENT_VOICE_TTS_URL` to wherever it is.
+
+```sh
+# on the GPU box
+git clone https://github.com/k2-fsa/OmniVoice.git
+cd OmniVoice
+python3 -m venv .venv && . .venv/bin/activate
+pip install -e . # pulls torch; on Apple Silicon this is the MPS build
+pip install fastapi uvicorn loguru soundfile
+
+curl -sLO https://raw.githubusercontent.com/scorbo2/ai-playground/master/TTS/server_omnivoice.py
+uvicorn server_omnivoice:app --host 0.0.0.0 --port 8181
+```
+
+The first request downloads the model (~3 GB) into your Hugging Face cache
+(`HF_HOME` if set). Confirm it is up:
+
+```sh
+curl -s http://:8181/health
+# {"status":"ok","serverType":"OmniVoice", ...}
+```
+
+Then, on the agent-voice machine, put that address in `agent-voice.env`:
+
+```sh
+export AGENT_VOICE_TTS_URL=http://:8181
+```
+
+**Apple Silicon note:** load the model in `float32`. float16 and bfloat16
+crash during weight loading on MPS (measured 2026-08 with torch 2.13).
+
+---
+
+## 3. Make a voice
+
+A voice is a directory under `voices/`:
+
+```
+voices/skippy/
+ ref.wav 3-10 seconds of the voice, mono, 24 kHz, 16-bit PCM
+ ref.txt the EXACT words spoken in ref.wav, one line
+ language.txt optional, default "English"
+ persona.md optional, see step 4
+```
+
+**Try it first with a fake voice (macOS):**
+
+```sh
+examples/make-voice-macos.sh voices/demo # needs ffmpeg: brew install ffmpeg
+```
+
+**A real voice, from any recording** (an audiobook, a podcast, a voice memo):
+
+1. Find 3–10 seconds where only that person is speaking, no music, no
+ crosstalk. One clean sentence is ideal. A 30-second clip does *not* clone
+ better — the model re-reads the clip on every request, so long clips are
+ just slower.
+2. Cut and convert it with ffmpeg (`-ss` start, `-t` length in seconds):
+
+ ```sh
+ mkdir -p voices/skippy
+ ffmpeg -ss 8:43:09 -i book.m4b -t 7.5 -vn -ac 1 -ar 24000 -sample_fmt s16 voices/skippy/ref.wav
+ ```
+
+3. Write the exact transcript to `voices/skippy/ref.txt`. Punctuation and
+ casing matter less than getting every word right; a wrong word here makes
+ the clone drift. (If you have whisper handy, draft with it and fix by hand.)
+4. Cut three or four candidates and pick by ear — clips from the same speaker
+ clone noticeably differently. `agent-voice speak --voice skippy-b "..."`
+ for each, keep the winner as `voices/skippy`.
+
+`agent-voice voices` lists what it found; `agent-voice doctor` warns about
+clips that are too long or not PCM WAV.
+
+---
+
+## 4. (Optional) Pick a writer model
+
+Without a writer, agent-voice speaks the `...` line an agent wrote,
+or the whole text. With a writer, you can pipe in any reply and a small model
+decides what the *character* would say about it. This is where the persona
+comes alive.
+
+Any OpenAI-compatible chat endpoint works. Local options that are known to
+work:
+
+| Server | `AGENT_VOICE_LLM_URL` | `AGENT_VOICE_LLM_MODEL` |
+|---|---|---|
+| [Ollama](https://ollama.com) | `http://127.0.0.1:11434` | e.g. `qwen3:8b` |
+| [LM Studio](https://lmstudio.ai) | `http://127.0.0.1:1234` | the loaded model's id |
+| [llama.cpp](https://github.com/ggml-org/llama.cpp) `llama-server` | `http://127.0.0.1:8080` | whatever it reports in `/v1/models` |
+| [oMLX](https://github.com/jundot/omlx) (Apple Silicon) | `http://127.0.0.1:11434` | e.g. `Qwen3.5-9B-4bit` |
+| OpenAI / any hosted API | its base URL | model name; set `AGENT_VOICE_LLM_API_KEY` |
+
+An 8–9B model is plenty; it writes the line in 2–3 seconds on a laptop.
+Thinking models are asked not to think (`enable_thinking: false`) and their
+`` blocks are stripped if they think anyway.
+
+```sh
+export AGENT_VOICE_LLM_URL=http://127.0.0.1:11434
+export AGENT_VOICE_LLM_MODEL=qwen3:8b
+```
+
+Give the voice a personality in `voices//persona.md` — a few lines of
+*who this is*, written as a system prompt. `examples/persona-ship-ai.md` is a
+starting point. The writer is told separately to keep it to one or two spoken
+sentences, so the persona file only needs to describe character.
+
+---
+
+## 5. Check and speak
+
+```sh
+.venv/bin/agent-voice doctor
+```
+
+```
+[ok ] python 3.12.4
+[ok ] player afplay
+[ok ] tts http://gpu-box:8181 (OmniVoice)
+[ok ] voices demo, skippy
+[ok ] writer qwen3:8b @ http://127.0.0.1:11434
+[ok ] ffmpeg found (for making voices)
+```
+
+Every `FAIL` line comes with a `fix:` line. When it is green:
+
+```sh
+.venv/bin/agent-voice speak --voice skippy "Say something, dum-dum."
+printf 'All 41 tests pass after the refactor.\n' | .venv/bin/agent-voice pipe --voice skippy
+.venv/bin/agent-voice serve # then POST /say from anything
+```
+
+---
+
+## Hooking up an agent
+
+- **It prints to stdout:** `my-agent | agent-voice pipe --voice skippy`.
+- **It can make HTTP calls:** run `agent-voice serve` and POST
+ `{"text": reply, "voice": "skippy"}` to `http://127.0.0.1:7161/say`.
+- **It is Python:** `from agent_voice import Config, Speaker` — see the README.
+- **It is Claude Code:** `integrations/claude-code/README.md`.
+
+For the cleanest results, tell the agent about the tag: *"End every reply
+with one spoken line wrapped as `...`: one or two short sentences,
+in character, plain words only."* Then the agent itself decides what is worth
+saying, and the writer model is only a fallback.
+
+---
+
+## Troubleshooting
+
+| Symptom | Cause / fix |
+|---|---|
+| `tts ... not answering GET /health` | Server not running, wrong port, or firewall. `curl http://:8181/health` from the agent-voice machine. |
+| `502` from `/say`, or `HTTPStatusError 422` | The TTS server rejected the request — usually `num_steps` outside 10–20 on scorbo2's server, or a `ref.wav` that isn't PCM. |
+| Speech is slow (10+ s per sentence) | TTS running on CPU, or a long `ref.wav`. Cut the clip to under 10 s; use a GPU / MPS box. |
+| Voice sounds nothing like the clip | `ref.txt` doesn't match the audio, or the clip has music/crosstalk. Re-cut and re-transcribe. |
+| Nothing plays but a WAV is written | No player found — `doctor` says which to install — or `AGENT_VOICE_PLAY=false`. |
+| Writer lines are flat or cut off | Try a larger model, lower `AGENT_VOICE_MAX_WORDS`, or improve `persona.md`. Lines are never cut mid-sentence; a cut-off means the model wrote past the cap. |
+| Two agents talk over each other | They can't through one `agent-voice serve` — it serializes. Two separate processes can; point both at one server. |
+| `agent-voice: command not found` | It's in `.venv/bin/`. Use the full path, add it to `PATH`, or `pipx install .`. |
diff --git a/README.md b/README.md
index 60b768e..3d8abbe 100644
--- a/README.md
+++ b/README.md
@@ -1,125 +1,168 @@
# agent-voice
-Give any LLM agent a cloned voice. Text goes in — a whole reply, or one line
-the agent tagged `...` — a persona decides what is worth saying out
-loud, an [OmniVoice](https://github.com/k2-fsa/OmniVoice)-style TTS server
-clones the voice, and the audio plays on the box you're sitting at.
-
-No framework required. Three doors, so it fits whatever your agents run in:
+**Give any LLM agent a cloned voice.** The agent's reply goes in; one
+in-character spoken line comes out of your speakers in a voice cloned from a
+3–10 second clip.
```sh
-agent-voice speak "Build is green." # say exactly this
-my-agent --reply | agent-voice pipe --voice skippy # speak the agent's reply
-agent-voice serve # POST /say from anything, in any language
+my-agent --reply | agent-voice pipe --voice skippy
+```
+
```
+[writer] Parser is fixed and all forty-one tests pass, Rico. You can stop hovering now.
+```
+
+- **Two channels.** The screen keeps the full technical reply. The speaker
+ gets one line — either the agent tags it (`...`) or a small
+ "writer" model reads the reply and decides what the character would say.
+- **Any agent, any language.** A CLI, a stdin pipe, and a tiny HTTP server
+ (`POST /say`). Nothing to import unless you want to.
+- **Any OmniVoice server.** Speaks the `/health` + `/synthesize` contract used
+ by [scorbo2/ai-playground](https://github.com/scorbo2/ai-playground/tree/master/TTS)
+ and [TalkWithMe](https://github.com/scorbo2/TalkWithMe). If you already run
+ one, it works unchanged.
+- **Small.** Python 3.11+, one dependency (`httpx`), standard-library HTTP
+ server, ~600 lines, 20 tests, mypy strict.
It grew out of a Claude Code session that wanted to talk as
-[Skippy](https://expeditionaryforce.fandom.com/wiki/Skippy) without hauling a
-browser around. The TTS contract is the one from scorbo2's
-[server_omnivoice.py](https://github.com/scorbo2/ai-playground/tree/master/TTS)
-and [TalkWithMe](https://github.com/scorbo2/TalkWithMe), so an existing server
-from either works unchanged.
-
-## How a line gets chosen
-
-`agent-voice pipe` and `POST /say` take a whole reply and pick what to say:
-
-1. **`` tag** — if the agent wrote `One spoken line.`, that line
- is spoken and nothing else. Tags quoted inside code spans are ignored. This
- is the "two channels" pattern: the screen gets the technical reply, the
- speaker gets the persona's one-liner.
-2. **Writer LLM** — otherwise, if `AGENT_VOICE_LLM_URL` is set, the reply is
- sent to that (OpenAI-compatible) model with the voice's `persona.md` and it
- answers with one or two spoken sentences in character (capped at
- `AGENT_VOICE_MAX_WORDS`, never cut mid-sentence). A small local model is
- plenty; a 9B does it in ~2.5 s.
-3. **Direct** — otherwise the text itself is spoken, markup stripped.
+[Skippy](https://expeditionaryforce.fandom.com/wiki/Skippy) without a browser
+in the loop.
-`--mode tag|writer|direct` forces one of these; `auto` is the cascade above.
+## Quick start
-## Install
+```sh
+git clone https://github.com/rodaddy/agent-voice.git
+cd agent-voice
+sh install.sh --demo # venv, install, demo voice (macOS), then a report
+```
+
+Then edit `agent-voice.env` (it's created for you) so `AGENT_VOICE_TTS_URL`
+points at your OmniVoice server, and:
```sh
-pip install -e . # from a checkout; Python 3.11+, one dependency (httpx)
+.venv/bin/agent-voice doctor # everything green?
+.venv/bin/agent-voice speak --voice demo "It works." # hear it
```
-Playback uses whatever the host has: `afplay` (macOS), `winsound` (Windows),
-`paplay` / `aplay` / `ffplay` (Linux).
+`doctor` names every missing piece and the fix for it. The full walkthrough —
+including standing up the OmniVoice server, making a real voice, and picking a
+writer model — is in **[INSTALL.md](INSTALL.md)**.
+
+## The three doors
+
+| You have... | Use | Example |
+|---|---|---|
+| A string | `agent-voice speak` | `agent-voice speak --voice skippy "Build is green."` |
+| An agent's reply on stdout | `agent-voice pipe` | `my-agent \| agent-voice pipe --voice skippy` |
+| Anything that can POST JSON | `agent-voice serve` | `curl localhost:7161/say -d '{"text":"...","voice":"skippy"}'` |
+
+## How the spoken line is chosen
+
+`pipe` and `POST /say` take a whole reply and pick what to say (`--mode auto`):
+
+1. **`` tag.** If the agent wrote `One spoken line.`, exactly
+ that is spoken. Tags quoted inside code spans are ignored, so an agent can
+ *talk about* the tag safely. Tell your agent: *"End every reply with one
+ spoken line wrapped as `...`: one or two short sentences, in
+ character, plain words only."*
+2. **Writer model.** Otherwise, if `AGENT_VOICE_LLM_URL` is set, the reply is
+ sent to that OpenAI-compatible model together with the voice's `persona.md`,
+ and the model answers with one or two sentences in character (capped at
+ `AGENT_VOICE_MAX_WORDS`, never cut mid-sentence). A small local model is
+ plenty — a 9B on a laptop writes the line in ~2.5 s.
+3. **Direct.** Otherwise the text itself is spoken, markup stripped.
-You also need an OmniVoice server. The quickest is scorbo2's
-[`server_omnivoice.py`](https://github.com/scorbo2/ai-playground/tree/master/TTS);
-any server exposing `GET /health` and `POST /synthesize` with the same JSON
-works.
+`--mode tag|writer|direct` forces one of them. Utterances are serialized, so
+two agents talking at once take turns instead of overlapping.
## Voices
-A voice is a directory:
+A voice is a directory. Make as many as you like.
```
voices/skippy/
- ref.wav 3-10 s of the voice, mono, 24 kHz (longer does not help and is slower)
+ ref.wav 3-10 s of the voice, mono, 24 kHz (longer is slower and no better)
ref.txt the exact words spoken in ref.wav
language.txt optional, default "English"
- persona.md optional: the system prompt the writer LLM speaks with
+ persona.md optional: who this voice IS -- the writer model speaks as this
```
`examples/make-voice-macos.sh voices/demo` builds a throwaway voice from the
-macOS `say` command so you can try the pipeline before you have a real clip.
-`examples/persona-ship-ai.md` is a starting persona.
+macOS `say` command. `examples/persona-ship-ai.md` is a starting persona.
+Cutting a real clip from a recording is in [INSTALL.md](INSTALL.md#3-make-a-voice).
+
+## HTTP server
+
+```sh
+agent-voice serve # http://127.0.0.1:7161
+curl -s localhost:7161/health
+curl -s localhost:7161/say -H 'content-type: application/json' \
+ -d '{"text": "Deployed.\nIt is live. Try not to break it.", "voice": "skippy"}'
+```
+
+```json
+{"spoken": "It is live. Try not to break it.", "wav": "out/skippy-20260828T025959Z.wav", "voice": "skippy", "source": "tag"}
+```
+
+`POST /say` takes `text`, optional `voice`, optional `mode`; answers `204` when
+there was nothing to say, `404` for an unknown voice, `502` if the TTS or
+writer failed.
+
+## Python
+
+```python
+from agent_voice import Config, Speaker
+
+speaker = Speaker(Config.from_env())
+spoken = speaker.speak(agent_reply, "skippy") # Spoken(text, wav, voice, source) or None
+```
## Configuration
-Everything is an `AGENT_VOICE_*` environment variable (see `config.py`):
+Environment variables, or the same names in `agent-voice.env` in the working
+directory (the environment wins). `agent-voice doctor` shows what is in effect.
| Variable | Default | Meaning |
|---|---|---|
-| `AGENT_VOICE_TTS_URL` | `http://127.0.0.1:8000` | OmniVoice server |
-| `AGENT_VOICE_VOICES_DIR` | `voices` | the library |
+| `AGENT_VOICE_TTS_URL` | `http://127.0.0.1:8181` | OmniVoice server (scorbo2's runs on 8181) |
+| `AGENT_VOICE_VOICES_DIR` | `voices` | the voice library |
| `AGENT_VOICE_VOICE` | first voice | default voice name |
| `AGENT_VOICE_LLM_URL` | (unset) | OpenAI-compatible base URL of the writer; unset = no writer |
| `AGENT_VOICE_LLM_MODEL` | | writer model id |
| `AGENT_VOICE_LLM_API_KEY` | | sent as `Authorization: Bearer` if set |
-| `AGENT_VOICE_NUM_STEPS` | `10` | OmniVoice diffusion steps |
+| `AGENT_VOICE_NUM_STEPS` | `10` | OmniVoice diffusion steps (10–20 on scorbo2's server) |
| `AGENT_VOICE_GUIDANCE_SCALE` | `1.2` | |
| `AGENT_VOICE_MAX_WORDS` | `35` | cap on a writer-produced line |
| `AGENT_VOICE_OUT_DIR` | `out` | where WAVs are saved |
| `AGENT_VOICE_PLAY` | `true` | `false` to only save the WAV |
| `AGENT_VOICE_HOST` / `_PORT` | `127.0.0.1` / `7161` | for `agent-voice serve` |
-## HTTP server
-
-```sh
-agent-voice serve
-curl -s localhost:7161/health
-curl -s localhost:7161/say -H 'content-type: application/json' \
- -d '{"text": "Deployed.\nIt is live. Try not to break it.", "voice": "skippy"}'
-# {"spoken": "It is live. Try not to break it.", "wav": "out/skippy-....wav", "voice": "skippy", "source": "tag"}
-```
-
-`POST /say` accepts `text`, optional `voice`, optional `mode`; returns 204 when
-there was nothing to say. Utterances are serialized, so two agents talking at
-once take turns instead of overlapping.
+## Integrations
-## Python
+- **[Claude Code](integrations/claude-code/)** — two hooks and a toggle make a
+ Claude Code session speak each reply (where this tool came from).
+- **Anything else** needs only a way to run `agent-voice pipe` with the reply
+ on stdin, or to POST the reply to `/say`. Shell agents, LangChain callbacks,
+ cron jobs, CI notifications — all the same three lines.
-```python
-from agent_voice import Config, Speaker
+## Commands
-speaker = Speaker(Config.from_env())
-spoken = speaker.speak(agent_reply, "skippy") # -> Spoken(text, wav, voice, source) or None
+```
+agent-voice speak [--voice NAME] TEXT say exactly this
+agent-voice pipe [--voice NAME] [--mode M] reply on stdin -> spoken line
+agent-voice serve HTTP: POST /say, GET /health
+agent-voice voices list the library
+agent-voice health TTS + library as JSON
+agent-voice doctor what's missing, and the fix
```
-## Integrations
-
-- `integrations/claude-code/` — two hooks and a toggle that make a Claude Code
- session speak each reply (the origin of this tool). Other hosts need only a
- way to run `agent-voice pipe` with the reply on stdin, or to POST to `/say`.
+`--no-play` before the subcommand saves the WAV without playing it.
## Development
```sh
-uv venv && uv pip install -e '.[dev]'
-pytest && ruff check && ruff format --check && mypy
+sh install.sh --dev
+.venv/bin/pytest && .venv/bin/ruff check && .venv/bin/ruff format --check && .venv/bin/mypy
```
-MIT.
+See [CONTRIBUTING.md](CONTRIBUTING.md). MIT licensed.
diff --git a/agent-voice.env.example b/agent-voice.env.example
new file mode 100644
index 0000000..923cf66
--- /dev/null
+++ b/agent-voice.env.example
@@ -0,0 +1,26 @@
+# agent-voice configuration. Copy to agent-voice.env (install.sh does this).
+# Read from the current directory; real environment variables override it.
+# Lines starting with # are ignored; "export" is optional.
+
+# --- required: the OmniVoice TTS server (INSTALL.md step 2) -----------------
+export AGENT_VOICE_TTS_URL=http://127.0.0.1:8181
+
+# --- voices (INSTALL.md step 3) --------------------------------------------
+export AGENT_VOICE_VOICES_DIR=voices
+# export AGENT_VOICE_VOICE=skippy # default voice; first in the library if unset
+
+# --- optional: writer model that turns a whole reply into one spoken line --
+# export AGENT_VOICE_LLM_URL=http://127.0.0.1:11434 # Ollama / LM Studio / llama.cpp / oMLX / hosted
+# export AGENT_VOICE_LLM_MODEL=qwen3:8b
+# export AGENT_VOICE_LLM_API_KEY= # only for endpoints that need one
+# export AGENT_VOICE_MAX_WORDS=35
+
+# --- synthesis and playback -------------------------------------------------
+# export AGENT_VOICE_NUM_STEPS=10 # 10-20 on scorbo2's server; more = slower, slightly cleaner
+# export AGENT_VOICE_GUIDANCE_SCALE=1.2
+# export AGENT_VOICE_OUT_DIR=out # WAVs are kept here
+# export AGENT_VOICE_PLAY=true # false = save the WAV, stay silent
+
+# --- agent-voice serve ------------------------------------------------------
+# export AGENT_VOICE_HOST=127.0.0.1
+# export AGENT_VOICE_PORT=7161
diff --git a/install.sh b/install.sh
new file mode 100644
index 0000000..4fb930c
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,97 @@
+#!/bin/sh
+# agent-voice installer -- one command, then `agent-voice doctor` says what is left.
+#
+# sh install.sh venv + install + agent-voice.env + doctor
+# sh install.sh --demo also make a demo voice from the macOS `say` command
+# sh install.sh --dev also install pytest / ruff / mypy
+#
+# Plain POSIX sh. Safe to re-run: it reuses .venv and never overwrites agent-voice.env.
+set -eu
+
+cd "$(dirname "$0")"
+DEV=0
+DEMO=0
+for arg in "$@"; do
+ case "$arg" in
+ --dev) DEV=1 ;;
+ --demo) DEMO=1 ;;
+ -h|--help) sed -n '2,7p' "$0"; exit 0 ;;
+ *) echo "install.sh: unknown option '$arg' (try --help)" >&2; exit 2 ;;
+ esac
+done
+
+say() { printf '\n==> %s\n' "$*"; }
+
+# 1. Python 3.11+ ------------------------------------------------------------
+find_python() {
+ for candidate in python3.13 python3.12 python3.11 python3 python; do
+ if command -v "$candidate" >/dev/null 2>&1 &&
+ "$candidate" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then
+ command -v "$candidate"
+ return 0
+ fi
+ done
+ return 1
+}
+
+say "Looking for Python 3.11+"
+if ! PY=$(find_python); then
+ cat >&2 <<'EOF'
+No Python 3.11+ found on PATH.
+ macOS: brew install python@3.12 (or https://www.python.org/downloads/)
+ Debian: sudo apt install python3.12 python3.12-venv
+ Fedora: sudo dnf install python3.12
+ Windows: https://www.python.org/downloads/ (then run this in Git Bash or WSL)
+ any: curl -LsSf https://astral.sh/uv/install.sh | sh && uv python install 3.12
+EOF
+ exit 1
+fi
+echo "using $PY ($("$PY" -c 'import sys; print(".".join(map(str, sys.version_info[:3])))'))"
+
+# 2. Virtualenv + package ----------------------------------------------------
+EXTRAS=""
+[ "$DEV" -eq 1 ] && EXTRAS="[dev]"
+
+say "Installing into .venv"
+if command -v uv >/dev/null 2>&1; then
+ [ -d .venv ] || uv venv -q .venv --python "$PY"
+ uv pip install -q --python .venv/bin/python -e ".${EXTRAS}"
+else
+ [ -d .venv ] || "$PY" -m venv .venv
+ .venv/bin/python -m pip install -q --upgrade pip
+ .venv/bin/python -m pip install -q -e ".${EXTRAS}"
+fi
+echo "installed: $(.venv/bin/agent-voice --help 2>/dev/null | head -1 || echo 'agent-voice')"
+
+# 3. Config file --------------------------------------------------------------
+if [ ! -f agent-voice.env ]; then
+ cp agent-voice.env.example agent-voice.env
+ echo "wrote agent-voice.env (edit AGENT_VOICE_TTS_URL to point at your OmniVoice server)"
+else
+ echo "keeping your existing agent-voice.env"
+fi
+
+# 4. Optional demo voice ------------------------------------------------------
+if [ "$DEMO" -eq 1 ]; then
+ say "Making a demo voice"
+ if [ "$(uname -s)" != "Darwin" ]; then
+ echo "--demo uses the macOS 'say' command; on this OS make a voice by hand (INSTALL.md step 3)" >&2
+ elif ! command -v ffmpeg >/dev/null 2>&1; then
+ echo "--demo needs ffmpeg: brew install ffmpeg" >&2
+ else
+ sh examples/make-voice-macos.sh voices/demo
+ fi
+fi
+
+# 5. Report -----------------------------------------------------------------
+say "Checking the setup (agent-voice doctor)"
+.venv/bin/agent-voice doctor || true
+
+cat <<'EOF'
+
+Next:
+ 1. edit agent-voice.env -> AGENT_VOICE_TTS_URL = your OmniVoice server (INSTALL.md step 2)
+ 2. add a voice under voices/ -> ref.wav + ref.txt (INSTALL.md step 3)
+ 3. .venv/bin/agent-voice doctor -> until it is all green
+ 4. .venv/bin/agent-voice speak --voice "It works."
+EOF
diff --git a/pyproject.toml b/pyproject.toml
index 31bc2bc..01fd741 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,6 +33,7 @@ packages = ["src/agent_voice"]
[tool.ruff]
line-length = 88
target-version = "py311"
+extend-exclude = ["*.md"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "D"]
diff --git a/src/agent_voice/__main__.py b/src/agent_voice/__main__.py
index b665834..693c7c8 100644
--- a/src/agent_voice/__main__.py
+++ b/src/agent_voice/__main__.py
@@ -1,4 +1,4 @@
-"""CLI: ``agent-voice speak | pipe | serve | voices | health``."""
+"""CLI: ``agent-voice speak | pipe | serve | voices | health | doctor``."""
from __future__ import annotations
@@ -54,6 +54,9 @@ def _parser() -> argparse.ArgumentParser:
sub.add_parser("health", help="check the TTS server and library").set_defaults(
func=_health
)
+ sub.add_parser(
+ "doctor", help="check everything and say how to fix it"
+ ).set_defaults(func=_doctor)
return p
@@ -98,5 +101,13 @@ def _health(speaker: Speaker, _args: argparse.Namespace) -> int:
return 0 if tts else 1
+def _doctor(speaker: Speaker, _args: argparse.Namespace) -> int:
+ from agent_voice.doctor import render, run
+
+ checks = run(speaker.config)
+ print(render(checks))
+ return 0 if all(c.ok or not c.required for c in checks) else 1
+
+
if __name__ == "__main__":
sys.exit(main())
diff --git a/src/agent_voice/config.py b/src/agent_voice/config.py
index 91c2370..e044f3c 100644
--- a/src/agent_voice/config.py
+++ b/src/agent_voice/config.py
@@ -1,4 +1,8 @@
-"""Configuration from ``AGENT_VOICE_*`` environment variables."""
+"""Configuration from ``AGENT_VOICE_*`` environment variables.
+
+``agent-voice.env`` in the current directory (``KEY=VALUE`` lines, optional
+``export``, ``#`` comments) is read first; real environment variables win.
+"""
from __future__ import annotations
@@ -9,13 +13,14 @@
from typing import Any
PREFIX = "AGENT_VOICE_"
+ENV_FILE = Path("agent-voice.env")
@dataclass(frozen=True)
class Config:
"""Every knob; the environment variable that sets it is in the comment."""
- tts_url: str = "http://127.0.0.1:8000" # AGENT_VOICE_TTS_URL
+ tts_url: str = "http://127.0.0.1:8181" # AGENT_VOICE_TTS_URL
voices_dir: Path = Path("voices") # AGENT_VOICE_VOICES_DIR
voice: str = "" # AGENT_VOICE_VOICE (default: first voice in the library)
llm_url: str = "" # AGENT_VOICE_LLM_URL (OpenAI-compatible base; "" = no writer)
@@ -30,9 +35,14 @@ class Config:
port: int = 7161 # AGENT_VOICE_PORT
@classmethod
- def from_env(cls, env: Mapping[str, str] | None = None) -> Config:
- """Build a config from ``env`` (default ``os.environ``)."""
- source = os.environ if env is None else env
+ def from_env(
+ cls, env: Mapping[str, str] | None = None, env_file: Path | None = ENV_FILE
+ ) -> Config:
+ """Build a config from ``env_file`` then ``env`` (default ``os.environ``)."""
+ source: dict[str, str] = {}
+ if env_file is not None and env_file.is_file():
+ source.update(read_env_file(env_file))
+ source.update(os.environ if env is None else env)
cfg = cls()
values: dict[str, Any] = {}
for f in fields(cls):
@@ -57,3 +67,18 @@ def _coerce(current: object, raw: str) -> object:
if isinstance(current, float):
return float(raw)
return raw
+
+
+def read_env_file(path: Path) -> dict[str, str]:
+ """Parse ``KEY=VALUE`` lines (``export`` prefix, quotes, ``#`` comments ok)."""
+ values: dict[str, str] = {}
+ for raw in path.read_text(encoding="utf-8").splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, _, value = line.removeprefix("export ").partition("=")
+ value = value.strip()
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
+ value = value[1:-1]
+ values[key.strip()] = value
+ return values
diff --git a/src/agent_voice/doctor.py b/src/agent_voice/doctor.py
new file mode 100644
index 0000000..c4f6cf0
--- /dev/null
+++ b/src/agent_voice/doctor.py
@@ -0,0 +1,202 @@
+"""``agent-voice doctor``: say exactly what is missing, and how to fix it."""
+
+from __future__ import annotations
+
+import shutil
+import sys
+import wave
+from dataclasses import dataclass
+from pathlib import Path
+
+import httpx
+
+from agent_voice.config import Config
+from agent_voice.play import find_player
+from agent_voice.tts import TTS
+from agent_voice.voices import list_voices
+
+MAX_REF_SECONDS = 12.0
+
+
+@dataclass(frozen=True)
+class Check:
+ """One line of the report."""
+
+ name: str
+ ok: bool
+ detail: str
+ fix: str = ""
+ required: bool = True
+
+ @property
+ def mark(self) -> str:
+ """``ok`` / ``FAIL`` / ``warn`` for the printed report."""
+ if self.ok:
+ return "ok "
+ return "FAIL" if self.required else "warn"
+
+
+def run(config: Config, *, transport: httpx.BaseTransport | None = None) -> list[Check]:
+ """Run every check and return the report."""
+ return [
+ _python(),
+ _player(config),
+ _tts(config, transport),
+ *_voices(config),
+ _writer(config, transport),
+ _ffmpeg(),
+ ]
+
+
+def _python() -> Check:
+ v = sys.version_info
+ ok = (v.major, v.minor) >= (3, 11)
+ return Check(
+ "python", ok, f"{v.major}.{v.minor}.{v.micro}", "agent-voice needs Python 3.11+"
+ )
+
+
+def _player(config: Config) -> Check:
+ if sys.platform == "win32":
+ return Check("player", True, "winsound (built in)")
+ argv = find_player()
+ if argv:
+ return Check("player", True, Path(argv[0]).name)
+ return Check(
+ "player",
+ False,
+ "no afplay / paplay / aplay / ffplay on PATH",
+ "install ffmpeg (ffplay) or pulseaudio-utils (paplay); "
+ "or set AGENT_VOICE_PLAY=false",
+ required=config.play, # headless boxes that only save WAVs are fine
+ )
+
+
+def _tts(config: Config, transport: httpx.BaseTransport | None) -> Check:
+ health = TTS(config.tts_url, transport=transport).health()
+ if health:
+ kind = health.get("serverType") or health.get("status") or "up"
+ return Check("tts", True, f"{config.tts_url} ({kind})")
+ return Check(
+ "tts",
+ False,
+ f"{config.tts_url} not answering GET /health",
+ "start an OmniVoice server (INSTALL.md step 2) or set AGENT_VOICE_TTS_URL",
+ )
+
+
+def _voices(config: Config) -> list[Check]:
+ names = list_voices(config.voices_dir)
+ if not names:
+ return [
+ Check(
+ "voices",
+ False,
+ f"no voices in {config.voices_dir}",
+ "add voices//ref.wav + ref.txt (INSTALL.md step 3) "
+ "or set AGENT_VOICE_VOICES_DIR",
+ )
+ ]
+ checks = [Check("voices", True, ", ".join(names))]
+ for name in names:
+ seconds = _wav_seconds(config.voices_dir / name / "ref.wav")
+ if seconds is None:
+ checks.append(
+ Check(
+ f"voice {name}",
+ False,
+ "ref.wav is not a PCM WAV",
+ "re-export it: "
+ "ffmpeg -i in -ac 1 -ar 24000 -sample_fmt s16 ref.wav",
+ )
+ )
+ elif seconds > MAX_REF_SECONDS:
+ checks.append(
+ Check(
+ f"voice {name}",
+ False,
+ f"ref.wav is {seconds:.1f} s",
+ "cut it to 3-10 s; long clips are slower and no better",
+ required=False,
+ )
+ )
+ return checks
+
+
+def _wav_seconds(path: Path) -> float | None:
+ try:
+ with wave.open(str(path), "rb") as w:
+ return w.getnframes() / float(w.getframerate() or 1)
+ except (wave.Error, EOFError, OSError):
+ return None
+
+
+def _writer(config: Config, transport: httpx.BaseTransport | None) -> Check:
+ if not config.has_writer:
+ hint = (
+ "optional: set AGENT_VOICE_LLM_URL + AGENT_VOICE_LLM_MODEL "
+ "for persona lines"
+ )
+ return Check(
+ "writer",
+ True,
+ "not configured (tag or direct speech only)",
+ hint,
+ required=False,
+ )
+ headers = (
+ {"Authorization": f"Bearer {config.llm_api_key}"} if config.llm_api_key else {}
+ )
+ try:
+ with httpx.Client(timeout=5.0, transport=transport, headers=headers) as client:
+ r = client.get(f"{config.llm_url.rstrip('/')}/v1/models")
+ r.raise_for_status()
+ ids = [m.get("id") for m in r.json().get("data", [])]
+ except (httpx.HTTPError, ValueError) as exc:
+ return Check(
+ "writer",
+ False,
+ f"{config.llm_url}: {type(exc).__name__}",
+ "check AGENT_VOICE_LLM_URL / _API_KEY; "
+ "the server must speak the OpenAI API",
+ )
+ if ids and config.llm_model not in ids:
+ return Check(
+ "writer",
+ False,
+ f"{config.llm_model} not in /v1/models "
+ f"({', '.join(str(i) for i in ids[:6])}...)",
+ "set AGENT_VOICE_LLM_MODEL to one of the listed ids",
+ required=False,
+ )
+ return Check("writer", True, f"{config.llm_model} @ {config.llm_url}")
+
+
+def _ffmpeg() -> Check:
+ if shutil.which("ffmpeg"):
+ return Check("ffmpeg", True, "found (for making voices)", required=False)
+ return Check(
+ "ffmpeg",
+ False,
+ "not found",
+ "only needed to cut reference clips: brew/apt install ffmpeg",
+ required=False,
+ )
+
+
+def render(checks: list[Check]) -> str:
+ """The printed report."""
+ lines = []
+ for c in checks:
+ lines.append(f"[{c.mark}] {c.name:<14} {c.detail}")
+ if not c.ok and c.fix:
+ lines.append(f" fix: {c.fix}")
+ elif c.ok and not c.required and c.fix:
+ lines.append(f" {c.fix}")
+ failed = [c for c in checks if not c.ok and c.required]
+ lines.append(
+ ""
+ if not failed
+ else f"{len(failed)} problem(s) to fix before agent-voice can speak."
+ )
+ return "\n".join(lines).rstrip()
diff --git a/tests/test_doctor.py b/tests/test_doctor.py
new file mode 100644
index 0000000..876c885
--- /dev/null
+++ b/tests/test_doctor.py
@@ -0,0 +1,64 @@
+import json
+
+import httpx
+import pytest
+
+from agent_voice import doctor
+from agent_voice.config import Config
+from agent_voice.doctor import render, run
+
+
+@pytest.fixture(autouse=True)
+def _host_independent(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(doctor, "find_player", lambda: ["/usr/bin/afplay"])
+ monkeypatch.setattr("shutil.which", lambda _name: "/usr/bin/ffmpeg")
+
+
+def _transport(tts_ok: bool, models: list[str]) -> httpx.MockTransport:
+ def handle(req: httpx.Request) -> httpx.Response:
+ if req.url.path == "/health":
+ return httpx.Response(
+ 200 if tts_ok else 503, json={"status": "ok", "serverType": "OmniVoice"}
+ )
+ if req.url.path == "/v1/models":
+ return httpx.Response(200, json={"data": [{"id": m} for m in models]})
+ return httpx.Response(404)
+
+ return httpx.MockTransport(handle)
+
+
+def test_all_good(config: Config) -> None:
+ checks = run(config, transport=_transport(True, ["fake"]))
+ by = {c.name: c for c in checks}
+ assert by["tts"].ok and by["voices"].ok and by["writer"].ok and by["python"].ok
+ assert "OmniVoice" in by["tts"].detail
+ assert "problem" not in render(checks)
+
+
+def test_tts_down_and_wrong_model(config: Config) -> None:
+ checks = run(config, transport=_transport(False, ["other"]))
+ by = {c.name: c for c in checks}
+ assert not by["tts"].ok and by["tts"].required and "INSTALL.md" in by["tts"].fix
+ assert not by["writer"].ok and not by["writer"].required
+ assert "1 problem(s)" in render(checks)
+
+
+def test_no_voices(config: Config, tmp_path) -> None: # type: ignore[no-untyped-def]
+ cfg = Config(**{**config.__dict__, "voices_dir": tmp_path / "empty", "llm_url": ""})
+ by = {c.name: c for c in run(cfg, transport=_transport(True, []))}
+ assert not by["voices"].ok and "ref.wav" in by["voices"].fix
+ assert by["writer"].ok and not by["writer"].required
+ assert json.dumps(by["writer"].detail) # serializable, sanity
+
+
+def test_missing_player_is_only_fatal_when_playing(
+ config: Config, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(doctor, "find_player", lambda: None)
+ monkeypatch.setattr("sys.platform", "linux")
+ loud = Config(**{**config.__dict__, "play": True})
+ playing = {c.name: c for c in run(loud, transport=_transport(True, ["fake"]))}
+ assert not playing["player"].ok and playing["player"].required
+ silent = Config(**{**config.__dict__, "play": False})
+ quiet = {c.name: c for c in run(silent, transport=_transport(True, ["fake"]))}
+ assert not quiet["player"].ok and not quiet["player"].required
diff --git a/tests/test_speaker.py b/tests/test_speaker.py
index 8a3f15b..5c797b7 100644
--- a/tests/test_speaker.py
+++ b/tests/test_speaker.py
@@ -82,3 +82,14 @@ def test_config_from_env(tmp_path: Path) -> None:
and c.guidance_scale == 1.5
)
assert not c.has_writer
+
+
+def test_env_file_then_environment_wins(tmp_path: Path) -> None:
+ f = tmp_path / "agent-voice.env"
+ f.write_text(
+ "# comment\nexport AGENT_VOICE_TTS_URL=http://file:1\n"
+ 'AGENT_VOICE_PORT="7199"\n\nAGENT_VOICE_MAX_WORDS=20\n'
+ )
+ c = Config.from_env({"AGENT_VOICE_PORT": "7100"}, env_file=f)
+ assert c.tts_url == "http://file:1" and c.port == 7100 and c.max_words == 20
+ assert Config.from_env({}, env_file=tmp_path / "missing").tts_url.endswith(":8181")