diff --git a/.github/scripts/verify_package.py b/.github/scripts/verify_package.py index 8732d43..f3b1cbb 100644 --- a/.github/scripts/verify_package.py +++ b/.github/scripts/verify_package.py @@ -73,6 +73,56 @@ def check_doctor(report: dict[str, object], service_status: str) -> None: assert checks["service"]["status"] == service_status +def verify_controls(cli: Path, system: str, output_dir: Path) -> None: + if system not in {"Linux", "Windows"}: + return + if system == "Linux": + os.environ["XDG_DATA_HOME"] = str(output_dir / "xdg-data") + os.environ["XDG_CONFIG_HOME"] = str(output_dir / "xdg-config") + + installed = run_cli(cli, "controls", "install", "--json") + assert installed["installed"] is True + assert installed["scheme"] == "agent-voice" + if system == "Linux": + desktop = Path(str(installed["path"])) + contents = desktop.read_text(encoding="utf-8") + assert "X-Agent-Voice-Owned=true" in contents + assert "-m agent_voice control-url %u" in contents + default = subprocess.run( + ["xdg-mime", "query", "default", "x-scheme-handler/agent-voice"], + check=True, + capture_output=True, + text=True, + ) + assert default.stdout.strip() == desktop.name + else: + import winreg + + base = r"Software\Classes\agent-voice" + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, base) as key: + assert winreg.QueryValueEx(key, "URL Protocol")[0] == "" + assert winreg.QueryValueEx(key, "AgentVoiceOwned")[0] == "1" + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, rf"{base}\shell\open\command" + ) as key: + command = winreg.QueryValueEx(key, None)[0] + assert "-m agent_voice control-url" in command + assert '"%1"' in command + + removed = run_cli(cli, "controls", "uninstall", "--json") + assert removed["removed"] is True + if system == "Linux": + assert not Path(str(installed["path"])).exists() + else: + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, base): + pass + except FileNotFoundError: + pass + else: + raise AssertionError("Windows control handler was not removed") + + def main() -> None: cli = Path(sys.argv[1]).resolve() system = platform.system() @@ -88,21 +138,19 @@ def main() -> None: cli, "speak", f"{system} generation verification.", - "--service", - "off", + "--no-service", "--output", str(local_wav), ) assert local["backend"] == "local" - assert local["played"] is False + assert "playback" not in local validate_wav(local_wav) labeled = run_cli( cli, "speak", f"{system} labeled speed verification.", - "--service", - "off", + "--no-service", "--label", "Package E2E", "--format", @@ -122,6 +170,7 @@ def main() -> None: == (Path(os.environ["AGENT_VOICE_HOME"]) / "recordings").resolve() ) validate_mp3(labeled_path) + verify_controls(cli, system, output_dir) log_path = output_dir / "service.log" with log_path.open("w", encoding="utf-8") as log: @@ -156,15 +205,13 @@ def main() -> None: cli, "speak", f"{system} localhost service verification.", - "--service", - "on", "--service-url", SERVICE_URL, "--output", str(service_wav), ) assert remote["backend"] == "service" - assert remote["played"] is False + assert "playback" not in remote validate_wav(service_wav) finally: service.terminate() diff --git a/.gitignore b/.gitignore index b2aebcf..4a3de57 100644 --- a/.gitignore +++ b/.gitignore @@ -12,10 +12,7 @@ config.json service-start.lock viewer.lock viewer.json -models/*.onnx -models/*.bin -models/*.lock -recordings/* +models/ +recordings/ IDEAS.md -!models/.gitkeep -!recordings/.gitkeep +launch-video/ diff --git a/README.md b/README.md index 709aac2..05cb9c3 100644 --- a/README.md +++ b/README.md @@ -33,63 +33,94 @@ Brazilian Portuguese, though quality varies. ## Install and setup -Install the CLI and download the speech model: +Install the CLI with `uv`: ```sh uv tool install agent-voice -agent-voice setup ``` -### Choose a delivery method +Or with `pipx`: + +```sh +pipx install agent-voice +``` -For desktop applications, the recommended way is to use the `-desktop` skills. -They embed a media player directly in the answer and currently support -Antigravity, Codex, and OpenCode: +Then download the speech model: ```sh -npx skills add yoav0gal/agent-voice --skill create-speech-recording-desktop --global -npx skills add yoav0gal/agent-voice --skill spoken-response-desktop --global +agent-voice setup ``` -For CLIs and portable Markdown delivery, install the normal skills: +For setup with experimental playback controls, see +[Experimental desktop playback controls](#experimental-desktop-playback-controls). + +Test the CLI directly: ```sh -npx skills add yoav0gal/agent-voice --skill create-speech-recording --global -npx skills add yoav0gal/agent-voice --skill spoken-response --global +agent-voice speak "Hello from Agent Voice." -p ``` -The normal skills deliver three links: +### Choose a skill + +> [!Note] +> **The skills are starting points. Copy them to customize delivery wording, +> recording defaults, playback behavior, or when an agent should offer audio to +> your liking!** + +Agent Voice provides two kinds of skill: + +- **`create-speech-recording`** turns supplied text into audio. Use it to create + a recording or read something aloud. +- **`spoken-response`** creates the spoken semantic twin of an assistant + response. It can speak the current response, the previous response, or later + responses in the thread. + +Choose how the recording should appear in your agent: + +| Variant | Delivery format | Supported surfaces | +| --- | --- | --- | +| Normal | Portable Markdown links to the web player, media app, and web audio | Anywhere basic links can be clicked | +| `-desktop` | Embedded native or HTML audio player | Codex Desktop, Antigravity, and OpenCode Desktop | +| ⚠️ `-controls` ⚠️ | Clickable `agent-voice://` playback links with a web-player fallback | Compatible desktop renderers on macOS, Linux, and Windows | + +For portable delivery, install the normal skills: + +```sh +npx skills add yoav0gal/agent-voice -g --skill create-speech-recording +npx skills add yoav0gal/agent-voice -g --skill spoken-response +``` -- **web player** — the recommended option; open it in an application browser. - In coding-agent apps, this view includes the written answer too. -- **media app** — opens the linked recording in your default media app. -- **web audio** — opens the audio directly and should start playing - automatically. +

+ Portable Agent Voice delivery with listening links and a terminal playback command +

-Test it: +For an embedded player in a supported desktop app, install the desktop skills: ```sh -agent-voice speak "Hello from Agent Voice." --play +npx skills add yoav0gal/agent-voice -g --skill create-speech-recording-desktop +npx skills add yoav0gal/agent-voice -g --skill spoken-response-desktop ``` -## Skills +

+ Agent Voice audio embedded natively inside a desktop conversation +

-Agent Voice includes two skills: +### Experimental desktop playback controls -- **create-speech-recording** turns supplied text into audio. Use it when you - want an agent to create a recording or read something aloud. -- **spoken-response** creates an audio version of an agent's written response. - Use it when you want to listen to a long answer instead of reading it. +> [!WARNING] +> Experimental feature. -Each skill also has a **`-desktop`** version that does the same job but -delivers the recording through a player embedded in the desktop app. +```sh +agent-voice controls install +npx skills add yoav0gal/agent-voice -g --skill create-speech-recording-controls +npx skills add yoav0gal/agent-voice -g --skill spoken-response-controls +``` -These skills are starting points. Copy them, edit them, and make them yours. -You can change delivery wording, recording defaults, playback behavior, or -when the agent should offer audio. +

+ Experimental Agent Voice playback controls with a web-player fallback +

-Each skill owns its delivery references. The CLI returns structured facts; the -installed skill decides how those facts are presented. +Remove the handler with `agent-voice controls uninstall`. ## CLI @@ -99,13 +130,16 @@ The CLI provides small primitives that agents can combine. Run | Command | What it does | | --- | --- | | `setup` | Download and verify speech model assets. | +| `update` | Upgrade Agent Voice through its `uv` or `pipx` installer. | | `speak` | Turn text or stdin into a recording. | | `play` | Play an existing local recording. | | `voices` | List supported language tags and voices. | | `models` | List speech models and variants. | | `config` | View or change persistent defaults. | | `doctor` | Check that Agent Voice is ready. | +| `service start\|stop` | Manage the background speech service. | | `viewer start\|stop` | Manage the local recording viewer. | +| `controls install\|uninstall` | Install or remove the experimental desktop protocol handler. | | `serve` | Start the localhost speech API. | ### Speak @@ -127,7 +161,7 @@ agent-voice speak --response-file "$RESPONSE_AS_MARKDOWN_FILE" \ # Choose the output and delivery agent-voice speak "Here is your summary." \ - --voice bf_emma --speed 1.2 --format mp3 --play + --voice bf_emma --speed 1.2 --format mp3 -p ``` | Option | Purpose | @@ -141,13 +175,14 @@ agent-voice speak "Here is your summary." \ | `-v, --voice NAME` | Select a voice. | | `--lang TAG` | Set the language tag (default: `en-us`). | | `--speed NUMBER` | Set pitch-preserving playback speed. | -| `--play` | Play the recording after creation. | -| `--service on\|off\|timed` | Control background inference. | -| `--service-timeout MINUTES` | Set the idle timeout for timed mode. | +| `-p, --play` | Start local playback after creation, without waiting for it to finish. | +| `--play-after SECONDS` | Schedule local playback after creation, without waiting. | +| `--controls` | Include experimental desktop playback control links. | +| `--no-service` | Run the same Agent Voice model inside this command, then unload it. | | `--model-id ID`, `--variant NAME` | Select a model and build. | `speak` prints one JSON receipt with the absolute recording path, file URI, -audio metadata, playback status, and available viewer links. This makes the +audio metadata, playback state (`started` or `scheduled`), and available viewer links. This makes the command reliable for both people and agents. ### Configure defaults @@ -156,16 +191,33 @@ command reliable for both people and agents. # Show current defaults agent-voice config -# Set your preferred voice, speed, format, and output directory -agent-voice config --voice bf_emma --speed 1.15 --format mp3 --output-dir ./recordings +# Set your preferred voice, speed, format, service timeout, and output directory +agent-voice config --voice bf_emma --speed 1.15 --format mp3 \ + --service-timeout 10 --output-dir ./recordings # Restore built-in defaults agent-voice config --reset ``` -The same values can be overridden per recording with `speak`. Service modes are -`on` for a persistent local service, `off` for embedded inference, and `timed` -to stop the service after an idle timeout. +Voice, speed, format, and output directory can be overridden per recording with +`speak`. + +### Manage the Agent Voice service + +By default, `speak` starts the Agent Voice background service when needed. Once +the model weights are loaded, the service keeps them warm between requests to +avoid another cold startup. It stops after 10 idle minutes by default, and each +completed speech request restarts that timer. Automatic startup reuses a running +service without changing its timeout; `service start` uses the saved timeout or +an explicit `--idle-timeout` value. + +```sh +agent-voice service start # stops after 10 idle minutes +agent-voice service start --idle-timeout 30 # set this process to 30 minutes +agent-voice service stop +``` + +Use `agent-voice serve` for a foreground service while debugging. ### Discover and diagnose @@ -186,6 +238,7 @@ agent will consume the result. ```sh agent-voice play "/absolute/path/recording.mp3" +agent-voice play "/absolute/path/recording.mp3" --after 10 agent-voice viewer start agent-voice viewer stop ``` @@ -199,6 +252,10 @@ language metadata are left alone. Opening a player or audio URL regenerates missing audio from its source with the original language and current voice and speed. +Playback commands return as soon as local playback starts, or immediately with +`scheduled` when a delay is requested; they never wait for the recording to end. + +> [!Note] > 🗒️ The viewer is a workaround for agent surfaces that do not support embedded audio. I expect native text-to-speech to become common across these platforms, which would be a better solution. For now, the viewer keeps playback and the diff --git a/assets/agent-voice-launch.gif b/assets/agent-voice-launch.gif index 9ab208a..4bcf074 100644 Binary files a/assets/agent-voice-launch.gif and b/assets/agent-voice-launch.gif differ diff --git a/assets/screenshots/controls-delivery.png b/assets/screenshots/controls-delivery.png new file mode 100644 index 0000000..a7810b5 Binary files /dev/null and b/assets/screenshots/controls-delivery.png differ diff --git a/assets/screenshots/desktop-delivery.png b/assets/screenshots/desktop-delivery.png new file mode 100644 index 0000000..5d5b224 Binary files /dev/null and b/assets/screenshots/desktop-delivery.png differ diff --git a/assets/screenshots/portable-delivery.png b/assets/screenshots/portable-delivery.png new file mode 100644 index 0000000..9a39275 Binary files /dev/null and b/assets/screenshots/portable-delivery.png differ diff --git a/docs/viewer-update.html b/docs/viewer-update.html deleted file mode 100644 index cdb8e76..0000000 --- a/docs/viewer-update.html +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - 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

- - -

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/audio/af_bella.mp3 b/docs/voice-sampler/audio/af_bella.mp3 deleted file mode 100644 index 2b97bda..0000000 Binary files a/docs/voice-sampler/audio/af_bella.mp3 and /dev/null differ diff --git a/docs/voice-sampler/audio/af_heart.mp3 b/docs/voice-sampler/audio/af_heart.mp3 deleted file mode 100644 index 8b93717..0000000 Binary files a/docs/voice-sampler/audio/af_heart.mp3 and /dev/null differ diff --git a/docs/voice-sampler/audio/af_nova.mp3 b/docs/voice-sampler/audio/af_nova.mp3 deleted file mode 100644 index d5c0f11..0000000 Binary files a/docs/voice-sampler/audio/af_nova.mp3 and /dev/null differ diff --git a/docs/voice-sampler/audio/af_sky.mp3 b/docs/voice-sampler/audio/af_sky.mp3 deleted file mode 100644 index f7eaa91..0000000 Binary files a/docs/voice-sampler/audio/af_sky.mp3 and /dev/null differ diff --git a/docs/voice-sampler/audio/am_adam.mp3 b/docs/voice-sampler/audio/am_adam.mp3 deleted file mode 100644 index 9b87bc2..0000000 Binary files a/docs/voice-sampler/audio/am_adam.mp3 and /dev/null differ diff --git a/docs/voice-sampler/audio/am_michael.mp3 b/docs/voice-sampler/audio/am_michael.mp3 deleted file mode 100644 index 2756522..0000000 Binary files a/docs/voice-sampler/audio/am_michael.mp3 and /dev/null differ diff --git a/docs/voice-sampler/audio/bf_emma.mp3 b/docs/voice-sampler/audio/bf_emma.mp3 deleted file mode 100644 index 0b9d014..0000000 Binary files a/docs/voice-sampler/audio/bf_emma.mp3 and /dev/null differ diff --git a/docs/voice-sampler/audio/bm_george.mp3 b/docs/voice-sampler/audio/bm_george.mp3 deleted file mode 100644 index ec8199e..0000000 Binary files a/docs/voice-sampler/audio/bm_george.mp3 and /dev/null differ diff --git a/docs/voice-sampler/index.html b/docs/voice-sampler/index.html deleted file mode 100644 index 64e8780..0000000 --- a/docs/voice-sampler/index.html +++ /dev/null @@ -1,594 +0,0 @@ - - - - - - - Agent Voice — Kokoro Voice Room - - - -
-
- -
-
- A listening room for your narrator -

Find your voice.

-
-
-

Eight voices read the exact same line at the exact same speed. Listen with headphones, then mark the one that feels right.

-
- 8 voices - MP3 · 24 kHz - Speed 1.0 - Offline Kokoro -
-
-
-
-
- -
-
-
-

Voice shelf

-

Playback stops automatically when you start another sample.

-
- -
- -
-
- 01 -
-
-

Heart

af_heart
- American · feminine -
- - -
-
- -
- 02 -
-
-

Bella

af_bella
- American · feminine -
- - -
-
- -
- 03 -
-
-

Nova

af_nova
- American · feminine -
- - -
-
- -
- 04 -
-
-

Sky

af_sky
- American · feminine -
- - -
-
- -
- 05 -
-
-

Adam

am_adam
- American · masculine -
- - -
-
- -
- 06 -
-
-

Michael

am_michael
- American · masculine -
- - -
-
- -
- 07 -
-
-

Emma

bf_emma
- British · feminine -
- - -
-
- -
- 08 -
-
-

George

bm_george
- British · masculine -
- - -
-
-
- - - -
- Shared sample line -
“Hello from Agent Voice. This sample uses the local Kokoro speech engine.”
-
-
- - - - - - diff --git a/models/.gitkeep b/models/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/models/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/pyproject.toml b/pyproject.toml index 81067b1..1e6a326 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-voice" -version = "0.7.0" +version = "0.8.0" description = "Local, free voice artifacts for AI agents" readme = "README.md" requires-python = ">=3.11,<3.14" diff --git a/recordings/.gitkeep b/recordings/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/recordings/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/skills/create-speech-recording-controls/SKILL.md b/skills/create-speech-recording-controls/SKILL.md new file mode 100644 index 0000000..d0ef6f3 --- /dev/null +++ b/skills/create-speech-recording-controls/SKILL.md @@ -0,0 +1,60 @@ +--- +name: create-speech-recording-controls +description: Turn text into speech with Agent Voice and clickable playback controls. Use when the user explicitly requests controlled playback on a compatible desktop renderer. +--- + +# Create Speech Recording Controls + +Agent Voice works best in English. For another supported language, choose a +matching voice with `agent-voice voices` and pass `--voice` and `--lang`. + +## Prepare + +Set `RESPONSE_AS_MARKDOWN` to the original text or Markdown. Set +`RESPONSE_AS_TEXT` to its spoken form. Never use `RESPONSE_AS_MARKDOWN` as the +speech input. +Use real line breaks in `RESPONSE_AS_MARKDOWN`, not escaped `\n` text. + +- For supplied text, preserve every word and punctuation mark in order while + translating presentation syntax into speech. +- For a requested summary or explanation, write natural speech with the same + meaning. +- For tables, state the headers once and read each row as labeled values. + +## Record + +```sh +agent-voice speak "$RESPONSE_AS_TEXT" --markdown "$RESPONSE_AS_MARKDOWN" --controls +``` + +For long text, use temporary files outside the workspace and remove them afterward: + +```sh +agent-voice speak --label "$LABEL" --response-file "$RESPONSE_AS_MARKDOWN_FILE" --controls < "$RESPONSE_AS_TEXT_FILE" +``` + +Set `LABEL` to a short subject. For an exact requested filename, replace +`--label "$LABEL"` with `--output "$PATH"`. + +For speaker playback, add `-p` and continue after the result reports +`playback.state: "started"`. Add `--play-after SECONDS` to schedule it without +blocking. + +## Deliver + +Use [default.md](references/delivery/default.md). + +## Setup + +If the command is unavailable: + +```sh +uv tool install agent-voice +agent-voice setup +agent-voice controls install +``` + +## Resources + +- CLI help: `agent-voice --help` +- Source and docs: [Agent Voice on GitHub](https://github.com/yoav0gal/agent-voice) diff --git a/skills/create-speech-recording-controls/agents/openai.yaml b/skills/create-speech-recording-controls/agents/openai.yaml new file mode 100644 index 0000000..4a17304 --- /dev/null +++ b/skills/create-speech-recording-controls/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Create Speech Recording Controls" + short_description: "Create speech with desktop playback controls" + default_prompt: "Use $create-speech-recording-controls to create a controlled speech recording." + +policy: + allow_implicit_invocation: false diff --git a/skills/create-speech-recording-controls/references/delivery/default.md b/skills/create-speech-recording-controls/references/delivery/default.md new file mode 100644 index 0000000..86f3747 --- /dev/null +++ b/skills/create-speech-recording-controls/references/delivery/default.md @@ -0,0 +1,24 @@ +Use the exact block below whenever delivering a recording. Replace each +placeholder with its receipt value. If `delivery.controls` is unavailable, use +the normal portable delivery instead. + +Set `$restart`, `$back`, `$toggle`, `$forward`, `$slower`, and `$faster` from +the matching keys in `delivery.controls`. Include the fallback row only when +`$browser_url` exists. + +Keep both `---` separators and the blank lines. Send the block without the +outer four-backtick fence. + +````markdown + +--- + +Agent Voice recording · ⚠️ Experimental ⚠️ + +Controls: [↺ Restart]($restart) · [↶ 10s]($back) · [⏯ Play / pause]($toggle) · [10s ↷]($forward) · [🐢 Slower]($slower) · [Faster 🐇]($faster) + +Fallback: [open web player]($browser_url) + +--- + +```` diff --git a/skills/create-speech-recording-desktop/SKILL.md b/skills/create-speech-recording-desktop/SKILL.md index 58b66a5..522fcf6 100644 --- a/skills/create-speech-recording-desktop/SKILL.md +++ b/skills/create-speech-recording-desktop/SKILL.md @@ -40,7 +40,8 @@ Set `LABEL` to a short subject. For an exact requested filename, replace `--label "$LABEL"` with `--output "$PATH"`. Use the returned `path` to deliver the recording. For speaker playback, add -`--play` and complete after the result reports `played: true`. +`-p`; continue after the result reports `playback.state: "started"`. Add +`--play-after SECONDS` to schedule it without blocking. ## Deliver diff --git a/skills/create-speech-recording/SKILL.md b/skills/create-speech-recording/SKILL.md index b02b5c5..34fbfe5 100644 --- a/skills/create-speech-recording/SKILL.md +++ b/skills/create-speech-recording/SKILL.md @@ -37,7 +37,8 @@ Set `LABEL` to a short subject. For an exact requested filename, replace `--label "$LABEL"` with `--output "$PATH"`. Use the returned `path` to deliver the recording. For speaker playback, add -`--play` and complete after the result reports `played: true`. +`-p`; continue after the result reports `playback.state: "started"`. Add +`--play-after SECONDS` to schedule it without blocking. ## Deliver diff --git a/skills/create-speech-recording/references/delivery/default.md b/skills/create-speech-recording/references/delivery/default.md index da1604b..7782826 100644 --- a/skills/create-speech-recording/references/delivery/default.md +++ b/skills/create-speech-recording/references/delivery/default.md @@ -18,7 +18,7 @@ Listen: [web player]($browser_url) · [media app]($file_uri) · [web audio]($aud Or run in the terminal ```sh -agent-voice play "$path" +agent-voice play "$path" # returns when playback starts ``` --- diff --git a/skills/spoken-response-controls/SKILL.md b/skills/spoken-response-controls/SKILL.md new file mode 100644 index 0000000..761f556 --- /dev/null +++ b/skills/spoken-response-controls/SKILL.md @@ -0,0 +1,69 @@ +--- +name: spoken-response-controls +description: Create the spoken semantic twin of an assistant response with clickable Agent Voice playback controls. Use when the user explicitly requests controlled playback on a compatible desktop renderer. +--- + +# Spoken Response Controls + +A spoken response is the audio semantic twin of an assistant response. + +## Mode + +Choose the mode from the user's request: + +- `Single` — create audio for the current response. This is the default. +- `Previous` — create audio for the most recent assistant response and return a + brief confirmation. +- `Thread` — create audio for the current and later responses until disabled or + the thread ends. + +## Respond + +1. Select the response: + - For `Single` and `Thread`, finalize the current response. + - For `Previous`, use the most recent assistant response. +2. Set `RESPONSE_AS_MARKDOWN` to the selected response's Markdown. Use real line + breaks, not escaped `\n` text. +3. Set `RESPONSE_AS_TEXT` to its spoken semantic twin: preserve meaning, detail, + and order while translating formatting into natural speech. Never use + `RESPONSE_AS_MARKDOWN` as the speech input. + - For tables, state the headers once and read each row as labeled values. + - For long code, explain it naturally and refer to the written response for + exact syntax. +4. Set `LABEL` to `SR`. When a thread title is already available, use + ` - SR`. +5. Create the recording with the configured voice, speed, and format: + + ```sh + agent-voice speak "$RESPONSE_AS_TEXT" --markdown "$RESPONSE_AS_MARKDOWN" --label "$LABEL" --controls + ``` + + For long responses, use temporary files outside the workspace and remove them + afterward: + + ```sh + agent-voice speak --label "$LABEL" --response-file "$RESPONSE_AS_MARKDOWN_FILE" --controls < "$RESPONSE_AS_TEXT_FILE" + ``` + +6. Place the controls above the written response or `Previous` confirmation using + [default.md](references/delivery/default.md). + +For speaker playback, add `-p` and continue after the result reports +`playback.state: "started"`. Add `--play-after SECONDS` to schedule it without +blocking. On synthesis failure, send the written response with a brief +failure note. + +## Setup + +If `agent-voice` is unavailable: + +```sh +uv tool install agent-voice +agent-voice setup +agent-voice controls install +``` + +## Resources + +- CLI help: `agent-voice --help` +- Source and docs: [Agent Voice on GitHub](https://github.com/yoav0gal/agent-voice) diff --git a/skills/spoken-response-controls/agents/openai.yaml b/skills/spoken-response-controls/agents/openai.yaml new file mode 100644 index 0000000..ff07399 --- /dev/null +++ b/skills/spoken-response-controls/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Spoken Response Controls" + short_description: "Spoken responses with desktop playback controls" + default_prompt: "Use $spoken-response-controls for this response." + +policy: + allow_implicit_invocation: false diff --git a/skills/spoken-response-controls/references/delivery/default.md b/skills/spoken-response-controls/references/delivery/default.md new file mode 100644 index 0000000..86f3747 --- /dev/null +++ b/skills/spoken-response-controls/references/delivery/default.md @@ -0,0 +1,24 @@ +Use the exact block below whenever delivering a recording. Replace each +placeholder with its receipt value. If `delivery.controls` is unavailable, use +the normal portable delivery instead. + +Set `$restart`, `$back`, `$toggle`, `$forward`, `$slower`, and `$faster` from +the matching keys in `delivery.controls`. Include the fallback row only when +`$browser_url` exists. + +Keep both `---` separators and the blank lines. Send the block without the +outer four-backtick fence. + +````markdown + +--- + +Agent Voice recording · ⚠️ Experimental ⚠️ + +Controls: [↺ Restart]($restart) · [↶ 10s]($back) · [⏯ Play / pause]($toggle) · [10s ↷]($forward) · [🐢 Slower]($slower) · [Faster 🐇]($faster) + +Fallback: [open web player]($browser_url) + +--- + +```` diff --git a/skills/spoken-response-desktop/SKILL.md b/skills/spoken-response-desktop/SKILL.md index 24d2dcf..6ab3f56 100644 --- a/skills/spoken-response-desktop/SKILL.md +++ b/skills/spoken-response-desktop/SKILL.md @@ -53,8 +53,9 @@ Choose the mode from the user's request: - OpenCode Desktop: [opencode-desktop.md](references/delivery/opencode-desktop.md) - Other (Codex Desktop): [default.md](references/delivery/default.md) -For speaker playback, add `--play` and complete after the result reports -`played: true`. On synthesis failure, send the written response with a brief +For speaker playback, add `-p` and continue after the result reports +`playback.state: "started"`. Add `--play-after SECONDS` to schedule it without +blocking. On synthesis failure, send the written response with a brief failure note. ## Setup diff --git a/skills/spoken-response/SKILL.md b/skills/spoken-response/SKILL.md index c674342..aea66c1 100644 --- a/skills/spoken-response/SKILL.md +++ b/skills/spoken-response/SKILL.md @@ -48,8 +48,9 @@ Choose the mode from the user's request: 6. Place the audio above the written response or `Previous` confirmation using [default.md](references/delivery/default.md). -For speaker playback, add `--play` and complete after the result reports -`played: true`. On synthesis failure, send the written response with a brief +For speaker playback, add `-p` and continue after the result reports +`playback.state: "started"`. Add `--play-after SECONDS` to schedule it without +blocking. On synthesis failure, send the written response with a brief failure note. ## Setup diff --git a/skills/spoken-response/references/delivery/default.md b/skills/spoken-response/references/delivery/default.md index da1604b..7782826 100644 --- a/skills/spoken-response/references/delivery/default.md +++ b/skills/spoken-response/references/delivery/default.md @@ -18,7 +18,7 @@ Listen: [web player]($browser_url) · [media app]($file_uri) · [web audio]($aud Or run in the terminal ```sh -agent-voice play "$path" +agent-voice play "$path" # returns when playback starts ``` --- diff --git a/src/agent_voice/__init__.py b/src/agent_voice/__init__.py index b792310..5c80027 100644 --- a/src/agent_voice/__init__.py +++ b/src/agent_voice/__init__.py @@ -1,3 +1,3 @@ """Local voice artifacts for AI agents.""" -__version__ = "0.7.0" +__version__ = "0.8.0" diff --git a/src/agent_voice/audio.py b/src/agent_voice/audio.py index 43a2eb7..6b7c25f 100644 --- a/src/agent_voice/audio.py +++ b/src/agent_voice/audio.py @@ -8,6 +8,7 @@ from collections.abc import Generator from dataclasses import dataclass from pathlib import Path +from typing import Callable import imageio_ffmpeg import miniaudio @@ -19,6 +20,13 @@ PLAYBACK_SAMPLE_RATE = 24_000 PLAYBACK_CHANNELS = 1 PLAYBACK_SAMPLE_WIDTH = 2 +PLAYBACK_SEEK_SECONDS = 10 +PLAYBACK_SPEEDS = (0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0) +PLAYBACK_ACTIONS = ("toggle", "restart", "back", "forward", "slower", "faster") + + +def _no_window_creation_flags() -> int: + return getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 @dataclass(frozen=True) @@ -31,6 +39,232 @@ class AudioRuntime: playback_error: str | None +@dataclass(frozen=True) +class PlaybackState: + recording: str + playing: bool + position_seconds: float + speed: float + + def to_dict(self) -> dict[str, object]: + return { + "recording": self.recording, + "playing": self.playing, + "position_seconds": round(self.position_seconds, 3), + "speed": self.speed, + } + + +class PlaybackController: + """Control one nonblocking local playback session.""" + + def __init__( + self, + *, + decoder: Callable[[Path], bytes] = lambda path: _decode_for_playback(path), + device_factory: Callable[ + ..., miniaudio.PlaybackDevice + ] = miniaudio.PlaybackDevice, + tempo_changer: Callable[[bytes, float], bytes] = lambda pcm, factor: ( + _change_pcm_tempo(pcm, factor) + ), + ) -> None: + self._decoder = decoder + self._device_factory = device_factory + self._tempo_changer = tempo_changer + self._command_lock = threading.Lock() + self._lock = threading.Lock() + self._recording: Path | None = None + self._source_pcm = b"" + self._pcm = b"" + self._offset = 0 + self._speed = 1.0 + self._playing = False + self._closed = False + self._silence = b"" + self._device: miniaudio.PlaybackDevice | None = None + self._stream: Generator[bytes | memoryview, int, None] | None = None + + def control(self, recording: Path, action: str) -> PlaybackState: + if action not in PLAYBACK_ACTIONS: + raise ValueError(f"Unsupported playback action: {action}") + + path = recording.expanduser().resolve() + with self._command_lock: + if self._closed: + raise RuntimeError("Playback controller is closed") + with self._lock: + changed = self._recording != path + if changed: + pcm = self._decoder(path) + with self._lock: + self._recording = path + self._source_pcm = pcm + self._pcm = pcm + self._offset = 0 + self._speed = 1.0 + self._playing = False + + if action in ("slower", "faster"): + with self._lock: + index = PLAYBACK_SPEEDS.index(self._speed) + step = -1 if action == "slower" else 1 + speed = PLAYBACK_SPEEDS[ + min(max(0, index + step), len(PLAYBACK_SPEEDS) - 1) + ] + source_pcm = self._source_pcm + current_speed = self._speed + if speed != current_speed: + # ponytail: rebuild one full PCM buffer per speed click; cache + # variants only if real recordings make latency or memory hurt. + pcm = self._tempo_changer(source_pcm, speed) + with self._lock: + source_offset = self._source_offset() + self._pcm = pcm + self._speed = speed + self._offset = self._playback_offset(source_offset) + if self._offset >= len(self._pcm): + self._playing = False + + with self._lock: + if action == "toggle": + if self._offset >= len(self._pcm): + self._offset = 0 + self._playing = True if changed else not self._playing + elif action == "restart": + self._offset = 0 + self._playing = True + elif action in ("back", "forward"): + seconds = ( + -PLAYBACK_SEEK_SECONDS + if action == "back" + else PLAYBACK_SEEK_SECONDS + ) + delta = seconds * PLAYBACK_SAMPLE_RATE * PLAYBACK_SAMPLE_WIDTH + source_offset = min( + max(0, self._source_offset() + delta), len(self._source_pcm) + ) + self._offset = self._playback_offset(source_offset) + if source_offset >= len(self._source_pcm): + self._playing = False + playing = self._playing + try: + self._sync_device(playing) + except BaseException: + with self._lock: + self._recording = None + self._source_pcm = b"" + self._pcm = b"" + self._offset = 0 + self._speed = 1.0 + self._playing = False + raise + with self._lock: + return self._state() + + def close(self) -> None: + with self._command_lock: + self._closed = True + with self._lock: + self._playing = False + self._discard_device() + + def _sync_device(self, playing: bool) -> None: + try: + if playing: + self._start_device() + return + if self._device is not None and self._device.running: + self._device.stop() + except miniaudio.MiniaudioError as error: + self._discard_device() + raise RuntimeError(f"audio playback failed: {error}") from error + + def _start_device(self) -> None: + if self._device is not None: + if not self._device.running and self._stream is not None: + self._device.start(self._stream) + return + stream = self._chunks() + next(stream) + device = self._device_factory( + output_format=miniaudio.SampleFormat.SIGNED16, + nchannels=PLAYBACK_CHANNELS, + sample_rate=PLAYBACK_SAMPLE_RATE, + app_name="Agent Voice", + ) + try: + device.start(stream) + except BaseException: + stream.close() + try: + device.close() + except miniaudio.MiniaudioError: + pass + raise + self._stream = stream + self._device = device + + def _discard_device(self) -> None: + if self._device is not None: + try: + self._device.close() + except miniaudio.MiniaudioError: + pass + self._device = None + if self._stream is not None: + self._stream.close() + self._stream = None + + def _chunks(self) -> Generator[bytes | memoryview, int, None]: + frame_width = PLAYBACK_CHANNELS * PLAYBACK_SAMPLE_WIDTH + required_frames = yield b"" + while True: + requested_bytes = (required_frames or 4096) * frame_width + with self._lock: + if self._playing and self._offset < len(self._pcm): + start = self._offset + self._offset = min(start + requested_bytes, len(self._pcm)) + chunk: bytes | memoryview = memoryview(self._pcm)[ + start : self._offset + ] + if self._offset >= len(self._pcm): + # ponytail: completion feeds silence; add an idle monitor if + # holding the audio device becomes a real resource problem. + self._playing = False + else: + if len(self._silence) < requested_bytes: + self._silence = bytes(requested_bytes) + chunk = memoryview(self._silence)[:requested_bytes] + required_frames = yield chunk + + def _state(self) -> PlaybackState: + if self._recording is None: + raise RuntimeError("No recording is loaded") + bytes_per_second = ( + PLAYBACK_SAMPLE_RATE * PLAYBACK_CHANNELS * PLAYBACK_SAMPLE_WIDTH + ) + return PlaybackState( + recording=self._recording.name, + playing=self._playing, + position_seconds=self._source_offset() / bytes_per_second, + speed=self._speed, + ) + + def _source_offset(self) -> int: + if self._offset >= len(self._pcm): + return len(self._source_pcm) + return min(int(self._offset * self._speed), len(self._source_pcm)) + + def _playback_offset(self, source_offset: int) -> int: + if source_offset >= len(self._source_pcm): + frame_width = PLAYBACK_CHANNELS * PLAYBACK_SAMPLE_WIDTH + return len(self._pcm) - len(self._pcm) % frame_width + frame_width = PLAYBACK_CHANNELS * PLAYBACK_SAMPLE_WIDTH + offset = int(source_offset / self._speed) + return min(offset - offset % frame_width, len(self._pcm)) + + def inspect_audio_runtime() -> AudioRuntime: """Inspect the bundled codec and native playback runtimes.""" try: @@ -99,7 +333,11 @@ def change_tempo( ] try: completed = subprocess.run( - command, input=source.tobytes(), check=True, capture_output=True + command, + input=source.tobytes(), + check=True, + capture_output=True, + creationflags=_no_window_creation_flags(), ) except subprocess.CalledProcessError as error: detail = error.stderr.decode(errors="replace").strip() @@ -107,6 +345,14 @@ def change_tempo( return np.frombuffer(completed.stdout, dtype="<f4").copy() +def _change_pcm_tempo(pcm: bytes, factor: float) -> bytes: + if factor == 1.0: + return pcm + samples = np.frombuffer(pcm, dtype="<i2").astype(np.float32) / 32_768 + changed = change_tempo(samples, PLAYBACK_SAMPLE_RATE, factor) + return np.clip(np.rint(changed * 32_768), -32_768, 32_767).astype("<i2").tobytes() + + def _tempo_factors(factor: float) -> list[float]: """Split aggressive speedups into pitch-preserving FFmpeg tempo stages.""" factors: list[float] = [] @@ -201,7 +447,12 @@ def _encode_audio( command += ["-codec:a", "aac", "-b:a", "128k"] command.append(str(destination)) try: - subprocess.run(command, check=True, capture_output=True) + subprocess.run( + command, + check=True, + capture_output=True, + creationflags=_no_window_creation_flags(), + ) except subprocess.CalledProcessError as error: detail = error.stderr.decode(errors="replace").strip() raise RuntimeError( @@ -237,7 +488,12 @@ def _decode_for_playback(path: Path) -> bytes: "pipe:1", ] try: - completed = subprocess.run(command, check=True, capture_output=True) + completed = subprocess.run( + command, + check=True, + capture_output=True, + creationflags=_no_window_creation_flags(), + ) except subprocess.CalledProcessError as error: detail = error.stderr.decode(errors="replace").strip() raise RuntimeError( diff --git a/src/agent_voice/cli.py b/src/agent_voice/cli.py index 7c418ef..4aa7ce4 100644 --- a/src/agent_voice/cli.py +++ b/src/agent_voice/cli.py @@ -8,16 +8,19 @@ from pathlib import Path from . import __version__ -from .audio import play_audio -from .client import DEFAULT_SERVICE_URL +from .client import ( + DEFAULT_SERVICE_URL, + ensure_service, + set_service_timeout, + stop_service, + validate_service_url, +) from .config import ( DEFAULT_FORMAT, - DEFAULT_SERVICE, DEFAULT_SERVICE_TIMEOUT_MINUTES, FORMATS, MAX_SPEED, MIN_SPEED, - SERVICE_MODES, config_path, load_defaults, reset_defaults, @@ -27,7 +30,8 @@ from .paths import resolved_recording_dir from .registry import MODEL_REGISTRY from .speaking import SpeakRequest, Speaker -from .viewer import ensure_viewer, stop_viewer +from .updates import notify_if_update_available, run_update +from .viewer import ensure_viewer, start_playback, stop_viewer def build_parser() -> argparse.ArgumentParser: @@ -38,10 +42,11 @@ def build_parser() -> argparse.ArgumentParser: epilog=( "examples:\n" " agent-voice setup\n" + " agent-voice controls install\n" " printf '%s' \"$TEXT\" | agent-voice speak --format mp3\n" ' agent-voice play "/path/to/recording.mp3"\n' " agent-voice doctor --json\n\n" - "Agent speech: read the JSON path; only report playback when played=true." + "Agent speech: use playback.state; started and scheduled do not mean finished." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -58,6 +63,12 @@ def build_parser() -> argparse.ArgumentParser: _add_model_arguments(setup) setup.add_argument("--force", action="store_true", help="download again") + subparsers.add_parser( + "update", + help="upgrade Agent Voice", + description="Upgrade Agent Voice through its uv or pipx installer.", + ) + speak = subparsers.add_parser( "speak", help="turn text into an audio recording", @@ -103,25 +114,29 @@ def build_parser() -> argparse.ArgumentParser: ) speak.add_argument("--lang", default="en-us", help="language tag (default: en-us)") speak.add_argument( + "-p", "--play", - action="store_true", - help="play after generating; successful JSON reports played=true", + dest="play_after", + action="store_const", + const=0.0, + help="start local playback after generating without waiting for completion", ) - _add_model_arguments(speak) speak.add_argument( - "--service", - choices=SERVICE_MODES, - help=( - "service mode (default: configured mode); on leaves the localhost " - "service running, off uses embedded inference, and timed stops the " - "service after an idle timeout" - ), + "--play-after", + type=_nonnegative_seconds, + metavar="SECONDS", + help="schedule local playback after generating without waiting", ) speak.add_argument( - "--service-timeout", - type=_positive_minutes, - metavar="MINUTES", - help="idle minutes in timed service mode (default: configured timeout)", + "--controls", + action="store_true", + help="include experimental desktop playback control links", + ) + _add_model_arguments(speak) + speak.add_argument( + "--no-service", + action="store_true", + help="run the Agent Voice model in this command, then unload it", ) speak.add_argument( "--service-url", @@ -162,18 +177,12 @@ def build_parser() -> argparse.ArgumentParser: default=argparse.SUPPRESS, help=f"set the default output format (default: {DEFAULT_FORMAT})", ) - config.add_argument( - "--service", - choices=SERVICE_MODES, - default=argparse.SUPPRESS, - help=f"set the default service mode (default: {DEFAULT_SERVICE})", - ) config.add_argument( "--service-timeout", type=_positive_minutes, default=argparse.SUPPRESS, metavar="MINUTES", - help=f"set the timed mode idle timeout (default: {DEFAULT_SERVICE_TIMEOUT_MINUTES:g})", + help=f"set the service idle timeout (default: {DEFAULT_SERVICE_TIMEOUT_MINUTES:g})", ) config.add_argument( "--output-dir", @@ -208,6 +217,41 @@ def build_parser() -> argparse.ArgumentParser: metavar="MINUTES", help="exit after this many idle minutes; omit to keep serving", ) + + service = subparsers.add_parser( + "service", + help="manage the background speech service", + description="Start or stop the localhost speech service.", + ) + service_actions = service.add_subparsers(dest="service_action", required=True) + service_start = service_actions.add_parser( + "start", help="start the timed background service" + ) + _add_model_arguments(service_start) + service_start.add_argument( + "--idle-timeout", + type=_positive_minutes, + metavar="MINUTES", + help=( + "stop after this many idle minutes (default: configured value; " + f"built-in {DEFAULT_SERVICE_TIMEOUT_MINUTES:g})" + ), + ) + service_stop = service_actions.add_parser( + "stop", help="stop the background service" + ) + for action in (service_start, service_stop): + action.add_argument( + "--service-url", + default=default_service_url, + help=f"localhost service base URL (default: {default_service_url})", + ) + action.add_argument( + "--json", + action="store_true", + help="print one machine-readable service report", + ) + doctor = subparsers.add_parser( "doctor", help="check local readiness", @@ -232,10 +276,16 @@ def build_parser() -> argparse.ArgumentParser: description="Play a local recording through the default audio output.", ) play.add_argument("recording", type=Path, help="local audio recording") + play.add_argument( + "--after", + type=_nonnegative_seconds, + metavar="SECONDS", + help="schedule playback without waiting", + ) play.add_argument( "--json", action="store_true", - help="print a receipt after playback completes", + help="print a receipt after playback starts or is scheduled", ) viewer = subparsers.add_parser( @@ -251,15 +301,47 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="print one machine-readable viewer report", ) + + controls = subparsers.add_parser( + "controls", + help="manage desktop click controls", + description="Install or remove the Agent Voice link handler.", + ) + control_actions = controls.add_subparsers(dest="controls_action", required=True) + install = control_actions.add_parser("install", help="install the link handler") + install.add_argument( + "--json", action="store_true", help="print one machine-readable report" + ) + uninstall = control_actions.add_parser( + "uninstall", help="remove the installed link handler" + ) + uninstall.add_argument( + "--json", action="store_true", help="print one machine-readable report" + ) + + control_url = subparsers.add_parser( + "control-url", + help="handle an Agent Voice control link", + description="Handle one agent-voice:// playback control link.", + ) + control_url.add_argument("url") + control_url.add_argument("--json", action="store_true") + return parser def main(argv: list[str] | None = None) -> None: parser = build_parser() args = parser.parse_args(argv) + if args.command != "update": + notify_if_update_available() try: if args.command == "setup": _setup(args) + elif args.command == "update": + return_code = run_update() + if return_code: + raise SystemExit(return_code) elif args.command == "speak": _speak(args) elif args.command == "voices": @@ -273,6 +355,8 @@ def main(argv: list[str] | None = None) -> None: _models(args) elif args.command == "config": _config(args) + elif args.command == "service": + _service(args) elif args.command == "serve": from .service import serve @@ -293,8 +377,13 @@ def main(argv: list[str] | None = None) -> None: _play(args) elif args.command == "viewer": _viewer(args) + elif args.command == "controls": + _controls(args) + elif args.command == "control-url": + _control_url(args) except (ValueError, RuntimeError, FileNotFoundError) as error: - print(f"Error: {error}", file=sys.stderr) + if sys.stderr is not None: + print(f"Error: {error}", file=sys.stderr) raise SystemExit(2) from error @@ -405,10 +494,10 @@ def _speak(args: argparse.Namespace) -> None: voice=args.voice, speed=args.speed, language=args.lang, - play=args.play, - service=args.service, - service_timeout_minutes=args.service_timeout, + play_after=args.play_after, + no_service=args.no_service, service_url=args.service_url, + controls=args.controls, ) ) print(json.dumps(receipt.to_dict())) @@ -431,8 +520,6 @@ def _config(args: argparse.Namespace) -> None: updates["speed"] = args.speed if hasattr(args, "format"): updates["format"] = args.format - if hasattr(args, "service"): - updates["service_mode"] = args.service if hasattr(args, "service_timeout"): updates["service_timeout_minutes"] = args.service_timeout if hasattr(args, "output_dir"): @@ -441,7 +528,7 @@ def _config(args: argparse.Namespace) -> None: if args.reset and updates: raise ValueError( "--reset cannot be combined with --voice, --speed, --format, " - "--service, --service-timeout, or --output-dir" + "--service-timeout, or --output-dir" ) if args.reset: defaults = reset_defaults() @@ -461,9 +548,7 @@ def _config(args: argparse.Namespace) -> None: print(f"Voice: {defaults.voice}") print(f"Speed: {defaults.speed:g}") print(f"Format: {defaults.format}") - print(f"Service mode: {defaults.service.mode}") - if defaults.service.timeout_minutes is not None: - print(f"Service timeout: {defaults.service.timeout_minutes:g} minutes") + print(f"Service timeout: {defaults.service_timeout_minutes:g} minutes") print(f"Output directory: {defaults.output_dir or 'default'}") print(f"Source: {payload['source']}") print(f"Config: {payload['path']}") @@ -481,16 +566,11 @@ def _play(args: argparse.Namespace) -> None: raise FileNotFoundError(f"Recording not found: {path}") if path.suffix.lower().lstrip(".") not in FORMATS: raise ValueError(f"Recording must use one of: {', '.join(FORMATS)}") - try: - play_audio(path) - except KeyboardInterrupt: - print("Playback stopped", file=sys.stderr) - raise SystemExit(130) from None - receipt = {"path": str(path), "played": True} + receipt = {"path": str(path), **start_playback(path, after=args.after)} if args.json: print(json.dumps(receipt)) else: - print(f"Played {path}") + print(f"Playback {receipt['state']}: {path}") def _positive_minutes(value: str) -> float: @@ -505,6 +585,20 @@ def _positive_minutes(value: str) -> float: return minutes +def _nonnegative_seconds(value: str) -> float: + try: + seconds = float(value) + except ValueError as error: + raise argparse.ArgumentTypeError( + "must be a non-negative number of seconds" + ) from error + if not math.isfinite(seconds) or seconds < 0: + raise argparse.ArgumentTypeError( + "must be a finite non-negative number of seconds" + ) + return seconds + + def _port(value: str) -> int: try: port = int(value) @@ -541,5 +635,62 @@ def _viewer(args: argparse.Namespace) -> None: print("Recording viewer: stopped") +def _service(args: argparse.Namespace) -> None: + url = validate_service_url(args.service_url) + if args.service_action == "stop": + stopped = stop_service(url) + report = {"running": False, "stopped": stopped, "url": url} + else: + timeout = ( + load_defaults().service_timeout_minutes + if args.idle_timeout is None + else args.idle_timeout + ) + health = ensure_service(url, _model_selection(args), timeout) + set_service_timeout(url, timeout) + report = { + **health, + "running": True, + "url": url, + "service_timeout_minutes": timeout, + } + + if args.json: + print(json.dumps(report)) + elif report["running"]: + print(f"Speech service: {url}") + else: + print(f"Speech service: {'stopped' if stopped else 'not running'}") + + +def _controls(args: argparse.Namespace) -> None: + from .controls import handler_path, install_handler, uninstall_handler + + installed = args.controls_action == "install" + path = install_handler() if installed else handler_path() + removed = False if installed else uninstall_handler() + report = { + "installed": installed, + "scheme": "agent-voice", + "path": str(path), + } + if not installed: + report["removed"] = removed + message = ( + f"Agent Voice controls: {path}" + if installed + else f"Agent Voice controls: {'removed' if removed else 'not installed'}" + ) + print(json.dumps(report) if args.json else message) + + +def _control_url(args: argparse.Namespace) -> None: + from .controls import trigger_control_url + + report = trigger_control_url(args.url) + if args.json: + print(json.dumps(report)) + + if __name__ == "__main__": main() diff --git a/src/agent_voice/client.py b/src/agent_voice/client.py index 0e41046..dc3c852 100644 --- a/src/agent_voice/client.py +++ b/src/agent_voice/client.py @@ -95,9 +95,7 @@ def ensure_service( except ServiceUnavailable: pass else: - matched = _require_matching_model(health, selection) - _configure_service_lifecycle(url, idle_timeout_minutes) - return matched + return _require_matching_model(health, selection) lifecycle = ( "no idle timeout" @@ -128,9 +126,7 @@ def ensure_service( "close_fds": True, } if os.name == "nt": - options["creationflags"] = ( - subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS - ) + options["creationflags"] = subprocess.CREATE_NO_WINDOW else: options["start_new_session"] = True process = subprocess.Popen(command, **options) @@ -151,7 +147,6 @@ def ensure_service( time.sleep(0.05) else: matched = _require_matching_model(health, selection) - _configure_service_lifecycle(url, idle_timeout_minutes) ready = True return matched raise ServiceUnavailable(f"service did not become ready: {last_error}") @@ -223,9 +218,44 @@ def request_speech( ) -def _configure_service_lifecycle( - service_url: str, idle_timeout_minutes: float | None -) -> None: +def stop_service(service_url: str, timeout: float = 2.0) -> bool: + url = validate_service_url(service_url) + try: + health_check(url, timeout=0.2) + except ServiceUnavailable: + return False + + request = urllib.request.Request( + url + "/shutdown", + data=b"{}", + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + payload = json.loads(response.read()) + except ( + OSError, + TimeoutError, + socket.timeout, + urllib.error.URLError, + json.JSONDecodeError, + ) as error: + raise ServiceUnavailable(f"could not stop service: {error}") from error + if not isinstance(payload, dict) or payload.get("status") != "stopping": + raise ServiceUnavailable("service shutdown returned an invalid response") + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + health_check(url, timeout=0.2) + except ServiceUnavailable: + return True + time.sleep(0.05) + raise ServiceUnavailable("service did not stop") + + +def set_service_timeout(service_url: str, idle_timeout_minutes: float) -> None: url = validate_service_url(service_url) + "/lifecycle" body = json.dumps({"idle_timeout_minutes": idle_timeout_minutes}).encode() request = urllib.request.Request( diff --git a/src/agent_voice/config.py b/src/agent_voice/config.py index 83fd2b0..7b8451f 100644 --- a/src/agent_voice/config.py +++ b/src/agent_voice/config.py @@ -4,7 +4,7 @@ import math import os import tempfile -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path from .paths import project_root @@ -12,38 +12,27 @@ DEFAULT_VOICE = "af_heart" DEFAULT_SPEED = 1.0 DEFAULT_FORMAT = "mp3" -DEFAULT_SERVICE = "timed" DEFAULT_SERVICE_TIMEOUT_MINUTES = 10.0 MIN_SPEED = 0.5 MAX_SPEED = 4.0 FORMATS = ("wav", "mp3", "opus", "m4a") -SERVICE_MODES = ("on", "off", "timed") _UNSET = object() -@dataclass(frozen=True) -class ServiceDefaults: - mode: str = DEFAULT_SERVICE - timeout_minutes: float | None = DEFAULT_SERVICE_TIMEOUT_MINUTES - - @dataclass(frozen=True) class SpeechDefaults: voice: str = DEFAULT_VOICE speed: float = DEFAULT_SPEED format: str = DEFAULT_FORMAT - service: ServiceDefaults = field(default_factory=ServiceDefaults) + service_timeout_minutes: float = DEFAULT_SERVICE_TIMEOUT_MINUTES output_dir: str | None = None def to_dict(self) -> dict[str, object]: - service: dict[str, object] = {"mode": self.service.mode} - if self.service.timeout_minutes is not None: - service["timeout_minutes"] = self.service.timeout_minutes return { "voice": self.voice, "speed": self.speed, "format": self.format, - "service": service, + "service": {"timeout_minutes": self.service_timeout_minutes}, "output_dir": self.output_dir, } @@ -64,13 +53,11 @@ def load_defaults() -> SpeechDefaults: ) from error if not isinstance(payload, dict): raise ValueError(f"Agent Voice config at {path} must be a JSON object") - service_mode, service_timeout_minutes = _service_values(payload) return _validated_defaults( payload.get("voice", DEFAULT_VOICE), payload.get("speed", DEFAULT_SPEED), payload.get("format", DEFAULT_FORMAT), - service_mode, - service_timeout_minutes, + _service_timeout(payload), payload.get("output_dir"), ) @@ -80,7 +67,6 @@ def update_defaults( voice: str | None = None, speed: float | None = None, format: str | None = None, - service_mode: str | None = None, service_timeout_minutes: float | None = None, output_dir: str | os.PathLike[str] | None | object = _UNSET, ) -> SpeechDefaults: @@ -89,10 +75,9 @@ def update_defaults( current.voice if voice is None else voice, current.speed if speed is None else speed, current.format if format is None else format, - _updated_service_mode(current.service, service_mode, service_timeout_minutes), - _updated_service_timeout( - current.service, service_mode, service_timeout_minutes - ), + current.service_timeout_minutes + if service_timeout_minutes is None + else service_timeout_minutes, current.output_dir if output_dir is _UNSET else output_dir, ) _write_config(updated) @@ -108,7 +93,6 @@ def _validated_defaults( voice: object, speed: object, format: object, - service_mode: object, service_timeout_minutes: object, output_dir: object, ) -> SpeechDefaults: @@ -122,27 +106,13 @@ def _validated_defaults( if not isinstance(format, str) or format.lower() not in FORMATS: raise ValueError(f"Default format must be one of: {', '.join(FORMATS)}") audio_format = format.lower() - if not isinstance(service_mode, str) or service_mode.lower() not in SERVICE_MODES: - raise ValueError( - f"Default service mode must be one of: {', '.join(SERVICE_MODES)}" - ) - normalized_service_mode = service_mode.lower() - if normalized_service_mode == "timed": - if isinstance(service_timeout_minutes, bool) or not isinstance( - service_timeout_minutes, (int, float) - ): - raise ValueError("Service timeout must be a number of minutes") - timeout = float(service_timeout_minutes) - if not math.isfinite(timeout) or timeout <= 0: - raise ValueError( - "Service timeout must be a finite number greater than zero" - ) - else: - if service_timeout_minutes is not None: - raise ValueError( - "Service timeout can only be set when service mode is timed" - ) - timeout = None + if isinstance(service_timeout_minutes, bool) or not isinstance( + service_timeout_minutes, (int, float) + ): + raise ValueError("Service timeout must be a number of minutes") + timeout = float(service_timeout_minutes) + if not math.isfinite(timeout) or timeout <= 0: + raise ValueError("Service timeout must be a finite number greater than zero") if output_dir is None: configured_output_dir = None else: @@ -159,54 +129,20 @@ def _validated_defaults( voice.strip(), value, audio_format, - ServiceDefaults(normalized_service_mode, timeout), + timeout, configured_output_dir, ) -def _service_values(payload: dict[str, object]) -> tuple[object, object]: +def _service_timeout(payload: dict[str, object]) -> object: service = payload.get("service") if service is None: - return DEFAULT_SERVICE, DEFAULT_SERVICE_TIMEOUT_MINUTES + return payload.get("service_timeout_minutes", DEFAULT_SERVICE_TIMEOUT_MINUTES) if isinstance(service, str): - legacy_mode = {"auto": "timed", "required": "on"}.get(service, service) - timeout = payload.get( - "service_timeout_minutes", DEFAULT_SERVICE_TIMEOUT_MINUTES - ) - return legacy_mode, timeout if legacy_mode == "timed" else None + return payload.get("service_timeout_minutes", DEFAULT_SERVICE_TIMEOUT_MINUTES) if not isinstance(service, dict): raise ValueError("Default service must be a JSON object") - mode = service.get("mode", DEFAULT_SERVICE) - timeout = service.get( - "timeout_minutes", - DEFAULT_SERVICE_TIMEOUT_MINUTES if mode == "timed" else None, - ) - return mode, timeout - - -def _updated_service_mode( - current: ServiceDefaults, - requested_mode: str | None, - requested_timeout: float | None, -) -> str: - if requested_timeout is not None and requested_mode is None: - return "timed" - return current.mode if requested_mode is None else requested_mode - - -def _updated_service_timeout( - current: ServiceDefaults, - requested_mode: str | None, - requested_timeout: float | None, -) -> float | None: - mode = _updated_service_mode(current, requested_mode, requested_timeout) - if mode != "timed": - return requested_timeout - if requested_timeout is not None: - return requested_timeout - if current.mode == "timed": - return current.timeout_minutes - return DEFAULT_SERVICE_TIMEOUT_MINUTES + return service.get("timeout_minutes", DEFAULT_SERVICE_TIMEOUT_MINUTES) def _write_config(defaults: SpeechDefaults) -> None: diff --git a/src/agent_voice/controls.py b/src/agent_voice/controls.py new file mode 100644 index 0000000..293857c --- /dev/null +++ b/src/agent_voice/controls.py @@ -0,0 +1,415 @@ +from __future__ import annotations + +import ctypes +import json +import os +import plistlib +import shutil +import subprocess +import sys +import tempfile +import urllib.error +import urllib.request +from pathlib import Path +from urllib.parse import unquote, urlsplit + +from .audio import PLAYBACK_ACTIONS +from .viewer import Viewer, active_viewer, valid_control_token + + +_SCHEME = "agent-voice" +_APP_NAME = "Agent Voice Controls.app" +_BUNDLE_ID = "com.yoavgal.agent-voice.link-handler" +_OWNED_BUNDLE_IDS = {_BUNDLE_ID, "com.yoavgal.agent-voice.controls"} +_LINUX_DESKTOP_NAME = "agent-voice-controls.desktop" +_WINDOWS_REGISTRY_PATH = rf"Software\Classes\{_SCHEME}" +_WINDOWS_OWNER_VALUE = "AgentVoiceOwned" +_LSREGISTER = Path( + "/System/Library/Frameworks/CoreServices.framework/Frameworks/" + "LaunchServices.framework/Support/lsregister" +) + + +def handler_path() -> Path | str: + if sys.platform == "darwin": + return ( + Path.home() + / "Library" + / "Application Support" + / "agent-voice" + / "integrations" + / _APP_NAME + ) + if sys.platform.startswith("linux"): + data_home = Path( + os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share") + ).expanduser() + return data_home / "applications" / _LINUX_DESKTOP_NAME + if sys.platform == "win32": + return rf"HKCU\{_WINDOWS_REGISTRY_PATH}" + raise RuntimeError(f"Agent Voice control links are unsupported on {sys.platform}") + + +def install_handler() -> Path | str: + if sys.platform == "darwin": + return _install_macos_handler() + if sys.platform.startswith("linux"): + return _install_linux_handler() + if sys.platform == "win32": + return _install_windows_handler() + raise RuntimeError(f"Agent Voice control links are unsupported on {sys.platform}") + + +def _install_macos_handler() -> Path: + destination = handler_path() + assert isinstance(destination, Path) + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=destination.parent) as temporary: + candidate = Path(temporary) / _APP_NAME + script = ( + "on dispatchControl(commandName, controlValue)\n" + 'do shell script "/usr/bin/nohup " & quoted form of ' + f'"{_applescript_string(sys.executable)}" & ' + '" -m agent_voice " & commandName & " " & ' + "quoted form of controlValue & " + '" >/dev/null 2>&1 &"\n' + "end dispatchControl\n" + "on open location theURL\n" + 'dispatchControl("control-url", theURL)\n' + "end open location" + ) + _run(["/usr/bin/osacompile", "-o", str(candidate), "-e", script]) + info_path = candidate / "Contents" / "Info.plist" + with info_path.open("rb") as stream: + info = plistlib.load(stream) + info.update( + { + "CFBundleIdentifier": _BUNDLE_ID, + "CFBundleName": "Agent Voice Controls", + "LSUIElement": True, + "CFBundleURLTypes": [ + { + "CFBundleTypeRole": "Viewer", + "CFBundleURLName": _BUNDLE_ID, + "CFBundleURLSchemes": [_SCHEME], + } + ], + } + ) + with info_path.open("wb") as stream: + plistlib.dump(info, stream) + _run(["/usr/bin/codesign", "--force", "--deep", "--sign", "-", str(candidate)]) + _run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(candidate)]) + + previous = destination.with_name(f".{destination.name}.previous") + if previous.exists() or previous.is_symlink(): + _require_our_handler(previous) + if destination.exists() or destination.is_symlink(): + _require_our_handler(destination) + if previous.exists(): + shutil.rmtree(previous) + destination.replace(previous) + try: + candidate.replace(destination) + _run([str(_LSREGISTER), "-f", str(destination)]) + _set_default_handler() + except BaseException: + shutil.rmtree(destination, ignore_errors=True) + if previous.exists(): + previous.replace(destination) + raise + shutil.rmtree(previous, ignore_errors=True) + return destination + + +def uninstall_handler() -> bool: + if sys.platform == "darwin": + return _uninstall_macos_handler() + if sys.platform.startswith("linux"): + return _uninstall_linux_handler() + if sys.platform == "win32": + return _uninstall_windows_handler() + raise RuntimeError(f"Agent Voice control links are unsupported on {sys.platform}") + + +def _uninstall_macos_handler() -> bool: + destination = handler_path() + assert isinstance(destination, Path) + if not destination.exists() and not destination.is_symlink(): + return False + _require_our_handler(destination) + _run([str(_LSREGISTER), "-u", str(destination)]) + shutil.rmtree(destination) + return True + + +def _install_linux_handler() -> Path: + destination = handler_path() + assert isinstance(destination, Path) + xdg_mime = shutil.which("xdg-mime") + if xdg_mime is None: + raise RuntimeError("xdg-mime is required to register Agent Voice controls") + if destination.exists() or destination.is_symlink(): + _require_our_linux_handler(destination) + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=destination.parent) as temporary: + candidate = Path(temporary) / destination.name + candidate.write_text( + "[Desktop Entry]\n" + "Type=Application\n" + "Name=Agent Voice Controls\n" + "Comment=Playback controls for Agent Voice\n" + f"Exec={_desktop_exec(sys.executable)} -m agent_voice control-url %u\n" + "Terminal=false\n" + "NoDisplay=true\n" + f"MimeType=x-scheme-handler/{_SCHEME};\n" + "X-Agent-Voice-Owned=true\n", + encoding="utf-8", + ) + candidate.chmod(0o755) + previous = destination.with_name(f".{destination.name}.previous") + if previous.exists() or previous.is_symlink(): + _require_our_linux_handler(previous) + if destination.exists() or destination.is_symlink(): + previous.unlink(missing_ok=True) + destination.replace(previous) + try: + candidate.replace(destination) + _run( + [ + xdg_mime, + "default", + destination.name, + f"x-scheme-handler/{_SCHEME}", + ] + ) + _update_linux_desktop_database(destination.parent) + except BaseException: + destination.unlink(missing_ok=True) + if previous.exists(): + previous.replace(destination) + raise + previous.unlink(missing_ok=True) + return destination + + +def _uninstall_linux_handler() -> bool: + destination = handler_path() + assert isinstance(destination, Path) + if not destination.exists() and not destination.is_symlink(): + return False + _require_our_linux_handler(destination) + destination.unlink() + _update_linux_desktop_database(destination.parent) + return True + + +def _install_windows_handler() -> str: + winreg = _winreg() + if _windows_handler_exists(winreg) and not _windows_handler_owned(winreg): + raise RuntimeError( + f"Refusing to replace unrecognized handler: {handler_path()}" + ) + executable = _windows_handler_executable() + values = { + _WINDOWS_REGISTRY_PATH: { + None: "URL:Agent Voice playback controls", + "URL Protocol": "", + _WINDOWS_OWNER_VALUE: "1", + }, + rf"{_WINDOWS_REGISTRY_PATH}\DefaultIcon": {None: sys.executable}, + rf"{_WINDOWS_REGISTRY_PATH}\shell\open\command": { + None: f'"{executable}" -m agent_voice control-url "%1"' + }, + } + for key_path, entries in values.items(): + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, key_path) as key: + for name, value in entries.items(): + winreg.SetValueEx(key, name, 0, winreg.REG_SZ, value) + return str(handler_path()) + + +def _windows_handler_executable() -> str: + windowed = Path(sys.executable).with_name("pythonw.exe") + if not windowed.is_file(): + raise RuntimeError("pythonw.exe is required for Agent Voice controls") + return str(windowed) + + +def _uninstall_windows_handler() -> bool: + winreg = _winreg() + if not _windows_handler_exists(winreg): + return False + if not _windows_handler_owned(winreg): + raise RuntimeError(f"Refusing to remove unrecognized handler: {handler_path()}") + for key_path in ( + rf"{_WINDOWS_REGISTRY_PATH}\shell\open\command", + rf"{_WINDOWS_REGISTRY_PATH}\shell\open", + rf"{_WINDOWS_REGISTRY_PATH}\shell", + rf"{_WINDOWS_REGISTRY_PATH}\DefaultIcon", + _WINDOWS_REGISTRY_PATH, + ): + try: + winreg.DeleteKey(winreg.HKEY_CURRENT_USER, key_path) + except FileNotFoundError: + pass + return True + + +def _require_our_linux_handler(desktop: Path) -> None: + if desktop.is_symlink(): + raise RuntimeError(f"Refusing to replace symlink: {desktop}") + try: + owned = "X-Agent-Voice-Owned=true" in desktop.read_text(encoding="utf-8") + except OSError: + owned = False + if not owned: + raise RuntimeError(f"Refusing to replace unrecognized handler: {desktop}") + + +def _update_linux_desktop_database(directory: Path) -> None: + update_database = shutil.which("update-desktop-database") + if update_database is not None: + _run([update_database, str(directory)]) + + +def _desktop_exec(executable: str) -> str: + escaped = [] + for character in executable: + if character == "\\": + escaped.append("\\" * 4) + elif character == '"': + escaped.append("\\" * 3 + character) + elif character in {"`", "$"}: + escaped.append("\\" * 2 + character) + elif character == "%": + escaped.append("%%") + else: + escaped.append(character) + return f'"{"".join(escaped)}"' + + +def _winreg(): + try: + import winreg + except ImportError as error: # pragma: no cover - only reachable off Windows + raise RuntimeError("Windows registry support is unavailable") from error + return winreg + + +def _windows_handler_exists(winreg) -> bool: + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _WINDOWS_REGISTRY_PATH): + return True + except OSError: + return False + + +def _windows_handler_owned(winreg) -> bool: + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _WINDOWS_REGISTRY_PATH) as key: + return winreg.QueryValueEx(key, _WINDOWS_OWNER_VALUE)[0] == "1" + except OSError: + return False + + +def parse_control_url(value: str) -> tuple[str, str]: + try: + url = urlsplit(value) + if url.port is not None: + raise ValueError + parts = [unquote(part, errors="strict") for part in url.path.split("/")[1:]] + except (UnicodeError, ValueError) as error: + raise ValueError("Invalid Agent Voice control link") from error + if ( + url.scheme != _SCHEME + or url.netloc != "control" + or url.username is not None + or url.password is not None + or url.query + or url.fragment + or len(parts) != 2 + or not valid_control_token(parts[0]) + or parts[1] not in PLAYBACK_ACTIONS + ): + raise ValueError("Invalid Agent Voice control link") + return parts[0], parts[1] + + +def trigger_control_url(value: str) -> dict[str, object]: + token, action = parse_control_url(value) + viewer = active_viewer() + if viewer is None or viewer.url is None: + raise RuntimeError("Recording viewer is not running") + return _trigger_control(viewer, token, action) + + +def _trigger_control(viewer: Viewer, token: str, action: str) -> dict[str, object]: + request = urllib.request.Request( + f"{viewer.url}/control/{token}/{action}", + headers={"X-Agent-Voice-Control": "1"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: + result = json.loads(response.read()) + except (OSError, urllib.error.URLError, json.JSONDecodeError) as error: + raise RuntimeError("Playback control failed") from error + if not isinstance(result, dict): + raise RuntimeError("Playback control returned an invalid response") + return result + + +def _run(command: list[str]) -> None: + try: + subprocess.run(command, check=True, capture_output=True, text=True) + except (OSError, subprocess.CalledProcessError) as error: + detail = getattr(error, "stderr", "").strip() + raise RuntimeError( + f"Could not update control handler{': ' + detail if detail else ''}" + ) from error + + +def _require_our_handler(app: Path) -> None: + if app.is_symlink(): + raise RuntimeError(f"Refusing to replace symlink: {app}") + try: + with (app / "Contents" / "Info.plist").open("rb") as stream: + owned = plistlib.load(stream).get("CFBundleIdentifier") in _OWNED_BUNDLE_IDS + except (OSError, plistlib.InvalidFileException): + owned = False + if not owned: + raise RuntimeError(f"Refusing to replace unrecognized app: {app}") + + +def _applescript_string(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"') + + +def _set_default_handler() -> None: + core_foundation = ctypes.CDLL( + "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" + ) + launch_services = ctypes.CDLL( + "/System/Library/Frameworks/CoreServices.framework/Frameworks/" + "LaunchServices.framework/LaunchServices" + ) + create = core_foundation.CFStringCreateWithCString + create.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_uint32] + create.restype = ctypes.c_void_p + release = core_foundation.CFRelease + release.argtypes = [ctypes.c_void_p] + set_handler = launch_services.LSSetDefaultHandlerForURLScheme + set_handler.argtypes = [ctypes.c_void_p, ctypes.c_void_p] + set_handler.restype = ctypes.c_int32 + scheme = create(None, _SCHEME.encode(), 0x08000100) + bundle = create(None, _BUNDLE_ID.encode(), 0x08000100) + try: + scheme_status = set_handler(scheme, bundle) + finally: + release(scheme) + release(bundle) + if scheme_status != 0: + raise RuntimeError( + f"Could not register Agent Voice controls (URL status {scheme_status})" + ) diff --git a/src/agent_voice/delivery.py b/src/agent_voice/delivery.py index b0d3471..ccc060b 100644 --- a/src/agent_voice/delivery.py +++ b/src/agent_voice/delivery.py @@ -6,10 +6,12 @@ from .media import CONTENT_TYPES from .viewer import ( ensure_viewer, + publish_control, publish_language, publish_recording, publish_player, publish_source, + recording_control_urls, recording_urls, ) @@ -19,6 +21,7 @@ class Delivery: browser_url: str | None = None audio_url: str | None = None recording_path: Path | None = None + controls: dict[str, str] | None = None warning: str | None = None @@ -30,6 +33,7 @@ def prepare_delivery( language: str = "en-us", audio_format: str | None = None, recordings_dir: Path | None = None, + controls: bool = False, ) -> Delivery: """Publish one recording to the lightweight local viewer.""" path = recording.expanduser().resolve() @@ -57,8 +61,20 @@ def prepare_delivery( warning=f"Could not start recording viewer; using file fallback ({error})", ) + control_urls = None + warning = None + if controls: + try: + control_urls = recording_control_urls(publish_control(published)) + except (OSError, RuntimeError, ValueError) as error: + warning = ( + f"Could not prepare playback controls; using viewer fallback ({error})" + ) + return Delivery( browser_url=browser_url, audio_url=audio_url, recording_path=published, + controls=control_urls, + warning=warning, ) diff --git a/src/agent_voice/service.py b/src/agent_voice/service.py index 1c936cb..2c33ab7 100644 --- a/src/agent_voice/service.py +++ b/src/agent_voice/service.py @@ -86,9 +86,6 @@ def do_GET(self) -> None: "model_id": descriptor.selection.model_id, "variant": descriptor.selection.variant, "ready": True, - "service_mode": ( - "timed" if idle_timeout_minutes is not None else "on" - ), "service_timeout_minutes": idle_timeout_minutes, }, ) @@ -102,6 +99,14 @@ def do_POST(self) -> None: if not self._host_is_local(): self._json(403, {"error": "Host must be localhost"}) return + if self.path == "/shutdown": + server = self.server + if not isinstance(server, IdleHTTPServer): + self._json(400, {"error": "Service shutdown is unavailable"}) + return + self._json(200, {"status": "stopping"}) + server.request_stop() + return if self.path == "/lifecycle": try: payload = self._read_json() @@ -117,9 +122,6 @@ def do_POST(self) -> None: 200, { "status": "ok", - "service_mode": ( - "timed" if server.idle_timeout_seconds is not None else "on" - ), "service_timeout_minutes": ( None if server.idle_timeout_seconds is None @@ -236,6 +238,7 @@ def __init__( self.last_request_completed = time.monotonic() self._active_requests = 0 self._activity_lock = threading.Lock() + self._stop_requested = threading.Event() def process_request( self, request: socket.socket, client_address: tuple[str, int] @@ -269,7 +272,7 @@ def set_idle_timeout_minutes(self, minutes: object) -> None: self.idle_timeout_seconds = value * 60 def serve_until_idle(self) -> None: - while True: + while not self._stop_requested.is_set(): idle_timeout, active_requests, last_completed = self._activity_snapshot() if idle_timeout is None or active_requests: self.timeout = 0.5 @@ -280,6 +283,9 @@ def serve_until_idle(self) -> None: self.timeout = min(0.5, remaining) self.handle_request() + def request_stop(self) -> None: + self._stop_requested.set() + def _mark_request_started(self) -> None: with self._activity_lock: self._active_requests += 1 diff --git a/src/agent_voice/speaking.py b/src/agent_voice/speaking.py index 5d6896f..ec3cad9 100644 --- a/src/agent_voice/speaking.py +++ b/src/agent_voice/speaking.py @@ -9,20 +9,14 @@ from pathlib import Path from typing import Protocol -from .audio import play_audio, write_audio +from .audio import write_audio from .client import ( DEFAULT_SERVICE_URL, ServiceUnavailable, ensure_service, request_speech, ) -from .config import ( - DEFAULT_SERVICE_TIMEOUT_MINUTES, - FORMATS, - SERVICE_MODES, - SpeechDefaults, - load_defaults, -) +from .config import FORMATS, SpeechDefaults, load_defaults from .delivery import Delivery, prepare_delivery from .model import ( ModelSelection, @@ -33,6 +27,7 @@ ) from .paths import resolved_recording_dir from .registry import MODEL_REGISTRY +from .viewer import start_playback @dataclass(frozen=True) @@ -46,26 +41,27 @@ class SpeakRequest: voice: str | None = None speed: float | None = None language: str = "en-us" - play: bool = False - service: str | None = None - service_timeout_minutes: float | None = None + play_after: float | None = None + no_service: bool = False service_url: str = DEFAULT_SERVICE_URL response_markdown: str | None = None + controls: bool = False @dataclass(frozen=True) class SpeakReceipt: recording: Recording selection: ModelSelection - played: bool delivery: Delivery + playback: dict[str, object] | None = None service_fallback: bool = False def to_dict(self) -> dict[str, object]: payload = self.recording.to_dict() payload["model_id"] = self.selection.model_id payload["variant"] = self.selection.variant - payload["played"] = self.played + if self.playback is not None: + payload["playback"] = self.playback if self.service_fallback: payload["service_fallback"] = True payload["file_uri"] = self.recording.path.resolve().as_uri() @@ -78,6 +74,8 @@ def to_dict(self) -> dict[str, object]: "recording_path": str(self.delivery.recording_path), } ) + if self.delivery.controls is not None: + delivery["controls"] = self.delivery.controls payload["delivery"] = delivery return payload @@ -99,10 +97,11 @@ class _ResolvedSpeakRequest: voice: str speed: float language: str - play: bool - service: str - service_timeout_minutes: float | None + play_after: float | None + no_service: bool + service_timeout_minutes: float service_url: str + controls: bool class _RecordingGenerator(Protocol): @@ -119,6 +118,7 @@ def __call__( language: str, audio_format: str, recordings_dir: Path, + controls: bool, ) -> Delivery: ... @@ -184,7 +184,9 @@ def __init__( defaults_loader: Callable[[], SpeechDefaults] = load_defaults, embedded: _RecordingGenerator | None = None, service: _RecordingGenerator | None = None, - playback: Callable[[Path], None] = play_audio, + playback: Callable[[Path, float | None], dict[str, object]] = ( + lambda path, delay: start_playback(path, after=delay) + ), delivery: _DeliveryPreparer = prepare_delivery, now: Callable[[], datetime] = datetime.now, notice: Callable[[str], None] | None = None, @@ -206,7 +208,7 @@ def speak(self, request: SpeakRequest) -> SpeakReceipt: resolved = self._resolve(request, defaults) fallback = False try: - if resolved.service == "off": + if resolved.no_service: recording = self._embedded.generate(resolved) else: try: @@ -224,8 +226,6 @@ def speak(self, request: SpeakRequest) -> SpeakReceipt: resolved.output.destination.unlink(missing_ok=True) raise - if resolved.play: - self._playback(recording.path) delivery = self._delivery( recording.path, resolved.response_markdown, @@ -233,14 +233,22 @@ def speak(self, request: SpeakRequest) -> SpeakReceipt: language=resolved.language, audio_format=recording.format, recordings_dir=resolved.output.recording_root, + controls=resolved.controls, ) if delivery.warning is not None: self._notice(f"Warning: {delivery.warning}") + playback = None + if resolved.play_after is not None: + if delivery.recording_path is None: + raise RuntimeError( + "Could not start playback because viewer delivery failed" + ) + playback = self._playback(delivery.recording_path, resolved.play_after) return SpeakReceipt( recording=recording, selection=resolved.selection, - played=resolved.play, delivery=delivery, + playback=playback, service_fallback=fallback, ) @@ -249,7 +257,6 @@ def _resolve( request: SpeakRequest, defaults: SpeechDefaults, ) -> _ResolvedSpeakRequest: - service, timeout = _resolve_service_policy(request, defaults) return _ResolvedSpeakRequest( text=request.text, response_markdown=( @@ -262,10 +269,11 @@ def _resolve( voice=request.voice if request.voice is not None else defaults.voice, speed=request.speed if request.speed is not None else defaults.speed, language=request.language, - play=request.play, - service=service, - service_timeout_minutes=timeout, + play_after=request.play_after, + no_service=request.no_service, + service_timeout_minutes=defaults.service_timeout_minutes, service_url=request.service_url, + controls=request.controls, ) def _plan_output( @@ -320,26 +328,6 @@ def _plan_output( ) -def _resolve_service_policy( - request: SpeakRequest, - defaults: SpeechDefaults, -) -> tuple[str, float | None]: - configured = defaults.service - service = request.service if request.service is not None else configured.mode - if service not in SERVICE_MODES: - raise ValueError(f"Service mode must be one of: {', '.join(SERVICE_MODES)}") - requested_timeout = request.service_timeout_minutes - if requested_timeout is not None and service != "timed": - raise ValueError("--service-timeout can only be used with --service timed") - if service != "timed": - return service, None - if requested_timeout is not None: - return service, requested_timeout - if configured.mode == "timed": - return service, configured.timeout_minutes - return service, DEFAULT_SERVICE_TIMEOUT_MINUTES - - def _require_planned_recording( recording: Recording, output: _OutputPlan, diff --git a/src/agent_voice/updates.py b/src/agent_voice/updates.py new file mode 100644 index 0000000..bb6bfd1 --- /dev/null +++ b/src/agent_voice/updates.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +from . import __version__ + +PYPI_URL = "https://pypi.org/pypi/agent-voice/json" +CHECK_INTERVAL_SECONDS = 24 * 60 * 60 + + +def notify_if_update_available() -> None: + if sys.stderr is None or not sys.stderr.isatty(): + return + + now = time.time() + cache = _read_cache() + changed = False + latest = cache.get("latest") + if now - _timestamp(cache.get("checked_at")) >= CHECK_INTERVAL_SECONDS: + latest = _latest_version() or latest + cache.update(checked_at=now, latest=latest) + changed = True + + latest_version = _stable_version(latest) if isinstance(latest, str) else None + current_version = _stable_version(__version__) + if ( + latest_version is not None + and current_version is not None + and latest_version > current_version + and now - _timestamp(cache.get("notified_at")) >= CHECK_INTERVAL_SECONDS + ): + print( + f"Agent Voice {latest} is available; run: agent-voice update", + file=sys.stderr, + ) + cache["notified_at"] = now + changed = True + + if changed: + _write_cache(cache) + + +def run_update() -> int: + prefix = Path(sys.prefix) + if (prefix / "pipx_metadata.json").is_file(): + command = _manager_command("pipx", "upgrade", "agent-voice") + elif (prefix / "uv-receipt.toml").is_file(): + command = _manager_command("uv", "tool", "upgrade", "agent-voice") + else: + raise RuntimeError( + "Could not identify a uv or pipx installation; update Agent Voice " + "with the tool that installed it" + ) + return subprocess.run(command, check=False).returncode + + +def _manager_command(name: str, *arguments: str) -> list[str]: + executable = shutil.which(name) + if executable is None: + raise RuntimeError( + f"{name} is required to update this Agent Voice installation" + ) + return [executable, *arguments] + + +def _latest_version() -> str | None: + request = urllib.request.Request( + PYPI_URL, headers={"User-Agent": f"agent-voice/{__version__}"} + ) + try: + with urllib.request.urlopen(request, timeout=1) as response: + latest = json.load(response)["info"]["version"] + except (OSError, KeyError, TypeError, ValueError): + return None + return ( + latest + if isinstance(latest, str) and _stable_version(latest) is not None + else None + ) + + +def _stable_version(version: str) -> tuple[int, int, int] | None: + # ponytail: stable releases only; use packaging.version if prereleases are added. + if not re.fullmatch(r"\d+\.\d+\.\d+", version): + return None + return tuple(map(int, version.split("."))) + + +def _cache_path() -> Path: + configured = os.environ.get("AGENT_VOICE_HOME") + if configured: + return Path(configured).expanduser().resolve() / "update-check.json" + if sys.platform == "darwin": + root = Path.home() / "Library" / "Caches" + elif sys.platform == "win32": + root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) + else: + root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) + return root / "agent-voice" / "update-check.json" + + +def _read_cache() -> dict[str, object]: + try: + cache = json.loads(_cache_path().read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return cache if isinstance(cache, dict) else {} + + +def _write_cache(cache: dict[str, object]) -> None: + try: + path = _cache_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(cache), encoding="utf-8") + except OSError: + pass + + +def _timestamp(value: object) -> float: + return float(value) if isinstance(value, (int, float)) else 0.0 diff --git a/src/agent_voice/viewer.py b/src/agent_voice/viewer.py index 3cfe07b..694d5fe 100644 --- a/src/agent_voice/viewer.py +++ b/src/agent_voice/viewer.py @@ -3,6 +3,8 @@ import hashlib import json import os +import re +import secrets import shutil import signal import subprocess @@ -17,16 +19,19 @@ from filelock import FileLock +from .audio import PLAYBACK_ACTIONS from .media import CONTENT_TYPES from .paths import project_root, recording_dir _TRANSCRIPT_DIRECTORY = ".agent-voice-viewer" _PLAYER_DIRECTORY = "players" +_CONTROL_DIRECTORY = "controls" _RECORDING_RETENTION_SECONDS = (4 * 24 + 18) * 60 * 60 _STARTUP_TIMEOUT_SECONDS = 15.0 _STARTUP_HEALTH_TIMEOUT_SECONDS = 1.0 -VIEWER_PROTOCOL = 3 +VIEWER_PROTOCOL = 9 +_CONTROL_TOKEN = re.compile(r"[A-Za-z0-9_-]{24}") @dataclass(frozen=True) @@ -80,10 +85,7 @@ def ensure_viewer(recordings_dir: Path | None = None) -> Viewer: stderr=subprocess.DEVNULL, close_fds=True, **( - { - "creationflags": subprocess.CREATE_NEW_PROCESS_GROUP - | subprocess.DETACHED_PROCESS - } + {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {"start_new_session": True} ), @@ -231,6 +233,14 @@ def publish_player(recording: Path, text: str) -> str: return f"{name}.html" +def publish_control(recording: Path) -> str: + root = transcript_path(recording).parent / _CONTROL_DIRECTORY + root.mkdir(parents=True, exist_ok=True, mode=0o700) + token = secrets.token_urlsafe(18) + _write_text(root / f"{token}.txt", recording.name) + return token + + def transcript_path(recording: Path) -> Path: path = recording.expanduser().resolve() digest = hashlib.sha256(path.name.encode()).hexdigest() @@ -270,6 +280,10 @@ def player_mapping_path(recordings: Path, player_name: str) -> Path: return recordings / _TRANSCRIPT_DIRECTORY / _PLAYER_DIRECTORY / f"{player_name}.txt" +def control_mapping_path(recordings: Path, token: str) -> Path: + return recordings / _TRANSCRIPT_DIRECTORY / _CONTROL_DIRECTORY / f"{token}.txt" + + def recording_urls( viewer: Viewer, recording: Path, @@ -284,6 +298,50 @@ def recording_urls( ) +def recording_control_urls(token: str) -> dict[str, str]: + if _CONTROL_TOKEN.fullmatch(token) is None: + raise ValueError("Invalid playback control token") + base = f"agent-voice://control/{token}" + return {action: f"{base}/{action}" for action in PLAYBACK_ACTIONS} + + +def valid_control_token(token: str) -> bool: + return _CONTROL_TOKEN.fullmatch(token) is not None + + +def active_viewer() -> Viewer | None: + return _running(_state()) + + +def start_playback(recording: Path, *, after: float | None = None) -> dict[str, object]: + """Ask the persistent local viewer to start one recording.""" + path = recording.expanduser().resolve() + if not path.is_file(): + raise FileNotFoundError(f"Recording not found: {path}") + if after is not None and after < 0: + raise ValueError("Playback delay must not be negative") + viewer = ensure_viewer(path.parent) + if viewer.url is None: + raise RuntimeError("Recording viewer is not running") + delay = "" if after is None else f"?after={after:g}" + request = urllib.request.Request( + f"{viewer.url}/play/{quote(path.name, safe='')}{delay}", + headers={"X-Agent-Voice-Playback": "1"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=15) as response: + result = json.loads(response.read()) + except (OSError, urllib.error.URLError, json.JSONDecodeError) as error: + raise RuntimeError("Playback could not be started") from error + if not isinstance(result, dict) or result.get("state") not in { + "started", + "scheduled", + }: + raise RuntimeError("Playback returned an invalid response") + return result + + def _state() -> dict[str, object]: try: value = json.loads((project_root() / "viewer.json").read_text(encoding="utf-8")) diff --git a/src/agent_voice/viewer_server.py b/src/agent_voice/viewer_server.py index 6bb28da..8d83719 100644 --- a/src/agent_voice/viewer_server.py +++ b/src/agent_voice/viewer_server.py @@ -5,6 +5,7 @@ import errno import html import json +import math import os import threading import time @@ -13,7 +14,7 @@ from pathlib import Path from socketserver import TCPServer from string import Template -from urllib.parse import quote, unquote, urlsplit +from urllib.parse import parse_qs, quote, unquote, urlsplit from markdown_it import MarkdownIt from pygments import highlight @@ -22,17 +23,19 @@ from pygments.util import ClassNotFound from . import __version__ -from .audio import write_audio +from .audio import PLAYBACK_ACTIONS, PlaybackController, write_audio from .config import load_defaults from .media import CONTENT_TYPES from .model import NamedVoice, SynthesisRequest from .registry import MODEL_REGISTRY from .viewer import ( + control_mapping_path, delete_expired_recordings, language_path, player_mapping_path, source_path, transcript_path, + valid_control_token, VIEWER_PROTOCOL, ) @@ -74,11 +77,38 @@ class Server(ThreadingHTTPServer): daemon_threads = True def __init__(self, recordings: Path, port: int = 0) -> None: + self.playback = PlaybackController() + self._timers: set[threading.Timer] = set() + self._timer_lock = threading.Lock() super().__init__(("127.0.0.1", port), Handler) self.recordings = recordings.resolve() self.regeneration_lock = threading.Lock() self.next_cleanup = time.monotonic() + def server_close(self) -> None: + with self._timer_lock: + for timer in self._timers: + timer.cancel() + self._timers.clear() + self.playback.close() + super().server_close() + + def schedule_playback(self, recording: Path, delay: float) -> None: + def start() -> None: + try: + self.playback.control(recording, "restart") + except (OSError, RuntimeError, ValueError): + pass + finally: + with self._timer_lock: + self._timers.discard(timer) + + timer = threading.Timer(delay, start) + timer.daemon = True + with self._timer_lock: + self._timers.add(timer) + timer.start() + def service_actions(self) -> None: now = time.monotonic() if now >= self.next_cleanup: @@ -104,14 +134,59 @@ def do_GET(self) -> None: def do_HEAD(self) -> None: self._get(head=True) + def do_POST(self) -> None: + server = self.server + if not isinstance(server, Server) or not self._valid_host(server): + self.send_error(403) + return + url = urlsplit(self.path) + if url.path.startswith("/play/"): + self._play(url, server) + return + if url.query or self.headers.get("X-Agent-Voice-Control") != "1": + self.send_error(403) + return + target = self._control_target(url.path, server) + if target is None: + self.send_error(404) + return + recording, action = target + try: + state = server.playback.control(recording, action) + except (OSError, RuntimeError, ValueError): + self.send_error(503, "Playback control failed") + return + self._send(json.dumps(state.to_dict()).encode(), "application/json", False) + + def _play(self, url, server: Server) -> None: + if self.headers.get("X-Agent-Voice-Playback") != "1": + self.send_error(403) + return + delay = _playback_delay(url.query) + if delay is None: + self.send_error(404) + return + recording = self._recording(url.path.removeprefix("/play/"), server) + if recording is None: + self.send_error(404) + return + if delay: + server.schedule_playback(recording, delay) + payload = {"state": "scheduled", "starts_in_seconds": delay} + else: + try: + payload = { + "state": "started", + **server.playback.control(recording, "restart").to_dict(), + } + except (OSError, RuntimeError, ValueError): + self.send_error(503, "Playback could not be started") + return + self._send(json.dumps(payload).encode(), "application/json", False) + 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}", - }: + if not isinstance(server, Server) or not self._valid_host(server): self.send_error(403) return @@ -134,6 +209,10 @@ def _get(self, *, head: bool) -> None: ) return + if url.path.startswith("/control/"): + self.send_error(405) + return + prefix = next( ( value @@ -193,21 +272,40 @@ def _player_recording(self, encoded: str, server: Server) -> Path | None: return None return self._recording(quote(recording_name, safe=""), server) + def _control_target(self, path: str, server: Server) -> tuple[Path, str] | None: + if not path.startswith("/control/"): + return None + try: + token, action = ( + unquote(part, errors="strict") + for part in path.removeprefix("/control/").split("/") + ) + except (UnicodeError, ValueError): + return None + if not valid_control_token(token) or action not in PLAYBACK_ACTIONS: + return None + try: + recording_name = control_mapping_path(server.recordings, token).read_text( + encoding="utf-8" + ) + except (OSError, UnicodeError): + return None + recording = self._recording(quote(recording_name, safe=""), server) + return None if recording is None else (recording, action) + + def _valid_host(self, server: Server) -> bool: + return self.headers.get("Host", "").lower() in { + f"127.0.0.1:{server.server_port}", + f"localhost:{server.server_port}", + } + 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() except (OSError, UnicodeError, ValueError): return None - if path.parent != server.recordings: + path = self._recording_path(name, server) + if path is None: return None if not path.is_file(): with server.regeneration_lock: @@ -224,6 +322,21 @@ def _recording(self, encoded: str, server: Server) -> Path | None: _regenerate_recording(path) return path if path.is_file() else None + def _recording_path(self, name: str, server: Server) -> Path | None: + try: + 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() + except (OSError, ValueError): + return None + return path if path.parent == server.recordings else None + def _send_file(self, path: Path, content_type: str, head: bool) -> bool: try: source = path.open("rb") @@ -359,6 +472,19 @@ def _byte_range(value: str, size: int) -> tuple[int, int]: return max(0, size - suffix_length), size - 1 +def _playback_delay(query: str) -> float | None: + if not query: + return 0.0 + values = parse_qs(query, keep_blank_values=True) + if set(values) != {"after"} or len(values["after"]) != 1: + return None + try: + delay = float(values["after"][0]) + except ValueError: + return None + return delay if math.isfinite(delay) and delay >= 0 else None + + def _player(recording: Path) -> bytes: name = recording.name try: diff --git a/tests/test_audio.py b/tests/test_audio.py index 61c12cb..db50b70 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -1,12 +1,19 @@ from __future__ import annotations import wave +from types import SimpleNamespace +import miniaudio import numpy as np import pytest from agent_voice import audio as audio_module -from agent_voice.audio import change_tempo, write_audio, write_audio_bytes +from agent_voice.audio import ( + PlaybackController, + change_tempo, + write_audio, + write_audio_bytes, +) def test_write_wav(tmp_path): @@ -121,6 +128,15 @@ def test_ffmpeg_executable_uses_packaged_runtime(monkeypatch): assert audio_module._ffmpeg_executable() == "/package/imageio_ffmpeg/ffmpeg" +def test_windows_ffmpeg_processes_do_not_open_console_windows(monkeypatch): + monkeypatch.setattr(audio_module, "os", SimpleNamespace(name="nt")) + monkeypatch.setattr( + audio_module.subprocess, "CREATE_NO_WINDOW", 0x08000000, raising=False + ) + + assert audio_module._no_window_creation_flags() == 0x08000000 + + @pytest.mark.parametrize("factor", [0.49, 4.01]) def test_change_tempo_rejects_unsupported_factor(factor): with pytest.raises(ValueError, match="between 0.5 and 4.0"): @@ -139,6 +155,7 @@ def run(command, **kwargs): return Completed() monkeypatch.setattr(audio_module, "_ffmpeg_executable", lambda: "/bundled/ffmpeg") + monkeypatch.setattr(audio_module, "_no_window_creation_flags", lambda: 123) monkeypatch.setattr(audio_module.subprocess, "run", run) monkeypatch.setattr(audio_module, "_play_pcm", played.append) @@ -148,7 +165,7 @@ def run(command, **kwargs): assert command[0] == "/bundled/ffmpeg" assert command[command.index("-f") + 1] == "s16le" assert command[command.index("-ar") + 1] == "24000" - assert kwargs == {"check": True, "capture_output": True} + assert kwargs == {"check": True, "capture_output": True, "creationflags": 123} assert played == [b"\x00\x00\x01\x00"] @@ -183,3 +200,224 @@ def start(self, stream): assert device_options["sample_rate"] == 24_000 assert device_options["app_name"] == "Agent Voice" assert closed == [True] + + +def test_playback_controller_toggle_pauses_and_resumes_without_redecoding(tmp_path): + recording = tmp_path / "sample.mp3" + recording.write_bytes(b"audio") + decodes = [] + devices = [] + + class Device: + def __init__(self): + devices.append(self) + self.running = False + + def start(self, stream): + self.stream = stream + self.running = True + + def stop(self): + self.running = False + + def close(self): + self.running = False + + controller = PlaybackController( + decoder=lambda path: decodes.append(path) or bytes(96_000), + device_factory=lambda **_options: Device(), + ) + try: + assert controller.control(recording, "toggle").playing is True + devices[0].stream.send(24_000) + assert controller.control(recording, "toggle").playing is False + resumed = controller.control(recording, "toggle") + assert resumed.playing is True + assert resumed.position_seconds == 1.0 + finally: + controller.close() + + assert decodes == [recording.resolve()] + + +def test_playback_controller_restarts_and_seeks_ten_seconds(tmp_path): + recording = tmp_path / "sample.mp3" + recording.write_bytes(b"audio") + controller = PlaybackController( + decoder=lambda _path: bytes(30 * 48_000), + device_factory=lambda **_options: SimpleNamespace( + start=lambda _stream: None, + close=lambda: None, + ), + ) + try: + assert controller.control(recording, "forward").position_seconds == 10 + assert controller.control(recording, "forward").position_seconds == 20 + end = controller.control(recording, "forward") + assert end.position_seconds == 30 + assert end.playing is False + assert controller.control(recording, "back").position_seconds == 20 + restarted = controller.control(recording, "restart") + assert restarted.position_seconds == 0 + assert restarted.playing is True + finally: + controller.close() + + +def test_playback_controller_changes_speed_from_half_to_double_at_same_position( + tmp_path, +): + recording = tmp_path / "sample.mp3" + recording.write_bytes(b"audio") + changes = [] + + class Device: + running = False + + def start(self, _stream): + self.running = True + + def stop(self): + self.running = False + + def close(self): + self.running = False + + def change_tempo(pcm, speed): + changes.append(speed) + return bytes(int(len(pcm) / speed)) + + controller = PlaybackController( + decoder=lambda _path: bytes(60 * 48_000), + device_factory=lambda **_options: Device(), + tempo_changer=change_tempo, + ) + try: + assert controller.control(recording, "forward").position_seconds == 10 + assert controller.control(recording, "slower").speed == 0.75 + slowest = controller.control(recording, "slower") + assert slowest.speed == 0.5 + assert slowest.position_seconds == 10 + assert controller.control(recording, "slower").speed == 0.5 + assert controller.control(recording, "forward").position_seconds == 20 + assert controller.control(recording, "back").position_seconds == 10 + + for _ in range(6): + fastest = controller.control(recording, "faster") + assert fastest.speed == 2.0 + assert fastest.position_seconds == pytest.approx(10, abs=0.001) + + assert controller.control(recording, "toggle").playing is True + changed_while_playing = controller.control(recording, "slower") + assert changed_while_playing.speed == 1.75 + assert changed_while_playing.playing is True + assert changed_while_playing.position_seconds == pytest.approx(10, abs=0.001) + + other = tmp_path / "other.mp3" + other.write_bytes(b"audio") + switched = controller.control(other, "faster") + assert switched.speed == 1.25 + assert switched.position_seconds == 0 + assert switched.playing is False + finally: + controller.close() + + assert changes == [ + 0.75, + 0.5, + 0.75, + 1.0, + 1.25, + 1.5, + 1.75, + 2.0, + 1.75, + 1.25, + ] + + +def test_playback_controller_uses_live_position_after_tempo_change(tmp_path): + recording = tmp_path / "sample.mp3" + recording.write_bytes(b"audio") + device = SimpleNamespace(running=False) + + def start(stream): + device.stream = stream + device.running = True + + device.start = start + device.stop = lambda: setattr(device, "running", False) + device.close = device.stop + + def change_tempo(pcm, speed): + device.stream.send(24_000) + return bytes(int(len(pcm) / speed)) + + controller = PlaybackController( + decoder=lambda _path: bytes(20 * 48_000), + device_factory=lambda **_options: device, + tempo_changer=change_tempo, + ) + try: + controller.control(recording, "toggle") + device.stream.send(24_000) + changed = controller.control(recording, "faster") + finally: + controller.close() + + assert changed.position_seconds == 2 + + +def test_playback_controller_keeps_prior_speed_when_tempo_change_fails(tmp_path): + recording = tmp_path / "sample.mp3" + recording.write_bytes(b"audio") + controller = PlaybackController( + decoder=lambda _path: bytes(30 * 48_000), + tempo_changer=lambda _pcm, _speed: (_ for _ in ()).throw( + RuntimeError("tempo failed") + ), + ) + try: + assert controller.control(recording, "forward").position_seconds == 10 + with pytest.raises(RuntimeError, match="tempo failed"): + controller.control(recording, "faster") + state = controller.control(recording, "back") + assert state.speed == 1.0 + assert state.position_seconds == 0 + assert state.playing is False + finally: + controller.close() + + +def test_playback_controller_recovers_after_device_start_failure(tmp_path): + recording = tmp_path / "sample.mp3" + recording.write_bytes(b"audio") + starts = [] + + class Device: + running = False + + def start(self, _stream): + starts.append(None) + if len(starts) == 1: + raise miniaudio.MiniaudioError("failed") + self.running = True + + def stop(self): + self.running = False + + def close(self): + self.running = False + + controller = PlaybackController( + decoder=lambda _path: bytes(48_000), + device_factory=lambda **_options: Device(), + ) + try: + with pytest.raises(RuntimeError, match="audio playback failed"): + controller.control(recording, "toggle") + assert controller.control(recording, "toggle").playing is True + finally: + controller.close() + + assert len(starts) == 2 diff --git a/tests/test_cli.py b/tests/test_cli.py index addf13b..184ee27 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -57,7 +57,7 @@ def test_top_level_help_has_a_compact_agent_workflow(capsys): assert 'agent-voice play "/path/to/recording.mp3"' in help_text assert "agent-voice doctor --json" in help_text assert ( - "Agent speech: read the JSON path; only report playback when played=true." + "Agent speech: use playback.state; started and scheduled do not mean finished." in help_text ) @@ -95,12 +95,15 @@ def test_serve_accepts_the_highest_valid_port(): "--output", "--format", "--play", - "--service", + "--play-after", + "--controls", + "--no-service", ), ), ("voices", ("--json", "--model-id", "--variant")), ("doctor", ("--service-url", "--json")), ("serve", ("--host", "--port", "--idle-timeout")), + ("service", ("start", "stop")), ("viewer", ("start", "stop")), ], ) @@ -113,14 +116,14 @@ def test_command_help_exposes_public_options(command, public_options, capsys): assert all(option in help_text for option in public_options) -def test_config_help_uses_service_modes_and_timeout_language(capsys): +def test_config_help_exposes_only_the_service_timeout(capsys): with pytest.raises(SystemExit) as exit_info: cli.main(["config", "--help"]) assert exit_info.value.code == 0 help_text = capsys.readouterr().out - assert "--service {on,off,timed}" in help_text assert "--service-timeout MINUTES" in help_text + assert "--service {" not in help_text assert "--keep-alive" not in help_text @@ -138,6 +141,61 @@ def test_viewer_commands_report_start_and_stop(tmp_path, monkeypatch, capsys): assert json.loads(capsys.readouterr().out) == stopped.to_dict() +def test_service_commands_report_start_and_stop(monkeypatch, capsys): + started = { + "status": "ok", + "service": "agent-voice", + "model_id": "kokoro", + "variant": "int8", + } + calls = [] + configured = [] + + def start(url, selection, timeout): + calls.append((url, selection, timeout)) + return started + + monkeypatch.setattr(cli, "ensure_service", start) + monkeypatch.setattr( + cli, + "set_service_timeout", + lambda url, timeout: configured.append((url, timeout)), + ) + monkeypatch.setattr(cli, "stop_service", lambda _url: True) + + cli.main(["config", "--service-timeout", "3.5", "--json"]) + capsys.readouterr() + cli.main(["service", "start", "--json"]) + assert json.loads(capsys.readouterr().out) == { + **started, + "running": True, + "url": "http://127.0.0.1:8765", + "service_timeout_minutes": 3.5, + } + assert calls == [("http://127.0.0.1:8765", ModelSelection("kokoro", "int8"), 3.5)] + assert configured == [("http://127.0.0.1:8765", 3.5)] + + cli.main(["service", "stop", "--json"]) + assert json.loads(capsys.readouterr().out) == { + "running": False, + "stopped": True, + "url": "http://127.0.0.1:8765", + } + + +def test_service_start_accepts_an_idle_timeout_and_has_help(capsys): + args = cli.build_parser().parse_args(["service", "start", "--idle-timeout", "2.5"]) + assert args.idle_timeout == 2.5 + + with pytest.raises(SystemExit) as exit_info: + cli.main(["service", "start", "--help"]) + + assert exit_info.value.code == 0 + help_text = capsys.readouterr().out + assert "--idle-timeout MINUTES" in help_text + assert "built-in 10" in help_text + + def test_model_arguments_separate_identity_from_variant(): parser = cli.build_parser() @@ -228,7 +286,7 @@ def test_voices_lists_language_tags_and_keeps_flat_json_voices(monkeypatch, caps def test_speak_dispatches_request_and_serializes_receipt(tmp_path, monkeypatch, capsys): captured = [] - payload = {"path": str(tmp_path / "recording.wav"), "played": False} + payload = {"path": str(tmp_path / "recording.wav")} class Receipt: def to_dict(self): @@ -264,15 +322,15 @@ def speak(self, request): "1.15", "--lang", "en-gb", - "--play", + "-p", + "--play-after", + "2", + "--controls", "--model-id", "kokoro", "--variant", "fp16", - "--service", - "timed", - "--service-timeout", - "2.5", + "--no-service", "--service-url", "http://127.0.0.1:9000", ] @@ -290,9 +348,9 @@ def speak(self, request): voice="bf_emma", speed=1.15, language="en-gb", - play=True, - service="timed", - service_timeout_minutes=2.5, + play_after=2.0, + controls=True, + no_service=True, service_url="http://127.0.0.1:9000", ) ] @@ -314,10 +372,10 @@ def speak(self, request): monkeypatch.setattr(cli, "Speaker", FakeSpeaker) monkeypatch.setattr(cli.sys, "stdin", io.StringIO("Text from stdin.")) - cli.main(["speak", "--service", "off"]) + cli.main(["speak", "--no-service"]) assert captured[0].text == "Text from stdin." - assert captured[0].service == "off" + assert captured[0].no_service is True assert json.loads(capsys.readouterr().out) == {"receipt": True} @@ -388,6 +446,20 @@ def speak(self, request): assert capsys.readouterr().err == "Error: invalid request\n" +def test_cli_error_without_console_exits_cleanly(monkeypatch): + class FakeSpeaker: + def speak(self, request): + raise ValueError("invalid request") + + monkeypatch.setattr(cli, "Speaker", FakeSpeaker) + monkeypatch.setattr(cli, "sys", SimpleNamespace(stderr=None)) + + with pytest.raises(SystemExit) as exit_info: + cli.main(["speak", "Visible text."]) + + assert exit_info.value.code == 2 + + def test_setup_prepares_model(tmp_path, monkeypatch, capsys): model_path = tmp_path / "model.onnx" monkeypatch.setattr( @@ -405,37 +477,41 @@ def test_setup_prepares_model(tmp_path, monkeypatch, capsys): assert capsys.readouterr().out == f"Ready: {model_path}\n" -def test_play_command_plays_existing_recording(tmp_path, monkeypatch, capsys): +def test_play_command_starts_existing_recording(tmp_path, monkeypatch, capsys): recording = tmp_path / "existing recording.mp3" recording.write_bytes(b"audio") calls = [] - monkeypatch.setattr(cli, "play_audio", lambda path: calls.append(path)) + monkeypatch.setattr( + cli, + "start_playback", + lambda path, *, after: calls.append((path, after)) or {"state": "started"}, + ) cli.main(["play", str(recording), "--json"]) - assert calls == [recording] + assert calls == [(recording, None)] assert json.loads(capsys.readouterr().out) == { "path": str(recording), - "played": True, + "state": "started", } -def test_play_command_stops_cleanly_on_keyboard_interrupt( - tmp_path, monkeypatch, capsys -): +def test_play_command_schedules_existing_recording(tmp_path, monkeypatch, capsys): recording = tmp_path / "recording.mp3" recording.write_bytes(b"audio") monkeypatch.setattr( cli, - "play_audio", - lambda _path: (_ for _ in ()).throw(KeyboardInterrupt), + "start_playback", + lambda _path, *, after: {"state": "scheduled", "starts_in_seconds": after}, ) - with pytest.raises(SystemExit) as exit_info: - cli.main(["play", str(recording)]) + cli.main(["play", str(recording), "--after", "10", "--json"]) - assert exit_info.value.code == 130 - assert capsys.readouterr().err == "Playback stopped\n" + assert json.loads(capsys.readouterr().out) == { + "path": str(recording), + "state": "scheduled", + "starts_in_seconds": 10.0, + } @pytest.mark.parametrize("name", ["missing.mp3", "recording.flac"]) diff --git a/tests/test_config.py b/tests/test_config.py index 3deb333..ca71917 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,7 +7,6 @@ from agent_voice import cli from agent_voice.config import ( DEFAULT_FORMAT, - DEFAULT_SERVICE, DEFAULT_SERVICE_TIMEOUT_MINUTES, DEFAULT_SPEED, DEFAULT_VOICE, @@ -26,8 +25,7 @@ def test_built_in_defaults_are_used_without_a_config(tmp_path, monkeypatch): assert defaults.voice == DEFAULT_VOICE assert defaults.speed == DEFAULT_SPEED assert defaults.format == DEFAULT_FORMAT - assert defaults.service.mode == DEFAULT_SERVICE - assert defaults.service.timeout_minutes == DEFAULT_SERVICE_TIMEOUT_MINUTES + assert defaults.service_timeout_minutes == DEFAULT_SERVICE_TIMEOUT_MINUTES assert defaults.output_dir is None assert not config_path().exists() @@ -44,7 +42,6 @@ def test_defaults_are_persisted_and_reset(tmp_path, monkeypatch): "speed": 1.15, "format": "mp3", "service": { - "mode": "timed", "timeout_minutes": 10.0, }, "output_dir": None, @@ -53,8 +50,7 @@ def test_defaults_are_persisted_and_reset(tmp_path, monkeypatch): reset_defaults() assert load_defaults().voice == DEFAULT_VOICE assert load_defaults().format == DEFAULT_FORMAT - assert load_defaults().service.mode == DEFAULT_SERVICE - assert load_defaults().service.timeout_minutes == 10.0 + assert load_defaults().service_timeout_minutes == 10.0 assert load_defaults().output_dir is None assert not config_path().exists() @@ -71,9 +67,8 @@ def test_service_timeout_is_changeable(tmp_path, monkeypatch): monkeypatch.setenv("AGENT_VOICE_HOME", str(tmp_path)) update_defaults(service_timeout_minutes=2.5) - assert load_defaults().service.timeout_minutes == 2.5 + assert load_defaults().service_timeout_minutes == 2.5 assert json.loads(config_path().read_text())["service"] == { - "mode": "timed", "timeout_minutes": 2.5, } @@ -90,26 +85,23 @@ def test_existing_config_inherits_new_service_defaults(tmp_path, monkeypatch): monkeypatch.setenv("AGENT_VOICE_HOME", str(tmp_path)) config_path().write_text('{"voice": "bf_emma", "speed": 1.15}') - assert load_defaults().service.timeout_minutes == 10.0 + assert load_defaults().service_timeout_minutes == 10.0 assert load_defaults().format == "mp3" - assert load_defaults().service.mode == "timed" assert load_defaults().output_dir is None -@pytest.mark.parametrize( - ("update", "message"), - [ - ({"format": "flac"}, "format"), - ({"service_mode": "sometimes"}, "service mode"), - ], -) -def test_invalid_format_and_service_defaults_are_rejected( - tmp_path, monkeypatch, update, message -): +def test_legacy_service_mode_is_ignored_but_its_timeout_is_kept(tmp_path, monkeypatch): + monkeypatch.setenv("AGENT_VOICE_HOME", str(tmp_path)) + config_path().write_text('{"service": {"mode": "timed", "timeout_minutes": 3.5}}') + + assert load_defaults().service_timeout_minutes == 3.5 + + +def test_invalid_format_default_is_rejected(tmp_path, monkeypatch): monkeypatch.setenv("AGENT_VOICE_HOME", str(tmp_path)) - with pytest.raises(ValueError, match=message): - update_defaults(**update) + with pytest.raises(ValueError, match="format"): + update_defaults(format="flac") def test_output_dir_is_changeable_and_can_restore_default(tmp_path, monkeypatch): @@ -146,8 +138,6 @@ def test_config_command_updates_and_reports_defaults(tmp_path, monkeypatch, caps "1.2", "--format", "mp3", - "--service", - "off", "--json", ] ) @@ -156,7 +146,7 @@ def test_config_command_updates_and_reports_defaults(tmp_path, monkeypatch, caps assert result["voice"] == "bf_emma" assert result["speed"] == 1.2 assert result["format"] == "mp3" - assert result["service"] == {"mode": "off"} + assert result["service"] == {"timeout_minutes": 10.0} assert result["output_dir"] is None assert result["source"] == "config" assert result["path"] == str(tmp_path / "config.json") @@ -167,7 +157,6 @@ def test_config_command_sets_service_timeout(tmp_path, monkeypatch, capsys): cli.main(["config", "--service-timeout", "3.5", "--json"]) assert json.loads(capsys.readouterr().out)["service"] == { - "mode": "timed", "timeout_minutes": 3.5, } @@ -191,7 +180,6 @@ def test_config_command_sets_and_resets_output_dir(tmp_path, monkeypatch, capsys ["--voice", "bf_emma"], ["--speed", "1.2"], ["--format", "mp3"], - ["--service", "off"], ["--service-timeout", "3.5"], ["--output-dir", "default"], ], diff --git a/tests/test_controls.py b/tests/test_controls.py new file mode 100644 index 0000000..7d788e8 --- /dev/null +++ b/tests/test_controls.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +import json +import os +import plistlib +import sys +from pathlib import Path + +import pytest + +from agent_voice import cli, controls + +TOKEN = "abcdefghijklmnopqrstuvwx" + + +def test_control_url_parser_accepts_only_scoped_agent_voice_links(): + assert controls.parse_control_url(f"agent-voice://control/{TOKEN}/toggle") == ( + TOKEN, + "toggle", + ) + assert controls.parse_control_url(f"agent-voice://control/{TOKEN}/faster") == ( + TOKEN, + "faster", + ) + + for invalid in ( + f"https://control/{TOKEN}/toggle", + f"agent-voice://other/{TOKEN}/toggle", + f"agent-voice://control/{TOKEN}/delete", + f"agent-voice://control/{TOKEN}/toggle?again=1", + f"agent-voice://control/{TOKEN}/toggle#again", + f"agent-voice://user@control/{TOKEN}/toggle", + "agent-voice://control/short/toggle", + ): + with pytest.raises(ValueError, match="Invalid Agent Voice control link"): + controls.parse_control_url(invalid) + + +def test_install_handler_builds_registers_and_reuses_owned_macos_app( + tmp_path, monkeypatch +): + app = tmp_path / "Agent Voice Controls.app" + commands = [] + + def run(command): + commands.append(command) + if command[0] == "/usr/bin/osacompile": + candidate = Path(command[command.index("-o") + 1]) + (candidate / "Contents").mkdir(parents=True) + with (candidate / "Contents" / "Info.plist").open("wb") as stream: + plistlib.dump({}, stream) + + monkeypatch.setattr(controls.sys, "platform", "darwin") + monkeypatch.setattr(controls, "handler_path", lambda: app) + monkeypatch.setattr(controls, "_run", run) + defaults = [] + monkeypatch.setattr(controls, "_set_default_handler", lambda: defaults.append(True)) + + assert controls.install_handler() == app + assert controls.install_handler() == app + + with (app / "Contents" / "Info.plist").open("rb") as stream: + info = plistlib.load(stream) + assert info["CFBundleIdentifier"] == "com.yoavgal.agent-voice.link-handler" + assert info["CFBundleURLTypes"][0]["CFBundleURLSchemes"] == ["agent-voice"] + assert "CFBundleDocumentTypes" not in info + assert "UTExportedTypeDeclarations" not in info + script = commands[0][-1] + assert "on open location theURL" in script + assert "on open controlFiles" not in script + assert "quoted form of controlValue" in script + assert sum(command[0].endswith("lsregister") for command in commands) == 2 + assert defaults == [True, True] + + +def test_install_handler_preserves_unowned_previous_app(tmp_path, monkeypatch): + app = tmp_path / "Agent Voice Controls.app" + previous = tmp_path / ".Agent Voice Controls.app.previous" + (previous / "Contents").mkdir(parents=True) + marker = previous / "keep.txt" + marker.write_text("user data") + with (previous / "Contents" / "Info.plist").open("wb") as stream: + plistlib.dump({"CFBundleIdentifier": "example.unowned"}, stream) + + def run(command): + if command[0] == "/usr/bin/osacompile": + candidate = Path(command[command.index("-o") + 1]) + (candidate / "Contents").mkdir(parents=True) + with (candidate / "Contents" / "Info.plist").open("wb") as stream: + plistlib.dump({}, stream) + + monkeypatch.setattr(controls.sys, "platform", "darwin") + monkeypatch.setattr(controls, "handler_path", lambda: app) + monkeypatch.setattr(controls, "_run", run) + + with pytest.raises(RuntimeError, match="unrecognized app"): + controls.install_handler() + + assert marker.read_text() == "user data" + assert not app.exists() + + +def test_uninstall_handler_is_idempotent_and_removes_only_owned_app( + tmp_path, monkeypatch +): + app = tmp_path / "Agent Voice Controls.app" + (app / "Contents").mkdir(parents=True) + with (app / "Contents" / "Info.plist").open("wb") as stream: + plistlib.dump( + {"CFBundleIdentifier": "com.yoavgal.agent-voice.link-handler"}, stream + ) + commands = [] + monkeypatch.setattr(controls.sys, "platform", "darwin") + monkeypatch.setattr(controls, "handler_path", lambda: app) + monkeypatch.setattr(controls, "_run", commands.append) + + assert controls.uninstall_handler() is True + assert controls.uninstall_handler() is False + assert commands == [[str(controls._LSREGISTER), "-u", str(app)]] + assert not app.exists() + + +def test_uninstall_handler_refuses_unowned_app(tmp_path, monkeypatch): + app = tmp_path / "Agent Voice Controls.app" + (app / "Contents").mkdir(parents=True) + with (app / "Contents" / "Info.plist").open("wb") as stream: + plistlib.dump({"CFBundleIdentifier": "example.unowned"}, stream) + monkeypatch.setattr(controls.sys, "platform", "darwin") + monkeypatch.setattr(controls, "handler_path", lambda: app) + + with pytest.raises(RuntimeError, match="unrecognized app"): + controls.uninstall_handler() + + assert app.exists() + + +def test_uninstall_handler_keeps_app_when_unregister_fails(tmp_path, monkeypatch): + app = tmp_path / "Agent Voice Controls.app" + (app / "Contents").mkdir(parents=True) + with (app / "Contents" / "Info.plist").open("wb") as stream: + plistlib.dump( + {"CFBundleIdentifier": "com.yoavgal.agent-voice.link-handler"}, stream + ) + monkeypatch.setattr(controls.sys, "platform", "darwin") + monkeypatch.setattr(controls, "handler_path", lambda: app) + monkeypatch.setattr( + controls, + "_run", + lambda _command: (_ for _ in ()).throw(RuntimeError("unregister failed")), + ) + + with pytest.raises(RuntimeError, match="unregister failed"): + controls.uninstall_handler() + + assert app.exists() + + +def test_handler_ownership_check_rejects_symlinks(tmp_path): + target = tmp_path / "owned.app" + (target / "Contents").mkdir(parents=True) + with (target / "Contents" / "Info.plist").open("wb") as stream: + plistlib.dump( + {"CFBundleIdentifier": "com.yoavgal.agent-voice.link-handler"}, stream + ) + link = tmp_path / "handler.app" + link.symlink_to(target) + + with pytest.raises(RuntimeError, match="symlink"): + controls._require_our_handler(link) + + +def test_linux_handler_installs_updates_and_uninstalls(tmp_path, monkeypatch): + data_home = tmp_path / "data home" + executable = tmp_path / "bin with $ % and `" / "python" + commands = [] + monkeypatch.setattr(controls.sys, "platform", "linux") + monkeypatch.setattr(controls.sys, "executable", str(executable)) + monkeypatch.setenv("XDG_DATA_HOME", str(data_home)) + monkeypatch.setattr( + controls.shutil, + "which", + lambda name: ( + f"/usr/bin/{name}" + if name in {"xdg-mime", "update-desktop-database"} + else None + ), + ) + monkeypatch.setattr(controls, "_run", commands.append) + + desktop = controls.install_handler() + assert desktop == data_home / "applications" / "agent-voice-controls.desktop" + contents = desktop.read_text(encoding="utf-8") + assert "X-Agent-Voice-Owned=true" in contents + assert "MimeType=x-scheme-handler/agent-voice;" in contents + assert ( + f"Exec={controls._desktop_exec(str(executable))} -m agent_voice control-url %u" + ) in contents + if os.name != "nt": + assert desktop.stat().st_mode & 0o111 + assert commands[:2] == [ + [ + "/usr/bin/xdg-mime", + "default", + desktop.name, + "x-scheme-handler/agent-voice", + ], + ["/usr/bin/update-desktop-database", str(desktop.parent)], + ] + + assert controls.install_handler() == desktop + assert controls.uninstall_handler() is True + assert controls.uninstall_handler() is False + assert not desktop.exists() + + +def test_linux_handler_refuses_unowned_desktop_file(tmp_path, monkeypatch): + desktop = tmp_path / "applications" / "agent-voice-controls.desktop" + desktop.parent.mkdir() + desktop.write_text("[Desktop Entry]\nName=Someone Else\n", encoding="utf-8") + monkeypatch.setattr(controls.sys, "platform", "linux") + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) + monkeypatch.setattr(controls.shutil, "which", lambda _name: "/usr/bin/xdg-mime") + + with pytest.raises(RuntimeError, match="unrecognized handler"): + controls.install_handler() + with pytest.raises(RuntimeError, match="unrecognized handler"): + controls.uninstall_handler() + assert "Someone Else" in desktop.read_text(encoding="utf-8") + + +class _RegistryKey: + def __init__(self, registry, path): + self.registry = registry + self.path = path + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + +class _FakeWinreg: + HKEY_CURRENT_USER = "HKCU" + REG_SZ = "REG_SZ" + + def __init__(self): + self.keys = {} + + def CreateKey(self, root, path): + assert root == self.HKEY_CURRENT_USER + parts = path.split("\\") + for end in range(1, len(parts) + 1): + self.keys.setdefault("\\".join(parts[:end]), {}) + return _RegistryKey(self, path) + + def OpenKey(self, root, path): + assert root == self.HKEY_CURRENT_USER + if path not in self.keys: + raise FileNotFoundError(path) + return _RegistryKey(self, path) + + def QueryValueEx(self, key, name): + try: + return self.keys[key.path][name], self.REG_SZ + except KeyError as error: + raise FileNotFoundError(name) from error + + def SetValueEx(self, key, name, reserved, kind, value): + assert reserved == 0 + assert kind == self.REG_SZ + self.keys[key.path][name] = value + + def DeleteKey(self, root, path): + assert root == self.HKEY_CURRENT_USER + if any(key.startswith(f"{path}\\") for key in self.keys): + raise OSError(f"Registry key has children: {path}") + del self.keys[path] + + +def test_windows_handler_installs_updates_and_uninstalls(tmp_path, monkeypatch): + executable = tmp_path / "Python with spaces" / "python.exe" + windowed = executable.with_name("pythonw.exe") + windowed.parent.mkdir(parents=True) + windowed.touch() + registry = _FakeWinreg() + monkeypatch.setattr(controls.sys, "platform", "win32") + monkeypatch.setattr(controls.sys, "executable", str(executable)) + monkeypatch.setitem(sys.modules, "winreg", registry) + + location = controls.install_handler() + base = r"Software\Classes\agent-voice" + assert location == rf"HKCU\{base}" + assert registry.keys[base] == { + None: "URL:Agent Voice playback controls", + "URL Protocol": "", + "AgentVoiceOwned": "1", + } + assert registry.keys[rf"{base}\shell\open\command"][None] == ( + f'"{windowed}" -m agent_voice control-url "%1"' + ) + + assert controls.install_handler() == location + assert controls.uninstall_handler() is True + assert controls.uninstall_handler() is False + assert base not in registry.keys + + +def test_windows_handler_requires_windowless_python(tmp_path, monkeypatch): + executable = tmp_path / "python.exe" + monkeypatch.setattr(controls.sys, "executable", str(executable)) + + with pytest.raises(RuntimeError, match="pythonw.exe is required"): + controls._windows_handler_executable() + + +def test_windows_handler_refuses_unowned_registry_key(monkeypatch): + registry = _FakeWinreg() + base = r"Software\Classes\agent-voice" + registry.CreateKey(registry.HKEY_CURRENT_USER, base) + registry.keys[base][None] = "Someone else's handler" + monkeypatch.setattr(controls.sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "winreg", registry) + + with pytest.raises(RuntimeError, match="unrecognized handler"): + controls.install_handler() + with pytest.raises(RuntimeError, match="unrecognized handler"): + controls.uninstall_handler() + assert registry.keys[base][None] == "Someone else's handler" + + +def test_controls_reject_unsupported_platform(monkeypatch): + monkeypatch.setattr(controls.sys, "platform", "freebsd") + with pytest.raises(RuntimeError, match="unsupported on freebsd"): + controls.install_handler() + + +def test_controls_cli_installs_handler_and_dispatches_links( + tmp_path, monkeypatch, capsys +): + app = tmp_path / "Agent Voice Controls.app" + monkeypatch.setattr(controls, "handler_path", lambda: app) + monkeypatch.setattr(controls, "install_handler", lambda: app) + monkeypatch.setattr(controls, "uninstall_handler", lambda: True) + monkeypatch.setattr( + controls, + "trigger_control_url", + lambda url: {"recording": url, "playing": True}, + ) + cli.main(["controls", "install", "--json"]) + installed = capsys.readouterr().out + assert '"scheme": "agent-voice"' in installed + cli.main(["controls", "uninstall", "--json"]) + assert json.loads(capsys.readouterr().out) == { + "installed": False, + "scheme": "agent-voice", + "path": str(app), + "removed": True, + } + cli.main(["control-url", f"agent-voice://control/{TOKEN}/toggle", "--json"]) + assert '"playing": true' in capsys.readouterr().out diff --git a/tests/test_delivery.py b/tests/test_delivery.py index 5bdf906..c6c0950 100644 --- a/tests/test_delivery.py +++ b/tests/test_delivery.py @@ -4,7 +4,7 @@ import pytest -from agent_voice import delivery +from agent_voice import delivery, viewer as viewer_module from agent_voice.viewer import Viewer, language_path, source_path @@ -12,10 +12,17 @@ def _viewer(root: Path) -> Viewer: return Viewer(root.resolve(), 49123, 123) -def test_prepare_delivery_uses_http_player_audio_and_file_links(tmp_path, monkeypatch): +def test_prepare_delivery_uses_http_player_audio_and_control_links( + tmp_path, monkeypatch +): recording = tmp_path / "Daily update & notes.mp3" recording.write_bytes(b"audio") monkeypatch.setattr(delivery, "ensure_viewer", _viewer) + monkeypatch.setattr( + viewer_module.secrets, + "token_urlsafe", + lambda _bytes: "abcdefghijklmnopqrstuvwx", + ) result = delivery.prepare_delivery( recording, @@ -23,6 +30,7 @@ def test_prepare_delivery_uses_http_player_audio_and_file_links(tmp_path, monkey source_text="Spoken narration.", language="en-gb", recordings_dir=tmp_path, + controls=True, ) assert result.warning is None @@ -33,6 +41,10 @@ def test_prepare_delivery_uses_http_player_audio_and_file_links(tmp_path, monkey assert result.audio_url == ( "http://127.0.0.1:49123/recordings/Daily%20update%20%26%20notes.mp3" ) + assert result.controls == { + action: f"agent-voice://control/abcdefghijklmnopqrstuvwx/{action}" + for action in ("toggle", "restart", "back", "forward", "slower", "faster") + } assert source_path(recording).read_text() == "Spoken narration." assert language_path(recording).read_text() == "en-gb" assert list(tmp_path.glob("*.html")) == [] @@ -63,6 +75,8 @@ def test_prepare_delivery_copies_external_output_and_stores_transcript( } assert source_path(output).read_text() == "Visible response text." assert result.audio_url.endswith("/recordings/report.m4a") + assert result.controls is None + assert not (managed / ".agent-voice-viewer" / "controls").exists() assert output.read_bytes() == b"m4a-audio" @@ -129,6 +143,32 @@ def test_viewer_failure_keeps_audio_and_uses_file_fallback(tmp_path, monkeypatch assert result.recording_path is None +def test_control_failure_keeps_normal_viewer_delivery(tmp_path, monkeypatch): + recording = tmp_path / "fallback.mp3" + recording.write_bytes(b"audio") + monkeypatch.setattr(delivery, "ensure_viewer", _viewer) + monkeypatch.setattr( + delivery, + "publish_control", + lambda _recording: (_ for _ in ()).throw(RuntimeError("not available")), + ) + + result = delivery.prepare_delivery( + recording, + "Visible response text.", + recordings_dir=tmp_path, + controls=True, + ) + + assert result.browser_url.endswith("/player/fallback.html") + assert result.audio_url.endswith("/recordings/fallback.mp3") + assert result.recording_path == recording + assert result.controls is None + assert result.warning == ( + "Could not prepare playback controls; using viewer fallback (not available)" + ) + + def test_prepare_delivery_rejects_unknown_audio_format(tmp_path): recording = tmp_path / "recording.flac" recording.write_bytes(b"audio") diff --git a/tests/test_service.py b/tests/test_service.py index c9318a9..3bbce10 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -5,6 +5,7 @@ import urllib.error import urllib.request from contextlib import contextmanager +from types import SimpleNamespace import numpy as np import pytest @@ -15,6 +16,8 @@ ensure_service, health_check, request_speech, + set_service_timeout, + stop_service, validate_service_url, ) from agent_voice.config import update_defaults @@ -217,6 +220,8 @@ def synthesize(self, request): ModelSelection("test-model", "test-variant"), 2.5, ) + assert server.idle_timeout_seconds is None + set_service_timeout(url, 2.5) assert server.idle_timeout_seconds == 150 unsafe_request = urllib.request.Request( f"{url}/v1/audio/speech", @@ -241,7 +246,6 @@ def synthesize(self, request): 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" @@ -379,6 +383,28 @@ class FakeModel: assert not thread.is_alive() +def test_service_stop_is_idempotent(): + model = SimpleNamespace( + descriptor=ModelDescriptor( + selection=ModelSelection("test-model", "test-variant"), + display_name="Test Model", + runtime="test-runtime", + capabilities=frozenset(), + ) + ) + server = create_server(model, "127.0.0.1", 0) + url = f"http://127.0.0.1:{server.server_port}" + thread = threading.Thread(target=server.serve_until_idle) + thread.start() + try: + assert stop_service(url) is True + thread.join(timeout=2) + assert not thread.is_alive() + assert stop_service(url) is False + finally: + server.server_close() + + @pytest.mark.parametrize( ("idle_timeout", "message"), [ @@ -386,10 +412,12 @@ class FakeModel: (None, "no idle timeout"), ], ) -def test_service_starts_detached_and_waits_for_health( +def test_service_starts_without_a_window_and_waits_for_health( tmp_path, monkeypatch, capsys, idle_timeout, message ): monkeypatch.setenv("AGENT_VOICE_HOME", str(tmp_path)) + monkeypatch.setattr(client, "os", SimpleNamespace(name="nt")) + monkeypatch.setattr(client.subprocess, "CREATE_NO_WINDOW", 123, raising=False) checks = 0 captured = {} @@ -417,12 +445,6 @@ def popen(command, **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) @@ -434,7 +456,6 @@ def popen(command, **options): ) 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], @@ -456,6 +477,8 @@ def popen(command, **options): assert captured["options"]["stdin"] is subprocess.DEVNULL assert captured["options"]["stdout"] is subprocess.DEVNULL assert captured["options"]["stderr"] is subprocess.DEVNULL + assert captured["options"]["creationflags"] == 123 + assert "start_new_session" not in captured["options"] def test_failed_service_startup_terminates_the_detached_process(tmp_path, monkeypatch): diff --git a/tests/test_speaking.py b/tests/test_speaking.py index a2ef44c..43d93d1 100644 --- a/tests/test_speaking.py +++ b/tests/test_speaking.py @@ -6,7 +6,7 @@ import pytest from agent_voice.client import ServiceUnavailable -from agent_voice.config import ServiceDefaults, SpeechDefaults, update_defaults +from agent_voice.config import SpeechDefaults, update_defaults from agent_voice.delivery import Delivery from agent_voice.model import ModelSelection, Recording from agent_voice.speaking import SpeakReceipt, SpeakRequest, Speaker @@ -49,11 +49,26 @@ def generate(self, request): def delivery_success(calls: list | None = None): def prepare( - recording, text, *, source_text, language, audio_format, recordings_dir + recording, + text, + *, + source_text, + language, + audio_format, + recordings_dir, + controls, ): if calls is not None: calls.append( - (recording, text, source_text, language, audio_format, recordings_dir) + ( + recording, + text, + source_text, + language, + audio_format, + recordings_dir, + controls, + ) ) resolved = recording.resolve() return Delivery( @@ -76,29 +91,26 @@ def make_speaker( notices: list[str] | None = None, now=None, ): - selected_defaults = defaults or SpeechDefaults( - service=ServiceDefaults("off", None), - output_dir=str(tmp_path), - ) + selected_defaults = defaults or SpeechDefaults(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), + playback=playback or (lambda _path, _delay: {"state": "started"}), 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): +def test_no_service_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")) + ).speak(SpeakRequest("Visible text.", SELECTION, no_service=True)) assert receipt.recording.backend == "local" assert receipt.recording.path.read_bytes() == b"recording" @@ -107,10 +119,10 @@ def test_service_off_uses_embedded_generation(tmp_path): assert receipt.service_fallback is False -def test_service_generation_uses_resolved_timed_policy(tmp_path): +def test_service_generation_uses_configured_timeout(tmp_path): service = FakeGenerator(backend="service", audio=b"service") defaults = SpeechDefaults( - service=ServiceDefaults("timed", 2.5), + service_timeout_minutes=2.5, output_dir=str(tmp_path), ) @@ -122,20 +134,10 @@ def test_service_generation_uses_resolved_timed_policy(tmp_path): assert receipt.recording.backend == "service" assert receipt.recording.path.read_bytes() == b"service" - assert service.requests[0].service == "timed" + assert service.requests[0].no_service is False 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", @@ -149,7 +151,7 @@ def test_unavailable_service_falls_back_to_embedded(tmp_path): embedded=embedded, service=service, notices=notices, - ).speak(SpeakRequest("Visible text.", SELECTION, service="timed")) + ).speak(SpeakRequest("Visible text.", SELECTION)) assert receipt.recording.backend == "local" assert receipt.service_fallback is True @@ -168,7 +170,7 @@ def test_non_availability_service_errors_do_not_fallback(tmp_path): tmp_path, embedded=embedded, service=service, - ).speak(SpeakRequest("Visible text.", SELECTION, service="on")) + ).speak(SpeakRequest("Visible text.", SELECTION)) assert embedded.requests == [] @@ -179,7 +181,6 @@ def test_saved_defaults_and_request_values_resolve_once(tmp_path): voice="bf_emma", speed=1.15, format="opus", - service=ServiceDefaults("off", None), output_dir=str(tmp_path), ) @@ -187,7 +188,7 @@ def test_saved_defaults_and_request_values_resolve_once(tmp_path): tmp_path, defaults=defaults, embedded=embedded, - ).speak(SpeakRequest("Visible text.", SELECTION)) + ).speak(SpeakRequest("Visible text.", SELECTION, no_service=True)) resolved = embedded.requests[0] assert (resolved.voice, resolved.speed) == ("bf_emma", 1.15) @@ -201,7 +202,6 @@ def test_request_values_override_saved_defaults(tmp_path): voice="af_heart", speed=1.0, format="mp3", - service=ServiceDefaults("timed", 10), output_dir=str(tmp_path / "configured"), ) command_line = tmp_path / "command-line" @@ -218,15 +218,15 @@ def test_request_values_override_saved_defaults(tmp_path): format="wav", voice="bf_emma", speed=1.25, - service="off", + no_service=True, ) ) resolved = embedded.requests[0] - assert (resolved.voice, resolved.speed, resolved.service) == ( + assert (resolved.voice, resolved.speed, resolved.no_service) == ( "bf_emma", 1.25, - "off", + True, ) assert receipt.recording.path.parent == command_line assert receipt.recording.path.suffix == ".wav" @@ -238,7 +238,6 @@ def test_environment_recording_root_overrides_config(tmp_path, monkeypatch): monkeypatch.setenv("AGENT_VOICE_RECORDING_DIR", str(environment)) calls = [] defaults = SpeechDefaults( - service=ServiceDefaults("off", None), output_dir=str(configured), ) @@ -246,7 +245,7 @@ def test_environment_recording_root_overrides_config(tmp_path, monkeypatch): tmp_path, defaults=defaults, delivery=delivery_success(calls), - ).speak(SpeakRequest("Visible text.", SELECTION)) + ).speak(SpeakRequest("Visible text.", SELECTION, no_service=True)) assert receipt.recording.path.parent == environment assert calls[0][5] == environment @@ -263,7 +262,6 @@ def test_live_config_cli_and_environment_precedence(tmp_path, monkeypatch): voice="bf_emma", speed=1.15, format="opus", - service_mode="off", output_dir=configured, ) monkeypatch.setenv("AGENT_VOICE_RECORDING_DIR", str(environment)) @@ -282,7 +280,7 @@ def test_live_config_cli_and_environment_precedence(tmp_path, monkeypatch): output_dir=command_line, format="wav", voice="af_nova", - service="off", + no_service=True, ) ) @@ -306,7 +304,7 @@ def test_exact_output_takes_precedence_and_extension_selects_format(tmp_path): label="ignored", output_dir=ignored, format="mp3", - service="off", + no_service=True, ) ) @@ -328,7 +326,7 @@ def test_extensionless_exact_output_uses_selected_format(tmp_path): SELECTION, output=output, format="m4a", - service="off", + no_service=True, ) ) @@ -343,7 +341,7 @@ def test_managed_output_supports_each_public_format(tmp_path, audio_format): "Visible text.", SELECTION, format=audio_format, - service="off", + no_service=True, ) ) @@ -360,7 +358,7 @@ def test_managed_output_uses_portable_label_and_collision_suffix(tmp_path): "Visible text.", SELECTION, label="Daily update!", - service="off", + no_service=True, ) ) @@ -372,7 +370,9 @@ 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")) + .speak( + SpeakRequest("Visible text.", SELECTION, label="SR", no_service=True) + ) .recording.path ) @@ -388,7 +388,7 @@ def test_failed_managed_generation_removes_reservation(tmp_path): with pytest.raises(RuntimeError, match="failed"): make_speaker(tmp_path, embedded=embedded).speak( - SpeakRequest("Visible text.", SELECTION, label="SR", service="off") + SpeakRequest("Visible text.", SELECTION, label="SR", no_service=True) ) assert list(tmp_path.iterdir()) == [] @@ -405,7 +405,7 @@ def test_failed_exact_generation_preserves_existing_destination(tmp_path): "Visible text.", SELECTION, output=output, - service="off", + no_service=True, ) ) @@ -447,32 +447,38 @@ def generate(self, request): make_speaker( tmp_path, embedded=DriftingGenerator(backend="local"), - ).speak(SpeakRequest("Visible text.", SELECTION, service="off")) + ).speak(SpeakRequest("Visible text.", SELECTION, no_service=True)) assert list(tmp_path.iterdir()) == [] -def test_played_becomes_true_only_after_playback_returns(tmp_path): +def test_playback_starts_after_delivery_without_waiting(tmp_path): events = [] - def playback(path): - events.append(path) + def playback(path, delay): + events.append((path, delay)) + return {"state": "scheduled", "starts_in_seconds": delay} receipt = make_speaker(tmp_path, playback=playback).speak( - SpeakRequest("Visible text.", SELECTION, play=True, service="off") + SpeakRequest( + "Visible text.", + SELECTION, + play_after=10, + no_service=True, + ) ) - assert events == [receipt.recording.path] - assert receipt.played is True + assert events == [(receipt.delivery.recording_path, 10)] + assert receipt.playback == {"state": "scheduled", "starts_in_seconds": 10} -def test_playback_failure_does_not_produce_a_truthful_receipt(tmp_path): - def fail(_path): +def test_playback_start_failure_does_not_produce_a_receipt(tmp_path): + def fail(_path, _delay): 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") + SpeakRequest("Visible text.", SELECTION, play_after=0, no_service=True) ) @@ -481,7 +487,6 @@ def test_delivery_receives_the_planned_recording_root(tmp_path): recording_root = tmp_path / "managed" output = tmp_path / "external" / "response.mp3" defaults = SpeechDefaults( - service=ServiceDefaults("off", None), output_dir=str(recording_root), ) @@ -494,12 +499,20 @@ def test_delivery_receives_the_planned_recording_root(tmp_path): "Visible text.", SELECTION, output=output, - service="off", + no_service=True, ) ) assert calls == [ - (output, "Visible text.", "Visible text.", "en-us", "mp3", recording_root) + ( + output, + "Visible text.", + "Visible text.", + "en-us", + "mp3", + recording_root, + False, + ) ] assert receipt.delivery.browser_url is not None @@ -513,20 +526,29 @@ def test_delivery_prefers_the_written_response(tmp_path): SELECTION, response_markdown="# Written response", language="he-il", - service="off", + controls=True, + no_service=True, ) ) assert calls[0][1] == "# Written response" assert calls[0][2] == "Spoken narration." assert calls[0][3] == "he-il" + assert calls[0][6] is True def test_delivery_failure_facts_are_typed_serialized_and_reported(tmp_path): notices = [] def fallback( - recording, text, *, source_text, language, audio_format, recordings_dir + recording, + text, + *, + source_text, + language, + audio_format, + recordings_dir, + controls, ): return Delivery(warning="viewer unavailable") @@ -534,7 +556,7 @@ def fallback( tmp_path, delivery=fallback, notices=notices, - ).speak(SpeakRequest("Visible text.", SELECTION, service="off")) + ).speak(SpeakRequest("Visible text.", SELECTION, no_service=True)) assert receipt.to_dict()["delivery"] == {} assert notices == ["Warning: viewer unavailable"] @@ -555,13 +577,14 @@ def test_receipt_serialization_preserves_public_json_shape(tmp_path): 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, + controls={"toggle": "agent-voice://control/token/toggle"}, ) receipt = SpeakReceipt( recording=recording, selection=ModelSelection("kokoro", "fp16"), - played=False, delivery=delivery, + playback={"state": "started", "playing": True}, service_fallback=True, ) @@ -576,41 +599,26 @@ def test_receipt_serialization_preserves_public_json_shape(tmp_path): "backend": "service", "model_id": "kokoro", "variant": "fp16", - "played": False, + "playback": {"state": "started", "playing": True}, "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), + "controls": {"toggle": "agent-voice://control/token/toggle"}, }, } -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") - ) +def test_public_interface_rejects_unknown_format(tmp_path): with pytest.raises(ValueError, match="Output format"): make_speaker(tmp_path).speak( SpeakRequest( "Visible text.", SELECTION, format="flac", - service="off", + no_service=True, ) ) @@ -625,7 +633,7 @@ def test_public_interface_rejects_unknown_service_or_format(tmp_path): ) 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") + SpeakRequest("Visible text.", SELECTION, label=label, no_service=True) ) assert receipt.recording.path.name.startswith(f"{expected}-") @@ -634,5 +642,5 @@ def test_managed_label_is_portable_and_bounded(tmp_path, label, 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") + SpeakRequest("Visible text.", SELECTION, label="🎙️ ---", no_service=True) ) diff --git a/tests/test_updates.py b/tests/test_updates.py new file mode 100644 index 0000000..850c649 --- /dev/null +++ b/tests/test_updates.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +from agent_voice import cli, updates + + +def test_update_notice_is_cached_for_a_day(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("AGENT_VOICE_HOME", str(tmp_path)) + monkeypatch.setattr(updates.sys.stderr, "isatty", lambda: True) + monkeypatch.setattr(updates.time, "time", lambda: 100_000.0) + monkeypatch.setattr(updates, "_latest_version", lambda: "99.0.0") + + updates.notify_if_update_available() + updates.notify_if_update_available() + + assert capsys.readouterr().err == ( + "Agent Voice 99.0.0 is available; run: agent-voice update\n" + ) + assert json.loads((tmp_path / "update-check.json").read_text())["latest"] == ( + "99.0.0" + ) + + +def test_update_notice_skips_noninteractive_use(monkeypatch, capsys): + monkeypatch.setattr(updates.sys.stderr, "isatty", lambda: False) + monkeypatch.setattr( + updates, + "_latest_version", + lambda: (_ for _ in ()).throw(AssertionError("network request")), + ) + + updates.notify_if_update_available() + + assert capsys.readouterr().err == "" + + +def test_update_notice_skips_when_no_console_is_attached(monkeypatch): + monkeypatch.setattr(updates, "sys", SimpleNamespace(stderr=None)) + monkeypatch.setattr( + updates, + "_latest_version", + lambda: (_ for _ in ()).throw(AssertionError("network request")), + ) + + updates.notify_if_update_available() + + +def test_run_update_delegates_to_uv(tmp_path, monkeypatch): + calls = [] + (tmp_path / "uv-receipt.toml").touch() + monkeypatch.setattr(updates.sys, "prefix", str(tmp_path)) + monkeypatch.setattr(updates.shutil, "which", lambda _name: "/usr/bin/uv") + monkeypatch.setattr( + updates.subprocess, + "run", + lambda command, check: ( + calls.append((command, check)) or SimpleNamespace(returncode=0) + ), + ) + + assert updates.run_update() == 0 + assert calls == [(["/usr/bin/uv", "tool", "upgrade", "agent-voice"], False)] + + +def test_run_update_delegates_to_pipx(tmp_path, monkeypatch): + calls = [] + (tmp_path / "pipx_metadata.json").touch() + monkeypatch.setattr(updates.sys, "prefix", str(tmp_path)) + monkeypatch.setattr(updates.shutil, "which", lambda _name: "/usr/bin/pipx") + monkeypatch.setattr( + updates.subprocess, + "run", + lambda command, check: ( + calls.append((command, check)) or SimpleNamespace(returncode=0) + ), + ) + + assert updates.run_update() == 0 + assert calls == [(["/usr/bin/pipx", "upgrade", "agent-voice"], False)] + + +def test_update_command_runs_without_checking_first(monkeypatch): + calls = [] + monkeypatch.setattr(cli, "run_update", lambda: calls.append("update") or 0) + monkeypatch.setattr( + cli, + "notify_if_update_available", + lambda: (_ for _ in ()).throw(AssertionError("update check")), + ) + + cli.main(["update"]) + + assert calls == ["update"] diff --git a/tests/test_viewer.py b/tests/test_viewer.py index c496599..506e211 100644 --- a/tests/test_viewer.py +++ b/tests/test_viewer.py @@ -16,16 +16,19 @@ from agent_voice import viewer as viewer_module from agent_voice import viewer_server +from agent_voice import controls as controls_module from agent_voice.viewer import ( VIEWER_PROTOCOL, Viewer, delete_expired_recordings, ensure_viewer, publish_language, + publish_control, publish_player, publish_recording, publish_source, recording_urls, + recording_control_urls, source_path, stop_viewer, transcript_path, @@ -147,6 +150,124 @@ def test_player_urls_keep_audio_formats_distinct(tmp_path): assert 'src="/recordings/sample.wav"' in response.read().decode() +def test_recording_control_url_toggles_one_nonblocking_player(tmp_path, monkeypatch): + recording = tmp_path / "sample.mp3" + recording.write_bytes(b"audio") + control_token = publish_control(recording) + calls = [] + + class Playback: + def control(self, path, action): + calls.append((path, action)) + playing = len(calls) % 2 == 1 + return SimpleNamespace(to_dict=lambda: {"playing": playing}) + + def close(self): + pass + + monkeypatch.setattr(viewer_server, "PlaybackController", Playback) + + with _running_viewer(tmp_path) as (server, url): + viewer = Viewer(tmp_path, server.server_port, 123) + monkeypatch.setattr(controls_module, "active_viewer", lambda: viewer) + control_url = recording_control_urls(control_token)["toggle"] + assert controls_module.trigger_control_url(control_url) == {"playing": True} + assert controls_module.trigger_control_url(control_url) == {"playing": False} + request = urllib.request.Request( + f"{url}/control/{control_token}/toggle", method="HEAD" + ) + with pytest.raises(urllib.error.HTTPError) as rejected: + urllib.request.urlopen(request) + assert rejected.value.code == 405 + + request = urllib.request.Request( + f"{url}/control/{control_token}/toggle", + method="POST", + ) + with pytest.raises(urllib.error.HTTPError) as rejected: + urllib.request.urlopen(request) + assert rejected.value.code == 403 + + request = urllib.request.Request( + f"{url}/control/{control_token}/delete", + headers={"X-Agent-Voice-Control": "1"}, + method="POST", + ) + with pytest.raises(urllib.error.HTTPError) as rejected: + urllib.request.urlopen(request) + assert rejected.value.code == 404 + + assert calls == [ + (recording.resolve(), "toggle"), + (recording.resolve(), "toggle"), + ] + + +def test_viewer_starts_or_schedules_local_playback(tmp_path, monkeypatch): + recording = tmp_path / "sample.mp3" + recording.write_bytes(b"audio") + calls = [] + played = threading.Event() + + class Playback: + def control(self, path, action): + calls.append((path, action)) + played.set() + return SimpleNamespace(to_dict=lambda: {"playing": True}) + + def close(self): + pass + + monkeypatch.setattr(viewer_server, "PlaybackController", Playback) + with _running_viewer(tmp_path) as (_, url): + headers = {"X-Agent-Voice-Playback": "1"} + request = urllib.request.Request( + f"{url}/play/sample.mp3", headers=headers, method="POST" + ) + with urllib.request.urlopen(request) as response: + assert json.loads(response.read()) == {"state": "started", "playing": True} + + with urllib.request.urlopen(request) as response: + assert json.loads(response.read()) == {"state": "started", "playing": True} + + played.clear() + request = urllib.request.Request( + f"{url}/play/sample.mp3?after=0.01", headers=headers, method="POST" + ) + with urllib.request.urlopen(request) as response: + assert json.loads(response.read()) == { + "state": "scheduled", + "starts_in_seconds": 0.01, + } + assert played.wait(timeout=1) + + assert calls == [(recording.resolve(), "restart")] * 3 + + +def test_rejected_control_probe_does_not_regenerate_missing_audio( + tmp_path, monkeypatch +): + recording = tmp_path / "missing.mp3" + control_token = publish_control(recording) + monkeypatch.setattr( + viewer_server, + "_regenerate_recording", + lambda _path: (_ for _ in ()).throw(AssertionError("unexpected regeneration")), + ) + + with _running_viewer(tmp_path) as (server, _url): + control_url = ( + f"http://127.0.0.1:{server.server_port}/control/{control_token}/toggle" + ) + request = urllib.request.Request( + control_url, + method="POST", + ) + with pytest.raises(urllib.error.HTTPError) as rejected: + urllib.request.urlopen(request) + assert rejected.value.code == 403 + + def test_viewer_supports_head(tmp_path): recording = tmp_path / "range.mp3" recording.write_bytes(b"0123456789") @@ -514,3 +635,35 @@ def test_dynamic_viewer_process_start_status_and_stop(tmp_path, monkeypatch): stopped = stop_viewer() assert stopped.running is False + + +def test_viewer_starts_without_a_window_on_windows(tmp_path, monkeypatch): + recordings = tmp_path / "recordings" + viewer = Viewer(recordings.resolve(), 8779, 123) + running = iter((None, None, viewer)) + captured = {} + + class Process: + def poll(self): + return None + + def popen(command, **options): + captured["options"] = options + return Process() + + monkeypatch.setenv("AGENT_VOICE_HOME", str(tmp_path / "home")) + monkeypatch.setattr(viewer_module, "os", SimpleNamespace(name="nt")) + monkeypatch.setattr( + viewer_module.subprocess, "CREATE_NO_WINDOW", 123, raising=False + ) + monkeypatch.setattr(viewer_module, "_state", lambda: {}) + monkeypatch.setattr( + viewer_module, + "_running", + lambda *_args, **_kwargs: next(running), + ) + monkeypatch.setattr(viewer_module.subprocess, "Popen", popen) + + assert ensure_viewer(recordings) == viewer + assert captured["options"]["creationflags"] == 123 + assert "start_new_session" not in captured["options"] diff --git a/uv.lock b/uv.lock index 0a40a81..eb40b41 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "agent-voice" -version = "0.7.0" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "filelock" },