diff --git a/stackvox/cli.py b/stackvox/cli.py index 5279503..a47a6eb 100644 --- a/stackvox/cli.py +++ b/stackvox/cli.py @@ -26,6 +26,7 @@ def _configure_logging() -> None: SUBCOMMANDS = { "serve", "stop", + "cancel", "status", "say", "speak", @@ -51,7 +52,7 @@ def _configure_logging() -> None: subcommand="${COMP_WORDS[1]:-}" if [[ ${COMP_CWORD} -eq 1 ]]; then - COMPREPLY=( $(compgen -W "speak say normalize serve stop status voices welcome paths config completion install-helper" -- "$cur") ) + COMPREPLY=( $(compgen -W "speak say normalize serve stop cancel status voices welcome paths config completion install-helper" -- "$cur") ) return 0 fi @@ -155,6 +156,7 @@ def _build_parser(defaults: config.Defaults | None = None) -> argparse.ArgumentP _add_voice_args(p_serve, defaults) sub.add_parser("stop", help="Stop the running daemon") + sub.add_parser("cancel", help="Stop the current utterance without shutting down the daemon") sub.add_parser("status", help="Print daemon status") sub.add_parser("voices", help="List available voices") sub.add_parser("welcome", help="Play a multilingual welcome message") @@ -388,6 +390,15 @@ def _cmd_stop(_: argparse.Namespace) -> int: return 0 if ok else 1 +def _cmd_cancel(_: argparse.Namespace) -> int: + # Interrupt the current utterance but leave the daemon running (no model + # reload). Nothing playing / no daemon is not an error. + if not daemon.is_running(): + return 0 + ok, _resp = daemon.cancel() + return 0 if ok else 1 + + def _cmd_status(_: argparse.Namespace) -> int: installed = updates._current_version() if daemon.is_running(): @@ -530,6 +541,7 @@ def main() -> int: "normalize": _cmd_normalize, "serve": _cmd_serve, "stop": _cmd_stop, + "cancel": _cmd_cancel, "status": _cmd_status, "voices": _cmd_voices, "welcome": _cmd_welcome, diff --git a/stackvox/daemon.py b/stackvox/daemon.py index a0529f9..61be30e 100644 --- a/stackvox/daemon.py +++ b/stackvox/daemon.py @@ -5,8 +5,10 @@ Request shapes: {"text": "...", "voice": "af_sarah", "speed": 1.0, "lang": "en-us"} - {"command": "stop"} + {"command": "stop"} # shut the daemon down + {"command": "cancel"} # stop the current utterance, keep the daemon up {"command": "ping"} + {"command": "version"} Replies: "ok", "busy", "err: ". """ @@ -203,6 +205,17 @@ def submit(self, req: dict) -> bool: def shutdown(self) -> None: self.stop_event.set() + def cancel(self) -> None: + """Stop the current utterance and drop anything queued, without shutting + the daemon down. Drains the queue first so the worker can't immediately + pick up a pending item, then aborts the in-flight playback.""" + while True: + try: + self.queue.get_nowait() + except queue.Empty: + break + self.tts.stop() + class _Handler(socketserver.StreamRequestHandler): def handle(self) -> None: @@ -231,6 +244,11 @@ def handle(self) -> None: threading.Thread(target=self.server.shutdown, daemon=True).start() return + if command == "cancel": + state.cancel() + self.wfile.write(b"ok\n") + return + text = req.get("text") if not text: self.wfile.write(b"err: missing text\n") @@ -345,6 +363,12 @@ def stop() -> tuple[bool, str]: return send({"command": "stop"}) +def cancel() -> tuple[bool, str]: + """Stop the daemon's current utterance (and drop the queue) without shutting + it down.""" + return send({"command": "cancel"}) + + def ping() -> tuple[bool, str]: return send({"command": "ping"}, timeout=PING_TIMEOUT_SECONDS) diff --git a/tests/test_cli.py b/tests/test_cli.py index ee23af0..0f0b633 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -523,3 +523,17 @@ def test_paths_and_config_route(self, mocker): mocker.patch.object(cli.sys, "argv", ["stackvox", "paths"]) assert cli.main() == 0 assert paths_handler.called + + +class TestCmdCancel: + def test_running_calls_daemon_cancel(self, mocker): + mocker.patch.object(cli.daemon, "is_running", return_value=True) + cancel = mocker.patch.object(cli.daemon, "cancel", return_value=(True, "ok")) + assert cli._cmd_cancel(_ns()) == 0 + cancel.assert_called_once() + + def test_no_daemon_is_a_noop(self, mocker): + mocker.patch.object(cli.daemon, "is_running", return_value=False) + cancel = mocker.patch.object(cli.daemon, "cancel") + assert cli._cmd_cancel(_ns()) == 0 + cancel.assert_not_called() diff --git a/tests/test_daemon_protocol.py b/tests/test_daemon_protocol.py index 0a906ee..c554f68 100644 --- a/tests/test_daemon_protocol.py +++ b/tests/test_daemon_protocol.py @@ -169,3 +169,11 @@ def test_send_helper_when_daemon_not_running(tmp_path, monkeypatch): ok, resp = daemon.send({"command": "ping"}) assert not ok assert resp == "daemon not running" + + +def test_cancel_stops_playback_and_keeps_daemon_up(server: ServerHarness): + reply = _roundtrip(server.sock, json.dumps({"command": "cancel"}) + "\n") + assert reply == "ok" + server.tts.stop.assert_called() # current utterance aborted + # unlike `stop`, the daemon stays up + assert _roundtrip(server.sock, json.dumps({"command": "ping"}) + "\n") == "ok"