From 86be87081a4654835f02ca86134861c05811b242 Mon Sep 17 00:00:00 2001 From: Rodaddy Date: Thu, 27 Aug 2026 23:09:48 -0400 Subject: [PATCH 1/4] docs+ux: public-repo polish -- rewritten README, INSTALL.md walkthrough with troubleshooting, install.sh (venv/uv, env file, demo voice, doctor), agent-voice doctor with a fix per failure, agent-voice.env loading, 8181 default, CI matrix, CONTRIBUTING, CHANGELOG --- .github/workflows/ci.yml | 35 ++++++ CHANGELOG.md | 20 ++++ CONTRIBUTING.md | 41 +++++++ INSTALL.md | 226 ++++++++++++++++++++++++++++++++++++ README.md | 189 ++++++++++++++++++------------ agent-voice.env.example | 26 +++++ install.sh | 97 ++++++++++++++++ src/agent_voice/__main__.py | 13 ++- src/agent_voice/config.py | 35 +++++- src/agent_voice/doctor.py | 201 ++++++++++++++++++++++++++++++++ tests/test_doctor.py | 43 +++++++ tests/test_speaker.py | 11 ++ 12 files changed, 858 insertions(+), 79 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 INSTALL.md create mode 100644 agent-voice.env.example create mode 100644 install.sh create mode 100644 src/agent_voice/doctor.py create mode 100644 tests/test_doctor.py 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/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..da1158f --- /dev/null +++ b/src/agent_voice/doctor.py @@ -0,0 +1,201 @@ +"""``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(), + _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() -> 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", + ) + + +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..4db9b37 --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,43 @@ +import json + +import httpx + +from agent_voice.config import Config +from agent_voice.doctor import render, run + + +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 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") From 2739ec9a6928584385ab7ddec775223ba2c3076a Mon Sep 17 00:00:00 2001 From: Rodaddy Date: Thu, 27 Aug 2026 23:11:31 -0400 Subject: [PATCH 2/4] ci: keep ruff format off Markdown code blocks --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) 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"] From 170685bb5d156067cacf6e0282db920a2171ac71 Mon Sep 17 00:00:00 2001 From: Rodaddy Date: Thu, 27 Aug 2026 23:13:28 -0400 Subject: [PATCH 3/4] doctor: missing player is only fatal when playback is on; tests no longer depend on the host's players --- src/agent_voice/doctor.py | 5 +++-- tests/test_doctor.py | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/agent_voice/doctor.py b/src/agent_voice/doctor.py index da1158f..c4f6cf0 100644 --- a/src/agent_voice/doctor.py +++ b/src/agent_voice/doctor.py @@ -40,7 +40,7 @@ def run(config: Config, *, transport: httpx.BaseTransport | None = None) -> list """Run every check and return the report.""" return [ _python(), - _player(), + _player(config), _tts(config, transport), *_voices(config), _writer(config, transport), @@ -56,7 +56,7 @@ def _python() -> Check: ) -def _player() -> Check: +def _player(config: Config) -> Check: if sys.platform == "win32": return Check("player", True, "winsound (built in)") argv = find_player() @@ -68,6 +68,7 @@ def _player() -> Check: "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 ) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 4db9b37..d9b4c55 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -1,11 +1,19 @@ 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(doctor.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": @@ -41,3 +49,15 @@ def test_no_voices(config: Config, tmp_path) -> None: # type: ignore[no-untyped 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(doctor.sys, "platform", "linux") + playing = {c.name: c for c in run(config, 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 From cf3e53428b9b54323860ea4a70d1920c8273010e Mon Sep 17 00:00:00 2001 From: Rodaddy Date: Thu, 27 Aug 2026 23:14:37 -0400 Subject: [PATCH 4/4] tests(doctor): patch by dotted name; playing case sets play=True --- tests/test_doctor.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index d9b4c55..876c885 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -11,7 +11,7 @@ @pytest.fixture(autouse=True) def _host_independent(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(doctor, "find_player", lambda: ["/usr/bin/afplay"]) - monkeypatch.setattr(doctor.shutil, "which", lambda _name: "/usr/bin/ffmpeg") + monkeypatch.setattr("shutil.which", lambda _name: "/usr/bin/ffmpeg") def _transport(tts_ok: bool, models: list[str]) -> httpx.MockTransport: @@ -55,8 +55,9 @@ def test_missing_player_is_only_fatal_when_playing( config: Config, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(doctor, "find_player", lambda: None) - monkeypatch.setattr(doctor.sys, "platform", "linux") - playing = {c.name: c for c in run(config, transport=_transport(True, ["fake"]))} + 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"]))}