macos-say-server 0.1.0__py3-none-any.whl

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,4 @@
1
+ from .cli import main
2
+ from .server import app
3
+
4
+ __all__ = ["app", "main"]
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,36 @@
1
+ import argparse
2
+ from pathlib import Path
3
+
4
+ import uvicorn
5
+
6
+ from .server import app, configure_cache
7
+
8
+
9
+ def build_parser() -> argparse.ArgumentParser:
10
+ parser = argparse.ArgumentParser(description="Run the macOS say HTTP server.")
11
+ parser.add_argument("--host", default="127.0.0.1", help="Host interface to bind to")
12
+ parser.add_argument("--port", type=int, default=2576, help="Port to listen on")
13
+ parser.add_argument("--cache-dir", type=Path, default=Path(".cache/audio"), help="Directory for cached AIFF files")
14
+ parser.add_argument("--cache-ttl-seconds", type=int, default=86400, help="Cache TTL in seconds; 0 disables reuse")
15
+ parser.add_argument(
16
+ "--cache-cleanup-interval-seconds",
17
+ type=int,
18
+ default=300,
19
+ help="Minimum seconds between lazy cleanup passes",
20
+ )
21
+ parser.add_argument("--cache-max-files", type=int, default=0, help="Maximum cached files; 0 means unlimited")
22
+ parser.add_argument("--cache-max-bytes", type=int, default=0, help="Maximum cached bytes; 0 means unlimited")
23
+ return parser
24
+
25
+
26
+ def main() -> int:
27
+ args = build_parser().parse_args()
28
+ configure_cache(
29
+ cache_dir=args.cache_dir,
30
+ ttl_seconds=args.cache_ttl_seconds,
31
+ cleanup_interval_seconds=args.cache_cleanup_interval_seconds,
32
+ max_files=args.cache_max_files,
33
+ max_bytes=args.cache_max_bytes,
34
+ )
35
+ uvicorn.run(app, host=args.host, port=args.port)
36
+ return 0
@@ -0,0 +1,283 @@
1
+ import os
2
+ import re
3
+ import subprocess
4
+ import tempfile
5
+ import time
6
+ from dataclasses import dataclass
7
+ from hashlib import sha256
8
+ from pathlib import Path
9
+ from typing import Iterator, Optional
10
+
11
+ from fastapi import FastAPI, HTTPException, Query
12
+ from fastapi.responses import StreamingResponse
13
+ from pydantic import BaseModel, Field
14
+
15
+
16
+ VOICE_PATTERN = re.compile(r"^(?P<name>.+?)\s{2,}(?P<locale>\S+)\s+#\s(?P<sample>.+)$")
17
+
18
+
19
+ @dataclass
20
+ class CacheConfig:
21
+ cache_dir: Path = Path(".cache/audio")
22
+ ttl_seconds: int = 86400
23
+ cleanup_interval_seconds: int = 300
24
+ max_files: int = 0
25
+ max_bytes: int = 0
26
+
27
+
28
+ CACHE_CONFIG = CacheConfig()
29
+ LAST_CACHE_CLEANUP_AT = 0.0
30
+
31
+ app = FastAPI(
32
+ title="macOS Say Server",
33
+ description="Expose the macOS say command over HTTP and return AIFF audio.",
34
+ version="0.1.0",
35
+ )
36
+
37
+
38
+ class SynthesizeRequest(BaseModel):
39
+ text: str = Field(..., min_length=1, description="Text that should be spoken")
40
+ voice: Optional[str] = Field(default=None, description="Optional macOS voice name")
41
+
42
+
43
+ def configure_cache(
44
+ *,
45
+ cache_dir: Path = Path(".cache/audio"),
46
+ ttl_seconds: int = 86400,
47
+ cleanup_interval_seconds: int = 300,
48
+ max_files: int = 0,
49
+ max_bytes: int = 0,
50
+ ) -> None:
51
+ global CACHE_CONFIG
52
+
53
+ CACHE_CONFIG = CacheConfig(
54
+ cache_dir=cache_dir,
55
+ ttl_seconds=max(0, ttl_seconds),
56
+ cleanup_interval_seconds=max(0, cleanup_interval_seconds),
57
+ max_files=max(0, max_files),
58
+ max_bytes=max(0, max_bytes),
59
+ )
60
+
61
+
62
+ def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
63
+ try:
64
+ return subprocess.run(command, capture_output=True, text=True, check=True)
65
+ except FileNotFoundError as exc:
66
+ raise HTTPException(status_code=500, detail="macOS say command is not available") from exc
67
+ except subprocess.CalledProcessError as exc:
68
+ detail = exc.stderr.strip() or exc.stdout.strip() or "macOS say command failed"
69
+ raise HTTPException(status_code=400, detail=detail) from exc
70
+
71
+
72
+ def _iter_audio_chunks(output_path: Path) -> Iterator[bytes]:
73
+ with output_path.open("rb") as audio_file:
74
+ while True:
75
+ chunk = audio_file.read(64 * 1024)
76
+ if not chunk:
77
+ break
78
+ yield chunk
79
+
80
+
81
+ def _cache_key(text: str, voice: Optional[str]) -> str:
82
+ digest = sha256()
83
+ digest.update((voice or "default").encode("utf-8"))
84
+ digest.update(b"\0")
85
+ digest.update(text.encode("utf-8"))
86
+ return digest.hexdigest()
87
+
88
+
89
+ def _cache_path(text: str, voice: Optional[str]) -> Path:
90
+ return CACHE_CONFIG.cache_dir / f"{_cache_key(text, voice)}.aiff"
91
+
92
+
93
+ def _is_cache_expired(path: Path, now: float) -> bool:
94
+ if CACHE_CONFIG.ttl_seconds == 0:
95
+ return True
96
+ try:
97
+ return now - path.stat().st_mtime >= CACHE_CONFIG.ttl_seconds
98
+ except FileNotFoundError:
99
+ return True
100
+
101
+
102
+ def _touch_cache(path: Path, now: float) -> None:
103
+ os.utime(path, (now, now))
104
+
105
+
106
+ def _cleanup_expired_cache(now: float) -> None:
107
+ global LAST_CACHE_CLEANUP_AT
108
+
109
+ if CACHE_CONFIG.ttl_seconds == 0:
110
+ return
111
+ if now - LAST_CACHE_CLEANUP_AT < CACHE_CONFIG.cleanup_interval_seconds:
112
+ return
113
+
114
+ LAST_CACHE_CLEANUP_AT = now
115
+ if not CACHE_CONFIG.cache_dir.exists():
116
+ return
117
+
118
+ for cached_file in CACHE_CONFIG.cache_dir.glob("*.aiff"):
119
+ if _is_cache_expired(cached_file, now):
120
+ cached_file.unlink(missing_ok=True)
121
+
122
+
123
+ def _prune_cache_limits(protected_path: Optional[Path] = None) -> None:
124
+ if CACHE_CONFIG.max_files == 0 and CACHE_CONFIG.max_bytes == 0:
125
+ return
126
+ if not CACHE_CONFIG.cache_dir.exists():
127
+ return
128
+
129
+ cache_entries: list[tuple[Path, float, int]] = []
130
+ total_bytes = 0
131
+ for cached_file in CACHE_CONFIG.cache_dir.glob("*.aiff"):
132
+ try:
133
+ stat_result = cached_file.stat()
134
+ except FileNotFoundError:
135
+ continue
136
+ file_size = stat_result.st_size
137
+ total_bytes += file_size
138
+ cache_entries.append((cached_file, stat_result.st_mtime, file_size))
139
+
140
+ cache_entries.sort(key=lambda entry: entry[1])
141
+ current_files = len(cache_entries)
142
+
143
+ for cached_file, _mtime, file_size in cache_entries:
144
+ if protected_path is not None and cached_file == protected_path:
145
+ continue
146
+
147
+ files_over_limit = CACHE_CONFIG.max_files > 0 and current_files > CACHE_CONFIG.max_files
148
+ bytes_over_limit = CACHE_CONFIG.max_bytes > 0 and total_bytes > CACHE_CONFIG.max_bytes
149
+ if not files_over_limit and not bytes_over_limit:
150
+ break
151
+
152
+ cached_file.unlink(missing_ok=True)
153
+ current_files -= 1
154
+ total_bytes -= file_size
155
+
156
+
157
+ def _cache_entries() -> list[tuple[Path, os.stat_result]]:
158
+ if not CACHE_CONFIG.cache_dir.exists():
159
+ return []
160
+
161
+ entries: list[tuple[Path, os.stat_result]] = []
162
+ for cached_file in CACHE_CONFIG.cache_dir.glob("*.aiff"):
163
+ try:
164
+ entries.append((cached_file, cached_file.stat()))
165
+ except FileNotFoundError:
166
+ continue
167
+ return entries
168
+
169
+
170
+ def _cache_stats() -> dict[str, object]:
171
+ now = time.time()
172
+ entries = _cache_entries()
173
+ expired_files = 0
174
+ total_bytes = 0
175
+ oldest_mtime: Optional[float] = None
176
+ newest_mtime: Optional[float] = None
177
+
178
+ for cached_file, stat_result in entries:
179
+ total_bytes += stat_result.st_size
180
+ file_mtime = stat_result.st_mtime
181
+ if oldest_mtime is None or file_mtime < oldest_mtime:
182
+ oldest_mtime = file_mtime
183
+ if newest_mtime is None or file_mtime > newest_mtime:
184
+ newest_mtime = file_mtime
185
+ if _is_cache_expired(cached_file, now):
186
+ expired_files += 1
187
+
188
+ return {
189
+ "cache_dir": str(CACHE_CONFIG.cache_dir),
190
+ "cache_dir_exists": CACHE_CONFIG.cache_dir.exists(),
191
+ "total_files": len(entries),
192
+ "expired_files": expired_files,
193
+ "total_bytes": total_bytes,
194
+ "ttl_seconds": CACHE_CONFIG.ttl_seconds,
195
+ "cleanup_interval_seconds": CACHE_CONFIG.cleanup_interval_seconds,
196
+ "max_files": CACHE_CONFIG.max_files,
197
+ "max_bytes": CACHE_CONFIG.max_bytes,
198
+ "oldest_mtime": oldest_mtime,
199
+ "newest_mtime": newest_mtime,
200
+ "last_cleanup_at": LAST_CACHE_CLEANUP_AT or None,
201
+ }
202
+
203
+
204
+ def _audio_response(output_path: Path, voice: Optional[str], cache_status: str) -> StreamingResponse:
205
+ filename_voice = (voice or "default").replace(" ", "-")
206
+ headers = {
207
+ "Content-Disposition": f'inline; filename="speech-{filename_voice}.aiff"',
208
+ "X-Cache": cache_status,
209
+ }
210
+ return StreamingResponse(
211
+ _iter_audio_chunks(output_path),
212
+ media_type="audio/aiff",
213
+ headers=headers,
214
+ )
215
+
216
+
217
+ def _synthesize_audio(text: str, voice: Optional[str]) -> StreamingResponse:
218
+ normalized_text = text.strip()
219
+ if not normalized_text:
220
+ raise HTTPException(status_code=400, detail="text must not be empty")
221
+
222
+ now = time.time()
223
+ CACHE_CONFIG.cache_dir.mkdir(parents=True, exist_ok=True)
224
+ _cleanup_expired_cache(now)
225
+
226
+ cached_path = _cache_path(normalized_text, voice)
227
+ if cached_path.exists() and not _is_cache_expired(cached_path, now):
228
+ _touch_cache(cached_path, now)
229
+ _prune_cache_limits(protected_path=cached_path)
230
+ return _audio_response(cached_path, voice, cache_status="HIT")
231
+
232
+ cached_path.unlink(missing_ok=True)
233
+
234
+ with tempfile.NamedTemporaryFile(dir=CACHE_CONFIG.cache_dir, suffix=".aiff", delete=False) as temporary_file:
235
+ output_path = Path(temporary_file.name)
236
+
237
+ command = ["say", "-o", str(output_path)]
238
+ if voice:
239
+ command.extend(["-v", voice])
240
+ command.append(normalized_text)
241
+
242
+ try:
243
+ _run_command(command)
244
+ except HTTPException:
245
+ output_path.unlink(missing_ok=True)
246
+ raise
247
+
248
+ os.replace(output_path, cached_path)
249
+ _touch_cache(cached_path, now)
250
+ _prune_cache_limits(protected_path=cached_path)
251
+ return _audio_response(cached_path, voice, cache_status="MISS")
252
+
253
+
254
+ @app.get("/health")
255
+ def healthcheck() -> dict[str, str]:
256
+ return {"status": "ok"}
257
+
258
+
259
+ @app.get("/voices")
260
+ def list_voices() -> list[dict[str, str]]:
261
+ result = _run_command(["say", "-v", "?"])
262
+ voices: list[dict[str, str]] = []
263
+ for line in result.stdout.splitlines():
264
+ match = VOICE_PATTERN.match(line.rstrip())
265
+ if not match:
266
+ continue
267
+ voices.append(match.groupdict())
268
+ return voices
269
+
270
+
271
+ @app.get("/cache/stats")
272
+ def cache_stats() -> dict[str, object]:
273
+ return _cache_stats()
274
+
275
+
276
+ @app.get("/speak")
277
+ def speak(text: str = Query(..., min_length=1), voice: Optional[str] = None) -> StreamingResponse:
278
+ return _synthesize_audio(text=text, voice=voice)
279
+
280
+
281
+ @app.post("/speak")
282
+ def speak_from_json(request: SynthesizeRequest) -> StreamingResponse:
283
+ return _synthesize_audio(text=request.text, voice=request.voice)
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: macos-say-server
3
+ Version: 0.1.0
4
+ Summary: Expose macOS say as an HTTP server that provides text-to-speech audio generation.
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: fastapi[standard]>=0.116.0
8
+ Requires-Dist: uvicorn>=0.35.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # macos-say-server
12
+
13
+ Expose macOS say as an HTTP server that provides text-to-speech audio generation.
14
+
15
+ ## Features
16
+
17
+ - `GET /health` for liveness checks.
18
+ - `GET /voices` to list the voices available from `say -v '?'`.
19
+ - `GET /cache/stats` to inspect current cache usage and limits.
20
+ - `GET /speak?text=...&voice=...` to return AIFF audio directly.
21
+ - `POST /speak` with JSON `{ "text": "...", "voice": "..." }` to return AIFF audio.
22
+ - Disk-backed AIFF cache keyed by `text + voice`, with lazy TTL cleanup.
23
+
24
+ ## Run
25
+
26
+ ```bash
27
+ uv sync
28
+ uv run macos-say-server
29
+ ```
30
+
31
+ The server binds to `127.0.0.1:2576` by default.
32
+
33
+ Use startup arguments to override bind settings:
34
+
35
+ ```bash
36
+ uv run macos-say-server --host 127.0.0.1 --port 2576
37
+ ```
38
+
39
+ You can also run the package module directly:
40
+
41
+ ```bash
42
+ uv run python -m macos_say_server --host 127.0.0.1 --port 2576
43
+ ```
44
+
45
+ ## Build
46
+
47
+ ```bash
48
+ uv build
49
+ ```
50
+
51
+ ## Cache
52
+
53
+ The service stores generated audio under `.cache/audio` by default.
54
+
55
+ - Cache key: hash of `text + voice`
56
+ - Expiration: `CACHE_TTL_SECONDS`, default `86400` seconds
57
+ - Cleanup: lazy cleanup during requests, at most once per `CACHE_CLEANUP_INTERVAL_SECONDS`, default `300` seconds
58
+ - On cache hit, the file timestamp is refreshed, so TTL is sliding rather than fixed from first generation
59
+ - Capacity control: optional LRU-style pruning using file modification time
60
+ - File count limit: `CACHE_MAX_FILES`, default `0` meaning unlimited
61
+ - Total size limit: `CACHE_MAX_BYTES`, default `0` meaning unlimited
62
+
63
+ When a cache limit is configured, the oldest entries are removed first. Because cache hits refresh the file timestamp, frequently used items stay warm and cold items are evicted first.
64
+
65
+ `GET /cache/stats` returns:
66
+
67
+ - `cache_dir` and whether it currently exists
68
+ - `total_files` and `total_bytes`
69
+ - `expired_files` under the current TTL view
70
+ - configured `ttl_seconds`, cleanup interval, and capacity limits
71
+ - oldest and newest cache entry timestamps
72
+
73
+ Startup arguments:
74
+
75
+ ```bash
76
+ uv run macos-say-server \
77
+ --cache-dir .cache/audio \
78
+ --cache-ttl-seconds 86400 \
79
+ --cache-cleanup-interval-seconds 300 \
80
+ --cache-max-files 0 \
81
+ --cache-max-bytes 0
82
+ ```
83
+
84
+ ## Examples
85
+
86
+ ```bash
87
+ curl http://127.0.0.1:2576/health
88
+ curl http://127.0.0.1:2576/voices
89
+ curl http://127.0.0.1:2576/cache/stats
90
+ curl -G http://127.0.0.1:2576/speak --data-urlencode "text=hello from macos say server" --output speech.aiff
91
+ curl -i -G http://127.0.0.1:2576/speak --data-urlencode "text=hello from macos say server" --output speech.aiff
92
+ curl -X POST http://127.0.0.1:2576/speak \
93
+ -H "content-type: application/json" \
94
+ -d '{"text":"hello from json","voice":"Samantha"}' \
95
+ --output speech.aiff
96
+ ```
97
+
98
+ If you inspect response headers, `X-Cache: MISS` means the file was newly synthesized, and `X-Cache: HIT` means it was served from cache.
@@ -0,0 +1,9 @@
1
+ macos_say_server/__init__.py,sha256=gjYy5tc_ad3p3cbTj5Z9OP_2RLgVXKdwYg9kSnafX64,73
2
+ macos_say_server/__main__.py,sha256=PSQ4rpL0dG6f-qH4N7H-gD9igQkdHzH4yVZDcW8lfZo,80
3
+ macos_say_server/cli.py,sha256=zB3JNM03yANPjnJ8l-20lVX-9mZgTWd1LI89Mea5ZtQ,1444
4
+ macos_say_server/server.py,sha256=v2AUYUocR1tAnnpUcxp5_ZEG-el4SwtK029UigjDQ0U,8860
5
+ macos_say_server-0.1.0.dist-info/METADATA,sha256=iKacJnzLKPuf9b3w1laY0ZstSUd6wHAH9oT87SU5_ws,3098
6
+ macos_say_server-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ macos_say_server-0.1.0.dist-info/entry_points.txt,sha256=-pL9OO8-sAB3afH4p84djluCBhx78ngTyy27qScoWck,63
8
+ macos_say_server-0.1.0.dist-info/licenses/LICENSE,sha256=hYRaJ2ed2IMo9RmO96UxUSTt9iwL49leBY5kVtDymRo,1063
9
+ macos_say_server-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ macos-say-server = macos_say_server.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lanbao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.