diff --git a/.github/scripts/verify_windows.py b/.github/scripts/verify_package.py similarity index 67% rename from .github/scripts/verify_windows.py rename to .github/scripts/verify_package.py index 8318371..8732d43 100644 --- a/.github/scripts/verify_windows.py +++ b/.github/scripts/verify_package.py @@ -2,6 +2,8 @@ import json import os +import platform +import re import subprocess import sys import time @@ -9,6 +11,8 @@ import wave from pathlib import Path +import imageio_ffmpeg + SERVICE_URL = "http://127.0.0.1:18765" @@ -22,34 +26,39 @@ def run_cli(cli: Path, *args: str) -> dict[str, object]: return json.loads(completed.stdout.strip().splitlines()[-1]) -def validate_wav(path: Path) -> None: - with wave.open(str(path), "rb") as audio: - assert audio.getnchannels() == 1 - assert audio.getframerate() == 24_000 - assert audio.getnframes() > 0 +def validate_decodable(path: Path) -> None: + ffmpeg = imageio_ffmpeg.get_ffmpeg_exe() + assert "imageio_ffmpeg" in Path(ffmpeg).as_posix() completed = subprocess.run( [ - "ffprobe", + ffmpeg, "-v", "error", - "-select_streams", - "a:0", - "-show_entries", - "stream=codec_name,sample_rate,channels", - "-of", - "json", + "-i", str(path), + "-f", + "s16le", + "-acodec", + "pcm_s16le", + "pipe:1", ], check=True, capture_output=True, - text=True, ) - stream = json.loads(completed.stdout)["streams"][0] - assert stream == { - "codec_name": "pcm_s16le", - "sample_rate": "24000", - "channels": 1, - } + assert completed.stdout + + +def validate_wav(path: Path) -> None: + with wave.open(str(path), "rb") as audio: + assert audio.getnchannels() == 1 + assert audio.getframerate() == 24_000 + assert audio.getnframes() > 0 + validate_decodable(path) + + +def validate_mp3(path: Path) -> None: + assert path.suffix == ".mp3" + validate_decodable(path) def check_doctor(report: dict[str, object], service_status: str) -> None: @@ -57,16 +66,17 @@ def check_doctor(report: dict[str, object], service_status: str) -> None: checks = {check["name"]: check for check in report["checks"]} assert checks["model"]["status"] == "pass" assert checks["runtime"]["status"] == "pass" - assert checks["playback"]["status"] == "warn" - assert "experimental" in checks["playback"]["detail"] + assert checks["compressed audio"]["status"] == "pass" + assert "bundled by imageio-ffmpeg" in checks["compressed audio"]["detail"] + assert checks["playback"]["status"] in {"pass", "warn"} + assert "miniaudio" in checks["playback"]["detail"] assert checks["service"]["status"] == service_status def main() -> None: - if sys.platform != "win32": - raise RuntimeError("This verification is intentionally Windows-only") cli = Path(sys.argv[1]).resolve() - output_dir = Path(os.environ["RUNNER_TEMP"]) / "kokoro-windows-e2e" + system = platform.system() + output_dir = Path(os.environ["RUNNER_TEMP"]) / "agent-voice-package-e2e" output_dir.mkdir(parents=True, exist_ok=True) subprocess.run([str(cli), "setup", "--model", "int8"], check=True) @@ -77,17 +87,42 @@ def main() -> None: local = run_cli( cli, "speak", - "Windows generation verification.", + f"{system} generation verification.", "--service", "off", "--output", str(local_wav), - "--json", ) assert local["backend"] == "local" assert local["played"] is False validate_wav(local_wav) + labeled = run_cli( + cli, + "speak", + f"{system} labeled speed verification.", + "--service", + "off", + "--label", + "Package E2E", + "--format", + "mp3", + "--speed", + "1.5", + ) + labeled_path = Path(str(labeled["path"])) + assert labeled["backend"] == "local" + assert labeled["speed"] == 1.5 + assert re.fullmatch( + r"Package-E2E-\d{2}-\d{2}-\d{2}-at-\d{2}-\d{2}\.mp3", + labeled_path.name, + ) + assert ( + labeled_path.parent + == (Path(os.environ["AGENT_VOICE_HOME"]) / "recordings").resolve() + ) + validate_mp3(labeled_path) + log_path = output_dir / "service.log" with log_path.open("w", encoding="utf-8") as log: service = subprocess.Popen( @@ -120,14 +155,13 @@ def main() -> None: remote = run_cli( cli, "speak", - "Windows localhost service verification.", + f"{system} localhost service verification.", "--service", - "required", + "on", "--service-url", SERVICE_URL, "--output", str(service_wav), - "--json", ) assert remote["backend"] == "service" assert remote["played"] is False @@ -140,6 +174,7 @@ def main() -> None: service.kill() service.wait(timeout=10) print(log_path.read_text(encoding="utf-8", errors="replace")) + print(f"Verified installed package on {system}") if __name__ == "__main__": diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e34f29d..c8dd74f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,16 @@ name: CI on: push: + branches: [main] pull_request: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: test: name: ${{ matrix.os }} / Python ${{ matrix.python-version }} @@ -26,28 +31,39 @@ jobs: enable-cache: true python-version: ${{ matrix.python-version }} - - name: Install FFmpeg on Linux - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install --yes ffmpeg - - - name: Install FFmpeg on macOS - if: runner.os == 'macOS' - run: brew install ffmpeg + - name: Cache verified Kokoro model + if: matrix.python-version == '3.13' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/agent-voice-data/models + key: kokoro-v1-int8-${{ runner.os }}-${{ runner.arch }} - name: Lint - run: uv run --frozen ruff check src tests + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11' + run: uv run --frozen ruff check src tests .github/scripts - name: Test run: uv run --frozen pytest -q - - name: Build packages - if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11' + - name: Build package + if: matrix.python-version == '3.13' run: uv build + - name: Install built wheel + if: matrix.python-version == '3.13' + run: | + uv venv --python ${{ matrix.python-version }} .ci-venv + uv pip install --python .ci-venv/bin/python dist/*.whl + + - name: Verify installed package with real model, audio, doctor, and service + if: matrix.python-version == '3.13' + env: + AGENT_VOICE_HOME: ${{ runner.temp }}/agent-voice-data + run: | + .ci-venv/bin/python .github/scripts/verify_package.py .ci-venv/bin/agent-voice + windows: - name: windows-latest / Python ${{ matrix.python-version }} / package E2E + name: windows-latest / Python ${{ matrix.python-version }} runs-on: windows-latest strategy: fail-fast: false @@ -63,25 +79,22 @@ jobs: enable-cache: true python-version: ${{ matrix.python-version }} - - name: Install FFmpeg - run: choco install ffmpeg --version=7.1.1 --yes --no-progress --allow-downgrade - - name: Cache verified Kokoro model - uses: actions/cache@v4 + if: matrix.python-version == '3.13' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: ${{ runner.temp }}/kokoro-data/models - key: kokoro-v1-int8-windows - - - name: Lint - run: uv run --frozen ruff check src tests .github/scripts + path: ${{ runner.temp }}/agent-voice-data/models + key: kokoro-v1-int8-${{ runner.os }}-${{ runner.arch }} - name: Test run: uv run --frozen pytest -q - name: Build package + if: matrix.python-version == '3.13' run: uv build - name: Install built wheel + if: matrix.python-version == '3.13' shell: pwsh run: | uv venv --python ${{ matrix.python-version }} .ci-venv @@ -89,8 +102,9 @@ jobs: uv pip install --python .ci-venv\Scripts\python.exe $wheel - name: Verify installed package with real model, audio, doctor, and service + if: matrix.python-version == '3.13' shell: pwsh env: - KOKORO_HOME: ${{ runner.temp }}/kokoro-data + AGENT_VOICE_HOME: ${{ runner.temp }}/agent-voice-data run: | - .ci-venv\Scripts\python.exe .github\scripts\verify_windows.py .ci-venv\Scripts\kokoro.exe + .ci-venv\Scripts\python.exe .github\scripts\verify_package.py .ci-venv\Scripts\agent-voice.exe diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5751280..d2d1178 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,7 +36,7 @@ jobs: runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/p/kokoro-cli + url: https://pypi.org/p/agent-voice permissions: id-token: write diff --git a/.gitignore b/.gitignore index 49ddb12..b2aebcf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,21 @@ .venv/ +.DS_Store __pycache__/ .pytest_cache/ .ruff_cache/ +.agents/ +skills-lock.json *.pyc *.egg-info/ dist/ config.json +service-start.lock +viewer.lock +viewer.json models/*.onnx models/*.bin models/*.lock recordings/* +IDEAS.md !models/.gitkeep !recordings/.gitkeep diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 9e23dcb..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,21 +0,0 @@ -# Kokoro CLI - -Use this project whenever the user asks you to read text aloud or create a local recording. - -```sh -kokoro speak "Text to read" --play --json -``` - -For long or shell-sensitive text, pipe stdin: - -```sh -printf '%s' "$TEXT" | kokoro speak --format mp3 --json -``` - -The final stdout line is JSON when `--json` is used. Return its absolute `path` to the user. Do not claim the user heard the result unless `--play` completed successfully. - -Only narrate text already visible to the user or text they explicitly supplied. Never narrate hidden reasoning, tool traces, secrets, or private instructions. Confirm the JSON field `played` is `true` before saying playback completed. - -The local service is OpenAI-shaped at `POST http://127.0.0.1:8765/v1/audio/speech`. It accepts `input`, `voice`, `speed`, `response_format`, and the local extension `play`. - -`kokoro doctor --json` checks local readiness. `speak` uses the healthy localhost service in `--service auto` mode and falls back to embedded inference; use `--service required` or `--service off` for strict behavior. diff --git a/README.md b/README.md index 72c2d43..5e4d1f3 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,172 @@ -# Kokoro CLI +# Agent Voice -[![skills.sh](https://skills.sh/b/yoav0gal/kokoro-cli)](https://skills.sh/yoav0gal/kokoro-cli) + + + + Agent Voice logo + -Offline text-to-speech for people and AI agents. Kokoro CLI runs Kokoro-82M locally, creates WAV, MP3, Opus, or M4A recordings, plays them on the host, and exposes an optional localhost API. +https://github.com/user-attachments/assets/975dcfd0-17ec-4912-b3b1-ec084077f858 -Audio generation is supported on Windows, macOS, and Linux with Python 3.11–3.13. -Windows playback through `ffplay` is experimental because automated CI cannot -prove audible output. +Local text-to-speech for people and AI agents, powered by +[Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) (only English is supported). -## Quick start - -Install the CLI from PyPI: - -```sh -uv tool install kokoro-cli -kokoro setup -kokoro speak "Hello from Kokoro." --play --json -``` +Agent Voice creates WAV, MP3, Opus, or M4A recordings on macOS, Linux, and +Windows without an API key. -`kokoro setup` downloads and verifies the default model and voices, about 121 MB. Synthesis is offline after setup. +Prebuilt dependencies cover macOS arm64/x64, Linux x64, and Windows x64. +Linux arm64 currently needs a C build toolchain for miniaudio. Native Windows +arm64 lacks an `imageio-ffmpeg` wheel; use x64 Python under Windows emulation. -Install [`uv`](https://docs.astral.sh/uv/) and optional FFmpeg on macOS with: +## Quick start ```sh -brew install uv ffmpeg +uv tool install agent-voice +agent-voice setup +agent-voice speak "Hello from Agent Voice." --play ``` -On Linux, use your package manager for FFmpeg. WAV works without it; MP3, Opus, M4A, and speeds above 2x require it. `pipx install kokoro-cli` is also supported. - -On Windows, install `uv`, then run the same `uv tool install kokoro-cli` -command from PowerShell. Models, recordings, and configuration default to -`%LOCALAPPDATA%\kokoro`. Install FFmpeg to enable compressed formats, speeds -above 2x, and experimental `--play` support through `ffplay`. +`agent-voice setup` downloads and verifies the speech model. -## Common commands +## How to use ```sh -# Create and play a recording -kokoro speak "The build is finished." --play +# See every recording option +agent-voice speak --help + +# Create a recording +agent-voice speak "The build is finished." -# Pipe agent-friendly input and return a JSON receipt -printf '%s' "Here is your summary." | kokoro speak --format mp3 --json +# Play an existing recording +agent-voice play "/absolute/path/recording.mp3" -# Choose a voice, speed, format, and output path -kokoro speak "A slower reading." --voice bf_emma --speed 0.85 -o recording.opus +# Create an MP3 with a readable filename +agent-voice speak "Here is your summary." --format mp3 --label summary -# Discover voices and manage persistent defaults -kokoro voices -kokoro config --json -kokoro config --voice bf_emma --speed 1.15 -kokoro config --reset +# Choose a voice, speed, and exact output +agent-voice speak "A slower reading." \ + --voice bf_emma --speed 0.85 --output recording.opus -# Verify the installation -kokoro doctor --json +# Safely pass agent text through stdin +printf '%s' "$VISIBLE_TEXT" | + agent-voice speak --format mp3 ``` -The default is `af_heart` at `1.0x`; supported speeds are `0.5`–`4.0`. Run `kokoro --help` for all options. +Every `agent-voice speak` command prints one machine-readable JSON receipt +containing the recording's absolute `path`, percent-encoded `file_uri`, audio +metadata, and a `delivery` object with optional `browser_url`, `audio_url`, and +`recording_path` viewer facts. + +Agent Voice stores the recording plus private transcript metadata in its managed +recordings directory. A lightweight localhost viewer renders the branded player +document—with the recording name, native audio controls, and response text—and +serves the actual WAV, MP3, Opus, or M4A recording. It starts automatically on +port `8779`, or on a free port when `8779` is occupied. + +Agents use exactly two delivery routes: + +1. Render `path` with the current surface's native audio player. +2. Otherwise render the installed skill's editable `recording-delivery.md` + template using the structured receipt values: + + ````markdown + Agent Voice recording recording.mp3 + Listen: [web player](http://127.0.0.1:8779/player/recording.html) · [media app](file:///absolute/path/recording.mp3) · [web audio](http://127.0.0.1:8779/recordings/recording.mp3) + ```sh + agent-voice play "/absolute/path/recording.mp3" + ``` + ```` + +The CLI owns delivery facts, not agent-facing recording prose. Each +independently installable skill carries `references/recording-delivery.md`, +where its wording can be customized. The skill derives the media link and +playback command from the receipt's `file_uri` and `path`, omitting viewer links +when those optional delivery facts are unavailable. + +The viewer prefers port `8779` so links survive restarts. If that port is +occupied, it selects a free port and reports it in the receipt. The web player +renders the complete branded document, the media-app link opens the local file +with the operating system default, and web audio serves the recording directly +over HTTP. + +The virtual player URL uses the recording name with an `.html` extension. If +that name already belongs to another format, Agent Voice adds `-2`, `-3`, and +so on. + +Manage the lightweight viewer explicitly when needed: -Recordings and models use the platform user-data directory. Override it with `KOKORO_HOME`, `KOKORO_MODEL_DIR`, or `KOKORO_RECORDING_DIR`. +```sh +agent-voice viewer start +agent-voice viewer stop +``` -## Agent skill +`--output` still writes the exact requested path. For HTTP delivery, Agent +Voice copies that audio into the managed recordings directory instead of +serving arbitrary filesystem paths or creating symlinks. -Install the standalone [`read-aloud`](https://skills.sh/yoav0gal/kokoro-cli/read-aloud) skill globally for Codex: +Explore the available voices and models, manage defaults, or check that Agent +Voice is ready: ```sh -npx skills add yoav0gal/kokoro-cli --skill read-aloud --global --agent codex --yes +agent-voice voices +agent-voice models +agent-voice config --voice bf_emma --speed 1.15 +agent-voice doctor --json ``` -The skill is installed separately from this repository. It invokes the global `kokoro` command and installs `kokoro-cli` from PyPI when needed. +## Defaults -## Local API +Run `agent-voice config` to view the active persisted settings and their +configuration file. -Start the localhost service: +| Setting | Built-in default | Save as default | Override once | +| --- | --- | --- | --- | +| Voice | `af_heart` | `config --voice NAME` | `speak --voice NAME` | +| Speed | `1.0×` | `config --speed NUMBER` | `speak --speed NUMBER` | +| Audio format | MP3 | `config --format FORMAT` | `speak --format FORMAT` | +| Recording directory | Agent Voice's `recordings/` directory | `config --output-dir DIR` | `speak --output-dir DIR` | +| Service | `timed` for `10` minutes | `config --service MODE [--service-timeout MINUTES]` | `speak --service MODE [--service-timeout MINUTES]` | -```sh -kokoro serve -``` +`on` leaves the service running, `off` uses embedded inference, and `timed` +stops the service after the configured number of idle minutes. -Then request speech through the OpenAI-shaped endpoint: +The service setting is stored as one object. Timed mode includes its duration: -```sh -curl -sS http://127.0.0.1:8765/v1/audio/speech \ - -H 'Content-Type: application/json' \ - -d '{"input":"Your agent has finished the task.","voice":"af_heart","response_format":"mp3"}' \ - -o agent-message.mp3 +```json +{ + "service": { + "mode": "timed", + "timeout_minutes": 10 + } +} ``` -The service only accepts localhost connections. `speak` uses a healthy service automatically and falls back to embedded inference; use `--service required` or `--service off` for strict behavior. +## Agent skills -## Development +[View Agent Voice on skills.sh](https://skills.sh/b/yoav0gal/agent-voice). ```sh -git clone https://github.com/yoav0gal/kokoro-cli.git -cd kokoro-cli -./kokoro setup -./kokoro doctor --json -uv run --frozen pytest -q +# Create speech recordings or read text aloud +npx skills add yoav0gal/agent-voice --skill create-speech-recording --global --agent codex --yes + +# Add audio to requested written responses +npx skills add yoav0gal/agent-voice --skill spoken-response --global --agent codex --yes ``` -The checkout wrapper keeps its environment, models, and recordings inside the repository. -On Windows, use `uv run --frozen kokoro setup` and -`uv run --frozen kokoro doctor --json` from the checkout. +Use `--agent '*'` instead of `--agent codex` to install the same skills for all +agent destinations recognized by the skills CLI. -## More +## Local speech API -- [Capabilities and product boundaries](https://github.com/yoav0gal/kokoro-cli/blob/main/docs/capabilities.html) -- [ygent integration contract](https://github.com/yoav0gal/kokoro-cli/blob/main/docs/ygent-integration.md) -- [Read-aloud skill source](https://github.com/yoav0gal/kokoro-cli/blob/main/skills/read-aloud/SKILL.md) -- [Issues](https://github.com/yoav0gal/kokoro-cli/issues) +```sh +agent-voice serve + +curl http://127.0.0.1:8765/v1/audio/speech \ + -H 'Content-Type: application/json' \ + -d '{"input":"The task is complete.","voice":"af_heart","response_format":"mp3"}' \ + --output speech.mp3 +``` -Kokoro-82M weights are Apache 2.0, `kokoro-onnx` is MIT, and model assets come from the `model-files-v1.0` release of `thewh1teagle/kokoro-onnx`. See [third-party notices](https://github.com/yoav0gal/kokoro-cli/blob/main/THIRD_PARTY_NOTICES.md). +The speech API binds only to localhost. It is separate from the lightweight +recording viewer. `agent-voice speak` uses the speech API automatically when +available and falls back to embedded inference. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 3e27647..43d9f42 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,7 +1,10 @@ # Third-party notices -This project downloads and runs third-party software but does not redistribute it in Git. +This project depends on third-party software and downloads model assets. - **Kokoro-82M model weights** — Copyright their respective authors, licensed under Apache License 2.0. Official model card and training attribution: - **kokoro-onnx** — Copyright kokoro-onnx contributors, licensed under the MIT License: - **ONNX model exports and voice bundle** — Downloaded from the `model-files-v1.0` release: +- **imageio-ffmpeg** — Copyright imageio-ffmpeg contributors, licensed under the BSD 2-Clause License. Its platform wheels include an FFmpeg executable: +- **FFmpeg** — The executable bundled by imageio-ffmpeg is licensed under GNU GPL version 2 or later. License and source information: +- **miniaudio Python bindings** — Copyright Irmen de Jong and contributors, licensed under the MIT License: diff --git a/agent-voice b/agent-voice new file mode 100755 index 0000000..8576f5b --- /dev/null +++ b/agent-voice @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu +PROJECT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +: "${AGENT_VOICE_HOME:=$PROJECT_DIR}" +export AGENT_VOICE_HOME +exec uv run --project "$PROJECT_DIR" agent-voice "$@" diff --git a/assets/brand/agent-voice-icon-voiceprint.png b/assets/brand/agent-voice-icon-voiceprint.png new file mode 100644 index 0000000..a67baf2 Binary files /dev/null and b/assets/brand/agent-voice-icon-voiceprint.png differ diff --git a/assets/brand/agent-voice-icon-voiceprint.svg b/assets/brand/agent-voice-icon-voiceprint.svg new file mode 100644 index 0000000..6976c85 --- /dev/null +++ b/assets/brand/agent-voice-icon-voiceprint.svg @@ -0,0 +1,17 @@ + + Agent Voice icon + An orange voiceprint with two outgoing sound waves. + + + + + + + + + + + + + + diff --git a/assets/brand/agent-voice-logo-voiceprint-dark.png b/assets/brand/agent-voice-logo-voiceprint-dark.png new file mode 100644 index 0000000..0786ce1 Binary files /dev/null and b/assets/brand/agent-voice-logo-voiceprint-dark.png differ diff --git a/assets/brand/agent-voice-logo-voiceprint-dark.svg b/assets/brand/agent-voice-logo-voiceprint-dark.svg new file mode 100644 index 0000000..fd8cc98 --- /dev/null +++ b/assets/brand/agent-voice-logo-voiceprint-dark.svg @@ -0,0 +1,32 @@ + + Agent Voice logo for dark backgrounds + Agent Voice wordmark with an orange voiceprint. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/brand/agent-voice-logo-voiceprint.png b/assets/brand/agent-voice-logo-voiceprint.png new file mode 100644 index 0000000..ef8ea5f Binary files /dev/null and b/assets/brand/agent-voice-logo-voiceprint.png differ diff --git a/assets/brand/agent-voice-logo-voiceprint.svg b/assets/brand/agent-voice-logo-voiceprint.svg new file mode 100644 index 0000000..f84a432 --- /dev/null +++ b/assets/brand/agent-voice-logo-voiceprint.svg @@ -0,0 +1,32 @@ + + Agent Voice logo + Agent Voice wordmark with an orange voiceprint. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/capabilities.html b/docs/capabilities.html deleted file mode 100644 index 0bc5b59..0000000 --- a/docs/capabilities.html +++ /dev/null @@ -1,156 +0,0 @@ - - - - - - Kokoro CLI — Capabilities - - - -
-
- Local speech, explicit proof -

Kokoro CLI

-

A self-contained, offline-first text-to-speech product for people and agents. Generate a recording, validate its metadata, optionally play it, and return a concrete file—not a promise.

-
- Version 0.4.0 · cross-platform generation - Verified TTS core preserved - Kokoro-82M via ONNX - 24 kHz mono - Localhost only -
-
- -
-

Current capabilities

-
-
01

Generate

Read text from an argument or stdin. Choose voice, speed, language, model variant, output path, and WAV, MP3, Opus, or M4A.

-
02

Play

Optionally play completed audio through afplay or ffplay. Success is reported only after the player exits successfully. Windows playback is experimental because CI cannot prove audible output.

-
03

Automate

Emit a proof-carrying JSON receipt with an absolute path, audio metadata, backend, and playback state. A localhost OpenAI-shaped HTTP endpoint supports long-lived agent workflows.

-
04

Inspect

List installed voices. Model artifacts are downloaded under a cross-platform OS process lock, written atomically, and checked against pinned sizes and SHA-256 hashes.

-
05

Diagnose

kokoro doctor is the product-health contract for runtime, model, codecs, playback, writable output, and optional service state.

-
06

Compose

Public CLI and HTTP contracts are deliberately small so other products can create speech without importing Kokoro internals.

-
-
- -
-

Commands

-
# Install the stable release globally from PyPI
-uv tool install kokoro-cli
-
-# Prepare and inspect
-kokoro setup
-kokoro doctor
-kokoro voices
-
-# Generate locally or through a healthy optional service
-kokoro speak "The build is ready." --format mp3 --json
-printf '%s' "$VISIBLE_TEXT" | kokoro speak --play --json
-kokoro speak "Strict local run." --service off --json
-kokoro speak "Strict service run." --service required --json
-
-# Long-lived localhost service
-kokoro serve
-curl http://127.0.0.1:8765/health
-

The installed kokoro command works from any directory. Contributors can use ./kokoro inside a macOS/Linux checkout or uv run --frozen kokoro on Windows.

-
- -
-

Execution architecture

-
-

Human or agent

Provides only visible, user-facing text to the CLI or HTTP contract.

- -

Transport

CLI checks the optional localhost service, with explicit required/off modes and embedded fallback.

- -

Speech core

Serialized Kokoro ONNX inference, atomic encoding, file/HTTP delivery, optional playback.

-
-
- -
-

Public contracts

- - - - - - - - -
SurfaceContractGuarantee
CLI resultFinal stdout line is JSON with absolute path, format, voice, speed, timings, backend, and played.Non-zero exit on generation, validation, required-service, or playback failure. Auto fallback is explicit in JSON.
HealthGET /healthReports service identity, version, active model variant, and readiness.
SpeechPOST /v1/audio/speechAccepts input, voice, speed, response_format, plus local lang and play.
VoicesGET /voicesReturns voices exposed by the loaded model bundle.
-
- -
-

Verified behavior

-
-

Automated checks

35 tests cover atomic audio and service output, persistent defaults, model selection and concurrent downloads, Windows data paths and playback messaging, payload validation, localhost binding, live health and speech requests, service fallback, required-service failure, and honest playback receipts. CI runs Ruff and pytest on macOS and Linux, plus Windows on every supported Python version.

-

Real product checks

The Windows release gate builds and installs the wheel into clean Python 3.11, 3.12, and 3.13 environments; downloads and verifies the real int8 assets; synthesizes and decodes 24 kHz mono WAV audio with embedded inference; and repeats synthesis through a live required localhost service. Playback is deliberately excluded from that claim.

-
-
- -
-

Boundaries and planned work

-
-

Deliberate boundaries

No remote binding, authentication layer, cloud TTS, speech-to-text, hidden reasoning narration, or HTML Drop implementation. This repository only exposes contracts another product can compose with.

-

Planned, not current

Streaming synthesis, sentence chunking for very long narration, richer install/uninstall helpers, audible Windows playback verification, and measured model-quality or latency comparisons.

-
-
- -
Repository capability record · Kokoro CLI 0.4.0 · updated 2026-07-24
-
- - diff --git a/docs/viewer-update.html b/docs/viewer-update.html new file mode 100644 index 0000000..cdb8e76 --- /dev/null +++ b/docs/viewer-update.html @@ -0,0 +1,74 @@ + + + + + + Agent Voice local viewer update + + + +

Agent Voice local viewer update

+

JSON recordings now include browser-friendly localhost links. They open as + rendered audio controls even when a local file link would open as code in an IDE.

+ +

What changed

+
    +
  • A tiny standard-library viewer starts automatically for JSON recordings.
  • +
  • browser_url opens the branded player document with the + recording name, audio controls, and response text; audio_url + serves the recording directly.
  • +
  • WAV, MP3, Opus, and M4A use their real filename and content type.
  • +
  • --output remains the exact requested destination. When it is + outside the managed recordings directory, a private copy is made for HTTP + delivery; no symlink is created.
  • +
  • The viewer binds only to 127.0.0.1, prefers port + 8779, falls back to a free port when needed, and serves only + the managed recordings directory.
  • +
+ +

New commands

+
agent-voice viewer start --json
+agent-voice viewer stop --json
+
+agent-voice speak "Viewer smoke test." \
+  --label viewer-smoke --service off
+

The receipt contains the generated path, plus:

+
delivery.browser_url
+delivery.audio_url
+delivery.recording_path
+

Routes normally use port 8779; use the reported port after a + collision:

+
http://127.0.0.1:<port>/player/<recording>.html
+http://127.0.0.1:<port>/recordings/<recording.ext>
+ +

What to test

+
    +
  1. Open browser_url from both an IDE terminal and a normal terminal. + Both should show the branded document, audio controls, and response text.
  2. +
  3. Open audio_url. It should play or download the real audio file.
  4. +
  5. Create the default MP3, then one alternate format such as + --format wav.
  6. +
  7. Use --output /tmp/my-recording.mp3. Confirm that exact file + exists and the HTTP copy in delivery.recording_path also works.
  8. +
  9. Run viewer stop; old links should stop. The next JSON recording + should restart the viewer on port 8779 unless it is occupied.
  10. +
  11. If the viewer cannot start, confirm the viewer URLs are absent. The + installed skill should render its recording-delivery.md template using the receipt's + top-level path and file_uri, omitting the + unavailable viewer links.
  12. +
+ +

Deliberate limit: links remain stable while + port 8779 is available. A collision forces a temporary free + port. Range requests and seeking optimizations are not added until needed.

+ + diff --git a/docs/voice-sampler/index.html b/docs/voice-sampler/index.html index 089065a..64e8780 100644 --- a/docs/voice-sampler/index.html +++ b/docs/voice-sampler/index.html @@ -4,7 +4,7 @@ - Kokoro Voice Room + Agent Voice — Kokoro Voice Room + + +
+
+ +
+

$RECORDING_NAME

+
+ + +
+
+

Response

+
$RESPONSE_TEXT
+
+
+ + diff --git a/src/agent_voice/viewer.py b/src/agent_voice/viewer.py new file mode 100644 index 0000000..f5853ad --- /dev/null +++ b/src/agent_voice/viewer.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import quote + +from filelock import FileLock + +from .media import CONTENT_TYPES +from .paths import project_root, recording_dir + + +_TRANSCRIPT_DIRECTORY = ".agent-voice-viewer" +_PLAYER_DIRECTORY = "players" +_STARTUP_TIMEOUT_SECONDS = 15.0 +_STARTUP_HEALTH_TIMEOUT_SECONDS = 1.0 + + +@dataclass(frozen=True) +class Viewer: + recordings_dir: Path + port: int | None = None + pid: int | None = None + + @property + def running(self) -> bool: + return self.port is not None + + @property + def url(self) -> str | None: + return None if self.port is None else f"http://127.0.0.1:{self.port}" + + def to_dict(self) -> dict[str, object]: + return { + "running": self.running, + "url": self.url, + "port": self.port, + "pid": self.pid, + "recordings_dir": str(self.recordings_dir), + } + + +def ensure_viewer(recordings_dir: Path | None = None) -> Viewer: + # ponytail: prefer one stable port and let the OS choose only on collision. + root = (recordings_dir or recording_dir()).expanduser().resolve() + with FileLock(project_root() / "viewer.lock", timeout=5): + state = _state() + current = _running(state) + if current and current.recordings_dir == root: + return current + if current: + _stop(state) + + state_path = project_root() / "viewer.json" + state_path.unlink(missing_ok=True) + root.mkdir(parents=True, exist_ok=True) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "agent_voice.viewer_server", + str(root), + str(state_path), + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + **( + { + "creationflags": subprocess.CREATE_NEW_PROCESS_GROUP + | subprocess.DETACHED_PROCESS + } + if os.name == "nt" + else {"start_new_session": True} + ), + ) + deadline = time.monotonic() + _STARTUP_TIMEOUT_SECONDS + while time.monotonic() < deadline: + returncode = process.poll() + if returncode is not None: + break + current = _running( + _state(), + timeout=_STARTUP_HEALTH_TIMEOUT_SECONDS, + ) + if current: + return current + time.sleep(0.05) + returncode = process.poll() + if returncode is None: + startup_state = _state() + process.terminate() + process.wait(timeout=2) + phase = startup_state.get("status", "state unavailable") + raise RuntimeError( + "Recording viewer did not become healthy within " + f"{_STARTUP_TIMEOUT_SECONDS:g} seconds " + f"(startup status: {phase})" + ) + raise RuntimeError( + f"Recording viewer exited during startup with status {returncode}" + ) + + +def stop_viewer() -> Viewer: + state = _state() + current = _running(state) + if not current: + (project_root() / "viewer.json").unlink(missing_ok=True) + return Viewer(_root(state)) + _stop(state) + return Viewer(current.recordings_dir) + + +def publish_recording( + recording: Path, + audio_format: str, + recordings_dir: Path | None = None, +) -> Path: + source = recording.expanduser().resolve() + root = (recordings_dir or recording_dir()).expanduser().resolve() + suffix = f".{audio_format.lower()}" + if audio_format.lower() not in CONTENT_TYPES: + raise ValueError(f"Unsupported recording format: {audio_format}") + if source.parent == root and source.suffix.lower() == suffix: + return source + + root.mkdir(parents=True, exist_ok=True) + base = root / ( + source.name if source.suffix.lower() == suffix else f"{source.name}{suffix}" + ) + destination = base + counter = 2 + while True: + try: + handle = os.open(destination, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError: + destination = base.with_name(f"{base.stem}-{counter}{base.suffix}") + counter += 1 + else: + os.close(handle) + break + handle, temporary_name = tempfile.mkstemp(dir=root) + os.close(handle) + temporary = Path(temporary_name) + try: + shutil.copyfile(source, temporary) + os.chmod(temporary, 0o600) + temporary.replace(destination) + except BaseException: + destination.unlink(missing_ok=True) + raise + finally: + temporary.unlink(missing_ok=True) + return destination + + +def publish_transcript(recording: Path, text: str) -> Path: + if not isinstance(text, str): + raise ValueError("Recording text must be a string") + + destination = transcript_path(recording) + destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + handle, temporary_name = tempfile.mkstemp(dir=destination.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(handle, "w", encoding="utf-8") as stream: + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + os.chmod(temporary, 0o600) + temporary.replace(destination) + finally: + temporary.unlink(missing_ok=True) + return destination + + +def publish_player(recording: Path, text: str) -> str: + publish_transcript(recording, text) + root = transcript_path(recording).parent / _PLAYER_DIRECTORY + root.mkdir(parents=True, exist_ok=True, mode=0o700) + base = recording.stem + name = base + counter = 2 + while True: + mapping = root / f"{name}.txt" + try: + handle = os.open( + mapping, + os.O_CREAT | os.O_EXCL | os.O_WRONLY, + 0o600, + ) + except FileExistsError: + try: + if mapping.read_text(encoding="utf-8") == recording.name: + return f"{name}.html" + except OSError: + pass + name = f"{base}-{counter}" + counter += 1 + else: + with os.fdopen(handle, "w", encoding="utf-8") as stream: + stream.write(recording.name) + stream.flush() + os.fsync(stream.fileno()) + return f"{name}.html" + + +def transcript_path(recording: Path) -> Path: + path = recording.expanduser().resolve() + digest = hashlib.sha256(path.name.encode()).hexdigest() + return path.parent / _TRANSCRIPT_DIRECTORY / f"{digest}.txt" + + +def player_mapping_path(recordings: Path, player_name: str) -> Path: + return recordings / _TRANSCRIPT_DIRECTORY / _PLAYER_DIRECTORY / f"{player_name}.txt" + + +def recording_urls( + viewer: Viewer, + recording: Path, + player_name: str, +) -> tuple[str, str]: + if not viewer.url: + raise RuntimeError("Recording viewer is not running") + name = quote(recording.name, safe="") + return ( + f"{viewer.url}/player/{quote(player_name, safe='')}", + f"{viewer.url}/recordings/{name}", + ) + + +def _state() -> dict[str, object]: + try: + value = json.loads((project_root() / "viewer.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return value if isinstance(value, dict) else {} + + +def _running( + state: dict[str, object], + *, + timeout: float = 0.25, +) -> Viewer | None: + try: + port, pid = int(state["port"]), int(state["pid"]) + root = _root(state) + with urllib.request.urlopen( + f"http://127.0.0.1:{port}/health", timeout=timeout + ) as response: + health = json.loads(response.read()) + except (KeyError, ValueError, OSError, urllib.error.URLError, json.JSONDecodeError): + return None + if health.get("service") != "agent-voice-viewer" or health.get("pid") != pid: + return None + return Viewer(root, port, pid) + + +def _stop(state: dict[str, object]) -> None: + pid = int(state["pid"]) + os.kill(pid, signal.SIGTERM) + for _ in range(100): + if not _running(state): + (project_root() / "viewer.json").unlink(missing_ok=True) + return + time.sleep(0.05) + raise RuntimeError("Recording viewer did not stop") + + +def _root(state: dict[str, object]) -> Path: + value = state.get("recordings_dir") + return ( + Path(value).resolve() if isinstance(value, str) else recording_dir().resolve() + ) diff --git a/src/agent_voice/viewer_server.py b/src/agent_voice/viewer_server.py new file mode 100644 index 0000000..0464732 --- /dev/null +++ b/src/agent_voice/viewer_server.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import argparse +import base64 +import errno +import html +import json +import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from importlib import resources +from pathlib import Path +from socketserver import TCPServer +from string import Template +from urllib.parse import quote, unquote, urlsplit + +from . import __version__ +from .media import CONTENT_TYPES +from .viewer import player_mapping_path, transcript_path + + +DEFAULT_VIEWER_PORT = 8779 + + +class Server(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, recordings: Path, port: int = 0) -> None: + super().__init__(("127.0.0.1", port), Handler) + self.recordings = recordings.resolve() + + def server_bind(self) -> None: + # HTTPServer.server_bind resolves the bind address with getfqdn(). + # The viewer is localhost-only, so avoid a DNS lookup that can block + # startup on otherwise healthy machines. + TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + + +class Handler(BaseHTTPRequestHandler): + server_version = f"AgentVoiceViewer/{__version__}" + + # ponytail: GET/HEAD are enough for the browser player; add byte ranges only + # if real recordings prove seeking needs them. + def do_GET(self) -> None: + self._get(head=False) + + def do_HEAD(self) -> None: + self._get(head=True) + + def _get(self, *, head: bool) -> None: + server = self.server + if not isinstance(server, Server) or self.headers.get( + "Host", "" + ).lower() not in { + f"127.0.0.1:{server.server_port}", + f"localhost:{server.server_port}", + }: + self.send_error(403) + return + + url = urlsplit(self.path) + if url.query: + self.send_error(404) + return + if url.path == "/health": + self._send( + json.dumps( + { + "service": "agent-voice-viewer", + "pid": os.getpid(), + "port": server.server_port, + } + ).encode(), + "application/json", + head, + ) + return + + prefix = next( + ( + value + for value in ("/recordings/", "/player/") + if url.path.startswith(value) + ), + None, + ) + encoded = url.path.removeprefix(prefix or "") + recording = ( + self._player_recording(encoded, server) + if prefix == "/player/" + else self._recording(encoded, server) + ) + if prefix is None or recording is None: + self.send_error(404) + elif prefix == "/player/": + self._send(_player(recording), "text/html; charset=utf-8", head) + else: + self._send_file( + recording, + CONTENT_TYPES[recording.suffix.lower().lstrip(".")], + head, + ) + + def _player_recording(self, encoded: str, server: Server) -> Path | None: + try: + name = unquote(encoded, errors="strict") + except (UnicodeError, ValueError): + return None + if not name.endswith(".html"): + return None + player_name = name.removesuffix(".html") + if ( + not player_name + or "/" in player_name + or "\\" in player_name + or Path(player_name).name != player_name + ): + return None + try: + recording_name = player_mapping_path( + server.recordings, + player_name, + ).read_text(encoding="utf-8") + except (OSError, UnicodeError): + return None + return self._recording(quote(recording_name, safe=""), server) + + def _recording(self, encoded: str, server: Server) -> Path | None: + try: + name = unquote(encoded, errors="strict") + if ( + not name + or "/" in name + or "\\" in name + or Path(name).name != name + or name.rpartition(".")[2].lower() not in CONTENT_TYPES + ): + return None + path = (server.recordings / name).resolve(strict=True) + except (OSError, UnicodeError, ValueError): + return None + return path if path.parent == server.recordings and path.is_file() else None + + def _send_file(self, path: Path, content_type: str, head: bool) -> None: + size = path.stat().st_size + requested_range = self.headers.get("Range") + if requested_range is None: + self.send_response(200) + self._headers(size, content_type, accept_ranges=True) + start = 0 + length = size + else: + try: + start, end = _byte_range(requested_range, size) + except ValueError: + self.send_response(416) + self._headers( + 0, + content_type, + accept_ranges=True, + content_range=f"bytes */{size}", + ) + return + length = end - start + 1 + self.send_response(206) + self._headers( + length, + content_type, + accept_ranges=True, + content_range=f"bytes {start}-{end}/{size}", + ) + if not head: + with path.open("rb") as source: + source.seek(start) + remaining = length + while remaining: + chunk = source.read(min(64 * 1024, remaining)) + if not chunk: + break + self.wfile.write(chunk) + remaining -= len(chunk) + + def _send(self, body: bytes, content_type: str, head: bool) -> None: + self.send_response(200) + self._headers(len(body), content_type) + if not head: + self.wfile.write(body) + + def _headers( + self, + length: int, + content_type: str, + *, + accept_ranges: bool = False, + content_range: str | None = None, + ) -> None: + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(length)) + if accept_ranges: + self.send_header("Accept-Ranges", "bytes") + if content_range is not None: + self.send_header("Content-Range", content_range) + self.send_header("Cache-Control", "private, no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + + def log_message(self, message: str, *args: object) -> None: + return + + +def serve(recordings: Path, state_file: Path) -> None: + recordings.mkdir(parents=True, exist_ok=True) + state_file.write_text( + json.dumps( + { + "service": "agent-voice-viewer", + "pid": os.getpid(), + "status": "binding", + "recordings_dir": str(recordings.resolve()), + } + ), + encoding="utf-8", + ) + server = create_server(recordings) + state_file.write_text( + json.dumps( + { + "service": "agent-voice-viewer", + "pid": os.getpid(), + "status": "ready", + "port": server.server_port, + "recordings_dir": str(recordings.resolve()), + } + ), + encoding="utf-8", + ) + os.chmod(state_file, 0o600) + server.serve_forever() + + +def create_server(recordings: Path) -> Server: + try: + return Server(recordings, DEFAULT_VIEWER_PORT) + except OSError as error: + if error.errno != errno.EADDRINUSE: + raise + return Server(recordings) + + +def _byte_range(value: str, size: int) -> tuple[int, int]: + if not value.startswith("bytes=") or "," in value or size <= 0: + raise ValueError("Unsupported byte range") + start_text, separator, end_text = value.removeprefix("bytes=").partition("-") + if not separator: + raise ValueError("Unsupported byte range") + + if start_text: + if not start_text.isdigit() or (end_text and not end_text.isdigit()): + raise ValueError("Unsupported byte range") + start = int(start_text) + end = size - 1 if not end_text else min(int(end_text), size - 1) + if start >= size or end < start: + raise ValueError("Unsatisfiable byte range") + return start, end + + if not end_text.isdigit(): + raise ValueError("Unsupported byte range") + suffix_length = int(end_text) + if suffix_length <= 0: + raise ValueError("Unsatisfiable byte range") + return max(0, size - suffix_length), size - 1 + + +def _player(recording: Path) -> bytes: + name = recording.name + try: + response_text = transcript_path(recording).read_text(encoding="utf-8") + except (OSError, UnicodeError): + response_text = "" + template = Template( + resources.files("agent_voice") + .joinpath("templates", "recording.html") + .read_text(encoding="utf-8") + ) + return template.substitute( + BRAND_ICON=_image_data_url("brand-icon.svg"), + BRAND_LOGO=_image_data_url("brand-logo.svg"), + PAGE_TITLE=html.escape(f"{name} · Agent Voice", quote=True), + RECORDING_NAME=html.escape(name, quote=True), + MEDIA_SOURCE=f"/recordings/{quote(name, safe='')}", + MEDIA_TYPE=CONTENT_TYPES[recording.suffix.lower().lstrip(".")], + RESPONSE_TEXT=html.escape(response_text), + ).encode() + + +def _image_data_url(name: str) -> str: + image = resources.files("agent_voice").joinpath("templates", name).read_bytes() + encoded = base64.b64encode(image).decode("ascii") + return f"data:image/svg+xml;base64,{encoded}" + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("recordings", type=Path) + parser.add_argument("state_file", type=Path) + args = parser.parse_args() + serve(args.recordings.resolve(), args.state_file.resolve()) + + +if __name__ == "__main__": + main() diff --git a/src/kokoro_cli/__init__.py b/src/kokoro_cli/__init__.py deleted file mode 100644 index 02c449d..0000000 --- a/src/kokoro_cli/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Local, agent-friendly Kokoro text-to-speech.""" - -__version__ = "0.4.0" diff --git a/src/kokoro_cli/audio.py b/src/kokoro_cli/audio.py deleted file mode 100644 index 6b5930f..0000000 --- a/src/kokoro_cli/audio.py +++ /dev/null @@ -1,182 +0,0 @@ -from __future__ import annotations - -import os -import shutil -import subprocess -import tempfile -import wave -from pathlib import Path - -import numpy as np -from numpy.typing import NDArray - -FORMATS = ("wav", "mp3", "opus", "m4a") -CONTENT_TYPES = { - "wav": "audio/wav", - "mp3": "audio/mpeg", - "opus": "audio/ogg", - "m4a": "audio/mp4", -} - - -def change_tempo( - samples: NDArray[np.floating], sample_rate: int, factor: float -) -> NDArray[np.float32]: - """Change speech tempo with ffmpeg while preserving the original pitch.""" - if factor == 1.0: - return np.asarray(samples, dtype=np.float32).reshape(-1) - if not 0.5 <= factor <= 2.0: - raise ValueError("Tempo factor must be between 0.5 and 2.0") - - ffmpeg = shutil.which("ffmpeg") - if not ffmpeg: - raise RuntimeError("ffmpeg is required for speech speeds above 2.0") - - source = np.asarray(samples, dtype=" Path: - audio_format = audio_format.lower() - if audio_format not in FORMATS: - raise ValueError( - f"Unsupported format '{audio_format}'. Choose: {', '.join(FORMATS)}" - ) - - destination = destination.expanduser().resolve() - destination.parent.mkdir(parents=True, exist_ok=True) - handle, temporary_name = tempfile.mkstemp( - prefix=f".{destination.name}.", - suffix=f".{audio_format}", - dir=destination.parent, - ) - os.close(handle) - temporary = Path(temporary_name) - try: - if audio_format == "wav": - _write_wav(samples, sample_rate, temporary) - else: - _encode_audio(samples, sample_rate, temporary, audio_format) - temporary.replace(destination) - finally: - temporary.unlink(missing_ok=True) - return destination - - -def write_audio_bytes(data: bytes, destination: Path) -> Path: - """Atomically persist audio returned by the localhost service.""" - if not data: - raise RuntimeError("The Kokoro service returned an empty audio response") - destination = destination.expanduser().resolve() - destination.parent.mkdir(parents=True, exist_ok=True) - handle, temporary_name = tempfile.mkstemp( - prefix=f".{destination.name}.", dir=destination.parent - ) - temporary = Path(temporary_name) - try: - with os.fdopen(handle, "wb") as output: - output.write(data) - temporary.replace(destination) - finally: - temporary.unlink(missing_ok=True) - return destination - - -def play_audio(path: Path) -> None: - afplay = shutil.which("afplay") - ffplay = shutil.which("ffplay") - players = [ffplay, afplay] if path.suffix.lower() == ".opus" else [afplay, ffplay] - players = [player for player in players if player] - if not players: - raise RuntimeError("No audio player found (expected afplay or ffplay)") - last_error: subprocess.CalledProcessError | None = None - for player in players: - command = [player, str(path)] - player_name = Path(str(player).replace("\\", "/")).name.lower() - if player_name in {"ffplay", "ffplay.exe"}: - command[1:1] = ["-nodisp", "-autoexit", "-loglevel", "error"] - try: - subprocess.run(command, check=True, capture_output=True) - return - except subprocess.CalledProcessError as error: - last_error = error - raise RuntimeError(f"Audio playback failed: {last_error}") - - -def _encode_audio( - samples: NDArray[np.floating], - sample_rate: int, - destination: Path, - audio_format: str, -) -> None: - ffmpeg = shutil.which("ffmpeg") - if not ffmpeg: - raise RuntimeError(f"ffmpeg is required to create {audio_format} files") - - with tempfile.TemporaryDirectory(prefix="kokoro-cli-") as directory: - source = Path(directory) / "source.wav" - _write_wav(samples, sample_rate, source) - command = [ - ffmpeg, - "-hide_banner", - "-loglevel", - "error", - "-y", - "-i", - str(source), - ] - if audio_format == "mp3": - command += ["-codec:a", "libmp3lame", "-q:a", "3"] - elif audio_format == "opus": - command += ["-codec:a", "libopus", "-b:a", "48k"] - else: - command += ["-codec:a", "aac", "-b:a", "128k"] - command.append(str(destination)) - try: - subprocess.run(command, check=True, capture_output=True) - except subprocess.CalledProcessError as error: - detail = error.stderr.decode(errors="replace").strip() - raise RuntimeError( - f"ffmpeg could not create {audio_format}: {detail}" - ) from error - - -def _write_wav(samples: NDArray[np.floating], sample_rate: int, path: Path) -> None: - normalized = np.asarray(samples, dtype=np.float32).reshape(-1) - pcm = (np.clip(normalized, -1.0, 1.0) * 32767).astype(" argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="kokoro", - description="Local Kokoro speech for humans and AI agents.", - ) - parser.add_argument( - "--version", action="version", version=f"%(prog)s {__version__}" - ) - subparsers = parser.add_subparsers(dest="command", required=True) - - setup = subparsers.add_parser("setup", help="download Kokoro model and voices") - setup.add_argument( - "--model", choices=MODEL_ASSETS, default="int8", help="model variant" - ) - setup.add_argument("--force", action="store_true", help="download again") - - speak = subparsers.add_parser("speak", help="turn text into an audio recording") - speak.add_argument("text", nargs="?", help="text to read; omit to read stdin") - speak.add_argument("-o", "--output", type=Path, help="output path") - speak.add_argument("-f", "--format", choices=FORMATS, default="wav") - speak.add_argument("-v", "--voice", help="voice name (default: configured voice)") - speak.add_argument( - "--speed", - type=float, - help=f"speech speed from {MIN_SPEED} to {MAX_SPEED} (default: configured speed)", - ) - speak.add_argument("--lang", default="en-us") - speak.add_argument("--play", action="store_true", help="play after generating") - speak.add_argument( - "--json", action="store_true", help="print machine-readable result" - ) - speak.add_argument( - "--model", choices=MODEL_ASSETS, default="int8", help="model variant" - ) - speak.add_argument( - "--service", - choices=("auto", "required", "off"), - default="auto", - help="use a healthy localhost service, require it, or use embedded inference", - ) - speak.add_argument( - "--service-url", - default=os.environ.get("KOKORO_SERVICE_URL", DEFAULT_SERVICE_URL), - help="localhost service base URL", - ) - - voices = subparsers.add_parser("voices", help="list installed voices") - voices.add_argument("--json", action="store_true") - voices.add_argument( - "--model", choices=MODEL_ASSETS, default="int8", help="model variant" - ) - - config = subparsers.add_parser("config", help="show or update speech defaults") - config.add_argument("--voice", help="set the default voice") - config.add_argument( - "--speed", - type=float, - help=f"set the default speed from {MIN_SPEED} to {MAX_SPEED}", - ) - config.add_argument( - "--reset", action="store_true", help="restore built-in defaults" - ) - config.add_argument("--json", action="store_true") - - serve_parser = subparsers.add_parser("serve", help="start the local HTTP API") - serve_parser.add_argument( - "--host", choices=("127.0.0.1", "localhost"), default="127.0.0.1" - ) - serve_parser.add_argument("--port", type=int, default=8765) - serve_parser.add_argument( - "--model", choices=MODEL_ASSETS, default="int8", help="model variant" - ) - doctor = subparsers.add_parser("doctor", help="check local product readiness") - doctor.add_argument( - "--model", choices=MODEL_ASSETS, default="int8", help="model variant" - ) - doctor.add_argument( - "--service-url", - default=os.environ.get("KOKORO_SERVICE_URL", DEFAULT_SERVICE_URL), - help="optional localhost service base URL", - ) - doctor.add_argument("--json", action="store_true") - return parser - - -def main(argv: list[str] | None = None) -> None: - parser = build_parser() - args = parser.parse_args(argv) - try: - if args.command == "setup": - model, voices = download_models(args.model, args.force) - print(f"Ready: {model}") - print(f"Voices: {voices}") - elif args.command == "speak": - _speak(args) - elif args.command == "voices": - engine = _engine(args.model) - voices = engine.voices() - print(json.dumps({"voices": voices}) if args.json else "\n".join(voices)) - elif args.command == "config": - _config(args) - elif args.command == "serve": - from .service import serve - - serve(_engine(args.model), args.host, args.port) - elif args.command == "doctor": - from .doctor import diagnose, format_report - - report = diagnose(args.model, args.service_url) - print(json.dumps(report) if args.json else format_report(report)) - if not report["ok"]: - raise SystemExit(1) - except (ValueError, RuntimeError, FileNotFoundError) as error: - print(f"Error: {error}", file=sys.stderr) - raise SystemExit(2) from error - - -def _engine(variant: str) -> SpeechEngine: - if not models_ready(variant): - print("Kokoro model not found; downloading it once...", file=sys.stderr) - download_models(variant) - return SpeechEngine(variant) - - -def _speak(args: argparse.Namespace) -> None: - defaults = load_defaults() - args.voice = args.voice if args.voice is not None else defaults.voice - args.speed = args.speed if args.speed is not None else defaults.speed - text = args.text if args.text is not None else sys.stdin.read() - audio_format = args.format - if args.output: - suffix = args.output.suffix.lower().lstrip(".") - if suffix: - if suffix not in FORMATS: - raise ValueError( - f"Output extension must be one of: {', '.join(FORMATS)}" - ) - audio_format = suffix - output = args.output - else: - timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f") - output = recording_dir() / f"kokoro-{timestamp}.{audio_format}" - - fallback_reason: str | None = None - result: dict[str, object] - if args.service != "off": - try: - health_check(args.service_url) - result = request_speech( - args.service_url, - text, - output, - audio_format, - args.voice, - args.speed, - args.lang, - ) - except ServiceUnavailable as error: - if args.service == "required": - raise RuntimeError( - f"Required Kokoro service unavailable: {error}" - ) from error - fallback_reason = str(error) - print( - f"Kokoro service unavailable; using embedded inference ({error})", - file=sys.stderr, - ) - result = _speak_locally(args, text, output, audio_format) - else: - result = _speak_locally(args, text, output, audio_format) - - path = Path(str(result["path"])) - if args.play: - play_audio(path) - result["played"] = args.play - if fallback_reason is not None: - result["service_fallback"] = True - if args.json: - print(json.dumps(result)) - else: - print(f"Created {path}") - print( - f"{_display_number(result.get('duration_seconds'))}s audio · {args.voice} · " - f"{result['backend']} backend" - ) - - -def _speak_locally( - args: argparse.Namespace, text: str, output: Path, audio_format: str -) -> dict[str, object]: - speech = _engine(args.model).synthesize( - text, voice=args.voice, speed=args.speed, lang=args.lang - ) - path = write_audio(speech.samples, speech.sample_rate, output, audio_format) - return { - "path": str(path), - "format": audio_format, - "voice": args.voice, - "speed": args.speed, - "sample_rate": speech.sample_rate, - "duration_seconds": round(speech.duration_seconds, 3), - "generation_seconds": round(speech.elapsed_seconds, 3), - "backend": "local", - } - - -def _config(args: argparse.Namespace) -> None: - if args.reset and (args.voice is not None or args.speed is not None): - raise ValueError("--reset cannot be combined with --voice or --speed") - if args.reset: - defaults = reset_defaults() - elif args.voice is not None or args.speed is not None: - defaults = update_defaults(voice=args.voice, speed=args.speed) - else: - defaults = load_defaults() - - payload: dict[str, object] = { - **defaults.to_dict(), - "source": "config" if config_path().is_file() else "built-in", - "path": str(config_path()), - } - if args.json: - print(json.dumps(payload)) - else: - print(f"Voice: {defaults.voice}") - print(f"Speed: {defaults.speed:g}") - print(f"Source: {payload['source']}") - print(f"Config: {payload['path']}") - - -def _display_number(value: object) -> str: - return f"{value:.1f}" if isinstance(value, (int, float)) else "unknown" - - -if __name__ == "__main__": - main() diff --git a/src/kokoro_cli/client.py b/src/kokoro_cli/client.py deleted file mode 100644 index 51d6b79..0000000 --- a/src/kokoro_cli/client.py +++ /dev/null @@ -1,133 +0,0 @@ -from __future__ import annotations - -import json -import socket -import urllib.error -import urllib.request -from pathlib import Path -from urllib.parse import urlparse - -from .audio import write_audio_bytes - -DEFAULT_SERVICE_URL = "http://127.0.0.1:8765" -LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"} - - -class ServiceUnavailable(RuntimeError): - """Raised when the optional localhost service cannot be used.""" - - -def validate_service_url(service_url: str) -> str: - url = service_url.rstrip("/") - parsed = urlparse(url) - if parsed.scheme != "http" or parsed.hostname not in LOCAL_HOSTS: - raise ValueError("Kokoro service URL must be localhost over http") - if ( - parsed.username - or parsed.password - or parsed.path not in ("", "/") - or parsed.query - or parsed.fragment - ): - raise ValueError( - "Kokoro service URL cannot include credentials, path, query, or fragment" - ) - return url - - -def health_check(service_url: str, timeout: float = 0.75) -> dict[str, object]: - url = validate_service_url(service_url) + "/health" - try: - with urllib.request.urlopen(url, timeout=timeout) as response: - payload = json.loads(response.read()) - except ( - OSError, - TimeoutError, - socket.timeout, - urllib.error.URLError, - json.JSONDecodeError, - ) as error: - raise ServiceUnavailable(f"health check failed: {error}") from error - if ( - not isinstance(payload, dict) - or payload.get("status") != "ok" - or payload.get("service") != "kokoro" - ): - raise ServiceUnavailable("health check returned an invalid response") - return payload - - -def request_speech( - service_url: str, - text: str, - destination: Path, - audio_format: str, - voice: str, - speed: float, - lang: str, - timeout: float = 300, -) -> dict[str, object]: - url = validate_service_url(service_url) + "/v1/audio/speech" - body = json.dumps( - { - "input": text, - "voice": voice, - "speed": speed, - "lang": lang, - "response_format": audio_format, - "play": False, - } - ).encode() - request = urllib.request.Request( - url, - data=body, - headers={"Content-Type": "application/json"}, - method="POST", - ) - try: - with urllib.request.urlopen(request, timeout=timeout) as response: - data = response.read() - headers = response.headers - except urllib.error.HTTPError as error: - detail = _http_error_detail(error) - if 400 <= error.code < 500: - raise ValueError( - f"Kokoro service rejected the request: {detail}" - ) from error - raise ServiceUnavailable(f"Kokoro service failed: {detail}") from error - except (OSError, TimeoutError, socket.timeout, urllib.error.URLError) as error: - raise ServiceUnavailable(f"speech request failed: {error}") from error - - path = write_audio_bytes(data, destination) - response_speed = _number_header(headers.get("X-Kokoro-Speed"), float) - return { - "path": str(path), - "format": audio_format, - "voice": headers.get("X-Kokoro-Voice", voice), - "speed": speed if response_speed is None else response_speed, - "sample_rate": _number_header(headers.get("X-Kokoro-Sample-Rate"), int), - "duration_seconds": _number_header(headers.get("X-Kokoro-Duration"), float), - "generation_seconds": _number_header( - headers.get("X-Kokoro-Generation-Seconds"), float - ), - "backend": "service", - } - - -def _http_error_detail(error: urllib.error.HTTPError) -> str: - try: - payload = json.loads(error.read()) - if isinstance(payload, dict) and isinstance(payload.get("error"), str): - return payload["error"] - except (json.JSONDecodeError, OSError): - pass - return f"HTTP {error.code}" - - -def _number_header( - value: str | None, converter: type[int] | type[float] -) -> int | float | None: - try: - return converter(value) if value is not None else None - except ValueError: - return None diff --git a/src/kokoro_cli/config.py b/src/kokoro_cli/config.py deleted file mode 100644 index d95337b..0000000 --- a/src/kokoro_cli/config.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -import json -import os -import tempfile -from dataclasses import asdict, dataclass -from pathlib import Path - -from .models import project_root - -DEFAULT_VOICE = "af_heart" -DEFAULT_SPEED = 1.0 -MIN_SPEED = 0.5 -MAX_SPEED = 4.0 - - -@dataclass(frozen=True) -class SpeechDefaults: - voice: str = DEFAULT_VOICE - speed: float = DEFAULT_SPEED - - def to_dict(self) -> dict[str, str | float]: - return asdict(self) - - -def config_path() -> Path: - return project_root() / "config.json" - - -def load_defaults() -> SpeechDefaults: - path = config_path() - if not path.is_file(): - return SpeechDefaults() - try: - payload = json.loads(path.read_text()) - except (OSError, json.JSONDecodeError) as error: - raise ValueError(f"Could not read Kokoro config at {path}: {error}") from error - if not isinstance(payload, dict): - raise ValueError(f"Kokoro config at {path} must be a JSON object") - return _validated_defaults( - payload.get("voice", DEFAULT_VOICE), - payload.get("speed", DEFAULT_SPEED), - ) - - -def update_defaults( - *, voice: str | None = None, speed: float | None = None -) -> SpeechDefaults: - current = load_defaults() - updated = _validated_defaults( - current.voice if voice is None else voice, - current.speed if speed is None else speed, - ) - _write_config(updated) - return updated - - -def reset_defaults() -> SpeechDefaults: - config_path().unlink(missing_ok=True) - return SpeechDefaults() - - -def _validated_defaults(voice: object, speed: object) -> SpeechDefaults: - if not isinstance(voice, str) or not voice.strip(): - raise ValueError("Default voice must be a non-empty string") - if isinstance(speed, bool) or not isinstance(speed, (int, float)): - raise ValueError("Default speed must be a number") - value = float(speed) - if not MIN_SPEED <= value <= MAX_SPEED: - raise ValueError(f"Default speed must be between {MIN_SPEED} and {MAX_SPEED}") - return SpeechDefaults(voice.strip(), value) - - -def _write_config(defaults: SpeechDefaults) -> None: - path = config_path() - path.parent.mkdir(parents=True, exist_ok=True) - descriptor, temporary_name = tempfile.mkstemp( - prefix=".config.", suffix=".tmp", dir=path.parent - ) - temporary = Path(temporary_name) - try: - with os.fdopen(descriptor, "w") as output: - json.dump(defaults.to_dict(), output, indent=2) - output.write("\n") - temporary.replace(path) - finally: - temporary.unlink(missing_ok=True) diff --git a/src/kokoro_cli/engine.py b/src/kokoro_cli/engine.py deleted file mode 100644 index 98db71c..0000000 --- a/src/kokoro_cli/engine.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -import threading -import time -from dataclasses import dataclass - -import numpy as np -from numpy.typing import NDArray - -from .audio import change_tempo -from .config import DEFAULT_SPEED, DEFAULT_VOICE, MAX_SPEED, MIN_SPEED -from .models import model_paths, models_ready - -KOKORO_MAX_SPEED = 2.0 - - -@dataclass(frozen=True) -class Speech: - samples: NDArray[np.floating] - sample_rate: int - elapsed_seconds: float - - @property - def duration_seconds(self) -> float: - return len(self.samples) / self.sample_rate - - -class SpeechEngine: - """Loads Kokoro once and serializes inference for predictable local use.""" - - def __init__(self, variant: str = "int8") -> None: - if not models_ready(variant): - raise FileNotFoundError( - f"Kokoro model is missing. Run: kokoro setup --model {variant}" - ) - from kokoro_onnx import Kokoro - - model, voices = model_paths(variant) - self.variant = variant - self._kokoro = Kokoro(str(model), str(voices)) - self._lock = threading.Lock() - - def voices(self) -> list[str]: - return self._kokoro.get_voices() - - def synthesize( - self, - text: str, - voice: str = DEFAULT_VOICE, - speed: float = DEFAULT_SPEED, - lang: str = "en-us", - ) -> Speech: - text = text.strip() - if not text: - raise ValueError("Text cannot be empty") - if len(text) > 20_000: - raise ValueError("Text is too long (maximum 20,000 characters per request)") - if not MIN_SPEED <= speed <= MAX_SPEED: - raise ValueError(f"Speed must be between {MIN_SPEED} and {MAX_SPEED}") - if voice not in self.voices(): - raise ValueError(f"Unknown voice '{voice}'") - - started = time.perf_counter() - synthesis_speed = min(speed, KOKORO_MAX_SPEED) - with self._lock: - samples, sample_rate = self._kokoro.create( - text, voice=voice, speed=synthesis_speed, lang=lang - ) - if speed > KOKORO_MAX_SPEED: - samples = change_tempo(samples, sample_rate, speed / synthesis_speed) - return Speech(samples, sample_rate, time.perf_counter() - started) diff --git a/src/kokoro_cli/models.py b/src/kokoro_cli/models.py deleted file mode 100644 index 7c24b49..0000000 --- a/src/kokoro_cli/models.py +++ /dev/null @@ -1,183 +0,0 @@ -from __future__ import annotations - -import hashlib -import os -import sys -import tempfile -import urllib.request -from pathlib import Path - -from filelock import FileLock - -RELEASE_BASE = ( - "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0" -) - -MODEL_ASSETS = { - "int8": ( - "kokoro-v1.0.int8.onnx", - 92_361_271, - "6e742170d309016e5891a994e1ce1559c702a2ccd0075e67ef7157974f6406cb", - ), - "fp16": ( - "kokoro-v1.0.fp16.onnx", - 177_464_787, - "c1610a859f3bdea01107e73e50100685af38fff88f5cd8e5c56df109ec880204", - ), - "full": ( - "kokoro-v1.0.onnx", - 325_532_387, - "7d5df8ecf7d4b1878015a32686053fd0eebe2bc377234608764cc0ef3636a6c5", - ), -} -VOICES_ASSET = ( - "voices-v1.0.bin", - 28_214_398, - "bca610b8308e8d99f32e6fe4197e7ec01679264efed0cac9140fe9c29f1fbf7d", -) - - -def project_root() -> Path: - """Return the writable Kokoro data root. - - The repository wrapper sets KOKORO_HOME to this checkout. An installed CLI - uses the platform's user data directory unless KOKORO_HOME overrides it. - """ - configured = os.environ.get("KOKORO_HOME") - if configured: - return Path(configured).expanduser().resolve() - - checkout = Path(__file__).resolve().parents[2] - if (checkout / "pyproject.toml").is_file() and (checkout / "kokoro").is_file(): - return checkout - - if sys.platform == "darwin": - return Path.home() / "Library" / "Application Support" / "kokoro" - if sys.platform == "win32": - local_app_data = os.environ.get("LOCALAPPDATA") - base = ( - Path(local_app_data).expanduser() - if local_app_data - else Path.home() / "AppData" / "Local" - ) - return (base / "kokoro").resolve() - xdg_data_home = os.environ.get("XDG_DATA_HOME") - base = ( - Path(xdg_data_home).expanduser() - if xdg_data_home - else Path.home() / ".local" / "share" - ) - return (base / "kokoro").resolve() - - -def recording_dir() -> Path: - configured = os.environ.get("KOKORO_RECORDING_DIR") - return ( - Path(configured).expanduser().resolve() - if configured - else project_root() / "recordings" - ) - - -def model_dir() -> Path: - configured = os.environ.get("KOKORO_MODEL_DIR") - return ( - Path(configured).expanduser().resolve() - if configured - else project_root() / "models" - ) - - -def model_paths(variant: str = "int8") -> tuple[Path, Path]: - if variant not in MODEL_ASSETS: - choices = ", ".join(MODEL_ASSETS) - raise ValueError(f"Unknown model variant '{variant}'. Choose one of: {choices}") - directory = model_dir() - return directory / MODEL_ASSETS[variant][0], directory / VOICES_ASSET[0] - - -def models_ready(variant: str = "int8") -> bool: - model, voices = model_paths(variant) - return _valid_asset(model, MODEL_ASSETS[variant]) and _valid_asset( - voices, VOICES_ASSET - ) - - -def download_models(variant: str = "int8", force: bool = False) -> tuple[Path, Path]: - model, voices = model_paths(variant) - model.parent.mkdir(parents=True, exist_ok=True) - _download_asset(MODEL_ASSETS[variant], model, force) - _download_asset(VOICES_ASSET, voices, force) - return model, voices - - -def _valid_asset(path: Path, asset: tuple[str, int, str]) -> bool: - _, expected_size, expected_sha256 = asset - return ( - path.is_file() - and path.stat().st_size == expected_size - and _sha256(path) == expected_sha256 - ) - - -def _download_asset( - asset: tuple[str, int, str], destination: Path, force: bool -) -> None: - name, expected_size, expected_sha256 = asset - lock_path = destination.with_suffix(destination.suffix + ".lock") - with FileLock(lock_path): - if not force and _valid_asset(destination, asset): - print(f"✓ {name} already downloaded and verified", file=sys.stderr) - return - - handle, partial_name = tempfile.mkstemp( - prefix=f".{name}.", suffix=".part", dir=destination.parent - ) - os.close(handle) - partial = Path(partial_name) - url = f"{RELEASE_BASE}/{name}" - print( - f"↓ Downloading {name} ({expected_size / 1_000_000:.1f} MB)", - file=sys.stderr, - ) - - try: - request = urllib.request.Request( - url, headers={"User-Agent": "kokoro-cli/0.1"} - ) - with ( - urllib.request.urlopen(request, timeout=30) as response, - partial.open("wb") as output, - ): - downloaded = 0 - while chunk := response.read(1024 * 1024): - output.write(chunk) - downloaded += len(chunk) - print( - f"\r {downloaded / expected_size:6.1%}", - end="", - file=sys.stderr, - flush=True, - ) - print(file=sys.stderr) - actual_size = partial.stat().st_size - if actual_size != expected_size: - raise RuntimeError( - f"Download size mismatch for {name}: got {actual_size}, expected {expected_size}" - ) - actual_sha256 = _sha256(partial) - if actual_sha256 != expected_sha256: - raise RuntimeError( - f"Checksum mismatch for {name}: got {actual_sha256}, expected {expected_sha256}" - ) - partial.replace(destination) - finally: - partial.unlink(missing_ok=True) - - -def _sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as source: - while chunk := source.read(1024 * 1024): - digest.update(chunk) - return digest.hexdigest() diff --git a/src/kokoro_cli/service.py b/src/kokoro_cli/service.py deleted file mode 100644 index 5deccc0..0000000 --- a/src/kokoro_cli/service.py +++ /dev/null @@ -1,166 +0,0 @@ -from __future__ import annotations - -import json -import tempfile -from dataclasses import dataclass -from http.server import BaseHTTPRequestHandler, HTTPServer -from pathlib import Path -from typing import Any - -from . import __version__ -from .audio import CONTENT_TYPES, FORMATS, play_audio, write_audio -from .config import load_defaults -from .engine import SpeechEngine - - -@dataclass(frozen=True) -class SpeechRequest: - text: str - voice: str - speed: float - lang: str - audio_format: str - play: bool - - -def validate_payload(payload: object) -> SpeechRequest: - if not isinstance(payload, dict): - raise ValueError("JSON body must be an object") - defaults = load_defaults() - text = payload.get("input", payload.get("text")) - voice = payload.get("voice", defaults.voice) - speed = payload.get("speed", defaults.speed) - lang = payload.get("lang", "en-us") - audio_format = payload.get("response_format", payload.get("format", "mp3")) - play = payload.get("play", False) - if not isinstance(text, str): - raise ValueError("input must be a string") - if not isinstance(voice, str) or not voice: - raise ValueError("voice must be a non-empty string") - if isinstance(speed, bool) or not isinstance(speed, (int, float)): - raise ValueError("speed must be a number") - if not isinstance(lang, str) or not lang: - raise ValueError("lang must be a non-empty string") - if not isinstance(audio_format, str) or audio_format.lower() not in FORMATS: - raise ValueError(f"response_format must be one of: {', '.join(FORMATS)}") - if not isinstance(play, bool): - raise ValueError("play must be a boolean") - return SpeechRequest(text, voice, float(speed), lang, audio_format.lower(), play) - - -class TTSRequestHandler(BaseHTTPRequestHandler): - engine: SpeechEngine - max_body_bytes = 100_000 - server_version = f"KokoroCLI/{__version__}" - - def setup(self) -> None: - super().setup() - self.connection.settimeout(30) - - def do_GET(self) -> None: - if self.path == "/health": - self._json( - 200, - { - "status": "ok", - "service": "kokoro", - "version": __version__, - "model": "Kokoro-82M", - "variant": self.engine.variant, - "ready": True, - }, - ) - elif self.path == "/voices": - self._json(200, {"voices": self.engine.voices()}) - else: - self._json(404, {"error": "Not found"}) - - def do_POST(self) -> None: - if self.path not in ("/speak", "/v1/audio/speech"): - self._json(404, {"error": "Not found"}) - return - try: - length = int(self.headers.get("Content-Length", "0")) - if length <= 0 or length > self.max_body_bytes: - raise ValueError("Request body must be between 1 and 100,000 bytes") - payload = json.loads(self.rfile.read(length)) - request = validate_payload(payload) - audio = self._synthesize(request) - except (ValueError, json.JSONDecodeError) as error: - self._json(400, {"error": str(error)}) - except Exception as error: - self._json(500, {"error": str(error)}) - else: - self.send_response(200) - data, audio_format, voice, metadata = audio - self.send_header("Content-Type", CONTENT_TYPES[audio_format]) - self.send_header("Content-Length", str(len(data))) - self.send_header("X-Kokoro-Voice", voice) - self.send_header("X-Kokoro-Speed", str(request.speed)) - self.send_header("X-Kokoro-Sample-Rate", str(metadata["sample_rate"])) - self.send_header("X-Kokoro-Duration", str(metadata["duration_seconds"])) - self.send_header( - "X-Kokoro-Generation-Seconds", str(metadata["generation_seconds"]) - ) - self.send_header("X-Kokoro-Played", str(metadata["played"]).lower()) - self.end_headers() - self.wfile.write(data) - - def _synthesize( - self, request: SpeechRequest - ) -> tuple[bytes, str, str, dict[str, object]]: - speech = self.engine.synthesize( - request.text, voice=request.voice, speed=request.speed, lang=request.lang - ) - - with tempfile.TemporaryDirectory(prefix="kokoro-service-") as directory: - path = Path(directory) / f"speech.{request.audio_format}" - write_audio(speech.samples, speech.sample_rate, path, request.audio_format) - data = path.read_bytes() - if request.play: - play_audio(path) - return ( - data, - request.audio_format, - request.voice, - { - "sample_rate": speech.sample_rate, - "duration_seconds": round(speech.duration_seconds, 3), - "generation_seconds": round(speech.elapsed_seconds, 3), - "played": request.play, - }, - ) - - def _json(self, status: int, payload: dict[str, Any]) -> None: - body = json.dumps(payload).encode() - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, message: str, *args: object) -> None: - print(f"[kokoro] {self.address_string()} {message % args}") - - -def create_server(engine: SpeechEngine, host: str, port: int) -> HTTPServer: - if host not in ("127.0.0.1", "localhost"): - raise ValueError("This personal audio service only binds to localhost") - handler = type( - "ConfiguredTTSRequestHandler", - (TTSRequestHandler,), - {"engine": engine}, - ) - return HTTPServer((host, port), handler) - - -def serve(engine: SpeechEngine, host: str, port: int) -> None: - server = create_server(engine, host, port) - print(f"Kokoro TTS listening on http://{host}:{port}") - print("POST /v1/audio/speech · GET /voices · GET /health") - try: - server.serve_forever() - except KeyboardInterrupt: - print("\nStopping Kokoro TTS") - finally: - server.server_close() diff --git a/tests/test_audio.py b/tests/test_audio.py index 42af429..61c12cb 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -5,8 +5,8 @@ import numpy as np import pytest -from kokoro_cli import audio as audio_module -from kokoro_cli.audio import change_tempo, write_audio, write_audio_bytes +from agent_voice import audio as audio_module +from agent_voice.audio import change_tempo, write_audio, write_audio_bytes def test_write_wav(tmp_path): @@ -62,7 +62,20 @@ def test_empty_service_audio_does_not_overwrite_existing_output(tmp_path): assert output.read_bytes() == b"original" -def test_change_tempo_uses_pitch_preserving_ffmpeg_filter(monkeypatch): +@pytest.mark.parametrize( + ("factor", "expected_filter"), + [ + (0.5, "atempo=0.5"), + (0.75, "atempo=0.75"), + (1.5, "atempo=1.5"), + (2.0, "atempo=2.0"), + (3.0, "atempo=2.0,atempo=1.5"), + (4.0, "atempo=2.0,atempo=2.0"), + ], +) +def test_change_tempo_uses_pitch_preserving_ffmpeg_filter( + monkeypatch, factor, expected_filter +): samples = np.arange(8, dtype=np.float32) commands = [] @@ -73,32 +86,100 @@ def run(command, **kwargs): commands.append((command, kwargs)) return Completed() - monkeypatch.setattr(audio_module.shutil, "which", lambda name: "/bin/ffmpeg") + monkeypatch.setattr(audio_module, "_ffmpeg_executable", lambda: "/bundled/ffmpeg") monkeypatch.setattr(audio_module.subprocess, "run", run) - changed = change_tempo(samples, 24_000, 2.0) + changed = change_tempo(samples, 24_000, factor) assert len(changed) == 4 command, kwargs = commands[0] - assert "atempo=2.0" in command + assert command[command.index("-filter:a") + 1] == expected_filter assert kwargs["input"] == samples.astype(" Viewer: + return Viewer(root.resolve(), 49123, 123) + + +def test_prepare_delivery_uses_http_player_audio_and_file_links(tmp_path, monkeypatch): + recording = tmp_path / "Daily update & notes.mp3" + recording.write_bytes(b"audio") + monkeypatch.setattr(delivery, "ensure_viewer", _viewer) + + result = delivery.prepare_delivery( + recording, + "Visible response text.", + recordings_dir=tmp_path, + ) + + assert result.warning is None + assert result.recording_path == recording + assert result.browser_url == ( + "http://127.0.0.1:49123/player/Daily%20update%20%26%20notes.html" + ) + assert result.audio_url == ( + "http://127.0.0.1:49123/recordings/Daily%20update%20%26%20notes.mp3" + ) + assert list(tmp_path.glob("*.html")) == [] + + +def test_prepare_delivery_copies_external_output_and_stores_transcript( + tmp_path, monkeypatch +): + output = tmp_path / "export" / "report.m4a" + output.parent.mkdir() + output.write_bytes(b"m4a-audio") + managed = tmp_path / "managed recordings" + monkeypatch.setattr(delivery, "ensure_viewer", _viewer) + + result = delivery.prepare_delivery( + output, + "Visible response text.", + audio_format="m4a", + recordings_dir=managed, + ) + + assert result.recording_path == managed / "report.m4a" + assert result.recording_path.read_bytes() == b"m4a-audio" + assert {path.name for path in managed.iterdir()} == { + "report.m4a", + ".agent-voice-viewer", + } + assert result.audio_url.endswith("/recordings/report.m4a") + assert output.read_bytes() == b"m4a-audio" + + +def test_prepare_delivery_adds_real_format_to_extensionless_output( + tmp_path, monkeypatch +): + output = tmp_path / "recording" + output.write_bytes(b"opus-audio") + managed = tmp_path / "managed" + monkeypatch.setattr(delivery, "ensure_viewer", _viewer) + + result = delivery.prepare_delivery( + output, + "Visible response text.", + audio_format="opus", + recordings_dir=managed, + ) + + assert result.recording_path == managed / "recording.opus" + assert result.audio_url.endswith("/recordings/recording.opus") + + +def test_prepare_delivery_preserves_existing_managed_recording(tmp_path, monkeypatch): + output = tmp_path / "report.mp3" + output.write_bytes(b"existing") + external = tmp_path / "external" / "report.mp3" + external.parent.mkdir() + external.write_bytes(b"new") + monkeypatch.setattr(delivery, "ensure_viewer", _viewer) + + result = delivery.prepare_delivery( + external, + "Visible response text.", + recordings_dir=tmp_path, + ) + + assert output.read_bytes() == b"existing" + assert result.recording_path == tmp_path / "report-2.mp3" + assert result.recording_path.read_bytes() == b"new" + + +def test_viewer_failure_keeps_audio_and_uses_file_fallback(tmp_path, monkeypatch): + recording = tmp_path / "fallback.mp3" + recording.write_bytes(b"audio") + monkeypatch.setattr( + delivery, + "ensure_viewer", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("not available")), + ) + + result = delivery.prepare_delivery( + recording, + "Visible response text.", + recordings_dir=tmp_path, + ) + + assert recording.read_bytes() == b"audio" + assert result.warning == ( + "Could not start recording viewer; using file fallback (not available)" + ) + assert result.browser_url is None + assert result.audio_url is None + assert result.recording_path is None + + +def test_prepare_delivery_rejects_unknown_audio_format(tmp_path): + recording = tmp_path / "recording.flac" + recording.write_bytes(b"audio") + + with pytest.raises(ValueError, match="Unsupported recording format"): + delivery.prepare_delivery(recording, "Visible response text.") diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 5f8037e..ba95f0d 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -1,27 +1,104 @@ from __future__ import annotations -from kokoro_cli import doctor +from agent_voice import doctor +from agent_voice.audio import AudioRuntime +from agent_voice.config import update_defaults +from agent_voice.model import ModelCheck, ModelStatus -def test_windows_playback_is_reported_as_experimental(tmp_path, monkeypatch): - monkeypatch.setattr(doctor.sys, "platform", "win32") - monkeypatch.setattr(doctor, "models_ready", lambda variant: True) - monkeypatch.setattr(doctor, "model_dir", lambda: tmp_path / "models") - monkeypatch.setattr(doctor, "recording_dir", lambda: tmp_path / "recordings") - monkeypatch.setattr( - doctor.shutil, - "which", - lambda name: r"C:\ffmpeg\bin\ffplay.exe" if name == "ffplay" else None, - ) +class ReadyModel: + def status(self): + return ModelStatus( + ready=True, + checks=( + ModelCheck("runtime", "pass", "runtime available"), + ModelCheck("model", "pass", "model available"), + ), + ) + + +def _prepare_doctor(tmp_path, monkeypatch, runtime: AudioRuntime) -> None: + monkeypatch.setenv("AGENT_VOICE_HOME", str(tmp_path / "home")) + monkeypatch.setattr(doctor, "inspect_audio_runtime", lambda: runtime) monkeypatch.setattr( doctor, "health_check", lambda url: (_ for _ in ()).throw(doctor.ServiceUnavailable("not running")), ) - report = doctor.diagnose("int8", "http://127.0.0.1:8765") + +def test_bundled_audio_runtime_is_ready(tmp_path, monkeypatch): + _prepare_doctor( + tmp_path, + monkeypatch, + AudioRuntime( + ffmpeg_path="/package/imageio_ffmpeg/ffmpeg", + ffmpeg_version="7.1", + ffmpeg_error=None, + miniaudio_version="1.71", + playback_backend="coreaudio", + playback_error=None, + ), + ) + + report = doctor.diagnose(ReadyModel(), "http://127.0.0.1:8765") + checks = {check["name"]: check for check in report["checks"]} + + assert report["ok"] is True + assert checks["compressed audio"]["status"] == "pass" + assert "bundled by imageio-ffmpeg" in checks["compressed audio"]["detail"] + assert checks["playback"] == { + "name": "playback", + "status": "pass", + "detail": "miniaudio 1.71 · coreaudio", + } + + +def test_missing_output_device_is_a_playback_warning(tmp_path, monkeypatch): + _prepare_doctor( + tmp_path, + monkeypatch, + AudioRuntime( + ffmpeg_path="/package/imageio_ffmpeg/ffmpeg", + ffmpeg_version="7.1", + ffmpeg_error=None, + miniaudio_version="1.71", + playback_backend=None, + playback_error="no suitable audio backend found", + ), + ) + + report = doctor.diagnose(ReadyModel(), "http://127.0.0.1:8765") playback = next(check for check in report["checks"] if check["name"] == "playback") + assert report["ok"] is True assert playback["status"] == "warn" - assert "experimental" in playback["detail"] - assert "not exercised by CI" in playback["detail"] + assert "output device unavailable" in playback["detail"] + + +def test_doctor_checks_the_configured_recording_directory(tmp_path, monkeypatch): + configured = tmp_path / "configured-recordings" + _prepare_doctor( + tmp_path, + monkeypatch, + AudioRuntime( + ffmpeg_path="/package/imageio_ffmpeg/ffmpeg", + ffmpeg_version="7.1", + ffmpeg_error=None, + miniaudio_version="1.71", + playback_backend="coreaudio", + playback_error=None, + ), + ) + update_defaults(output_dir=configured) + + report = doctor.diagnose(ReadyModel(), "http://127.0.0.1:8765") + recordings = next( + check for check in report["checks"] if check["name"] == "recordings" + ) + + assert recordings == { + "name": "recordings", + "status": "pass", + "detail": f"{configured} is writable", + } diff --git a/tests/test_engine.py b/tests/test_engine.py index 9e03033..8b7d5fd 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,12 +1,20 @@ from __future__ import annotations -import threading +from pathlib import Path import numpy as np import pytest -from kokoro_cli import engine as engine_module -from kokoro_cli.engine import SpeechEngine +from agent_voice import kokoro as kokoro_module +from agent_voice.kokoro import KokoroAdapter +from agent_voice.model import ( + ModelSelection, + NamedVoice, + ReferenceVoice, + SetupReceipt, + SynthesisRequest, + UnsupportedCapability, +) class FakeKokoro: @@ -21,32 +29,66 @@ def create(self, text, voice, speed, lang): return np.zeros(24_000, dtype=np.float32), 24_000 -def fake_engine(): - engine = object.__new__(SpeechEngine) - engine.variant = "int8" - engine._kokoro = FakeKokoro() - engine._lock = threading.Lock() - return engine +def fake_model(monkeypatch): + runtime = FakeKokoro() + model = KokoroAdapter( + ModelSelection("kokoro", "int8"), + runtime_factory=lambda model_path, voices_path: runtime, + ) + monkeypatch.setattr(model, "setup", lambda **kwargs: SetupReceipt(())) + return model, runtime -def test_speed_above_native_limit_uses_post_synthesis_tempo(monkeypatch): - engine = fake_engine() +@pytest.mark.parametrize("speed", [0.5, 0.75, 1.0, 1.5, 2.0, 4.0]) +def test_speed_uses_natural_synthesis_then_post_processing(monkeypatch, speed): + model, runtime = fake_model(monkeypatch) tempo_factors = [] def change_tempo(samples, sample_rate, factor): tempo_factors.append(factor) - return samples[::2] + return samples - monkeypatch.setattr(engine_module, "change_tempo", change_tempo) + monkeypatch.setattr(kokoro_module, "change_tempo", change_tempo) - speech = engine.synthesize("Fast speech", speed=4.0) + speech = model.synthesize( + SynthesisRequest( + "Tempo-adjusted speech", + voice=NamedVoice("af_heart"), + speed=speed, + language="en-us", + ) + ) - assert engine._kokoro.speed == 2.0 - assert tempo_factors == [2.0] - assert speech.duration_seconds == 0.5 + assert runtime.speed == 1.0 + assert tempo_factors == [speed] + assert speech.duration_seconds == 1.0 @pytest.mark.parametrize("speed", [0.49, 4.01]) -def test_speed_outside_supported_range_is_rejected(speed): +def test_speed_outside_supported_range_is_rejected(monkeypatch, speed): + model, _ = fake_model(monkeypatch) with pytest.raises(ValueError, match="between 0.5 and 4.0"): - fake_engine().synthesize("Invalid speed", speed=speed) + model.synthesize(SynthesisRequest("Invalid speed", speed=speed)) + + +def test_kokoro_rejects_reference_audio_through_the_model_interface( + monkeypatch, tmp_path +): + model, _ = fake_model(monkeypatch) + + with pytest.raises(UnsupportedCapability, match="named voices"): + model.synthesize( + SynthesisRequest( + "Reference audio is not a Kokoro capability", + voice=ReferenceVoice(Path(tmp_path / "voice.wav")), + ) + ) + + +def test_descriptor_separates_model_identity_from_variant(): + descriptor = KokoroAdapter(ModelSelection("kokoro", "fp16")).descriptor + + assert descriptor.selection.model_id == "kokoro" + assert descriptor.selection.variant == "fp16" + assert descriptor.display_name == "Kokoro-82M" + assert descriptor.runtime == "kokoro-onnx" diff --git a/tests/test_models.py b/tests/test_models.py index 1bee598..616fa91 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,40 +1,77 @@ from __future__ import annotations import hashlib +import io import threading -from kokoro_cli import models as models_module -from kokoro_cli.models import MODEL_ASSETS, model_paths, project_root +import pytest + +from agent_voice import kokoro as kokoro_module +from agent_voice import paths as paths_module +from agent_voice.kokoro import KOKORO_VARIANTS, KokoroAdapter +from agent_voice.model import ModelSelection +from agent_voice.paths import project_root +from agent_voice.registry import MODEL_REGISTRY def test_default_model_is_compact_int8(): - model, voices = model_paths() - assert model.name == "kokoro-v1.0.int8.onnx" - assert voices.name == "voices-v1.0.bin" - assert MODEL_ASSETS["int8"][1] < 100_000_000 - assert len(MODEL_ASSETS["int8"][2]) == 64 + descriptor = KokoroAdapter().descriptor + + assert descriptor.selection == ModelSelection("kokoro", "int8") + assert KOKORO_VARIANTS == ("int8", "fp16", "full") + + +def test_registry_is_the_model_composition_root(): + assert MODEL_REGISTRY.model_ids == ("kokoro",) + assert MODEL_REGISTRY.select() == ModelSelection("kokoro", "int8") + assert MODEL_REGISTRY.select("kokoro", "fp16") == ModelSelection("kokoro", "fp16") + assert isinstance( + MODEL_REGISTRY.create(ModelSelection("kokoro", "full")), + KokoroAdapter, + ) def test_unknown_model_is_rejected(): try: - model_paths("tiny") + KokoroAdapter(ModelSelection("kokoro", "tiny")) except ValueError as error: - assert "Unknown model variant" in str(error) + assert "Unknown Kokoro variant" in str(error) else: raise AssertionError("expected ValueError") +def test_setup_prepares_assets_once_per_adapter(tmp_path, monkeypatch): + monkeypatch.setenv("AGENT_VOICE_MODEL_DIR", str(tmp_path)) + downloads = [] + + def download(asset, destination, force): + downloads.append((asset[0], destination, force)) + + monkeypatch.setattr(kokoro_module, "_download_asset", download) + model = KokoroAdapter(ModelSelection("kokoro", "int8")) + + first = model.setup() + second = model.setup() + + assert first == second + assert [item[0] for item in downloads] == [ + "kokoro-v1.0.int8.onnx", + "voices-v1.0.bin", + ] + assert all(item[1].parent == tmp_path for item in downloads) + + def test_windows_uses_local_app_data(tmp_path, monkeypatch): - installed_module = tmp_path / "site-packages" / "kokoro_cli" / "models.py" + installed_module = tmp_path / "site-packages" / "agent_voice" / "paths.py" installed_module.parent.mkdir(parents=True) installed_module.touch() local_app_data = tmp_path / "LocalAppData" - monkeypatch.delenv("KOKORO_HOME", raising=False) + monkeypatch.delenv("AGENT_VOICE_HOME", raising=False) monkeypatch.setenv("LOCALAPPDATA", str(local_app_data)) - monkeypatch.setattr(models_module, "__file__", str(installed_module)) - monkeypatch.setattr(models_module.sys, "platform", "win32") + monkeypatch.setattr(paths_module, "__file__", str(installed_module)) + monkeypatch.setattr(paths_module.sys, "platform", "win32") - assert project_root() == (local_app_data / "kokoro").resolve() + assert project_root() == (local_app_data / "agent-voice").resolve() def test_concurrent_downloads_share_one_verified_asset(tmp_path, monkeypatch): @@ -71,12 +108,12 @@ def urlopen(*args, **kwargs): calls.append((args, kwargs)) return Response() - monkeypatch.setattr(models_module.urllib.request, "urlopen", urlopen) + monkeypatch.setattr(kokoro_module.urllib.request, "urlopen", urlopen) errors = [] def download(): try: - models_module._download_asset(asset, destination, False) + kokoro_module._download_asset(asset, destination, False) except Exception as error: # pragma: no cover - surfaced by assertion below errors.append(error) @@ -94,3 +131,27 @@ def download(): assert not second.is_alive() assert destination.read_bytes() == payload assert len(calls) == 1 + + +def test_failed_download_preserves_destination_and_removes_partial_file( + tmp_path, monkeypatch +): + destination = tmp_path / "tiny.onnx" + destination.write_bytes(b"existing model") + payload = b"incomplete" + asset = ( + destination.name, + len(payload) + 1, + hashlib.sha256(payload).hexdigest(), + ) + monkeypatch.setattr( + kokoro_module.urllib.request, + "urlopen", + lambda *args, **kwargs: io.BytesIO(payload), + ) + + with pytest.raises(RuntimeError, match="size mismatch"): + kokoro_module._download_asset(asset, destination, True) + + assert destination.read_bytes() == b"existing model" + assert list(tmp_path.glob("*.part")) == [] diff --git a/tests/test_service.py b/tests/test_service.py index 742d5ea..c9318a9 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,12 +1,44 @@ +import json +import subprocess import threading +import time +import urllib.error +import urllib.request +from contextlib import contextmanager import numpy as np import pytest -from kokoro_cli.client import ServiceUnavailable, health_check, request_speech -from kokoro_cli.config import update_defaults -from kokoro_cli.engine import Speech -from kokoro_cli.service import create_server, serve, validate_payload +from agent_voice import client, service +from agent_voice.client import ( + ServiceUnavailable, + ensure_service, + health_check, + request_speech, + validate_service_url, +) +from agent_voice.config import update_defaults +from agent_voice.model import ( + ModelDescriptor, + ModelSelection, + NamedVoice, + Speech, + VoiceCatalog, +) +from agent_voice.service import create_server, serve, validate_payload + + +@contextmanager +def _running_server(model): + server = create_server(model, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server, f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) def test_openai_shaped_payload_is_validated(): @@ -20,13 +52,25 @@ def test_openai_shaped_payload_is_validated(): def test_payload_uses_saved_defaults(tmp_path, monkeypatch): - monkeypatch.setenv("KOKORO_HOME", str(tmp_path)) - update_defaults(voice="bf_emma", speed=1.15) + monkeypatch.setenv("AGENT_VOICE_HOME", str(tmp_path)) + update_defaults(voice="bf_emma", speed=1.15, format="m4a") request = validate_payload({"input": "hello"}) assert request.voice == "bf_emma" assert request.speed == 1.15 + assert request.audio_format == "m4a" + + +def test_legacy_payload_aliases_are_not_used(tmp_path, monkeypatch): + monkeypatch.setenv("AGENT_VOICE_HOME", str(tmp_path)) + update_defaults(format="m4a") + + with pytest.raises(ValueError, match="input must be a string"): + validate_payload({"text": "hello"}) + + request = validate_payload({"input": "hello", "format": "wav"}) + assert request.audio_format == "m4a" @pytest.mark.parametrize( @@ -48,23 +92,140 @@ def test_remote_bind_is_rejected(): serve(object(), "0.0.0.0", 8765) +@pytest.mark.parametrize("port", (-1, 65_536, True, 1.5)) +def test_server_rejects_invalid_ports(port): + with pytest.raises(ValueError, match="Port must be an integer"): + create_server(object(), "127.0.0.1", port) + + +def test_serve_reports_the_actual_bound_port(monkeypatch, capsys): + events = [] + + class FakeServer: + server_port = 49_123 + + def serve_until_idle(self): + events.append("serve") + + def server_close(self): + events.append("close") + + monkeypatch.setattr(service, "create_server", lambda *args: FakeServer()) + + service.serve(object(), "127.0.0.1", 0) + + assert "http://127.0.0.1:49123" in capsys.readouterr().out + assert events == ["serve", "close"] + + +@pytest.mark.parametrize( + "service_url", + [ + "https://localhost:8765", + "http://example.com:8765", + "http://user@localhost:8765", + "http://localhost:8765/path", + "http://localhost:8765?query", + "http://localhost:8765#fragment", + "http://localhost:8765:extra", + "http://localhost:70000", + ], +) +def test_service_url_rejects_non_local_or_ambiguous_syntax(service_url): + with pytest.raises(ValueError, match="Agent Voice service URL"): + validate_service_url(service_url) + + +@pytest.mark.parametrize( + "host_template", + [ + "localhost:{port}?extra", + "localhost:{port}#fragment", + "user@localhost:{port}", + "localhost:{port}/path", + "localhost:{port}:extra", + "localhost:{port},attacker.example", + "localhost", + "localhost:{wrong_port}", + ], +) +def test_host_header_rejects_extra_or_mismatched_syntax(host_template): + with _running_server(object()) as (server, url): + port = server.server_port + request = urllib.request.Request( + f"{url}/health", + headers={ + "Host": host_template.format(port=port, wrong_port=port + 1), + }, + ) + with pytest.raises(urllib.error.HTTPError) as rejected: + urllib.request.urlopen(request, timeout=1) + assert rejected.value.code == 403 + assert json.loads(rejected.value.read())["error"] == "Host must be localhost" + + +def test_legacy_speak_endpoint_is_not_available(): + with _running_server(object()) as (_, url): + request = urllib.request.Request( + f"{url}/speak", + data=b'{"input":"hello"}', + headers={"Content-Type": "application/json"}, + method="POST", + ) + with pytest.raises(urllib.error.HTTPError) as rejected: + urllib.request.urlopen(request, timeout=1) + assert rejected.value.code == 404 + assert json.loads(rejected.value.read())["error"] == "Not found" + + def test_health_and_speech_contract(tmp_path): - class FakeEngine: - variant = "int8" + class FakeModel: + descriptor = ModelDescriptor( + selection=ModelSelection("test-model", "test-variant"), + display_name="Test Model", + runtime="test-runtime", + capabilities=frozenset(), + ) - def voices(self): - return ["af_heart"] + def voice_catalog(self): + return VoiceCatalog( + named=(NamedVoice("af_heart"),), + default=NamedVoice("af_heart"), + accepts_reference_audio=False, + ) - def synthesize(self, text, voice, speed, lang): - assert text == "hello from the client" + def synthesize(self, request): + assert request.text == "hello from the client" return Speech(np.zeros(2_400, dtype=np.float32), 24_000, 0.01) - server = create_server(FakeEngine(), "127.0.0.1", 0) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - url = f"http://127.0.0.1:{server.server_port}" - try: + with _running_server(FakeModel()) as (server, url): health = health_check(url) + hostile_host = urllib.request.Request( + f"{url}/health", + headers={"Host": f"attacker.example:{server.server_port}"}, + ) + with pytest.raises(urllib.error.HTTPError) as hostile: + urllib.request.urlopen(hostile_host, timeout=1) + ensure_service( + url, + ModelSelection("test-model", "test-variant"), + None, + ) + assert server.idle_timeout_seconds is None + ensure_service( + url, + ModelSelection("test-model", "test-variant"), + 2.5, + ) + assert server.idle_timeout_seconds == 150 + unsafe_request = urllib.request.Request( + f"{url}/v1/audio/speech", + data=b'{"input":"browser request"}', + headers={"Content-Type": "text/plain"}, + method="POST", + ) + with pytest.raises(urllib.error.HTTPError) as unsafe: + urllib.request.urlopen(unsafe_request, timeout=1) result = request_speech( url, "hello from the client", @@ -74,21 +235,122 @@ def synthesize(self, text, voice, speed, lang): 1.0, "en-us", ) + + assert health["service"] == "agent-voice" + assert health["engine"] == "test-runtime" + assert health["model"] == "Test Model" + assert health["model_id"] == "test-model" + assert health["variant"] == "test-variant" + assert health["service_mode"] == "on" + assert health["service_timeout_minutes"] is None + assert hostile.value.code == 403 + assert json.loads(hostile.value.read())["error"] == "Host must be localhost" + assert unsafe.value.code == 400 + assert ( + json.loads(unsafe.value.read())["error"] + == "Content-Type must be application/json" + ) + assert result.backend == "service" + assert result.speed == 1.0 + assert result.sample_rate == 24_000 + assert result.duration_seconds == 0.1 + assert (tmp_path / "service.wav").stat().st_size > 44 + + +def test_health_remains_available_while_speech_is_running(tmp_path): + synthesis_started = threading.Event() + finish_synthesis = threading.Event() + speech_errors = [] + + class BlockingModel: + descriptor = ModelDescriptor( + selection=ModelSelection("test-model", "test-variant"), + display_name="Test Model", + runtime="test-runtime", + capabilities=frozenset(), + ) + + def synthesize(self, request): + synthesis_started.set() + assert finish_synthesis.wait(timeout=2) + return Speech(np.zeros(2_400, dtype=np.float32), 24_000, 0.01) + + with _running_server(BlockingModel()) as (_, url): + + def speak(): + try: + request_speech( + url, + "hello from the client", + tmp_path / "service.wav", + "wav", + "af_heart", + 1.0, + "en-us", + ) + except Exception as error: # pragma: no cover - asserted below + speech_errors.append(error) + + worker = threading.Thread(target=speak, daemon=True) + worker.start() + try: + assert synthesis_started.wait(timeout=1) + health = health_check(url, timeout=0.5) + assert health["status"] == "ok" + finally: + finish_synthesis.set() + worker.join(timeout=2) + + assert not speech_errors + assert not worker.is_alive() + + +def test_idle_server_waits_for_an_active_request(): + request_started = threading.Event() + finish_request = threading.Event() + + class BlockingModel: + descriptor = ModelDescriptor( + selection=ModelSelection("test-model", "test-variant"), + display_name="Test Model", + runtime="test-runtime", + capabilities=frozenset(), + ) + + def voice_catalog(self): + request_started.set() + assert finish_request.wait(timeout=2) + return VoiceCatalog( + named=(NamedVoice("af_heart"),), + default=NamedVoice("af_heart"), + accepts_reference_audio=False, + ) + + server = create_server(BlockingModel(), "127.0.0.1", 0, idle_timeout_seconds=0.05) + thread = threading.Thread(target=server.serve_until_idle, daemon=True) + thread.start() + request = threading.Thread( + target=lambda: urllib.request.urlopen( + f"http://127.0.0.1:{server.server_port}/voices", timeout=2 + ).read(), + daemon=True, + ) + request.start() + try: + assert request_started.wait(timeout=1) + time.sleep(0.1) + assert thread.is_alive() finally: - server.shutdown() - server.server_close() + finish_request.set() + request.join(timeout=2) thread.join(timeout=2) + server.server_close() - assert health["service"] == "kokoro" - assert health["variant"] == "int8" - assert result["backend"] == "service" - assert result["speed"] == 1.0 - assert result["sample_rate"] == 24_000 - assert result["duration_seconds"] == 0.1 - assert (tmp_path / "service.wav").stat().st_size > 44 + assert not request.is_alive() + assert not thread.is_alive() -def test_health_rejects_a_non_kokoro_service(monkeypatch): +def test_health_rejects_a_non_agent_voice_service(monkeypatch): class Response: def __enter__(self): return self @@ -102,3 +364,219 @@ def read(self): monkeypatch.setattr("urllib.request.urlopen", lambda *args, **kwargs: Response()) with pytest.raises(ServiceUnavailable, match="invalid response"): health_check("http://127.0.0.1:8765") + + +def test_idle_server_stops_after_timeout(): + class FakeModel: + pass + + server = create_server(FakeModel(), "127.0.0.1", 0, idle_timeout_seconds=0.05) + thread = threading.Thread(target=server.serve_until_idle, daemon=True) + thread.start() + thread.join(timeout=1) + server.server_close() + + assert not thread.is_alive() + + +@pytest.mark.parametrize( + ("idle_timeout", "message"), + [ + (2.5, "2.5 minute idle timeout"), + (None, "no idle timeout"), + ], +) +def test_service_starts_detached_and_waits_for_health( + tmp_path, monkeypatch, capsys, idle_timeout, message +): + monkeypatch.setenv("AGENT_VOICE_HOME", str(tmp_path)) + checks = 0 + captured = {} + + def check(*args, **kwargs): + nonlocal checks + checks += 1 + if checks == 1: + raise ServiceUnavailable("not running") + return { + "status": "ok", + "service": "agent-voice", + "model_id": "kokoro", + "variant": "int8", + } + + class Process: + returncode = None + + def poll(self): + return None + + def popen(command, **options): + captured["command"] = command + captured["options"] = options + return Process() + + monkeypatch.setattr(client, "health_check", check) + configured = [] + monkeypatch.setattr( + client, + "_configure_service_lifecycle", + lambda url, timeout: configured.append((url, timeout)), + ) + monkeypatch.setattr(client.subprocess, "Popen", popen) + monkeypatch.setattr(client.time, "sleep", lambda _: None) + + result = ensure_service( + "http://127.0.0.1:9876", + ModelSelection("kokoro", "int8"), + idle_timeout, + startup_timeout=1, + ) + + assert result["status"] == "ok" + assert configured == [("http://127.0.0.1:9876", idle_timeout)] + assert message in capsys.readouterr().err + expected_command = [ + captured["command"][0], + "-m", + "agent_voice", + "serve", + "--host", + "127.0.0.1", + "--port", + "9876", + "--model-id", + "kokoro", + "--variant", + "int8", + ] + if idle_timeout is not None: + expected_command.extend(["--idle-timeout", str(idle_timeout)]) + assert captured["command"] == expected_command + assert captured["options"]["stdin"] is subprocess.DEVNULL + assert captured["options"]["stdout"] is subprocess.DEVNULL + assert captured["options"]["stderr"] is subprocess.DEVNULL + + +def test_failed_service_startup_terminates_the_detached_process(tmp_path, monkeypatch): + monkeypatch.setenv("AGENT_VOICE_HOME", str(tmp_path)) + monkeypatch.setattr( + client, + "health_check", + lambda *args, **kwargs: (_ for _ in ()).throw( + ServiceUnavailable("not running") + ), + ) + times = iter((0.0, 2.0)) + monkeypatch.setattr(client.time, "monotonic", lambda: next(times)) + events = [] + + class Process: + returncode = None + + def poll(self): + return None + + def terminate(self): + events.append("terminate") + + def wait(self, timeout): + events.append(("wait", timeout)) + return 0 + + monkeypatch.setattr(client.subprocess, "Popen", lambda *args, **kwargs: Process()) + + with pytest.raises(ServiceUnavailable, match="did not become ready"): + ensure_service( + "http://127.0.0.1:9876", + ModelSelection("kokoro", "int8"), + 2.5, + startup_timeout=1, + ) + + assert events == ["terminate", ("wait", 2)] + + +def test_hung_service_process_is_killed_after_termination_timeout(): + events = [] + + class Process: + def poll(self): + return None + + def terminate(self): + events.append("terminate") + + def wait(self, timeout): + events.append(("wait", timeout)) + if events.count(("wait", timeout)) == 1: + raise subprocess.TimeoutExpired("agent-voice", timeout) + return 0 + + def kill(self): + events.append("kill") + + client._terminate_process(Process()) + + assert events == [ + "terminate", + ("wait", 2), + "kill", + ("wait", 2), + ] + + +def test_running_service_with_another_model_is_not_reused(monkeypatch): + monkeypatch.setattr( + client, + "health_check", + lambda *args, **kwargs: { + "status": "ok", + "service": "agent-voice", + "model_id": "kokoro", + "variant": "int8", + }, + ) + monkeypatch.setattr( + client.subprocess, + "Popen", + lambda *args, **kwargs: pytest.fail("mismatched service must not be reused"), + ) + + with pytest.raises(ServiceUnavailable, match="requested kokoro/fp16"): + ensure_service( + "http://127.0.0.1:9876", + ModelSelection("kokoro", "fp16"), + 2.5, + startup_timeout=1, + ) + + +def test_speech_request_rejects_a_mismatched_running_model(tmp_path, monkeypatch): + monkeypatch.setattr( + client, + "health_check", + lambda *args, **kwargs: { + "status": "ok", + "service": "agent-voice", + "model_id": "kokoro", + "variant": "int8", + }, + ) + monkeypatch.setattr( + client.urllib.request, + "urlopen", + lambda *args, **kwargs: pytest.fail("mismatch must fail before synthesis"), + ) + + with pytest.raises(ServiceUnavailable, match="requested kokoro/fp16"): + request_speech( + "http://127.0.0.1:9876", + "hello", + tmp_path / "speech.wav", + "wav", + "af_heart", + 1.0, + "en-us", + selection=ModelSelection("kokoro", "fp16"), + ) diff --git a/tests/test_skills.py b/tests/test_skills.py new file mode 100644 index 0000000..ba550bb --- /dev/null +++ b/tests/test_skills.py @@ -0,0 +1,105 @@ +from pathlib import Path +from string import Template + + +PROJECT = Path(__file__).parents[1] + + +def test_create_speech_recording_skill_matches_cli_and_delivery_contract(): + skill_root = PROJECT / "skills/create-speech-recording" + skill = (skill_root / "SKILL.md").read_text(encoding="utf-8") + fallback = (skill_root / "references/recording-delivery.md").read_text( + encoding="utf-8" + ) + + assert "name: create-speech-recording" in skill + assert 'agent-voice speak "Text to record"' in skill + assert 'agent-voice speak --label "$LABEL" < "$TEXT_FILE"' in skill + assert "speak --json" not in skill + assert "delivery.fallback_markdown" not in skill + assert "references/recording-delivery.md" in skill + assert "references/recording.md" not in skill + assert "fallback-response.md" not in skill + assert "fallback-response-local.md" not in skill + assert "delivery.play_command" not in skill + assert "render the returned `path`" in skill + assert "`played: true`" in skill + assert "/Users/" not in skill + assert "agent-voice serve" not in skill + assert "$browser_url" in fallback and "$audio_url" in fallback + assert "$file_uri" in fallback and "$path" in fallback + assert "$RECORDING_NAME" not in fallback + assert "$PLAY_COMMAND" not in fallback + + +def test_spoken_response_skill_matches_thread_modes_and_delivery_contract(): + skill_root = PROJECT / "skills/spoken-response" + skill = (skill_root / "SKILL.md").read_text(encoding="utf-8") + metadata = (skill_root / "agents/openai.yaml").read_text(encoding="utf-8") + + assert "name: spoken-response" in skill + assert "spoken semantic twin" in skill + assert 'agent-voice speak --label "$LABEL" < "$TEXT_FILE"' in skill + assert "$FINAL_RESPONSE" not in skill + assert "`Thread`" in skill + assert "delivery.fallback_markdown" not in skill + assert "references/recording-delivery.md" in skill + assert "references/recording.md" not in skill + assert "fallback-response.md" not in skill + assert "fallback-response-local.md" not in skill + assert "delivery.play_command" not in skill + assert "render the returned" in skill and "`path`" in skill + assert "--speed" not in skill and "--service" not in skill + assert "allow_implicit_invocation: true" in metadata + + +def test_independently_installable_skills_own_matching_fallback_references(): + agent_voice = PROJECT / "skills/create-speech-recording/references" + spoken_responses = PROJECT / "skills/spoken-response/references" + + assert (agent_voice / "recording-delivery.md").read_text(encoding="utf-8") == ( + spoken_responses / "recording-delivery.md" + ).read_text(encoding="utf-8") + + +def test_fallback_references_render_only_structured_receipt_values(): + references = PROJECT / "skills/create-speech-recording/references" + common = { + "file_uri": "file:///recordings/daily-update.mp3", + "path": "/recordings/daily-update.mp3", + } + + fallback = (references / "recording-delivery.md").read_text(encoding="utf-8") + viewer = Template(fallback).substitute( + **common, + browser_url="http://127.0.0.1:8779/player/daily-update.html", + audio_url="http://127.0.0.1:8779/recordings/daily-update.mp3", + ) + local = Template( + fallback.replace("[web player]($browser_url) · ", "").replace( + " · [web audio]($audio_url)", "" + ) + ).substitute(**common) + + assert "$" not in viewer + assert "[web player](http://127.0.0.1:8779/player/daily-update.html)" in viewer + assert "[web audio](http://127.0.0.1:8779/recordings/daily-update.mp3)" in viewer + assert "$" not in local + assert "[media app](file:///recordings/daily-update.mp3)" in local + assert 'agent-voice play "/recordings/daily-update.mp3"' in local + assert "[web player]" not in local + assert "[web audio]" not in local + + +def test_readme_uses_the_published_skill_install_commands(): + readme = (PROJECT / "README.md").read_text(encoding="utf-8") + + assert ( + "npx skills add yoav0gal/agent-voice --skill create-speech-recording " + "--global --agent codex --yes" + ) in readme + assert ( + "npx skills add yoav0gal/agent-voice --skill spoken-response " + "--global --agent codex --yes" + ) in readme + assert "skills/read-aloud" not in readme diff --git a/tests/test_speaking.py b/tests/test_speaking.py new file mode 100644 index 0000000..a4c2da6 --- /dev/null +++ b/tests/test_speaking.py @@ -0,0 +1,612 @@ +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime + +import pytest + +from agent_voice.client import ServiceUnavailable +from agent_voice.config import ServiceDefaults, SpeechDefaults, update_defaults +from agent_voice.delivery import Delivery +from agent_voice.model import ModelSelection, Recording +from agent_voice.speaking import SpeakReceipt, SpeakRequest, Speaker + + +SELECTION = ModelSelection("kokoro", "int8") + + +class FakeGenerator: + def __init__( + self, + *, + backend: str, + audio: bytes = b"recording", + error: Exception | None = None, + ) -> None: + self.backend = backend + self.audio = audio + self.error = error + self.requests = [] + + def generate(self, request): + self.requests.append(request) + if self.error is not None: + raise self.error + destination = request.output.destination.expanduser().resolve() + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(self.audio) + return Recording( + path=destination, + format=request.output.audio_format, + voice=request.voice, + speed=request.speed, + sample_rate=24_000, + duration_seconds=1.0, + generation_seconds=0.1, + backend=self.backend, + ) + + +def delivery_success(calls: list | None = None): + def prepare(recording, text, *, audio_format, recordings_dir): + if calls is not None: + calls.append((recording, text, audio_format, recordings_dir)) + resolved = recording.resolve() + return Delivery( + browser_url="http://127.0.0.1:49123/player/recording.html", + audio_url="http://127.0.0.1:49123/recordings/recording.mp3", + recording_path=resolved, + ) + + return prepare + + +def make_speaker( + tmp_path, + *, + defaults: SpeechDefaults | None = None, + embedded: FakeGenerator | None = None, + service: FakeGenerator | None = None, + playback=None, + delivery=None, + notices: list[str] | None = None, + now=None, +): + selected_defaults = defaults or SpeechDefaults( + service=ServiceDefaults("off", None), + output_dir=str(tmp_path), + ) + return Speaker( + defaults_loader=lambda: selected_defaults, + embedded=embedded or FakeGenerator(backend="local"), + service=service or FakeGenerator(backend="service"), + playback=playback or (lambda _path: None), + delivery=delivery or delivery_success(), + notice=(notices.append if notices is not None else lambda _message: None), + now=now or (lambda: datetime(2026, 7, 26, 10, 13)), + ) + + +def test_service_off_uses_embedded_generation(tmp_path): + embedded = FakeGenerator(backend="local") + service = FakeGenerator(backend="service") + receipt = make_speaker( + tmp_path, + embedded=embedded, + service=service, + ).speak(SpeakRequest("Visible text.", SELECTION, service="off")) + + assert receipt.recording.backend == "local" + assert receipt.recording.path.read_bytes() == b"recording" + assert len(embedded.requests) == 1 + assert service.requests == [] + assert receipt.service_fallback is False + + +def test_service_generation_uses_resolved_timed_policy(tmp_path): + service = FakeGenerator(backend="service", audio=b"service") + defaults = SpeechDefaults( + service=ServiceDefaults("timed", 2.5), + output_dir=str(tmp_path), + ) + + receipt = make_speaker( + tmp_path, + defaults=defaults, + service=service, + ).speak(SpeakRequest("Visible text.", SELECTION)) + + assert receipt.recording.backend == "service" + assert receipt.recording.path.read_bytes() == b"service" + assert service.requests[0].service == "timed" + assert service.requests[0].service_timeout_minutes == 2.5 + + +def test_on_service_uses_no_idle_timeout(tmp_path): + service = FakeGenerator(backend="service") + + make_speaker(tmp_path, service=service).speak( + SpeakRequest("Visible text.", SELECTION, service="on") + ) + + assert service.requests[0].service_timeout_minutes is None + + +def test_unavailable_service_falls_back_to_embedded(tmp_path): + service = FakeGenerator( + backend="service", + error=ServiceUnavailable("not running"), + ) + embedded = FakeGenerator(backend="local", audio=b"local") + notices = [] + + receipt = make_speaker( + tmp_path, + embedded=embedded, + service=service, + notices=notices, + ).speak(SpeakRequest("Visible text.", SELECTION, service="timed")) + + assert receipt.recording.backend == "local" + assert receipt.service_fallback is True + assert receipt.recording.path.read_bytes() == b"local" + assert notices == [ + "Agent Voice service unavailable; using embedded inference (not running)" + ] + + +def test_non_availability_service_errors_do_not_fallback(tmp_path): + service = FakeGenerator(backend="service", error=ValueError("bad request")) + embedded = FakeGenerator(backend="local") + + with pytest.raises(ValueError, match="bad request"): + make_speaker( + tmp_path, + embedded=embedded, + service=service, + ).speak(SpeakRequest("Visible text.", SELECTION, service="on")) + + assert embedded.requests == [] + + +def test_saved_defaults_and_request_values_resolve_once(tmp_path): + embedded = FakeGenerator(backend="local") + defaults = SpeechDefaults( + voice="bf_emma", + speed=1.15, + format="opus", + service=ServiceDefaults("off", None), + output_dir=str(tmp_path), + ) + + receipt = make_speaker( + tmp_path, + defaults=defaults, + embedded=embedded, + ).speak(SpeakRequest("Visible text.", SELECTION)) + + resolved = embedded.requests[0] + assert (resolved.voice, resolved.speed) == ("bf_emma", 1.15) + assert receipt.recording.format == "opus" + assert receipt.recording.path.suffix == ".opus" + + +def test_request_values_override_saved_defaults(tmp_path): + embedded = FakeGenerator(backend="local") + defaults = SpeechDefaults( + voice="af_heart", + speed=1.0, + format="mp3", + service=ServiceDefaults("timed", 10), + output_dir=str(tmp_path / "configured"), + ) + command_line = tmp_path / "command-line" + + receipt = make_speaker( + tmp_path, + defaults=defaults, + embedded=embedded, + ).speak( + SpeakRequest( + "Visible text.", + SELECTION, + output_dir=command_line, + format="wav", + voice="bf_emma", + speed=1.25, + service="off", + ) + ) + + resolved = embedded.requests[0] + assert (resolved.voice, resolved.speed, resolved.service) == ( + "bf_emma", + 1.25, + "off", + ) + assert receipt.recording.path.parent == command_line + assert receipt.recording.path.suffix == ".wav" + + +def test_environment_recording_root_overrides_config(tmp_path, monkeypatch): + environment = tmp_path / "environment" + configured = tmp_path / "configured" + monkeypatch.setenv("AGENT_VOICE_RECORDING_DIR", str(environment)) + calls = [] + defaults = SpeechDefaults( + service=ServiceDefaults("off", None), + output_dir=str(configured), + ) + + receipt = make_speaker( + tmp_path, + defaults=defaults, + delivery=delivery_success(calls), + ).speak(SpeakRequest("Visible text.", SELECTION)) + + assert receipt.recording.path.parent == environment + assert calls[0][3] == environment + assert not configured.exists() + + +def test_live_config_cli_and_environment_precedence(tmp_path, monkeypatch): + home = tmp_path / "home" + configured = tmp_path / "configured" + environment = tmp_path / "environment" + command_line = tmp_path / "command-line" + monkeypatch.setenv("AGENT_VOICE_HOME", str(home)) + update_defaults( + voice="bf_emma", + speed=1.15, + format="opus", + service_mode="off", + output_dir=configured, + ) + monkeypatch.setenv("AGENT_VOICE_RECORDING_DIR", str(environment)) + calls = [] + + receipt = Speaker( + embedded=FakeGenerator(backend="local"), + service=FakeGenerator(backend="service"), + delivery=delivery_success(calls), + notice=lambda _message: None, + now=lambda: datetime(2026, 7, 26, 10, 13), + ).speak( + SpeakRequest( + "Visible text.", + SELECTION, + output_dir=command_line, + format="wav", + voice="af_nova", + service="off", + ) + ) + + assert receipt.recording.path.parent == command_line + assert receipt.recording.format == "wav" + assert receipt.recording.voice == "af_nova" + assert receipt.recording.speed == 1.15 + assert calls[0][3] == environment + + +def test_exact_output_takes_precedence_and_extension_selects_format(tmp_path): + output = tmp_path / "exact.wav" + ignored = tmp_path / "ignored" + notices = [] + + receipt = make_speaker(tmp_path, notices=notices).speak( + SpeakRequest( + "Visible text.", + SELECTION, + output=output, + label="ignored", + output_dir=ignored, + format="mp3", + service="off", + ) + ) + + assert receipt.recording.path == output + assert receipt.recording.format == "wav" + assert not ignored.exists() + assert notices == [ + "Warning: --output specifies the exact destination; " + "ignoring --label and --output-dir" + ] + + +def test_extensionless_exact_output_uses_selected_format(tmp_path): + output = tmp_path / "exact" + + receipt = make_speaker(tmp_path).speak( + SpeakRequest( + "Visible text.", + SELECTION, + output=output, + format="m4a", + service="off", + ) + ) + + assert receipt.recording.path == output + assert receipt.recording.format == "m4a" + + +@pytest.mark.parametrize("audio_format", ("wav", "mp3", "opus", "m4a")) +def test_managed_output_supports_each_public_format(tmp_path, audio_format): + receipt = make_speaker(tmp_path).speak( + SpeakRequest( + "Visible text.", + SELECTION, + format=audio_format, + service="off", + ) + ) + + assert receipt.recording.format == audio_format + assert receipt.recording.path.suffix == f".{audio_format}" + + +def test_managed_output_uses_portable_label_and_collision_suffix(tmp_path): + existing = tmp_path / "Daily-update-07-26-26-at-10-13.mp3" + existing.write_bytes(b"existing") + + receipt = make_speaker(tmp_path).speak( + SpeakRequest( + "Visible text.", + SELECTION, + label="Daily update!", + service="off", + ) + ) + + assert receipt.recording.path.name == "Daily-update-07-26-26-at-10-13-2.mp3" + assert existing.read_bytes() == b"existing" + + +def test_managed_reservations_are_unique_across_speakers(tmp_path): + def speak(_index): + return ( + make_speaker(tmp_path) + .speak(SpeakRequest("Visible text.", SELECTION, label="SR", service="off")) + .recording.path + ) + + with ThreadPoolExecutor(max_workers=8) as executor: + paths = list(executor.map(speak, range(8))) + + assert len(set(paths)) == 8 + assert all(path.is_file() for path in paths) + + +def test_failed_managed_generation_removes_reservation(tmp_path): + embedded = FakeGenerator(backend="local", error=RuntimeError("failed")) + + with pytest.raises(RuntimeError, match="failed"): + make_speaker(tmp_path, embedded=embedded).speak( + SpeakRequest("Visible text.", SELECTION, label="SR", service="off") + ) + + assert list(tmp_path.iterdir()) == [] + + +def test_failed_exact_generation_preserves_existing_destination(tmp_path): + output = tmp_path / "existing.mp3" + output.write_bytes(b"existing") + embedded = FakeGenerator(backend="local", error=RuntimeError("failed")) + + with pytest.raises(RuntimeError, match="failed"): + make_speaker(tmp_path, embedded=embedded).speak( + SpeakRequest( + "Visible text.", + SELECTION, + output=output, + service="off", + ) + ) + + assert output.read_bytes() == b"existing" + + +@pytest.mark.parametrize( + ("path_change", "format_change", "message"), + [ + (True, False, "unexpected recording path"), + (False, True, "unexpected recording format"), + ], +) +def test_backend_must_honor_the_single_output_plan( + tmp_path, + path_change, + format_change, + message, +): + class DriftingGenerator(FakeGenerator): + def generate(self, request): + recording = super().generate(request) + return Recording( + path=( + recording.path.with_name("other.mp3") + if path_change + else recording.path + ), + format="wav" if format_change else recording.format, + voice=recording.voice, + speed=recording.speed, + sample_rate=recording.sample_rate, + duration_seconds=recording.duration_seconds, + generation_seconds=recording.generation_seconds, + backend=recording.backend, + ) + + with pytest.raises(RuntimeError, match=message): + make_speaker( + tmp_path, + embedded=DriftingGenerator(backend="local"), + ).speak(SpeakRequest("Visible text.", SELECTION, service="off")) + + assert list(tmp_path.iterdir()) == [] + + +def test_played_becomes_true_only_after_playback_returns(tmp_path): + events = [] + + def playback(path): + events.append(path) + + receipt = make_speaker(tmp_path, playback=playback).speak( + SpeakRequest("Visible text.", SELECTION, play=True, service="off") + ) + + assert events == [receipt.recording.path] + assert receipt.played is True + + +def test_playback_failure_does_not_produce_a_truthful_receipt(tmp_path): + def fail(_path): + raise RuntimeError("no audio device") + + with pytest.raises(RuntimeError, match="no audio device"): + make_speaker(tmp_path, playback=fail).speak( + SpeakRequest("Visible text.", SELECTION, play=True, service="off") + ) + + +def test_delivery_receives_the_planned_recording_root(tmp_path): + calls = [] + recording_root = tmp_path / "managed" + output = tmp_path / "external" / "response.mp3" + defaults = SpeechDefaults( + service=ServiceDefaults("off", None), + output_dir=str(recording_root), + ) + + receipt = make_speaker( + tmp_path, + defaults=defaults, + delivery=delivery_success(calls), + ).speak( + SpeakRequest( + "Visible text.", + SELECTION, + output=output, + service="off", + ) + ) + + assert calls == [(output, "Visible text.", "mp3", recording_root)] + assert receipt.delivery.browser_url is not None + + +def test_delivery_failure_facts_are_typed_serialized_and_reported(tmp_path): + notices = [] + + def fallback(recording, text, *, audio_format, recordings_dir): + return Delivery(warning="viewer unavailable") + + receipt = make_speaker( + tmp_path, + delivery=fallback, + notices=notices, + ).speak(SpeakRequest("Visible text.", SELECTION, service="off")) + + assert receipt.to_dict()["delivery"] == {} + assert notices == ["Warning: viewer unavailable"] + + +def test_receipt_serialization_preserves_public_json_shape(tmp_path): + recording = Recording( + path=tmp_path / "recording.mp3", + format="mp3", + voice="af_heart", + speed=1.0, + sample_rate=24_000, + duration_seconds=1.234, + generation_seconds=0.456, + backend="service", + ) + delivery = Delivery( + browser_url="http://127.0.0.1:49123/player/recording.html", + audio_url="http://127.0.0.1:49123/recordings/recording.mp3", + recording_path=recording.path, + ) + + receipt = SpeakReceipt( + recording=recording, + selection=ModelSelection("kokoro", "fp16"), + played=False, + delivery=delivery, + service_fallback=True, + ) + + assert receipt.to_dict() == { + "path": str(recording.path), + "format": "mp3", + "voice": "af_heart", + "speed": 1.0, + "sample_rate": 24_000, + "duration_seconds": 1.234, + "generation_seconds": 0.456, + "backend": "service", + "model_id": "kokoro", + "variant": "fp16", + "played": False, + "service_fallback": True, + "file_uri": recording.path.as_uri(), + "delivery": { + "browser_url": "http://127.0.0.1:49123/player/recording.html", + "audio_url": "http://127.0.0.1:49123/recordings/recording.mp3", + "recording_path": str(recording.path), + }, + } + + +def test_service_timeout_requires_timed_mode(tmp_path): + with pytest.raises(ValueError, match="can only be used"): + make_speaker(tmp_path).speak( + SpeakRequest( + "Visible text.", + SELECTION, + service="on", + service_timeout_minutes=2.5, + ) + ) + + +def test_public_interface_rejects_unknown_service_or_format(tmp_path): + with pytest.raises(ValueError, match="Service mode"): + make_speaker(tmp_path).speak( + SpeakRequest("Visible text.", SELECTION, service="sometimes") + ) + with pytest.raises(ValueError, match="Output format"): + make_speaker(tmp_path).speak( + SpeakRequest( + "Visible text.", + SELECTION, + format="flac", + service="off", + ) + ) + + +@pytest.mark.parametrize( + ("label", "expected"), + [ + ("Review CLI status - SR", "Review-CLI-status-SR"), + (" spaced / punctuation ", "spaced-punctuation"), + ("A" * 60, "A" * 48), + ], +) +def test_managed_label_is_portable_and_bounded(tmp_path, label, expected): + receipt = make_speaker(tmp_path).speak( + SpeakRequest("Visible text.", SELECTION, label=label, service="off") + ) + + assert receipt.recording.path.name.startswith(f"{expected}-") + + +def test_managed_label_rejects_values_without_ascii_letters_or_numbers(tmp_path): + with pytest.raises(ValueError, match="at least one ASCII"): + make_speaker(tmp_path).speak( + SpeakRequest("Visible text.", SELECTION, label="🎙️ ---", service="off") + ) diff --git a/tests/test_viewer.py b/tests/test_viewer.py new file mode 100644 index 0000000..34b63c0 --- /dev/null +++ b/tests/test_viewer.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import errno +import json +import socket +import threading +import urllib.error +import urllib.request +from contextlib import contextmanager +from pathlib import Path + +import pytest + +from agent_voice.viewer import ( + Viewer, + ensure_viewer, + publish_player, + publish_recording, + recording_urls, + stop_viewer, + transcript_path, +) +from agent_voice import viewer_server +from agent_voice.viewer_server import DEFAULT_VIEWER_PORT, Server + + +@contextmanager +def _running_viewer(recordings: Path): + server = Server(recordings) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server, f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +@pytest.mark.parametrize( + ("name", "content_type"), + [ + ("sample.wav", "audio/wav"), + ("sample.mp3", "audio/mpeg"), + ("sample.opus", "audio/ogg"), + ("sample.m4a", "audio/mp4"), + ], +) +def test_viewer_serves_supported_audio_and_dynamic_player(tmp_path, name, content_type): + recording = tmp_path / name + recording.write_bytes(b"0123456789") + player_name = publish_player( + recording, + 'Visible response.', + ) + + with _running_viewer(tmp_path) as (_, url): + with urllib.request.urlopen(f"{url}/recordings/{name}") as response: + assert response.status == 200 + assert response.headers["Content-Type"] == content_type + assert response.read() == b"0123456789" + + with urllib.request.urlopen(f"{url}/player/{player_name}") as response: + document = response.read().decode() + assert response.headers["Content-Type"] == "text/html; charset=utf-8" + assert '