speak-cli 0.1.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.
@@ -0,0 +1,27 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v5
14
+ - run: uv sync
15
+ - run: uv run pytest -q
16
+
17
+ publish:
18
+ needs: test
19
+ runs-on: ubuntu-latest
20
+ environment: pypi
21
+ permissions:
22
+ id-token: write # PyPI trusted publishing
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: astral-sh/setup-uv@v5
26
+ - run: uv build
27
+ - run: uv publish
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ dist/
6
+ *.wav
7
+ .pytest_cache/
8
+ uv.lock
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: speak-cli
3
+ Version: 0.1.0
4
+ Summary: Speak text out loud from the command line using Supertonic 3 (local, offline TTS)
5
+ Project-URL: Repository, https://github.com/MohamedAliRashad/tts-cli
6
+ Project-URL: Issues, https://github.com/MohamedAliRashad/tts-cli/issues
7
+ Author: Mohamed Rashad
8
+ License: MIT
9
+ Keywords: cli,offline,speech,supertonic,text-to-speech,tts
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: supertonic>=1.3.1
22
+ Description-Content-Type: text/markdown
23
+
24
+ # speak — local text-to-speech CLI
25
+
26
+ Speak text out loud from the command line using [Supertonic 3](https://huggingface.co/Supertone/supertonic-3) — a fast, ~99M-parameter TTS model that runs entirely on your CPU. No cloud, no API keys.
27
+
28
+ ```bash
29
+ speak "hello world"
30
+ echo "piped text works too" | speak
31
+ speak -v noah "a different voice"
32
+ speak "مرحبا بالعالم" # language auto-detected from the text
33
+ speak -o clip.wav "save to a file instead"
34
+ ```
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ uv tool install speak-cli
40
+ ```
41
+
42
+ Or without waiting for a PyPI release, straight from GitHub:
43
+
44
+ ```bash
45
+ uv tool install git+https://github.com/MohamedAliRashad/tts-cli
46
+ ```
47
+
48
+ (From a clone, `uv tool install .` works too.) All dependencies are prebuilt Python wheels — no compilers, no apt packages. On the first `speak`, the Supertonic 3 models (~400 MB) are downloaded once to `~/.cache/supertonic3/`; everything after that works fully offline. `say` is installed as an alias of `speak` (handy, but if another tool on your machine already provides `say`, just use `speak`).
49
+
50
+ ## How it stays fast
51
+
52
+ The first call starts a background daemon that keeps the models loaded in memory, so subsequent calls speak in well under a second. The daemon exits after 15 minutes idle (tune with `SPEAK_IDLE_TIMEOUT` seconds) and is respawned transparently. If the daemon can't run for any reason, `speak` silently falls back to in-process synthesis — it always works.
53
+
54
+ ```bash
55
+ speak --stop # stop the daemon manually
56
+ speak --no-daemon # bypass the daemon for one call
57
+ ```
58
+
59
+ ## Voices
60
+
61
+ ```
62
+ speak --list-voices
63
+ ```
64
+
65
+ | Female | Male |
66
+ |---|---|
67
+ | sara *(default)* | james |
68
+ | emma | daniel |
69
+ | lily | leo |
70
+ | maya | ryan |
71
+ | nora | noah |
72
+
73
+ `--voice` accepts any of these names, case-insensitive. Set a persistent default with `export SPEAK_VOICE=noah`.
74
+
75
+ ## Options
76
+
77
+ | Flag | Default | Meaning |
78
+ |---|---|---|
79
+ | `-v, --voice` | sara | voice name |
80
+ | `-s, --speed` | 1.05 | speech speed (0.7–2.0) |
81
+ | `-l, --lang` | auto | language code, `auto`, or `na` (language-agnostic) |
82
+ | `--steps` | 8 | quality/speed trade-off (5–12) |
83
+ | `-o, --out FILE` | — | write WAV instead of playing (`--play` for both) |
84
+ | `--verbose` | — | show detected language, timing, synthesis path |
85
+
86
+ Language auto-detection is script-based: Arabic, Japanese, Korean, Russian, Greek, and Hindi are detected from their alphabets; all Latin-script text is assumed English (use `--lang fr`, `--lang de`, … to override). Supported languages: en ko ja ar bg cs da de el es et fi fr hi hr hu id it lt lv nl pl pt ro ru sk sl sv tr uk vi.
87
+
88
+ Expression tags can be embedded in the text: `speak "well <laugh> that was funny"` (also `<breath>`, `<sigh>`, …).
89
+
90
+ ## Playback
91
+
92
+ Audio is played through the first working system player among `paplay`, `pw-play`, `aplay`, `afplay` (macOS), `ffplay`, `play` (sox) — every mainstream desktop has at least one. If none works, `speak` tells you and suggests `--out`.
93
+
94
+ ## Licenses
95
+
96
+ CLI code: MIT. Supertonic 3 model weights: [OpenRAIL-M](https://huggingface.co/Supertone/supertonic-3/blob/main/LICENSE) (via the `supertonic` SDK's auto-download).
@@ -0,0 +1,73 @@
1
+ # speak — local text-to-speech CLI
2
+
3
+ Speak text out loud from the command line using [Supertonic 3](https://huggingface.co/Supertone/supertonic-3) — a fast, ~99M-parameter TTS model that runs entirely on your CPU. No cloud, no API keys.
4
+
5
+ ```bash
6
+ speak "hello world"
7
+ echo "piped text works too" | speak
8
+ speak -v noah "a different voice"
9
+ speak "مرحبا بالعالم" # language auto-detected from the text
10
+ speak -o clip.wav "save to a file instead"
11
+ ```
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ uv tool install speak-cli
17
+ ```
18
+
19
+ Or without waiting for a PyPI release, straight from GitHub:
20
+
21
+ ```bash
22
+ uv tool install git+https://github.com/MohamedAliRashad/tts-cli
23
+ ```
24
+
25
+ (From a clone, `uv tool install .` works too.) All dependencies are prebuilt Python wheels — no compilers, no apt packages. On the first `speak`, the Supertonic 3 models (~400 MB) are downloaded once to `~/.cache/supertonic3/`; everything after that works fully offline. `say` is installed as an alias of `speak` (handy, but if another tool on your machine already provides `say`, just use `speak`).
26
+
27
+ ## How it stays fast
28
+
29
+ The first call starts a background daemon that keeps the models loaded in memory, so subsequent calls speak in well under a second. The daemon exits after 15 minutes idle (tune with `SPEAK_IDLE_TIMEOUT` seconds) and is respawned transparently. If the daemon can't run for any reason, `speak` silently falls back to in-process synthesis — it always works.
30
+
31
+ ```bash
32
+ speak --stop # stop the daemon manually
33
+ speak --no-daemon # bypass the daemon for one call
34
+ ```
35
+
36
+ ## Voices
37
+
38
+ ```
39
+ speak --list-voices
40
+ ```
41
+
42
+ | Female | Male |
43
+ |---|---|
44
+ | sara *(default)* | james |
45
+ | emma | daniel |
46
+ | lily | leo |
47
+ | maya | ryan |
48
+ | nora | noah |
49
+
50
+ `--voice` accepts any of these names, case-insensitive. Set a persistent default with `export SPEAK_VOICE=noah`.
51
+
52
+ ## Options
53
+
54
+ | Flag | Default | Meaning |
55
+ |---|---|---|
56
+ | `-v, --voice` | sara | voice name |
57
+ | `-s, --speed` | 1.05 | speech speed (0.7–2.0) |
58
+ | `-l, --lang` | auto | language code, `auto`, or `na` (language-agnostic) |
59
+ | `--steps` | 8 | quality/speed trade-off (5–12) |
60
+ | `-o, --out FILE` | — | write WAV instead of playing (`--play` for both) |
61
+ | `--verbose` | — | show detected language, timing, synthesis path |
62
+
63
+ Language auto-detection is script-based: Arabic, Japanese, Korean, Russian, Greek, and Hindi are detected from their alphabets; all Latin-script text is assumed English (use `--lang fr`, `--lang de`, … to override). Supported languages: en ko ja ar bg cs da de el es et fi fr hi hr hu id it lt lv nl pl pt ro ru sk sl sv tr uk vi.
64
+
65
+ Expression tags can be embedded in the text: `speak "well <laugh> that was funny"` (also `<breath>`, `<sigh>`, …).
66
+
67
+ ## Playback
68
+
69
+ Audio is played through the first working system player among `paplay`, `pw-play`, `aplay`, `afplay` (macOS), `ffplay`, `play` (sox) — every mainstream desktop has at least one. If none works, `speak` tells you and suggests `--out`.
70
+
71
+ ## Licenses
72
+
73
+ CLI code: MIT. Supertonic 3 model weights: [OpenRAIL-M](https://huggingface.co/Supertone/supertonic-3/blob/main/LICENSE) (via the `supertonic` SDK's auto-download).
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "speak-cli"
3
+ version = "0.1.0"
4
+ description = "Speak text out loud from the command line using Supertonic 3 (local, offline TTS)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Mohamed Rashad" }]
9
+ keywords = ["tts", "text-to-speech", "supertonic", "cli", "speech", "offline"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Environment :: Console",
13
+ "Intended Audience :: End Users/Desktop",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: POSIX :: Linux",
16
+ "Operating System :: MacOS",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Multimedia :: Sound/Audio :: Speech",
21
+ ]
22
+ dependencies = [
23
+ "supertonic>=1.3.1",
24
+ ]
25
+
26
+ [project.urls]
27
+ Repository = "https://github.com/MohamedAliRashad/tts-cli"
28
+ Issues = "https://github.com/MohamedAliRashad/tts-cli/issues"
29
+
30
+ [project.scripts]
31
+ say = "speak_cli.cli:main"
32
+ speak = "speak_cli.cli:main"
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "pytest>=8.0",
37
+ ]
38
+
39
+ [build-system]
40
+ requires = ["hatchling"]
41
+ build-backend = "hatchling.build"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/speak_cli"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,177 @@
1
+ """`speak` — speak text out loud using Supertonic 3, locally and offline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from . import __version__, voices
9
+ from .langdetect import SUPPORTED_LANGS, detect_lang
10
+
11
+
12
+ def build_parser() -> argparse.ArgumentParser:
13
+ p = argparse.ArgumentParser(
14
+ prog="speak",
15
+ description="Speak text out loud using Supertonic 3 (local TTS, no cloud).",
16
+ epilog='Examples: speak "hello world" | echo hi | speak | speak -v noah -l ar "مرحبا"',
17
+ )
18
+ p.add_argument("text", nargs="*", help="text to speak (reads stdin if omitted)")
19
+ p.add_argument("-v", "--voice", default=None, metavar="NAME",
20
+ help="voice name (see --list-voices; default: sara)")
21
+ p.add_argument("-s", "--speed", type=float, default=1.05, metavar="X",
22
+ help="speech speed, 0.7-2.0 (default: 1.05)")
23
+ p.add_argument("-l", "--lang", default="auto", metavar="CODE",
24
+ help='language code, "auto" (default) or "na"')
25
+ p.add_argument("--steps", type=int, default=8, metavar="N",
26
+ help="denoising steps 5-12, higher = better quality (default: 8)")
27
+ p.add_argument("-o", "--out", metavar="FILE",
28
+ help="save WAV to FILE instead of playing")
29
+ p.add_argument("--play", action="store_true",
30
+ help="with --out: also play the audio")
31
+ p.add_argument("--list-voices", action="store_true", help="list voices and exit")
32
+ p.add_argument("--no-daemon", action="store_true",
33
+ help="synthesize in-process instead of using the daemon")
34
+ p.add_argument("--stop", action="store_true",
35
+ help="stop the background daemon and exit")
36
+ p.add_argument("--verbose", action="store_true",
37
+ help="print timing and which synthesis/playback path was used")
38
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
39
+ return p
40
+
41
+
42
+ def _resolve_text(args: argparse.Namespace) -> str:
43
+ if args.text:
44
+ return " ".join(args.text).strip()
45
+ if not sys.stdin.isatty():
46
+ return sys.stdin.read().strip()
47
+ return ""
48
+
49
+
50
+ def _synthesize(req: dict, no_daemon: bool, verbose: bool) -> bytes:
51
+ """Get WAV bytes: daemon first, silent fallback to in-process."""
52
+ import time
53
+
54
+ from . import ipc
55
+ from .engine import models_cached
56
+
57
+ first_run = not models_cached()
58
+ if first_run:
59
+ print(
60
+ "First run: downloading Supertonic 3 models (~400 MB, one time)...",
61
+ file=sys.stderr,
62
+ )
63
+
64
+ t0 = time.monotonic()
65
+ if not no_daemon and not first_run:
66
+ try:
67
+ wav = ipc.get_wav_from_daemon(req)
68
+ if verbose:
69
+ print(f"[speak] daemon path, {time.monotonic() - t0:.2f}s", file=sys.stderr)
70
+ return wav
71
+ except Exception as e:
72
+ ipc.log_client(f"daemon path failed, falling back to one-shot: {e!r}")
73
+ if verbose:
74
+ print(f"[speak] daemon failed ({e}), one-shot fallback", file=sys.stderr)
75
+
76
+ from .engine import Engine
77
+
78
+ engine = Engine()
79
+ wav, _ = engine.synthesize(
80
+ text=req["text"],
81
+ voice=req["voice"],
82
+ speed=req["speed"],
83
+ lang=req["lang"],
84
+ steps=req["steps"],
85
+ )
86
+ if verbose:
87
+ print(f"[speak] one-shot path, {time.monotonic() - t0:.2f}s", file=sys.stderr)
88
+ if first_run and not no_daemon:
89
+ # Warm a daemon in the background so the next call is fast.
90
+ try:
91
+ from . import ipc as _ipc
92
+
93
+ _ipc.spawn_daemon(_ipc.runtime_paths())
94
+ except Exception:
95
+ pass
96
+ return wav
97
+
98
+
99
+ def main(argv: list[str] | None = None) -> int:
100
+ args = build_parser().parse_args(argv)
101
+
102
+ if args.list_voices:
103
+ print(voices.listing())
104
+ return 0
105
+
106
+ if args.stop:
107
+ from . import ipc
108
+
109
+ n = ipc.stop_daemons()
110
+ print(f"stopped {n} daemon(s)" if n else "no daemon running")
111
+ return 0
112
+
113
+ text = _resolve_text(args)
114
+ if not text:
115
+ print("speak: no text to speak (pass text as arguments or pipe via stdin)",
116
+ file=sys.stderr)
117
+ return 2
118
+
119
+ try:
120
+ _, style_id = voices.resolve(args.voice or voices.default_voice())
121
+ except ValueError as e:
122
+ print(f"speak: {e}", file=sys.stderr)
123
+ return 2
124
+
125
+ if not 0.7 <= args.speed <= 2.0:
126
+ print("speak: --speed must be between 0.7 and 2.0", file=sys.stderr)
127
+ return 2
128
+ if not 1 <= args.steps <= 32:
129
+ print("speak: --steps must be between 1 and 32", file=sys.stderr)
130
+ return 2
131
+
132
+ lang = detect_lang(text) if args.lang == "auto" else args.lang.lower()
133
+ if lang not in SUPPORTED_LANGS:
134
+ supported = " ".join(sorted(SUPPORTED_LANGS))
135
+ print(f"speak: unsupported language {lang!r}. Supported: {supported}",
136
+ file=sys.stderr)
137
+ return 2
138
+ if args.verbose:
139
+ print(f"[speak] voice={style_id} lang={lang} speed={args.speed} steps={args.steps}",
140
+ file=sys.stderr)
141
+
142
+ req = {
143
+ "cmd": "speak",
144
+ "text": text,
145
+ "voice": style_id,
146
+ "speed": args.speed,
147
+ "lang": lang,
148
+ "steps": args.steps,
149
+ }
150
+
151
+ try:
152
+ wav = _synthesize(req, no_daemon=args.no_daemon, verbose=args.verbose)
153
+
154
+ if args.out:
155
+ with open(args.out, "wb") as f:
156
+ f.write(wav)
157
+ if args.verbose:
158
+ print(f"[speak] wrote {args.out}", file=sys.stderr)
159
+
160
+ if not args.out or args.play:
161
+ from .playback import PlaybackError, play
162
+
163
+ try:
164
+ play(wav, verbose=args.verbose)
165
+ except PlaybackError as e:
166
+ print(f"speak: {e}", file=sys.stderr)
167
+ return 1
168
+ except KeyboardInterrupt:
169
+ return 130
170
+ except Exception as e:
171
+ print(f"speak: {e}", file=sys.stderr)
172
+ return 1
173
+ return 0
174
+
175
+
176
+ if __name__ == "__main__":
177
+ sys.exit(main())
@@ -0,0 +1,103 @@
1
+ """Background daemon: keeps Supertonic models warm, serves synthesis requests.
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.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import fcntl
12
+ import json
13
+ import os
14
+ import socket
15
+ import sys
16
+
17
+ from . import ipc
18
+ from .engine import Engine
19
+
20
+ DEFAULT_IDLE_TIMEOUT = 900.0
21
+
22
+
23
+ def _idle_timeout() -> float:
24
+ try:
25
+ return max(5.0, float(os.environ["SPEAK_IDLE_TIMEOUT"]))
26
+ except (KeyError, ValueError):
27
+ return DEFAULT_IDLE_TIMEOUT
28
+
29
+
30
+ def handle_request(conn: socket.socket, engine: Engine) -> bool:
31
+ """Serve one connection. Returns False when asked to shut down."""
32
+ try:
33
+ req = json.loads(ipc.recv_line(conn))
34
+ cmd = req.get("cmd")
35
+ if cmd == "shutdown":
36
+ conn.sendall(b'{"status": "ok"}\n')
37
+ return False
38
+ if cmd == "ping":
39
+ conn.sendall(b'{"status": "ok"}\n')
40
+ return True
41
+ if cmd != "speak":
42
+ raise ValueError(f"unknown command {cmd!r}")
43
+ wav, duration = engine.synthesize(
44
+ text=req["text"],
45
+ voice=req.get("voice", "F1"),
46
+ speed=req.get("speed", 1.05),
47
+ lang=req.get("lang", "en"),
48
+ steps=req.get("steps", 8),
49
+ )
50
+ header = json.dumps(
51
+ {"status": "ok", "nbytes": len(wav), "duration": duration}
52
+ )
53
+ conn.sendall(header.encode() + b"\n" + wav)
54
+ except Exception as e: # one bad request must never kill the daemon
55
+ with contextlib.suppress(OSError):
56
+ conn.sendall(
57
+ json.dumps({"status": "error", "message": str(e)}).encode() + b"\n"
58
+ )
59
+ return True
60
+
61
+
62
+ def main() -> None:
63
+ paths = ipc.runtime_paths()
64
+
65
+ lock = open(paths.lock, "w")
66
+ try:
67
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
68
+ except OSError:
69
+ return # another daemon is already serving
70
+ lock.write(str(os.getpid()))
71
+ lock.flush()
72
+
73
+ print(f"[daemon] starting, pid={os.getpid()}", flush=True)
74
+ engine = Engine()
75
+ engine.prewarm()
76
+ print("[daemon] models loaded and prewarmed", flush=True)
77
+
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)
82
+ srv.listen(8)
83
+ srv.settimeout(_idle_timeout())
84
+
85
+ try:
86
+ while True:
87
+ try:
88
+ conn, _ = srv.accept()
89
+ except socket.timeout:
90
+ print("[daemon] idle timeout, exiting", flush=True)
91
+ break
92
+ with conn:
93
+ conn.settimeout(ipc.REQUEST_TIMEOUT)
94
+ if not handle_request(conn, engine):
95
+ print("[daemon] shutdown requested", flush=True)
96
+ break
97
+ finally:
98
+ paths.sock.unlink(missing_ok=True)
99
+ paths.lock.unlink(missing_ok=True)
100
+
101
+
102
+ if __name__ == "__main__":
103
+ sys.exit(main())
@@ -0,0 +1,70 @@
1
+ """Lazy wrapper around the Supertonic 3 SDK producing WAV bytes.
2
+
3
+ `supertonic` (and its onnxruntime import) is only loaded when an Engine is
4
+ constructed, so fast CLI paths (--version, --list-voices, --stop) never pay
5
+ the import cost.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ import os
12
+ from pathlib import Path
13
+
14
+ SAMPLE_RATE = 44100
15
+
16
+ MODEL_CACHE = Path(os.environ.get("SUPERTONIC_CACHE", "")) if os.environ.get(
17
+ "SUPERTONIC_CACHE"
18
+ ) else Path.home() / ".cache" / "supertonic3"
19
+
20
+
21
+ def models_cached() -> bool:
22
+ """True if the Supertonic model assets look downloaded already."""
23
+ try:
24
+ return any(MODEL_CACHE.rglob("*.onnx"))
25
+ except OSError:
26
+ return False
27
+
28
+
29
+ class Engine:
30
+ def __init__(self) -> None:
31
+ from supertonic import TTS
32
+
33
+ self._tts = TTS(auto_download=True)
34
+ self._styles: dict[str, object] = {}
35
+
36
+ def _style(self, style_id: str):
37
+ if style_id not in self._styles:
38
+ self._styles[style_id] = self._tts.get_voice_style(voice_name=style_id)
39
+ return self._styles[style_id]
40
+
41
+ def synthesize(
42
+ self,
43
+ text: str,
44
+ voice: str = "F1",
45
+ speed: float = 1.05,
46
+ lang: str = "en",
47
+ steps: int = 8,
48
+ ) -> tuple[bytes, float]:
49
+ """Synthesize text and return (wav_bytes, duration_seconds)."""
50
+ import soundfile as sf
51
+
52
+ wav, duration = self._tts.synthesize(
53
+ text=text,
54
+ voice_style=self._style(voice),
55
+ total_steps=steps,
56
+ speed=speed,
57
+ max_chunk_length=300,
58
+ silence_duration=0.3,
59
+ lang=lang,
60
+ verbose=False,
61
+ )
62
+ import numpy as np
63
+
64
+ buf = io.BytesIO()
65
+ sf.write(buf, wav.squeeze(), SAMPLE_RATE, format="WAV", subtype="PCM_16")
66
+ return buf.getvalue(), float(np.asarray(duration).sum())
67
+
68
+ def prewarm(self) -> None:
69
+ """Run a tiny synthesis so the first real request hits warm ONNX kernels."""
70
+ self.synthesize("Hi.", steps=2)
@@ -0,0 +1,155 @@
1
+ """Client/daemon plumbing: runtime paths, JSON-line framing, spawn + connect.
2
+
3
+ Protocol: one request per connection. Client sends a single JSON line;
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).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import socket
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ from . import __version__
21
+
22
+ CONNECT_TIMEOUT = 30.0 # daemon cold-start budget (models already cached)
23
+ REQUEST_TIMEOUT = 300.0 # long texts take a while to synthesize
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class RuntimePaths:
28
+ dir: Path
29
+ sock: Path
30
+ lock: Path
31
+ daemon_log: Path
32
+ client_log: Path
33
+
34
+
35
+ def runtime_paths() -> RuntimePaths:
36
+ runtime = os.environ.get("XDG_RUNTIME_DIR")
37
+ if runtime and os.path.isdir(runtime):
38
+ base = Path(runtime) / "speak-cli"
39
+ 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"
44
+ log_dir.mkdir(parents=True, exist_ok=True)
45
+ return RuntimePaths(
46
+ dir=base,
47
+ sock=base / f"daemon-{__version__}.sock",
48
+ lock=base / f"daemon-{__version__}.lock",
49
+ daemon_log=log_dir / "daemon.log",
50
+ client_log=log_dir / "client.log",
51
+ )
52
+
53
+
54
+ def log_client(message: str) -> None:
55
+ try:
56
+ with open(runtime_paths().client_log, "a") as f:
57
+ f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message}\n")
58
+ except OSError:
59
+ pass
60
+
61
+
62
+ def recv_line(sock: socket.socket, max_bytes: int = 1_000_000) -> bytes:
63
+ chunks = []
64
+ total = 0
65
+ while total < max_bytes:
66
+ b = sock.recv(1)
67
+ if not b:
68
+ break
69
+ if b == b"\n":
70
+ return b"".join(chunks)
71
+ chunks.append(b)
72
+ total += 1
73
+ raise ValueError("malformed message (no newline)")
74
+
75
+
76
+ def recv_exact(sock: socket.socket, n: int) -> bytes:
77
+ buf = bytearray()
78
+ while len(buf) < n:
79
+ chunk = sock.recv(min(65536, n - len(buf)))
80
+ if not chunk:
81
+ raise ConnectionError("connection closed mid-payload")
82
+ buf.extend(chunk)
83
+ return bytes(buf)
84
+
85
+
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:
89
+ s.settimeout(REQUEST_TIMEOUT)
90
+ s.connect(str(sock_path))
91
+ s.sendall(json.dumps(req).encode() + b"\n")
92
+ header = json.loads(recv_line(s))
93
+ if header.get("status") != "ok":
94
+ raise RuntimeError(header.get("message", "daemon error"))
95
+ nbytes = header.get("nbytes", 0)
96
+ return recv_exact(s, nbytes) if nbytes else b""
97
+
98
+
99
+ def spawn_daemon(paths: RuntimePaths) -> None:
100
+ """Start a detached daemon process; harmless if one is already running."""
101
+ with open(paths.daemon_log, "ab") as log:
102
+ subprocess.Popen(
103
+ [sys.executable, "-m", "speak_cli.daemon"],
104
+ stdin=subprocess.DEVNULL,
105
+ stdout=log,
106
+ stderr=log,
107
+ start_new_session=True,
108
+ )
109
+
110
+
111
+ def get_wav_from_daemon(req: dict) -> bytes:
112
+ """Request synthesis from the daemon, auto-spawning it if needed.
113
+
114
+ Raises on any failure — the caller falls back to one-shot synthesis.
115
+ """
116
+ if os.name != "posix":
117
+ raise RuntimeError("daemon mode is POSIX-only")
118
+ paths = runtime_paths()
119
+ try:
120
+ return _request(paths.sock, req)
121
+ except (FileNotFoundError, ConnectionRefusedError):
122
+ # Not running (or crashed leaving a stale socket): clean up and spawn.
123
+ paths.sock.unlink(missing_ok=True)
124
+ spawn_daemon(paths)
125
+ deadline = time.monotonic() + CONNECT_TIMEOUT
126
+ while True:
127
+ try:
128
+ return _request(paths.sock, req)
129
+ except (FileNotFoundError, ConnectionRefusedError):
130
+ if time.monotonic() > deadline:
131
+ raise TimeoutError(
132
+ f"daemon did not start within {CONNECT_TIMEOUT:.0f}s"
133
+ ) from None
134
+ time.sleep(0.15)
135
+
136
+
137
+ def stop_daemons() -> int:
138
+ """Shut down daemons of any version in any runtime dir; returns how many responded."""
139
+ candidates = {runtime_paths().dir, Path.home() / ".cache" / "speak-cli" / "run"}
140
+ runtime = os.environ.get("XDG_RUNTIME_DIR")
141
+ if runtime:
142
+ candidates.add(Path(runtime) / "speak-cli")
143
+ stopped = 0
144
+ for base in candidates:
145
+ if not base.is_dir():
146
+ continue
147
+ for sock_path in base.glob("daemon-*.sock"):
148
+ try:
149
+ _request(sock_path, {"cmd": "shutdown"})
150
+ stopped += 1
151
+ except OSError:
152
+ sock_path.unlink(missing_ok=True) # stale
153
+ except Exception:
154
+ pass
155
+ return stopped
@@ -0,0 +1,69 @@
1
+ """Unicode-script-based language guessing for Supertonic 3.
2
+
3
+ Deliberately tiny: no models, no dependencies. Latin-script languages are
4
+ indistinguishable this way and map to English — use --lang to override.
5
+ Scripts Supertonic doesn't support (Han-only/Chinese, Thai, Hebrew, ...)
6
+ map to "na", Supertonic's language-agnostic mode.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ # (start, end, bucket) — checked in order, first match wins
12
+ _RANGES: list[tuple[int, int, str]] = [
13
+ (0x0600, 0x06FF, "ar"),
14
+ (0x0750, 0x077F, "ar"),
15
+ (0x08A0, 0x08FF, "ar"),
16
+ (0xFB50, 0xFDFF, "ar"),
17
+ (0xFE70, 0xFEFF, "ar"),
18
+ (0x3040, 0x30FF, "kana"),
19
+ (0x31F0, 0x31FF, "kana"),
20
+ (0xAC00, 0xD7AF, "ko"),
21
+ (0x1100, 0x11FF, "ko"),
22
+ (0x3130, 0x318F, "ko"),
23
+ (0x4E00, 0x9FFF, "han"),
24
+ (0x3400, 0x4DBF, "han"),
25
+ (0xF900, 0xFAFF, "han"),
26
+ (0x0400, 0x04FF, "cyr"),
27
+ (0x0500, 0x052F, "cyr"),
28
+ (0x0370, 0x03FF, "el"),
29
+ (0x1F00, 0x1FFF, "el"),
30
+ (0x0900, 0x097F, "hi"),
31
+ (0x0E00, 0x0E7F, "unsupported"), # Thai
32
+ (0x0590, 0x05FF, "unsupported"), # Hebrew
33
+ (0x0041, 0x005A, "latin"),
34
+ (0x0061, 0x007A, "latin"),
35
+ (0x00C0, 0x024F, "latin"),
36
+ ]
37
+
38
+ _BUCKET_TO_LANG = {
39
+ "ar": "ar",
40
+ "ko": "ko",
41
+ "cyr": "ru",
42
+ "el": "el",
43
+ "hi": "hi",
44
+ "han": "na",
45
+ "unsupported": "na",
46
+ "latin": "en",
47
+ }
48
+
49
+ SUPPORTED_LANGS = frozenset(
50
+ "en ko ja ar bg cs da de el es et fi fr hi hr hu id it lt lv "
51
+ "nl pl pt ro ru sk sl sv tr uk vi na".split()
52
+ )
53
+
54
+
55
+ def detect_lang(text: str) -> str:
56
+ counts: dict[str, int] = {}
57
+ for ch in text:
58
+ cp = ord(ch)
59
+ for lo, hi, bucket in _RANGES:
60
+ if lo <= cp <= hi:
61
+ counts[bucket] = counts.get(bucket, 0) + 1
62
+ break
63
+ if not counts:
64
+ return "en"
65
+ # Japanese text is often Han-heavy; any kana at all means Japanese.
66
+ if counts.get("kana"):
67
+ return "ja"
68
+ dominant = max(counts, key=lambda k: counts[k])
69
+ return _BUCKET_TO_LANG[dominant]
@@ -0,0 +1,61 @@
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
+ )
@@ -0,0 +1,55 @@
1
+ """Friendly-name registry for Supertonic 3 voice styles.
2
+
3
+ Upstream ships anonymous style files (M1-M5, F1-F5); we give each a name.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+
10
+ # name -> (style_id, gender)
11
+ VOICES: dict[str, tuple[str, str]] = {
12
+ "sara": ("F1", "female"),
13
+ "emma": ("F2", "female"),
14
+ "lily": ("F3", "female"),
15
+ "maya": ("F4", "female"),
16
+ "nora": ("F5", "female"),
17
+ "james": ("M1", "male"),
18
+ "daniel": ("M2", "male"),
19
+ "leo": ("M3", "male"),
20
+ "ryan": ("M4", "male"),
21
+ "noah": ("M5", "male"),
22
+ }
23
+
24
+ DEFAULT_VOICE = "sara"
25
+
26
+ def default_voice() -> str:
27
+ env = os.environ.get("SPEAK_VOICE", "").strip()
28
+ if env:
29
+ try:
30
+ return resolve(env)[0]
31
+ except ValueError:
32
+ pass # bad SPEAK_VOICE should not break the tool
33
+ return DEFAULT_VOICE
34
+
35
+
36
+ def resolve(voice: str) -> tuple[str, str]:
37
+ """Resolve a voice name (case-insensitive) to (name, style_id).
38
+
39
+ Raises ValueError with a helpful message for unknown voices.
40
+ """
41
+ key = voice.strip().lower()
42
+ if key in VOICES:
43
+ return key, VOICES[key][0]
44
+ valid = ", ".join(VOICES)
45
+ raise ValueError(f"unknown voice {voice!r}. Valid voices: {valid}")
46
+
47
+
48
+ def listing() -> str:
49
+ """Human-readable voice table for --list-voices."""
50
+ default = default_voice()
51
+ lines = []
52
+ for name, (_, gender) in VOICES.items():
53
+ mark = " (default)" if name == default else ""
54
+ lines.append(f"{name:<7} {gender}{mark}")
55
+ return "\n".join(lines)
@@ -0,0 +1,18 @@
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()
@@ -0,0 +1,58 @@
1
+ from speak_cli.langdetect import SUPPORTED_LANGS, detect_lang
2
+
3
+
4
+ def test_english():
5
+ assert detect_lang("Hello, world!") == "en"
6
+
7
+
8
+ def test_arabic():
9
+ assert detect_lang("مرحبا بالعالم") == "ar"
10
+
11
+
12
+ def test_arabic_with_latin_word():
13
+ assert detect_lang("مرحبا بالعالم من Python") == "ar"
14
+
15
+
16
+ def test_japanese_kana():
17
+ assert detect_lang("こんにちは") == "ja"
18
+
19
+
20
+ def test_japanese_han_heavy_with_kana():
21
+ assert detect_lang("日本語の文章です") == "ja"
22
+
23
+
24
+ def test_han_only_maps_to_na():
25
+ assert detect_lang("你好世界") == "na"
26
+
27
+
28
+ def test_korean():
29
+ assert detect_lang("안녕하세요") == "ko"
30
+
31
+
32
+ def test_cyrillic():
33
+ assert detect_lang("Привет мир") == "ru"
34
+
35
+
36
+ def test_greek():
37
+ assert detect_lang("Γεια σου κόσμε") == "el"
38
+
39
+
40
+ def test_hindi():
41
+ assert detect_lang("नमस्ते दुनिया") == "hi"
42
+
43
+
44
+ def test_hebrew_unsupported_maps_to_na():
45
+ assert detect_lang("שלום עולם") == "na"
46
+
47
+
48
+ def test_numbers_only_default_en():
49
+ assert detect_lang("12345 !!!") == "en"
50
+
51
+
52
+ def test_empty():
53
+ assert detect_lang("") == "en"
54
+
55
+
56
+ def test_all_outputs_supported():
57
+ for text in ["hi", "مرحبا", "こんにちは", "你好", "안녕", "Привет", "Γεια", "नमस्ते"]:
58
+ assert detect_lang(text) in SUPPORTED_LANGS
@@ -0,0 +1,41 @@
1
+ import pytest
2
+
3
+ from speak_cli import voices
4
+
5
+
6
+ def test_resolve_friendly_name():
7
+ assert voices.resolve("sara") == ("sara", "F1")
8
+ assert voices.resolve("noah") == ("noah", "M5")
9
+
10
+
11
+ def test_resolve_case_insensitive():
12
+ assert voices.resolve("SARA") == ("sara", "F1")
13
+ assert voices.resolve("James") == ("james", "M1")
14
+
15
+
16
+ def test_resolve_raw_id_rejected():
17
+ with pytest.raises(ValueError, match="unknown voice"):
18
+ voices.resolve("F1")
19
+
20
+
21
+ def test_resolve_unknown_raises():
22
+ with pytest.raises(ValueError, match="unknown voice"):
23
+ voices.resolve("bogus")
24
+
25
+
26
+ def test_default_voice_env_override(monkeypatch):
27
+ monkeypatch.setenv("SPEAK_VOICE", "noah")
28
+ assert voices.default_voice() == "noah"
29
+
30
+
31
+ def test_default_voice_bad_env_falls_back(monkeypatch):
32
+ monkeypatch.setenv("SPEAK_VOICE", "not-a-voice")
33
+ assert voices.default_voice() == voices.DEFAULT_VOICE
34
+
35
+
36
+ def test_listing_contains_all_and_default(monkeypatch):
37
+ monkeypatch.delenv("SPEAK_VOICE", raising=False)
38
+ out = voices.listing()
39
+ for name in voices.VOICES:
40
+ assert name in out
41
+ assert "(default)" in out