speak-cli 1.1.2__tar.gz → 1.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- speak_cli-1.2.0/.github/workflows/ci.yml +39 -0
- {speak_cli-1.1.2 → speak_cli-1.2.0}/.github/workflows/publish.yml +7 -1
- {speak_cli-1.1.2 → speak_cli-1.2.0}/PKG-INFO +4 -3
- {speak_cli-1.1.2 → speak_cli-1.2.0}/README.md +3 -2
- {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/daemon.py +48 -15
- {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/ipc.py +83 -26
- speak_cli-1.2.0/src/speak_cli/playback.py +104 -0
- speak_cli-1.2.0/tests/test_ipc_paths.py +87 -0
- speak_cli-1.1.2/src/speak_cli/playback.py +0 -61
- speak_cli-1.1.2/tests/test_ipc_paths.py +0 -18
- {speak_cli-1.1.2 → speak_cli-1.2.0}/.gitignore +0 -0
- {speak_cli-1.1.2 → speak_cli-1.2.0}/.python-version +0 -0
- {speak_cli-1.1.2 → speak_cli-1.2.0}/pyproject.toml +0 -0
- {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/__init__.py +0 -0
- {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/chunker.py +0 -0
- {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/cli.py +0 -0
- {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/config.py +0 -0
- {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/engine.py +0 -0
- {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/langdetect.py +0 -0
- {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/voices.py +0 -0
- {speak_cli-1.1.2 → speak_cli-1.2.0}/tests/test_chunker.py +0 -0
- {speak_cli-1.1.2 → speak_cli-1.2.0}/tests/test_engine_filter.py +0 -0
- {speak_cli-1.1.2 → speak_cli-1.2.0}/tests/test_langdetect.py +0 -0
- {speak_cli-1.1.2 → speak_cli-1.2.0}/tests/test_voices.py +0 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
strategy:
|
|
11
|
+
fail-fast: false
|
|
12
|
+
matrix:
|
|
13
|
+
os: [ubuntu-latest, windows-latest]
|
|
14
|
+
runs-on: ${{ matrix.os }}
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
with:
|
|
18
|
+
fetch-depth: 0
|
|
19
|
+
- uses: astral-sh/setup-uv@v5
|
|
20
|
+
- run: uv sync
|
|
21
|
+
- run: uv run pytest -q
|
|
22
|
+
|
|
23
|
+
- name: Cache Supertonic models
|
|
24
|
+
uses: actions/cache@v4
|
|
25
|
+
with:
|
|
26
|
+
path: ~/.cache/supertonic3
|
|
27
|
+
key: supertonic3-models-${{ runner.os }}-v1
|
|
28
|
+
|
|
29
|
+
- name: Synthesis smoke test (one-shot)
|
|
30
|
+
run: uv run speak --no-daemon -o smoke1.wav "hello from continuous integration"
|
|
31
|
+
|
|
32
|
+
- name: Synthesis smoke test (daemon path)
|
|
33
|
+
env:
|
|
34
|
+
SPEAK_IDLE_TIMEOUT: "30"
|
|
35
|
+
run: uv run speak -o smoke2.wav "daemon transport check"
|
|
36
|
+
|
|
37
|
+
- name: Stop daemon
|
|
38
|
+
if: always()
|
|
39
|
+
run: uv run speak --stop
|
|
@@ -7,9 +7,15 @@ on:
|
|
|
7
7
|
|
|
8
8
|
jobs:
|
|
9
9
|
test:
|
|
10
|
-
|
|
10
|
+
strategy:
|
|
11
|
+
fail-fast: false
|
|
12
|
+
matrix:
|
|
13
|
+
os: [ubuntu-latest, windows-latest]
|
|
14
|
+
runs-on: ${{ matrix.os }}
|
|
11
15
|
steps:
|
|
12
16
|
- uses: actions/checkout@v4
|
|
17
|
+
with:
|
|
18
|
+
fetch-depth: 0
|
|
13
19
|
- uses: astral-sh/setup-uv@v5
|
|
14
20
|
- run: uv sync
|
|
15
21
|
- run: uv run pytest -q
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: speak-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Speak text out loud from the command line using Supertonic 3 (local, offline TTS)
|
|
5
5
|
Project-URL: Repository, https://github.com/MohamedAliRashad/tts-cli
|
|
6
6
|
Project-URL: Issues, https://github.com/MohamedAliRashad/tts-cli/issues
|
|
@@ -31,12 +31,13 @@ Description-Content-Type: text/markdown
|
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
# If you don't have uv installed, run this first:
|
|
34
|
-
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
34
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh # Linux / macOS
|
|
35
|
+
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" # Windows
|
|
35
36
|
|
|
36
37
|
# if you do just run this command
|
|
37
38
|
uv tool install speak-cli
|
|
38
39
|
```
|
|
39
|
-
The voice models (~400 MB) download automatically the first time you speak.
|
|
40
|
+
The voice models (~400 MB) download automatically the first time you speak. Works on Linux, macOS, and Windows.
|
|
40
41
|
|
|
41
42
|
## How to use ?
|
|
42
43
|
|
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
# If you don't have uv installed, run this first:
|
|
11
|
-
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
11
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh # Linux / macOS
|
|
12
|
+
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" # Windows
|
|
12
13
|
|
|
13
14
|
# if you do just run this command
|
|
14
15
|
uv tool install speak-cli
|
|
15
16
|
```
|
|
16
|
-
The voice models (~400 MB) download automatically the first time you speak.
|
|
17
|
+
The voice models (~400 MB) download automatically the first time you speak. Works on Linux, macOS, and Windows.
|
|
17
18
|
|
|
18
19
|
## How to use ?
|
|
19
20
|
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
"""Background daemon: keeps Supertonic models warm, serves synthesis requests.
|
|
2
2
|
|
|
3
|
-
Run as `python -m speak_cli.daemon`. Single instance is enforced with
|
|
4
|
-
|
|
5
|
-
are harmless. Exits on idle timeout to
|
|
3
|
+
Run as `python -m speak_cli.daemon`. Single instance is enforced with a
|
|
4
|
+
locked lock file (flock on POSIX, msvcrt on Windows); a second copy exits 0
|
|
5
|
+
immediately, so racing auto-spawns are harmless. Exits on idle timeout to
|
|
6
|
+
free memory. Listens on a Unix socket (POSIX) or loopback TCP (Windows).
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
from __future__ import annotations
|
|
9
10
|
|
|
10
11
|
import contextlib
|
|
11
|
-
import fcntl
|
|
12
12
|
import json
|
|
13
13
|
import os
|
|
14
14
|
import socket
|
|
15
15
|
import sys
|
|
16
|
+
from typing import IO
|
|
16
17
|
|
|
17
18
|
from . import ipc
|
|
18
19
|
from .engine import Engine
|
|
@@ -27,6 +28,43 @@ def _idle_timeout() -> float:
|
|
|
27
28
|
return DEFAULT_IDLE_TIMEOUT
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
def _try_lock(lock_file: IO) -> bool:
|
|
32
|
+
"""Take an exclusive non-blocking lock; False if another daemon holds it."""
|
|
33
|
+
if ipc.WINDOWS:
|
|
34
|
+
import msvcrt
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
lock_file.seek(0)
|
|
38
|
+
msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
|
|
39
|
+
return True
|
|
40
|
+
except OSError:
|
|
41
|
+
return False
|
|
42
|
+
import fcntl
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
46
|
+
return True
|
|
47
|
+
except OSError:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _bind_server(paths: ipc.RuntimePaths) -> socket.socket:
|
|
52
|
+
"""Bind the platform's listening socket and publish the endpoint."""
|
|
53
|
+
if ipc.WINDOWS:
|
|
54
|
+
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
55
|
+
srv.bind(("127.0.0.1", 0))
|
|
56
|
+
port = srv.getsockname()[1]
|
|
57
|
+
tmp = paths.port.with_suffix(".tmp")
|
|
58
|
+
tmp.write_text(str(port))
|
|
59
|
+
tmp.replace(paths.port) # atomic publish
|
|
60
|
+
return srv
|
|
61
|
+
paths.sock.unlink(missing_ok=True)
|
|
62
|
+
srv = socket.socket(socket.AF_UNIX) # type: ignore[attr-defined]
|
|
63
|
+
srv.bind(str(paths.sock))
|
|
64
|
+
os.chmod(paths.sock, 0o600)
|
|
65
|
+
return srv
|
|
66
|
+
|
|
67
|
+
|
|
30
68
|
def handle_request(conn: socket.socket, engine: Engine) -> bool:
|
|
31
69
|
"""Serve one connection. Returns False when asked to shut down."""
|
|
32
70
|
try:
|
|
@@ -63,22 +101,15 @@ def main() -> None:
|
|
|
63
101
|
paths = ipc.runtime_paths()
|
|
64
102
|
|
|
65
103
|
lock = open(paths.lock, "w")
|
|
66
|
-
|
|
67
|
-
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
68
|
-
except OSError:
|
|
104
|
+
if not _try_lock(lock):
|
|
69
105
|
return # another daemon is already serving
|
|
70
|
-
lock.write(str(os.getpid()))
|
|
71
|
-
lock.flush()
|
|
72
106
|
|
|
73
107
|
print(f"[daemon] starting, pid={os.getpid()}", flush=True)
|
|
74
108
|
engine = Engine()
|
|
75
109
|
engine.prewarm()
|
|
76
110
|
print("[daemon] models loaded and prewarmed", flush=True)
|
|
77
111
|
|
|
78
|
-
paths
|
|
79
|
-
srv = socket.socket(socket.AF_UNIX)
|
|
80
|
-
srv.bind(str(paths.sock))
|
|
81
|
-
os.chmod(paths.sock, 0o600)
|
|
112
|
+
srv = _bind_server(paths)
|
|
82
113
|
srv.listen(8)
|
|
83
114
|
srv.settimeout(_idle_timeout())
|
|
84
115
|
|
|
@@ -95,8 +126,10 @@ def main() -> None:
|
|
|
95
126
|
print("[daemon] shutdown requested", flush=True)
|
|
96
127
|
break
|
|
97
128
|
finally:
|
|
98
|
-
|
|
99
|
-
|
|
129
|
+
with contextlib.suppress(OSError):
|
|
130
|
+
paths.endpoint.unlink(missing_ok=True)
|
|
131
|
+
with contextlib.suppress(OSError):
|
|
132
|
+
paths.lock.unlink(missing_ok=True) # may be held on Windows; fine
|
|
100
133
|
|
|
101
134
|
|
|
102
135
|
if __name__ == "__main__":
|
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Protocol: one request per connection. Client sends a single JSON line;
|
|
4
4
|
daemon replies a JSON header line, then (for "speak") exactly `nbytes` of
|
|
5
|
-
raw WAV.
|
|
6
|
-
|
|
5
|
+
raw WAV. Filenames embed the package version, so upgrading the package
|
|
6
|
+
naturally retires old daemons (they exit via idle timeout).
|
|
7
|
+
|
|
8
|
+
Transport: a Unix domain socket on POSIX; on Windows, loopback TCP with the
|
|
9
|
+
daemon's ephemeral port stored in a `daemon-<version>.port` file. The wire
|
|
10
|
+
protocol is identical, and the endpoint file's suffix selects the transport.
|
|
7
11
|
"""
|
|
8
12
|
|
|
9
13
|
from __future__ import annotations
|
|
10
14
|
|
|
15
|
+
import contextlib
|
|
11
16
|
import json
|
|
12
17
|
import os
|
|
13
18
|
import socket
|
|
@@ -19,6 +24,8 @@ from pathlib import Path
|
|
|
19
24
|
|
|
20
25
|
from . import __version__
|
|
21
26
|
|
|
27
|
+
WINDOWS = os.name == "nt"
|
|
28
|
+
|
|
22
29
|
CONNECT_TIMEOUT = 30.0 # daemon cold-start budget (models already cached)
|
|
23
30
|
REQUEST_TIMEOUT = 300.0 # long texts take a while to synthesize
|
|
24
31
|
|
|
@@ -26,26 +33,42 @@ REQUEST_TIMEOUT = 300.0 # long texts take a while to synthesize
|
|
|
26
33
|
@dataclass(frozen=True)
|
|
27
34
|
class RuntimePaths:
|
|
28
35
|
dir: Path
|
|
29
|
-
sock: Path
|
|
36
|
+
sock: Path # POSIX endpoint (unix socket)
|
|
37
|
+
port: Path # Windows endpoint (file holding the TCP port number)
|
|
30
38
|
lock: Path
|
|
31
39
|
daemon_log: Path
|
|
32
40
|
client_log: Path
|
|
33
41
|
|
|
42
|
+
@property
|
|
43
|
+
def endpoint(self) -> Path:
|
|
44
|
+
return self.port if WINDOWS else self.sock
|
|
34
45
|
|
|
35
|
-
|
|
46
|
+
|
|
47
|
+
def _state_dirs() -> tuple[Path, Path]:
|
|
48
|
+
"""Return (run_dir, log_dir) for the current platform."""
|
|
49
|
+
if WINDOWS:
|
|
50
|
+
local = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
|
|
51
|
+
base = local / "speak-cli"
|
|
52
|
+
return base / "run", base
|
|
36
53
|
runtime = os.environ.get("XDG_RUNTIME_DIR")
|
|
37
54
|
if runtime and os.path.isdir(runtime):
|
|
38
|
-
|
|
55
|
+
run_dir = Path(runtime) / "speak-cli"
|
|
39
56
|
else:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
run_dir = Path.home() / ".cache" / "speak-cli" / "run"
|
|
58
|
+
return run_dir, Path.home() / ".cache" / "speak-cli"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def runtime_paths() -> RuntimePaths:
|
|
62
|
+
run_dir, log_dir = _state_dirs()
|
|
63
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
44
64
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
with contextlib.suppress(OSError):
|
|
66
|
+
os.chmod(run_dir, 0o700)
|
|
45
67
|
return RuntimePaths(
|
|
46
|
-
dir=
|
|
47
|
-
sock=
|
|
48
|
-
|
|
68
|
+
dir=run_dir,
|
|
69
|
+
sock=run_dir / f"daemon-{__version__}.sock",
|
|
70
|
+
port=run_dir / f"daemon-{__version__}.port",
|
|
71
|
+
lock=run_dir / f"daemon-{__version__}.lock",
|
|
49
72
|
daemon_log=log_dir / "daemon.log",
|
|
50
73
|
client_log=log_dir / "client.log",
|
|
51
74
|
)
|
|
@@ -83,11 +106,38 @@ def recv_exact(sock: socket.socket, n: int) -> bytes:
|
|
|
83
106
|
return bytes(buf)
|
|
84
107
|
|
|
85
108
|
|
|
86
|
-
def
|
|
87
|
-
"""
|
|
88
|
-
|
|
109
|
+
def _connect(endpoint: Path) -> socket.socket:
|
|
110
|
+
"""Connect to a daemon endpoint file (.sock or .port).
|
|
111
|
+
|
|
112
|
+
Raises FileNotFoundError if the endpoint file is absent and
|
|
113
|
+
ConnectionRefusedError if it exists but no daemon answers.
|
|
114
|
+
"""
|
|
115
|
+
if endpoint.suffix == ".port":
|
|
116
|
+
try:
|
|
117
|
+
port = int(endpoint.read_text().strip())
|
|
118
|
+
except (OSError, ValueError):
|
|
119
|
+
raise FileNotFoundError(str(endpoint)) from None
|
|
120
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
121
|
+
try:
|
|
122
|
+
s.settimeout(REQUEST_TIMEOUT)
|
|
123
|
+
s.connect(("127.0.0.1", port))
|
|
124
|
+
except OSError:
|
|
125
|
+
s.close()
|
|
126
|
+
raise ConnectionRefusedError(f"no daemon on port {port}") from None
|
|
127
|
+
return s
|
|
128
|
+
s = socket.socket(socket.AF_UNIX) # type: ignore[attr-defined]
|
|
129
|
+
try:
|
|
89
130
|
s.settimeout(REQUEST_TIMEOUT)
|
|
90
|
-
s.connect(str(
|
|
131
|
+
s.connect(str(endpoint))
|
|
132
|
+
except OSError:
|
|
133
|
+
s.close()
|
|
134
|
+
raise
|
|
135
|
+
return s
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _request(endpoint: Path, req: dict) -> bytes:
|
|
139
|
+
"""Send one request; return WAV bytes (or b"" for control commands)."""
|
|
140
|
+
with contextlib.closing(_connect(endpoint)) as s:
|
|
91
141
|
s.sendall(json.dumps(req).encode() + b"\n")
|
|
92
142
|
header = json.loads(recv_line(s))
|
|
93
143
|
if header.get("status") != "ok":
|
|
@@ -98,13 +148,22 @@ def _request(sock_path: Path, req: dict) -> bytes:
|
|
|
98
148
|
|
|
99
149
|
def spawn_daemon(paths: RuntimePaths) -> None:
|
|
100
150
|
"""Start a detached daemon process; harmless if one is already running."""
|
|
151
|
+
kwargs: dict = {}
|
|
152
|
+
if WINDOWS:
|
|
153
|
+
kwargs["creationflags"] = (
|
|
154
|
+
subprocess.DETACHED_PROCESS
|
|
155
|
+
| subprocess.CREATE_NEW_PROCESS_GROUP
|
|
156
|
+
| getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
kwargs["start_new_session"] = True
|
|
101
160
|
with open(paths.daemon_log, "ab") as log:
|
|
102
161
|
subprocess.Popen(
|
|
103
162
|
[sys.executable, "-m", "speak_cli.daemon"],
|
|
104
163
|
stdin=subprocess.DEVNULL,
|
|
105
164
|
stdout=log,
|
|
106
165
|
stderr=log,
|
|
107
|
-
|
|
166
|
+
**kwargs,
|
|
108
167
|
)
|
|
109
168
|
|
|
110
169
|
|
|
@@ -113,19 +172,17 @@ def get_wav_from_daemon(req: dict) -> bytes:
|
|
|
113
172
|
|
|
114
173
|
Raises on any failure — the caller falls back to one-shot synthesis.
|
|
115
174
|
"""
|
|
116
|
-
if os.name != "posix":
|
|
117
|
-
raise RuntimeError("daemon mode is POSIX-only")
|
|
118
175
|
paths = runtime_paths()
|
|
119
176
|
try:
|
|
120
|
-
return _request(paths.
|
|
177
|
+
return _request(paths.endpoint, req)
|
|
121
178
|
except (FileNotFoundError, ConnectionRefusedError):
|
|
122
|
-
# Not running (or crashed leaving a stale
|
|
123
|
-
paths.
|
|
179
|
+
# Not running (or crashed leaving a stale endpoint): clean up, spawn.
|
|
180
|
+
paths.endpoint.unlink(missing_ok=True)
|
|
124
181
|
spawn_daemon(paths)
|
|
125
182
|
deadline = time.monotonic() + CONNECT_TIMEOUT
|
|
126
183
|
while True:
|
|
127
184
|
try:
|
|
128
|
-
return _request(paths.
|
|
185
|
+
return _request(paths.endpoint, req)
|
|
129
186
|
except (FileNotFoundError, ConnectionRefusedError):
|
|
130
187
|
if time.monotonic() > deadline:
|
|
131
188
|
raise TimeoutError(
|
|
@@ -144,12 +201,12 @@ def stop_daemons() -> int:
|
|
|
144
201
|
for base in candidates:
|
|
145
202
|
if not base.is_dir():
|
|
146
203
|
continue
|
|
147
|
-
for
|
|
204
|
+
for endpoint in [*base.glob("daemon-*.sock"), *base.glob("daemon-*.port")]:
|
|
148
205
|
try:
|
|
149
|
-
_request(
|
|
206
|
+
_request(endpoint, {"cmd": "shutdown"})
|
|
150
207
|
stopped += 1
|
|
151
208
|
except OSError:
|
|
152
|
-
|
|
209
|
+
endpoint.unlink(missing_ok=True) # stale
|
|
153
210
|
except Exception:
|
|
154
211
|
pass
|
|
155
212
|
return stopped
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Play WAV bytes through whatever audio facility the system has.
|
|
2
|
+
|
|
3
|
+
No Python audio dependency on purpose: simpleaudio is unmaintained and
|
|
4
|
+
sounddevice needs a system PortAudio library. On Windows the stdlib's
|
|
5
|
+
winsound plays WAVs natively (PowerShell as a fallback); elsewhere every
|
|
6
|
+
mainstream desktop has at least one of the players below. Each option is
|
|
7
|
+
tried until one succeeds.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PlaybackError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_PLAYERS: list[tuple[str, list[str]]] = [
|
|
24
|
+
("paplay", []),
|
|
25
|
+
("pw-play", []),
|
|
26
|
+
("aplay", ["-q"]),
|
|
27
|
+
("afplay", []),
|
|
28
|
+
("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet"]),
|
|
29
|
+
("play", ["-q"]),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _tmp_dir() -> str | None:
|
|
34
|
+
runtime = os.environ.get("XDG_RUNTIME_DIR")
|
|
35
|
+
if runtime and os.path.isdir(runtime) and os.access(runtime, os.W_OK):
|
|
36
|
+
return runtime # tmpfs: no disk I/O
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _play_windows(path: str, errors: list[str]) -> bool:
|
|
41
|
+
try:
|
|
42
|
+
import winsound
|
|
43
|
+
|
|
44
|
+
winsound.PlaySound(path, winsound.SND_FILENAME)
|
|
45
|
+
return True
|
|
46
|
+
except Exception as e:
|
|
47
|
+
errors.append(f"winsound: {e}")
|
|
48
|
+
powershell = shutil.which("powershell") or shutil.which("pwsh")
|
|
49
|
+
if powershell:
|
|
50
|
+
proc = subprocess.run(
|
|
51
|
+
[powershell, "-NoProfile", "-Command",
|
|
52
|
+
f"(New-Object Media.SoundPlayer '{path}').PlaySync()"],
|
|
53
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
|
54
|
+
)
|
|
55
|
+
if proc.returncode == 0:
|
|
56
|
+
return True
|
|
57
|
+
errors.append(f"powershell: exit {proc.returncode}")
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _play_players(path: str, errors: list[str]) -> str | None:
|
|
62
|
+
for name, args in _PLAYERS:
|
|
63
|
+
exe = shutil.which(name)
|
|
64
|
+
if not exe:
|
|
65
|
+
continue
|
|
66
|
+
proc = subprocess.run(
|
|
67
|
+
[exe, *args, path],
|
|
68
|
+
stdout=subprocess.DEVNULL,
|
|
69
|
+
stderr=subprocess.PIPE,
|
|
70
|
+
)
|
|
71
|
+
if proc.returncode == 0:
|
|
72
|
+
return name
|
|
73
|
+
errors.append(f"{name}: exit {proc.returncode}")
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def play(wav_bytes: bytes, verbose: bool = False) -> None:
|
|
78
|
+
"""Play WAV bytes; raises PlaybackError if nothing works."""
|
|
79
|
+
errors: list[str] = []
|
|
80
|
+
# delete=False + close before playing: on Windows another process cannot
|
|
81
|
+
# open a file we still hold open.
|
|
82
|
+
f = tempfile.NamedTemporaryFile(suffix=".wav", dir=_tmp_dir(), delete=False)
|
|
83
|
+
try:
|
|
84
|
+
f.write(wav_bytes)
|
|
85
|
+
f.close()
|
|
86
|
+
if sys.platform == "win32" and _play_windows(f.name, errors):
|
|
87
|
+
if verbose:
|
|
88
|
+
print("[speak] played via winsound/powershell", flush=True)
|
|
89
|
+
return
|
|
90
|
+
player = _play_players(f.name, errors)
|
|
91
|
+
if player:
|
|
92
|
+
if verbose:
|
|
93
|
+
print(f"[speak] played via {player}", flush=True)
|
|
94
|
+
return
|
|
95
|
+
finally:
|
|
96
|
+
try:
|
|
97
|
+
os.unlink(f.name)
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
detail = "; ".join(errors) if errors else "no audio player found on PATH"
|
|
101
|
+
raise PlaybackError(
|
|
102
|
+
f"could not play audio ({detail}). "
|
|
103
|
+
"Use --out FILE to save the audio to a WAV file instead."
|
|
104
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from speak_cli import __version__, ipc
|
|
6
|
+
|
|
7
|
+
posix_only = pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only")
|
|
8
|
+
windows_only = pytest.mark.skipif(sys.platform != "win32", reason="Windows-only")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@posix_only
|
|
12
|
+
def test_runtime_paths_created(tmp_path, monkeypatch):
|
|
13
|
+
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path))
|
|
14
|
+
paths = ipc.runtime_paths()
|
|
15
|
+
assert paths.dir.is_dir()
|
|
16
|
+
assert (paths.dir.stat().st_mode & 0o777) == 0o700
|
|
17
|
+
assert paths.sock.name == f"daemon-{__version__}.sock"
|
|
18
|
+
assert paths.sock.parent == paths.dir
|
|
19
|
+
assert paths.endpoint == paths.sock
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@posix_only
|
|
23
|
+
def test_runtime_paths_fallback_without_xdg(tmp_path, monkeypatch):
|
|
24
|
+
monkeypatch.delenv("XDG_RUNTIME_DIR", raising=False)
|
|
25
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
26
|
+
paths = ipc.runtime_paths()
|
|
27
|
+
assert str(paths.dir).startswith(str(tmp_path))
|
|
28
|
+
assert paths.dir.is_dir()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@windows_only
|
|
32
|
+
def test_runtime_paths_windows(tmp_path, monkeypatch):
|
|
33
|
+
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
|
34
|
+
paths = ipc.runtime_paths()
|
|
35
|
+
assert paths.dir.is_dir()
|
|
36
|
+
assert str(paths.dir).startswith(str(tmp_path))
|
|
37
|
+
assert paths.port.name == f"daemon-{__version__}.port"
|
|
38
|
+
assert paths.endpoint == paths.port
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_endpoint_missing_raises_filenotfound(tmp_path):
|
|
42
|
+
endpoint = tmp_path / "daemon-0.0.0.port"
|
|
43
|
+
with pytest.raises(FileNotFoundError):
|
|
44
|
+
ipc._connect(endpoint)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_stale_port_file_raises_connection_refused(tmp_path):
|
|
48
|
+
endpoint = tmp_path / "daemon-0.0.0.port"
|
|
49
|
+
endpoint.write_text("1") # port 1: nothing listens there
|
|
50
|
+
with pytest.raises(ConnectionRefusedError):
|
|
51
|
+
ipc._connect(endpoint)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_garbage_port_file_raises_filenotfound(tmp_path):
|
|
55
|
+
endpoint = tmp_path / "daemon-0.0.0.port"
|
|
56
|
+
endpoint.write_text("not-a-number")
|
|
57
|
+
with pytest.raises(FileNotFoundError):
|
|
58
|
+
ipc._connect(endpoint)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_tcp_transport_round_trip(tmp_path):
|
|
62
|
+
"""Exercises the Windows .port transport end-to-end (works on any OS)."""
|
|
63
|
+
import json
|
|
64
|
+
import socket
|
|
65
|
+
import threading
|
|
66
|
+
|
|
67
|
+
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
68
|
+
srv.bind(("127.0.0.1", 0))
|
|
69
|
+
srv.listen(1)
|
|
70
|
+
endpoint = tmp_path / "daemon-0.0.0.port"
|
|
71
|
+
endpoint.write_text(str(srv.getsockname()[1]))
|
|
72
|
+
payload = b"RIFFfakewav" * 100
|
|
73
|
+
|
|
74
|
+
def serve():
|
|
75
|
+
conn, _ = srv.accept()
|
|
76
|
+
with conn:
|
|
77
|
+
req = json.loads(ipc.recv_line(conn))
|
|
78
|
+
assert req["cmd"] == "speak"
|
|
79
|
+
header = json.dumps({"status": "ok", "nbytes": len(payload)})
|
|
80
|
+
conn.sendall(header.encode() + b"\n" + payload)
|
|
81
|
+
srv.close()
|
|
82
|
+
|
|
83
|
+
t = threading.Thread(target=serve)
|
|
84
|
+
t.start()
|
|
85
|
+
out = ipc._request(endpoint, {"cmd": "speak", "text": "hi"})
|
|
86
|
+
t.join()
|
|
87
|
+
assert out == payload
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
"""Play WAV bytes through whatever audio player the system has.
|
|
2
|
-
|
|
3
|
-
No Python audio dependency on purpose: simpleaudio is unmaintained and
|
|
4
|
-
sounddevice needs a system PortAudio library. Every mainstream desktop has
|
|
5
|
-
at least one of the players below; each is tried until one exits 0.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import os
|
|
11
|
-
import shutil
|
|
12
|
-
import subprocess
|
|
13
|
-
import tempfile
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class PlaybackError(RuntimeError):
|
|
17
|
-
pass
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
_PLAYERS: list[tuple[str, list[str]]] = [
|
|
21
|
-
("paplay", []),
|
|
22
|
-
("pw-play", []),
|
|
23
|
-
("aplay", ["-q"]),
|
|
24
|
-
("afplay", []),
|
|
25
|
-
("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet"]),
|
|
26
|
-
("play", ["-q"]),
|
|
27
|
-
]
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _tmp_dir() -> str | None:
|
|
31
|
-
runtime = os.environ.get("XDG_RUNTIME_DIR")
|
|
32
|
-
if runtime and os.path.isdir(runtime) and os.access(runtime, os.W_OK):
|
|
33
|
-
return runtime # tmpfs: no disk I/O
|
|
34
|
-
return None
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def play(wav_bytes: bytes, verbose: bool = False) -> None:
|
|
38
|
-
"""Play WAV bytes; raises PlaybackError if no player works."""
|
|
39
|
-
errors: list[str] = []
|
|
40
|
-
with tempfile.NamedTemporaryFile(suffix=".wav", dir=_tmp_dir()) as f:
|
|
41
|
-
f.write(wav_bytes)
|
|
42
|
-
f.flush()
|
|
43
|
-
for name, args in _PLAYERS:
|
|
44
|
-
exe = shutil.which(name)
|
|
45
|
-
if not exe:
|
|
46
|
-
continue
|
|
47
|
-
proc = subprocess.run(
|
|
48
|
-
[exe, *args, f.name],
|
|
49
|
-
stdout=subprocess.DEVNULL,
|
|
50
|
-
stderr=subprocess.PIPE,
|
|
51
|
-
)
|
|
52
|
-
if proc.returncode == 0:
|
|
53
|
-
if verbose:
|
|
54
|
-
print(f"[speak] played via {name}", flush=True)
|
|
55
|
-
return
|
|
56
|
-
errors.append(f"{name}: exit {proc.returncode}")
|
|
57
|
-
detail = "; ".join(errors) if errors else "no audio player found on PATH"
|
|
58
|
-
raise PlaybackError(
|
|
59
|
-
f"could not play audio ({detail}). "
|
|
60
|
-
"Use --out FILE to save the audio to a WAV file instead."
|
|
61
|
-
)
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
from speak_cli import __version__, ipc
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def test_runtime_paths_created(tmp_path, monkeypatch):
|
|
5
|
-
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path))
|
|
6
|
-
paths = ipc.runtime_paths()
|
|
7
|
-
assert paths.dir.is_dir()
|
|
8
|
-
assert (paths.dir.stat().st_mode & 0o777) == 0o700
|
|
9
|
-
assert paths.sock.name == f"daemon-{__version__}.sock"
|
|
10
|
-
assert paths.sock.parent == paths.dir
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def test_runtime_paths_fallback_without_xdg(tmp_path, monkeypatch):
|
|
14
|
-
monkeypatch.delenv("XDG_RUNTIME_DIR", raising=False)
|
|
15
|
-
monkeypatch.setenv("HOME", str(tmp_path))
|
|
16
|
-
paths = ipc.runtime_paths()
|
|
17
|
-
assert str(paths.dir).startswith(str(tmp_path))
|
|
18
|
-
assert paths.dir.is_dir()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|