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.
Files changed (24) hide show
  1. speak_cli-1.2.0/.github/workflows/ci.yml +39 -0
  2. {speak_cli-1.1.2 → speak_cli-1.2.0}/.github/workflows/publish.yml +7 -1
  3. {speak_cli-1.1.2 → speak_cli-1.2.0}/PKG-INFO +4 -3
  4. {speak_cli-1.1.2 → speak_cli-1.2.0}/README.md +3 -2
  5. {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/daemon.py +48 -15
  6. {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/ipc.py +83 -26
  7. speak_cli-1.2.0/src/speak_cli/playback.py +104 -0
  8. speak_cli-1.2.0/tests/test_ipc_paths.py +87 -0
  9. speak_cli-1.1.2/src/speak_cli/playback.py +0 -61
  10. speak_cli-1.1.2/tests/test_ipc_paths.py +0 -18
  11. {speak_cli-1.1.2 → speak_cli-1.2.0}/.gitignore +0 -0
  12. {speak_cli-1.1.2 → speak_cli-1.2.0}/.python-version +0 -0
  13. {speak_cli-1.1.2 → speak_cli-1.2.0}/pyproject.toml +0 -0
  14. {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/__init__.py +0 -0
  15. {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/chunker.py +0 -0
  16. {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/cli.py +0 -0
  17. {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/config.py +0 -0
  18. {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/engine.py +0 -0
  19. {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/langdetect.py +0 -0
  20. {speak_cli-1.1.2 → speak_cli-1.2.0}/src/speak_cli/voices.py +0 -0
  21. {speak_cli-1.1.2 → speak_cli-1.2.0}/tests/test_chunker.py +0 -0
  22. {speak_cli-1.1.2 → speak_cli-1.2.0}/tests/test_engine_filter.py +0 -0
  23. {speak_cli-1.1.2 → speak_cli-1.2.0}/tests/test_langdetect.py +0 -0
  24. {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
- runs-on: ubuntu-latest
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.1.2
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 an
4
- flock'd lock file; a second copy exits 0 immediately, so racing auto-spawns
5
- are harmless. Exits on idle timeout to free memory.
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
- try:
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.sock.unlink(missing_ok=True)
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
- paths.sock.unlink(missing_ok=True)
99
- paths.lock.unlink(missing_ok=True)
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. The socket filename embeds the package version, so upgrading the
6
- package naturally retires old daemons (they exit via idle timeout).
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
- def runtime_paths() -> RuntimePaths:
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
- base = Path(runtime) / "speak-cli"
55
+ run_dir = Path(runtime) / "speak-cli"
39
56
  else:
40
- base = Path.home() / ".cache" / "speak-cli" / "run"
41
- base.mkdir(parents=True, exist_ok=True)
42
- os.chmod(base, 0o700)
43
- log_dir = Path.home() / ".cache" / "speak-cli"
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=base,
47
- sock=base / f"daemon-{__version__}.sock",
48
- lock=base / f"daemon-{__version__}.lock",
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 _request(sock_path: Path, req: dict) -> bytes:
87
- """Send one request; return WAV bytes (or b"" for control commands)."""
88
- with socket.socket(socket.AF_UNIX) as s:
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(sock_path))
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
- start_new_session=True,
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.sock, req)
177
+ return _request(paths.endpoint, req)
121
178
  except (FileNotFoundError, ConnectionRefusedError):
122
- # Not running (or crashed leaving a stale socket): clean up and spawn.
123
- paths.sock.unlink(missing_ok=True)
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.sock, req)
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 sock_path in base.glob("daemon-*.sock"):
204
+ for endpoint in [*base.glob("daemon-*.sock"), *base.glob("daemon-*.port")]:
148
205
  try:
149
- _request(sock_path, {"cmd": "shutdown"})
206
+ _request(endpoint, {"cmd": "shutdown"})
150
207
  stopped += 1
151
208
  except OSError:
152
- sock_path.unlink(missing_ok=True) # stale
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