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.
- macos_say_server/__init__.py +4 -0
- macos_say_server/__main__.py +5 -0
- macos_say_server/cli.py +36 -0
- macos_say_server/server.py +283 -0
- macos_say_server-0.1.0.dist-info/METADATA +98 -0
- macos_say_server-0.1.0.dist-info/RECORD +9 -0
- macos_say_server-0.1.0.dist-info/WHEEL +4 -0
- macos_say_server-0.1.0.dist-info/entry_points.txt +2 -0
- macos_say_server-0.1.0.dist-info/licenses/LICENSE +21 -0
macos_say_server/cli.py
ADDED
|
@@ -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,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.
|