smartvoice 0.2.0__py3-none-win_amd64.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.
Files changed (78) hide show
  1. smartvoice/__init__.py +3 -0
  2. smartvoice/__main__.py +425 -0
  3. smartvoice/_version.py +3 -0
  4. smartvoice/adapters/__init__.py +1 -0
  5. smartvoice/adapters/inference/__init__.py +1 -0
  6. smartvoice/adapters/inference/composite_provider.py +122 -0
  7. smartvoice/adapters/inference/factory.py +26 -0
  8. smartvoice/adapters/inference/qwen_tts/__init__.py +1 -0
  9. smartvoice/adapters/inference/qwen_tts/native_runtime.py +215 -0
  10. smartvoice/adapters/inference/qwen_tts/provider.py +177 -0
  11. smartvoice/adapters/inference/sherpa_onnx/__init__.py +1 -0
  12. smartvoice/adapters/inference/sherpa_onnx/provider.py +528 -0
  13. smartvoice/adapters/platform/__init__.py +1 -0
  14. smartvoice/adapters/platform/host_metrics.py +161 -0
  15. smartvoice/adapters/storage/__init__.py +1 -0
  16. smartvoice/adapters/storage/catalog_model_repository.py +52 -0
  17. smartvoice/api/__init__.py +1 -0
  18. smartvoice/api/v1/__init__.py +1 -0
  19. smartvoice/api/v1/routes.py +410 -0
  20. smartvoice/app.py +423 -0
  21. smartvoice/ascii-art.txt +28 -0
  22. smartvoice/config/__init__.py +1 -0
  23. smartvoice/config/settings.py +115 -0
  24. smartvoice/domain/__init__.py +1 -0
  25. smartvoice/domain/capabilities.py +21 -0
  26. smartvoice/domain/contracts.py +54 -0
  27. smartvoice/domain/errors.py +68 -0
  28. smartvoice/domain/models.py +34 -0
  29. smartvoice/ports/__init__.py +1 -0
  30. smartvoice/ports/inference.py +56 -0
  31. smartvoice/ports/model_repository.py +33 -0
  32. smartvoice/resources/bin/libgcc_s_seh-1.dll +0 -0
  33. smartvoice/resources/bin/libgfortran-5.dll +0 -0
  34. smartvoice/resources/bin/libgomp-1.dll +0 -0
  35. smartvoice/resources/bin/libopenblas.dll +4 -0
  36. smartvoice/resources/bin/libquadmath-0.dll +0 -0
  37. smartvoice/resources/bin/libwinpthread-1.dll +0 -0
  38. smartvoice/resources/bin/licenses/OpenBLAS-LICENSE +29 -0
  39. smartvoice/resources/bin/licenses/OpenBLAS-LICENSE-lapack +48 -0
  40. smartvoice/resources/bin/licenses/RUNTIME-SOURCES.txt +4 -0
  41. smartvoice/resources/bin/licenses/gcc-libs-RUNTIME.LIBRARY.EXCEPTION +73 -0
  42. smartvoice/resources/bin/licenses/libgcc-COPYING.RUNTIME +73 -0
  43. smartvoice/resources/bin/licenses/libgfortran-COPYING.RUNTIME +73 -0
  44. smartvoice/resources/bin/licenses/libgomp-COPYING.RUNTIME +73 -0
  45. smartvoice/resources/bin/licenses/libquadmath-COPYING.LIB +504 -0
  46. smartvoice/resources/bin/licenses/libwinpthread-COPYING +57 -0
  47. smartvoice/resources/bin/licenses/msys2-runtime-COPYING +674 -0
  48. smartvoice/resources/bin/msys-2.0.dll +0 -0
  49. smartvoice/resources/bin/msys-gcc_s-seh-1.dll +0 -0
  50. smartvoice/resources/bin/qwen_tts.exe +0 -0
  51. smartvoice/resources/models.json +347 -0
  52. smartvoice/resources/router.json +218 -0
  53. smartvoice/resources/smartvoice-favicon.png +0 -0
  54. smartvoice/resources/smartvoice-logo.png +0 -0
  55. smartvoice/resources/smartvoice.json +16 -0
  56. smartvoice/services/__init__.py +1 -0
  57. smartvoice/services/builtin_resources.py +16 -0
  58. smartvoice/services/file_integrity.py +76 -0
  59. smartvoice/services/host_metrics.py +9 -0
  60. smartvoice/services/inference_queue.py +82 -0
  61. smartvoice/services/language_detection.py +96 -0
  62. smartvoice/services/model_catalog_constants.py +8 -0
  63. smartvoice/services/model_download.py +339 -0
  64. smartvoice/services/model_jobs.py +89 -0
  65. smartvoice/services/model_local_state.py +64 -0
  66. smartvoice/services/model_management.py +59 -0
  67. smartvoice/services/model_registry.py +93 -0
  68. smartvoice/services/model_router.py +192 -0
  69. smartvoice/services/model_storage.py +372 -0
  70. smartvoice/services/speech.py +110 -0
  71. smartvoice/services/spoken_language_identifier.py +109 -0
  72. smartvoice/services/transcription.py +125 -0
  73. smartvoice/web/test.html +39 -0
  74. smartvoice-0.2.0.dist-info/METADATA +282 -0
  75. smartvoice-0.2.0.dist-info/RECORD +78 -0
  76. smartvoice-0.2.0.dist-info/WHEEL +5 -0
  77. smartvoice-0.2.0.dist-info/licenses/LICENSE +201 -0
  78. smartvoice-0.2.0.dist-info/top_level.txt +1 -0
smartvoice/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """SmartVoice local speech service."""
2
+
3
+ from smartvoice._version import __version__
smartvoice/__main__.py ADDED
@@ -0,0 +1,425 @@
1
+ """Serve the API or manage local model files from the command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import ipaddress
7
+ import json
8
+ import socket
9
+ import sys
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+ from dataclasses import replace
14
+ from pathlib import Path
15
+
16
+ import uvicorn
17
+
18
+ from smartvoice.config.settings import Settings
19
+ from smartvoice.adapters.storage.catalog_model_repository import CatalogModelRepository
20
+ from smartvoice.domain.errors import SmartVoiceError
21
+ from smartvoice.services.model_download import ModelDownloadCancelled
22
+ from smartvoice.services.model_jobs import ModelJobManager
23
+ from smartvoice.services.model_management import ModelManagementService
24
+
25
+
26
+ def _format_model_list(
27
+ models: list[dict[str, object]],
28
+ native_models: list[dict[str, object]] | None = None,
29
+ ) -> str:
30
+ native_models = native_models or []
31
+ all_models = [*models, *native_models]
32
+ if not all_models:
33
+ return "The model catalog is empty."
34
+
35
+ noun = "model" if len(all_models) == 1 else "models"
36
+ lines = [f"SmartVoice models ({len(all_models)} {noun})"]
37
+ task_groups = (("transcription", "STT"), ("speech", "TTS"), ("native", "SmartVoice native"))
38
+ for is_installed, state_title in (
39
+ (True, "Installed"),
40
+ (False, "Not installed"),
41
+ ):
42
+ state_models = [
43
+ model for model in all_models
44
+ if model.get("status", model.get("availability", "available" if model.get("installed") else "not_installed"))
45
+ in ({"available", "unavailable"} if is_installed else {"not_installed"})
46
+ ]
47
+ lines.extend(["", f"{state_title} ({len(state_models)})"])
48
+ if not state_models:
49
+ lines.append(" (none)")
50
+ continue
51
+ for task, task_title in task_groups:
52
+ task_models = [model for model in state_models if model.get("task") == task]
53
+ if not task_models:
54
+ continue
55
+ lines.extend(["", f" {task_title} ({len(task_models)})"])
56
+ for model in task_models:
57
+ languages = ", ".join(str(language) for language in model.get("languages", [])) or "Not specified"
58
+ name = model.get("name", "Unnamed model")
59
+ model_id = model.get("id", "Unknown")
60
+ backend = model.get("backend", "Unknown")
61
+ size = model.get("installed_size_bytes")
62
+ if isinstance(size, int):
63
+ size_label = f"{float(size) / 1024**2:.0f} MiB on disk"
64
+ else:
65
+ estimate = model.get("estimated_size_bytes")
66
+ size_label = f"~{float(estimate) / 1024**2:.0f} MiB estimated" if isinstance(estimate, int) else "Size unknown"
67
+ status = model.get("status", model.get("availability", "available" if model.get("installed") else "not_installed"))
68
+ if status == "unavailable":
69
+ status_label = f" | Unavailable ({model.get('availability_reason') or 'The active inference runtime cannot use this model.'})"
70
+ elif status == "available":
71
+ status_label = " | Available"
72
+ else:
73
+ status_label = " | Not installed"
74
+ lines.append(f" - {name} ({model_id}) | {languages} | {backend} | {size_label}{status_label}")
75
+ return "\n".join(lines)
76
+
77
+
78
+ def _models_with_availability(
79
+ models: list[dict[str, object]],
80
+ runtime_by_backend: dict[str, dict[str, object]],
81
+ ) -> list[dict[str, object]]:
82
+ results = []
83
+ for original in models:
84
+ model = dict(original)
85
+ if model.get("status") == "uninstalled":
86
+ model["availability"] = "not_installed"
87
+ elif model.get("status") == "invalid":
88
+ model["availability"] = "unavailable"
89
+ model["availability_reason"] = "Model files are incomplete or fail integrity checks. Reinstall this model."
90
+ elif model.get("status") == "installed" and not runtime_by_backend.get(
91
+ str(model.get("backend")), {}
92
+ ).get("reason"):
93
+ model["availability"] = "available"
94
+ else:
95
+ model["availability"] = "unavailable"
96
+ runtime = runtime_by_backend.get(str(model.get("backend")), {})
97
+ model["availability_reason"] = runtime.get("reason") or (
98
+ "The model files or required inference runtime failed verification."
99
+ )
100
+ model["status"] = model.pop("availability")
101
+ model.pop("installed", None)
102
+ results.append(model)
103
+ return results
104
+
105
+
106
+ def _native_model_status(settings: Settings, sherpa_runtime: dict[str, object]) -> dict[str, object]:
107
+ from smartvoice.services.spoken_language_identifier import (
108
+ installed_language_id_model_dir,
109
+ language_id_model_dir,
110
+ )
111
+
112
+ directory = language_id_model_dir(settings)
113
+ installed = installed_language_id_model_dir(settings)
114
+ runtime_issue = sherpa_runtime.get("reason")
115
+ if installed is not None and runtime_issue is None:
116
+ availability = "available"
117
+ reason = None
118
+ elif installed is not None:
119
+ availability = "unavailable"
120
+ reason = runtime_issue
121
+ elif directory.exists():
122
+ availability = "unavailable"
123
+ reason = "Whisper Tiny files are incomplete or fail integrity checks. Run models install-language-id to repair them."
124
+ else:
125
+ availability = "not_installed"
126
+ reason = None
127
+ return {
128
+ "id": "sherpa-onnx-whisper-tiny-int8-language-id",
129
+ "name": "Whisper Tiny (spoken-language identification)",
130
+ "task": "native",
131
+ "languages": ["multilingual"],
132
+ "backend": "SmartVoice native",
133
+ "status": availability,
134
+ "availability_reason": reason,
135
+ "installed_size_bytes": (
136
+ sum((installed / filename).stat().st_size for filename in (
137
+ "tiny-encoder.int8.onnx", "tiny-decoder.int8.onnx", "tiny-tokens.txt"
138
+ ))
139
+ if installed else None
140
+ ),
141
+ }
142
+
143
+
144
+ def _is_loopback(host: str) -> bool:
145
+ if host.lower() == "localhost":
146
+ return True
147
+ try:
148
+ return ipaddress.ip_address(host).is_loopback
149
+ except ValueError:
150
+ return False
151
+
152
+
153
+ def _print_startup_banner(address: str, settings: Settings, debug: bool) -> None:
154
+ art_path = Path(__file__).with_name("ascii-art.txt")
155
+ source_lines = art_path.read_text(encoding="utf-8").strip("\n").splitlines()
156
+ scale = 1.0
157
+ source_width = max((len(line) for line in source_lines), default=0)
158
+ art_width = max(1, round(source_width * scale))
159
+ art_height = max(1, round(len(source_lines) * scale))
160
+ art_lines = []
161
+ for row in range(art_height):
162
+ source_line = source_lines[min(len(source_lines) - 1, int(row / scale))].ljust(source_width)
163
+ art_lines.append("".join(
164
+ source_line[min(source_width - 1, int(column / scale))]
165
+ for column in range(art_width)
166
+ ).rstrip())
167
+ art_width = max((len(line) for line in art_lines), default=0)
168
+ border = f"+{'-' * (art_width + 2)}+"
169
+ print(border)
170
+ for line in art_lines:
171
+ print(f"| {line.ljust(art_width)} |")
172
+ print(border)
173
+ debug_label = " | DEBUG MODE" if debug else ""
174
+ print(f"Starting SmartVoice API at {address} (CPU, {settings.num_threads} inference threads{debug_label})")
175
+ print(f"Docs: {address}/docs | Test: {address}/test")
176
+
177
+
178
+ def _smartvoice_is_running(host: str, port: int) -> bool:
179
+ display_host = f"[{host}]" if ":" in host else host
180
+ try:
181
+ with urllib.request.urlopen(f"http://{display_host}:{port}/health", timeout=0.4) as response:
182
+ return json.loads(response.read().decode("utf-8")).get("status") == "ok"
183
+ except (OSError, urllib.error.URLError, json.JSONDecodeError):
184
+ return False
185
+
186
+
187
+ def _serve(args: list[str]) -> None:
188
+ parser = argparse.ArgumentParser(description="Run the SmartVoice local speech API")
189
+ parser.add_argument("--config", type=Path, default=None, help="Optional JSON configuration file")
190
+ parser.add_argument("--host", default=None, help="Bind address (loopback only in this release)")
191
+ parser.add_argument("--port", type=int, default=None, help="HTTP port")
192
+ parser.add_argument("--data-dir", type=Path, default=None, help="Override the local SmartVoice data directory")
193
+ parser.add_argument("--num-threads", type=int, default=None, help="Override CPU inference threads")
194
+ parser.add_argument("--debug", action="store_true", help="Log HTTP request/response headers and bodies and enable DEBUG logging")
195
+ parsed = parser.parse_args(args)
196
+ try:
197
+ settings = Settings.from_env(parsed.config, parsed.data_dir, initialize_user_config=True)
198
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
199
+ parser.error(f"Invalid configuration: {exc}")
200
+ settings = replace(
201
+ settings,
202
+ server_host=parsed.host or settings.server_host,
203
+ server_port=parsed.port or settings.server_port,
204
+ data_dir=settings.data_dir,
205
+ num_threads=max(1, parsed.num_threads) if parsed.num_threads else settings.num_threads,
206
+ log_level="DEBUG" if parsed.debug else settings.log_level,
207
+ )
208
+ if not _is_loopback(settings.server_host):
209
+ parser.error("Only loopback addresses are supported until remote access has authentication and risk controls.")
210
+ display_host = f"[{settings.server_host}]" if ":" in settings.server_host else settings.server_host
211
+ address = f"http://{display_host}:{settings.server_port}"
212
+ try:
213
+ with socket.create_connection((settings.server_host, settings.server_port), timeout=0.2):
214
+ try:
215
+ with urllib.request.urlopen(f"{address}/health", timeout=0.5) as response:
216
+ health = json.loads(response.read().decode("utf-8"))
217
+ if health.get("status") == "ok":
218
+ parser.error(f"SmartVoice is already running at {address} (version {health.get('version', 'unknown')}).")
219
+ parser.error(f"Port {settings.server_port} is already in use on {settings.server_host}.")
220
+ except (OSError, urllib.error.URLError, json.JSONDecodeError):
221
+ parser.error(f"Port {settings.server_port} is already in use on {settings.server_host}.")
222
+ except OSError:
223
+ pass
224
+ settings.models_dir.mkdir(parents=True, exist_ok=True)
225
+ _print_startup_banner(address, settings, parsed.debug)
226
+ print(f"SmartVoice data directory: {settings.data_dir}")
227
+ print(f"Model directory: {settings.models_dir}")
228
+ from smartvoice.app import create_app
229
+
230
+ uvicorn.run(
231
+ create_app(settings=settings, debug_http=parsed.debug), host=settings.server_host, port=settings.server_port,
232
+ log_level=settings.log_level.lower(),
233
+ )
234
+
235
+
236
+ def _models(args: list[str]) -> None:
237
+ parser = argparse.ArgumentParser(prog="python -m smartvoice models")
238
+ parser.add_argument("--config", type=Path, default=None, help="Optional JSON configuration file")
239
+ subparsers = parser.add_subparsers(dest="action", required=True)
240
+ list_parser = subparsers.add_parser("list", help="List catalog entries and installation state")
241
+ list_parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
242
+ install_parser = subparsers.add_parser("install", help="Download and install a catalog model")
243
+ install_parser.add_argument("model_id", help="Catalog model ID, or 'all' to install every uninstalled catalog model")
244
+ install_parser.add_argument("--source", help="Optional HTTPS base URL for a Hugging Face-compatible model mirror")
245
+ uninstall_parser = subparsers.add_parser("uninstall", help="Remove an installed model")
246
+ uninstall_parser.add_argument("model_id")
247
+ export_parser = subparsers.add_parser("export", help="Create a portable offline model package")
248
+ export_parser.add_argument("model_id")
249
+ export_parser.add_argument("destination", type=Path)
250
+ import_parser = subparsers.add_parser("import", help="Import and verify a portable offline model package")
251
+ import_parser.add_argument("archive", type=Path)
252
+ subparsers.add_parser("install-language-id", help="Install the Whisper Tiny spoken-language detection assets")
253
+ parsed = parser.parse_args(args)
254
+ try:
255
+ settings = Settings.from_env(parsed.config)
256
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
257
+ parser.error(f"Invalid configuration: {exc}")
258
+ model_management = ModelManagementService(
259
+ ModelJobManager(settings), CatalogModelRepository(settings)
260
+ )
261
+ last_output = 0.0
262
+
263
+ def progress(downloaded: int, total: int | None) -> None:
264
+ nonlocal last_output
265
+ now = time.monotonic()
266
+ if now - last_output < 0.5 and total and downloaded < total:
267
+ return
268
+ if total:
269
+ percent = downloaded * 100 / total
270
+ end = "\n" if downloaded >= total else ""
271
+ print(f"\rDownloading {downloaded / 1024**2:.1f}/{total / 1024**2:.1f} MiB ({percent:.1f}%)", end=end, flush=True)
272
+ else:
273
+ print(f"\rDownloaded {downloaded / 1024**2:.1f} MiB", end="", flush=True)
274
+ last_output = now
275
+
276
+ def status(message: str) -> None:
277
+ print(f"\n{message}", flush=True)
278
+
279
+ if parsed.action == "list":
280
+ from smartvoice.adapters.inference.factory import create_inference_provider
281
+ from smartvoice.services.model_storage import catalog_models
282
+
283
+ if hasattr(sys.stdout, "reconfigure"):
284
+ sys.stdout.reconfigure(encoding="utf-8")
285
+ inference_provider = create_inference_provider(settings, model_management.model_repository)
286
+ backend_runtimes = inference_provider.runtime().get("backends", {})
287
+ if not isinstance(backend_runtimes, dict):
288
+ backend_runtimes = {}
289
+ models = _models_with_availability(catalog_models(settings), backend_runtimes)
290
+ native_models = [_native_model_status(settings, backend_runtimes.get("sherpa-onnx", {}))]
291
+ if parsed.json:
292
+ print(json.dumps([*models, *native_models], ensure_ascii=False, indent=2))
293
+ else:
294
+ print(_format_model_list(models, native_models))
295
+ return
296
+
297
+ if parsed.action == "install-language-id":
298
+ from smartvoice.services.spoken_language_identifier import ensure_language_id_model
299
+
300
+ try:
301
+ destination = ensure_language_id_model(settings, progress, status)
302
+ except (OSError, ValueError, SmartVoiceError) as exc:
303
+ parser.error(str(exc))
304
+ print(f"\nInstalled spoken-language detection assets at: {destination}")
305
+ return
306
+
307
+ if parsed.action == "uninstall":
308
+ if _smartvoice_is_running(settings.server_host, settings.server_port):
309
+ parser.error("Stop the SmartVoice service before uninstalling a model so loaded files are not removed.")
310
+ try:
311
+ result = model_management.uninstall(parsed.model_id)
312
+ except (OSError, ValueError, SmartVoiceError) as exc:
313
+ parser.error(str(exc))
314
+ size = int(result["removed_bytes"])
315
+ print(f"Removed {parsed.model_id}; released {size / 1024**2:.1f} MiB.")
316
+ return
317
+ if parsed.action == "export":
318
+ try:
319
+ model_management.export(parsed.model_id, parsed.destination)
320
+ except (OSError, ValueError, SmartVoiceError) as exc:
321
+ parser.error(str(exc))
322
+ print(f"Exported {parsed.model_id} to {parsed.destination}.")
323
+ return
324
+ if parsed.action == "import":
325
+ try:
326
+ result = model_management.import_archive(parsed.archive)
327
+ except (OSError, ValueError, SmartVoiceError, json.JSONDecodeError) as exc:
328
+ parser.error(str(exc))
329
+ print(f"Imported model {result['id']}.")
330
+ return
331
+
332
+ if parsed.model_id == "all":
333
+ if parsed.source:
334
+ parser.error("--source cannot be used with 'install all'; each model uses its catalog source.")
335
+ catalog = model_management.catalog()["data"]
336
+ pending = [model for model in catalog if model.get("status") == "uninstalled"]
337
+ invalid = [model for model in catalog if model.get("status") == "invalid"]
338
+ if invalid:
339
+ print(
340
+ "Skipping models with existing invalid files: "
341
+ + ", ".join(str(model.get("id")) for model in invalid)
342
+ + ". Remove them with 'models uninstall <model-id>' before retrying."
343
+ )
344
+ if not pending:
345
+ print("No uninstalled catalog models to install.")
346
+ else:
347
+ print(f"Installing {len(pending)} uninstalled catalog models.")
348
+ model_ids = [str(model["id"]) for model in pending]
349
+ else:
350
+ model_ids = [parsed.model_id]
351
+
352
+ for model_id in model_ids:
353
+ last_output = 0.0
354
+ try:
355
+ spec = model_management.get_spec(model_id)
356
+ source_label = parsed.source or "catalog source"
357
+ if parsed.model_id == "all":
358
+ print(f"\nInstalling {spec.name} ({spec.id})")
359
+ if spec.file_sources:
360
+ print(f"Model files are fetched from the {source_label}; each file is checked against its catalog SHA-256.")
361
+ else:
362
+ print(f"The archive is fetched from the {source_label} and checked against its catalog SHA-256.")
363
+ print("Review the model license before redistribution.")
364
+ destination = model_management.install(model_id, progress, source=parsed.source)
365
+ except (OSError, ValueError, SmartVoiceError, ModelDownloadCancelled) as exc:
366
+ parser.error(f"Failed to install {model_id}: {exc}")
367
+ print(f"\nInstalled at: {destination}")
368
+
369
+ if parsed.model_id == "all":
370
+ from smartvoice.services.spoken_language_identifier import ensure_language_id_model
371
+
372
+ print("\nEnsuring Whisper Tiny is installed for automatic language identification.")
373
+ try:
374
+ destination = ensure_language_id_model(settings, progress, status)
375
+ except (OSError, ValueError) as exc:
376
+ parser.error(f"Failed to install Whisper Tiny language-identification assets: {exc}")
377
+ print(f"\nWhisper Tiny language-identification assets ready at: {destination}")
378
+
379
+
380
+ def _router(args: list[str]) -> None:
381
+ parser = argparse.ArgumentParser(prog="python -m smartvoice router")
382
+ parser.add_argument("--config", type=Path, default=None, help="Optional JSON configuration file")
383
+ parser.add_argument("--host", default=None, help="Running service bind address")
384
+ parser.add_argument("--port", type=int, default=None, help="Running service port")
385
+ actions = parser.add_subparsers(dest="action", required=True)
386
+ actions.add_parser("reload", help="Reload router.json in the running SmartVoice service")
387
+ parsed = parser.parse_args(args)
388
+ try:
389
+ settings = Settings.from_env(parsed.config)
390
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
391
+ parser.error(f"Invalid configuration: {exc}")
392
+ host = parsed.host or settings.server_host
393
+ port = parsed.port or settings.server_port
394
+ if not _is_loopback(host):
395
+ parser.error("Router reload is restricted to a local SmartVoice service.")
396
+ display_host = f"[{host}]" if ":" in host else host
397
+ address = f"http://{display_host}:{port}/v1/router/reload"
398
+ try:
399
+ request = urllib.request.Request(address, data=b"{}", headers={"Content-Type": "application/json"}, method="POST")
400
+ with urllib.request.urlopen(request, timeout=10) as response:
401
+ payload = json.loads(response.read().decode("utf-8"))
402
+ except urllib.error.HTTPError as exc:
403
+ try:
404
+ payload = json.loads(exc.read().decode("utf-8"))
405
+ message = payload.get("error", {}).get("message", payload)
406
+ except (UnicodeDecodeError, json.JSONDecodeError):
407
+ message = exc.reason
408
+ parser.error(f"Router reload failed: {message}")
409
+ except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
410
+ parser.error(f"Could not reload router configuration from the running service at {address}: {exc}")
411
+ print(f"Router configuration reloaded (sha256 {payload.get('sha256', 'unknown')}).")
412
+
413
+
414
+ def main() -> None:
415
+ args = sys.argv[1:]
416
+ if args and args[0] == "models":
417
+ _models(args[1:])
418
+ elif args and args[0] == "router":
419
+ _router(args[1:])
420
+ else:
421
+ _serve(args)
422
+
423
+
424
+ if __name__ == "__main__":
425
+ main()
smartvoice/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """The single source of truth for the SmartVoice release version."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1 @@
1
+ """Adapters connecting the domain to concrete libraries and platforms."""
@@ -0,0 +1 @@
1
+ """Inference engine adapters."""
@@ -0,0 +1,122 @@
1
+ """Routes provider-neutral inference calls to model-specific adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Sequence
7
+
8
+ from smartvoice.domain.contracts import InstalledModel, LanguageIdentificationResult, ProviderCapabilityDocument, SynthesizedSpeech, TranscriptionResult
9
+ from smartvoice.domain.errors import UnsupportedFeatureError
10
+ from smartvoice.ports.inference import (
11
+ InferenceProvider,
12
+ LanguageIdentifier,
13
+ LanguageIdentifierStatus,
14
+ ModelLifecycle,
15
+ RuntimeLifecycle,
16
+ )
17
+ from smartvoice.ports.model_repository import ModelRepository
18
+
19
+ DEFAULT_MODEL_IDS = {
20
+ "transcription": "stt-sensevoice-small-int8",
21
+ "speech": "tts-kokoro-multilingual-v1-1-zh-en",
22
+ }
23
+
24
+
25
+ class CompositeInferenceProvider:
26
+ """Combines inference adapters without exposing backend choices to services."""
27
+
28
+ def __init__(self, providers: Mapping[str, InferenceProvider], model_repository: ModelRepository) -> None:
29
+ self.providers = dict(providers)
30
+ self.model_repository = model_repository
31
+
32
+ def installed_models(self) -> Sequence[InstalledModel]:
33
+ return [model for provider in self.providers.values() for model in provider.installed_models()]
34
+
35
+ def runtime(self) -> dict[str, object]:
36
+ runtimes = {backend: provider.runtime() for backend, provider in self.providers.items()}
37
+ primary = next(iter(runtimes.values()), {})
38
+ common = {
39
+ key: value
40
+ for key, value in primary.items()
41
+ if key not in {
42
+ "backend", "backends", "installed_model_count", "actual_device",
43
+ "provider_status", "requested_device", "reason",
44
+ }
45
+ }
46
+ available = [
47
+ runtime for runtime in runtimes.values()
48
+ if runtime.get("provider_status") == "available"
49
+ ]
50
+ actual_devices = {runtime.get("actual_device") for runtime in available}
51
+ requested_devices = {runtime.get("requested_device") for runtime in runtimes.values()}
52
+ reasons = [str(runtime["reason"]) for runtime in runtimes.values() if runtime.get("reason")]
53
+ return {
54
+ **common,
55
+ "backend": "multi-adapter",
56
+ "backends": runtimes,
57
+ "requested_device": next(iter(requested_devices)) if len(requested_devices) == 1 else None,
58
+ "actual_device": next(iter(actual_devices)) if len(actual_devices) == 1 else None,
59
+ "provider_status": "available" if available else "unavailable",
60
+ "reason": None if available else "; ".join(reasons) or "No inference adapter is available.",
61
+ "installed_model_count": len(self.installed_models()),
62
+ }
63
+
64
+ def capabilities(self) -> ProviderCapabilityDocument:
65
+ tasks: list[dict[str, object]] = []
66
+ for backend, provider in self.providers.items():
67
+ for task in provider.capabilities().get("tasks", []):
68
+ task_document = dict(task)
69
+ task_document.setdefault("backend", backend)
70
+ tasks.append(task_document)
71
+ return {
72
+ "api_version": "v1",
73
+ "capability_schema_version": "1.0",
74
+ "backend": "multi-adapter",
75
+ "backends": list(self.providers),
76
+ "tasks": tasks,
77
+ }
78
+
79
+ def transcribe(
80
+ self, audio: bytes, language: str = "auto", model_id: str | None = None,
81
+ ) -> TranscriptionResult:
82
+ selected_id = model_id or DEFAULT_MODEL_IDS["transcription"]
83
+ return self._provider_for_model(selected_id).transcribe(audio, language, selected_id)
84
+
85
+ def synthesize(
86
+ self, text: str, voice: str = "default", speed: float = 1.0,
87
+ model_id: str | None = None, language: str = "auto",
88
+ ) -> SynthesizedSpeech:
89
+ selected_id = model_id or DEFAULT_MODEL_IDS["speech"]
90
+ return self._provider_for_model(selected_id).synthesize(text, voice, speed, selected_id, language)
91
+
92
+ def identify_language(self, audio: bytes) -> LanguageIdentificationResult:
93
+ for provider in self.providers.values():
94
+ if isinstance(provider, LanguageIdentifier):
95
+ return provider.identify_language(audio)
96
+ raise UnsupportedFeatureError("Spoken-language identification is not available from the configured adapters.")
97
+
98
+ def language_identification_available(self) -> bool:
99
+ return any(
100
+ isinstance(provider, LanguageIdentifierStatus)
101
+ and provider.language_identification_available()
102
+ for provider in self.providers.values()
103
+ )
104
+
105
+ def is_model_loaded(self, model_id: str) -> bool:
106
+ provider = self._provider_for_model(model_id)
107
+ return provider.is_model_loaded(model_id) if isinstance(provider, ModelLifecycle) else False
108
+
109
+ def close(self) -> None:
110
+ """Close managed runtimes owned by the composed adapters."""
111
+ for provider in self.providers.values():
112
+ if isinstance(provider, RuntimeLifecycle):
113
+ provider.close()
114
+
115
+ def _provider_for_model(self, model_id: str) -> InferenceProvider:
116
+ spec = self.model_repository.get_spec(model_id)
117
+ provider = self.providers.get(spec.backend)
118
+ if provider is None:
119
+ raise UnsupportedFeatureError(
120
+ f"No inference adapter is configured for model backend {spec.backend!r}."
121
+ )
122
+ return provider
@@ -0,0 +1,26 @@
1
+ """Create the configured inference adapters at the application boundary."""
2
+
3
+ from smartvoice.adapters.inference.composite_provider import CompositeInferenceProvider
4
+ from smartvoice.adapters.storage.catalog_model_repository import CatalogModelRepository
5
+ from smartvoice.config.settings import Settings
6
+ from smartvoice.ports.model_repository import ModelRepository
7
+
8
+
9
+ def create_inference_provider(
10
+ settings: Settings,
11
+ model_repository: ModelRepository | None = None,
12
+ ) -> CompositeInferenceProvider:
13
+ """Build the standard adapter set while keeping backend wiring in one place."""
14
+ repository = model_repository or CatalogModelRepository(settings)
15
+
16
+ # Import concrete adapters here to keep platform/runtime imports at the edge.
17
+ from smartvoice.adapters.inference.qwen_tts.provider import QwenTTSProvider
18
+ from smartvoice.adapters.inference.sherpa_onnx.provider import SherpaOnnxProvider
19
+
20
+ return CompositeInferenceProvider(
21
+ {
22
+ "sherpa-onnx": SherpaOnnxProvider(settings, repository),
23
+ "qwen-tts": QwenTTSProvider(settings, repository),
24
+ },
25
+ repository,
26
+ )
@@ -0,0 +1 @@
1
+ """Qwen3-TTS inference adapter."""