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.
+
+
+
-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 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.
+
+
+
-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
-
- A tiny standard-library viewer starts automatically for JSON recordings.
- browser_url opens the branded player document with the
- recording name, audio controls, and response text; audio_url
- serves the recording directly.
- WAV, MP3, Opus, and M4A use their real filename and content type.
- --output remains the exact requested destination. When it is
- outside the managed recordings directory, a private copy is made for HTTP
- delivery; no symlink is created.
- The viewer binds only to 127.0.0.1, prefers port
- 8779, falls back to a free port when needed, and serves only
- the managed recordings directory.
-
-
- New commands
- agent-voice viewer start --json
-agent-voice viewer stop --json
-
-agent-voice speak "Viewer smoke test." \
- --label viewer-smoke --service off
- The receipt contains the generated path, plus:
- delivery.browser_url
-delivery.audio_url
-delivery.recording_path
- Routes normally use port 8779; use the reported port after a
- collision:
- http://127.0.0.1:<port>/player/<recording>.html
-http://127.0.0.1:<port>/recordings/<recording.ext>
-
- What to test
-
- Open browser_url from both an IDE terminal and a normal terminal.
- Both should show the branded document, audio controls, and response text.
- Open audio_url. It should play or download the real audio file.
- Create the default MP3, then one alternate format such as
- --format wav.
- Use --output /tmp/my-recording.mp3. Confirm that exact file
- exists and the HTTP copy in delivery.recording_path also works.
- Run viewer stop; old links should stop. The next JSON recording
- should restart the viewer on port 8779 unless it is occupied.
- 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.
-
-
- 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
-
-
-
-
-
-
- Agent Voice · Kokoro Voice Room
- Generated locally
-
-
-
- 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
-
-
-
-
-
-
-
-
-
-
-
- 01
-
-
-
Heart af_heart
-
American · feminine
-
-
-
Choose Heart
-
-
-
-
- 02
-
-
-
Bella af_bella
-
American · feminine
-
-
-
Choose Bella
-
-
-
-
- 03
-
-
-
Nova af_nova
-
American · feminine
-
-
-
Choose Nova
-
-
-
-
- 04
-
-
-
Sky af_sky
-
American · feminine
-
-
-
Choose Sky
-
-
-
-
- 05
-
-
-
Adam am_adam
-
American · masculine
-
-
-
Choose Adam
-
-
-
-
- 06
-
-
-
Michael am_michael
-
American · masculine
-
-
-
Choose Michael
-
-
-
-
- 07
-
-
-
Emma bf_emma
-
British · feminine
-
-
-
Choose Emma
-
-
-
-
- 08
-
-
-
George bm_george
-
British · masculine
-
-
-
Choose George
-
-
-
-
-
-
-
- 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=" bytes:
+ if factor == 1.0:
+ return pcm
+ samples = np.frombuffer(pcm, dtype=" 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" },