Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion stackvox/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def _configure_logging() -> None:
SUBCOMMANDS = {
"serve",
"stop",
"cancel",
"status",
"say",
"speak",
Expand All @@ -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

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 25 additions & 1 deletion stackvox/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <msg>".
"""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)

Expand Down
14 changes: 14 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
8 changes: 8 additions & 0 deletions tests/test_daemon_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading