hermes-mobile-plugin 0.0.15__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.
- hermes_mobile_plugin/__init__.py +215 -0
- hermes_mobile_plugin/audio.py +253 -0
- hermes_mobile_plugin/audio_routes.py +502 -0
- hermes_mobile_plugin/cli.py +473 -0
- hermes_mobile_plugin/commands_routes.py +166 -0
- hermes_mobile_plugin/config.py +102 -0
- hermes_mobile_plugin/constants.py +122 -0
- hermes_mobile_plugin/ip_detector.py +127 -0
- hermes_mobile_plugin/plugin.yaml +18 -0
- hermes_mobile_plugin/providers_routes.py +182 -0
- hermes_mobile_plugin/qr_generator.py +276 -0
- hermes_mobile_plugin/supervisor.py +595 -0
- hermes_mobile_plugin/system_routes.py +448 -0
- hermes_mobile_plugin/update_routes.py +268 -0
- hermes_mobile_plugin-0.0.15.dist-info/METADATA +135 -0
- hermes_mobile_plugin-0.0.15.dist-info/RECORD +20 -0
- hermes_mobile_plugin-0.0.15.dist-info/WHEEL +5 -0
- hermes_mobile_plugin-0.0.15.dist-info/entry_points.txt +3 -0
- hermes_mobile_plugin-0.0.15.dist-info/licenses/LICENSE +21 -0
- hermes_mobile_plugin-0.0.15.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Hermes Mobile QR Plugin - Clean architecture implementation.
|
|
2
|
+
|
|
3
|
+
Generates a QR code for the Hermes Mobile App to connect directly to the
|
|
4
|
+
Hermes Agent Desktop gateway (port 8642), and exposes voice STT/TTS routes
|
|
5
|
+
on the gateway so the mobile app can speak and listen without an extra
|
|
6
|
+
service.
|
|
7
|
+
|
|
8
|
+
Both behaviors are opt-in via ``plugins.hermes-mobile-qr`` in
|
|
9
|
+
``~/.hermes/config.yaml``:
|
|
10
|
+
|
|
11
|
+
plugins:
|
|
12
|
+
hermes-mobile-qr:
|
|
13
|
+
auto_generate: true # QR code on startup
|
|
14
|
+
audio: true # voice routes on api_server
|
|
15
|
+
|
|
16
|
+
Audio routes are mounted via the standard plugin handler mechanism
|
|
17
|
+
(``ctx.register_platform_handler("api_server", _wire)``), which gives
|
|
18
|
+
the plugin a handle on the api_server's aiohttp ``web.Application``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import logging
|
|
23
|
+
import threading
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Optional
|
|
26
|
+
|
|
27
|
+
from .constants import PLUGIN_VERSION
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HermesMobileQRPlugin:
|
|
33
|
+
"""Plugin that generates QR codes for mobile app connection to 8642 gateway.
|
|
34
|
+
|
|
35
|
+
Used by the legacy entry-point style loader. The standard
|
|
36
|
+
``register(ctx)`` function below is the path the modern plugin loader
|
|
37
|
+
uses; both entry points are kept for backward compatibility.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, config: dict, plugin_dir: Path):
|
|
41
|
+
self.config = config
|
|
42
|
+
self.plugin_dir = plugin_dir
|
|
43
|
+
self._qr_generated = False
|
|
44
|
+
|
|
45
|
+
async def startup(self) -> None:
|
|
46
|
+
"""Called when Hermes Agent starts up (legacy entry-point style)."""
|
|
47
|
+
try:
|
|
48
|
+
from .config import get_plugin_config
|
|
49
|
+
plugin_config = get_plugin_config(self.config)
|
|
50
|
+
except ImportError:
|
|
51
|
+
plugin_config = self.config.get("plugins", {}).get("hermes-mobile-qr", {})
|
|
52
|
+
|
|
53
|
+
if plugin_config.get("auto_generate", True):
|
|
54
|
+
logger.info("[Hermes Mobile QR v%s] Plugin starting...", PLUGIN_VERSION)
|
|
55
|
+
await self._generate_qr_and_start_supervisor()
|
|
56
|
+
else:
|
|
57
|
+
logger.info("[Hermes Mobile QR v%s] Auto-generate disabled", PLUGIN_VERSION)
|
|
58
|
+
|
|
59
|
+
async def shutdown(self) -> None:
|
|
60
|
+
logger.info("[Hermes Mobile QR] Plugin shutting down...")
|
|
61
|
+
|
|
62
|
+
async def _generate_qr_and_start_supervisor(self) -> None:
|
|
63
|
+
if self._qr_generated:
|
|
64
|
+
return
|
|
65
|
+
try:
|
|
66
|
+
from .qr_generator import generate_qr
|
|
67
|
+
await generate_qr(self.config, self.plugin_dir)
|
|
68
|
+
self._qr_generated = True
|
|
69
|
+
logger.info("[Hermes Mobile QR] QR code generated successfully")
|
|
70
|
+
|
|
71
|
+
from .supervisor import ensure_running
|
|
72
|
+
ensure_running()
|
|
73
|
+
logger.info("[Hermes Mobile QR] Gateway watchdog verified/spawned")
|
|
74
|
+
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.warning("[Hermes Mobile QR] Failed to initialize: %s", e)
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Standard plugin entry point (register(ctx))
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
# Registration is keyed on the aiohttp app INSTANCE, not a global bool: a
|
|
83
|
+
# second api_server connect onto a DIFFERENT app must still get its routes,
|
|
84
|
+
# while a double-wire of the SAME app stays a no-op. (WeakSet so a dead app
|
|
85
|
+
# never pins memory and tests get a clean slate per instance.)
|
|
86
|
+
import weakref
|
|
87
|
+
|
|
88
|
+
_audio_wired_apps = weakref.WeakSet()
|
|
89
|
+
_audio_lock = threading.Lock()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _wire_audio_routes(native: Any, adapter: Any) -> None:
|
|
93
|
+
"""Factory passed to ``ctx.register_platform_handler("api_server", ...)``.
|
|
94
|
+
|
|
95
|
+
Receives the api_server's aiohttp ``web.Application`` as ``native``
|
|
96
|
+
and registers the plugin's voice routes on it. Guarded per-app so a
|
|
97
|
+
second connect of the SAME app doesn't double-register, while a fresh
|
|
98
|
+
app (hot-reload, tests) still gets mounted.
|
|
99
|
+
"""
|
|
100
|
+
with _audio_lock:
|
|
101
|
+
if native is not None and native in _audio_wired_apps:
|
|
102
|
+
logger.debug("[hermes-mobile-qr] Audio routes already registered on this app; skipping")
|
|
103
|
+
return
|
|
104
|
+
try:
|
|
105
|
+
from .audio_routes import register as _register_routes
|
|
106
|
+
from .system_routes import register as _register_system_routes
|
|
107
|
+
from .commands_routes import register as _register_command_routes
|
|
108
|
+
from .update_routes import register as _register_update_routes
|
|
109
|
+
from .providers_routes import register as _register_providers_routes
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
logger.warning(
|
|
112
|
+
"[hermes-mobile-qr] Could not import route modules: %s", exc
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
if native is None:
|
|
116
|
+
logger.debug(
|
|
117
|
+
"[hermes-mobile-qr] api_server native is None; "
|
|
118
|
+
"audio routes not mounted on this run"
|
|
119
|
+
)
|
|
120
|
+
return
|
|
121
|
+
try:
|
|
122
|
+
_register_routes(native)
|
|
123
|
+
_register_system_routes(native)
|
|
124
|
+
_register_command_routes(native)
|
|
125
|
+
_register_update_routes(native)
|
|
126
|
+
_register_providers_routes(native)
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
logger.exception(
|
|
129
|
+
"[hermes-mobile-qr] Failed to register audio routes: %s", exc
|
|
130
|
+
)
|
|
131
|
+
return
|
|
132
|
+
if native is not None:
|
|
133
|
+
_audio_wired_apps.add(native)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def _maybe_generate_qr(ctx: Any) -> None:
|
|
137
|
+
"""Best-effort QR generation on startup, mirroring the legacy flow.
|
|
138
|
+
|
|
139
|
+
``PluginContext`` exposes no ``.config``/``.plugin_dir`` attributes — the
|
|
140
|
+
QR payload needs the REAL platforms.api_server config, so load it from
|
|
141
|
+
config.yaml directly, and write the HTML next to the plugin package
|
|
142
|
+
(never the gateway's CWD).
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
from .config import load_hermes_config, get_plugin_config
|
|
146
|
+
hermes_config = load_hermes_config()
|
|
147
|
+
plugin_config = get_plugin_config(hermes_config)
|
|
148
|
+
except ImportError:
|
|
149
|
+
hermes_config, plugin_config = {}, {}
|
|
150
|
+
|
|
151
|
+
if not plugin_config.get("auto_generate", True):
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
from .constants import PLUGIN_DIR
|
|
156
|
+
from .qr_generator import generate_qr
|
|
157
|
+
out_dir = Path(ctx.plugin_dir) if getattr(ctx, "plugin_dir", None) else PLUGIN_DIR
|
|
158
|
+
await generate_qr(hermes_config, out_dir)
|
|
159
|
+
logger.info("[Hermes Mobile QR] QR code generated via register(ctx)")
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
logger.warning("[Hermes Mobile QR] QR generation skipped: %s", exc)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def register(ctx: Any) -> None:
|
|
165
|
+
"""Standard plugin entry point. Called by Hermes plugin loader.
|
|
166
|
+
|
|
167
|
+
Registers the plugin's voice STT/TTS routes on the api_server
|
|
168
|
+
platform, and (if enabled) generates a QR code on startup.
|
|
169
|
+
"""
|
|
170
|
+
logger.info(
|
|
171
|
+
"[Hermes Mobile QR v%s] register(ctx) called", PLUGIN_VERSION
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Register the api_server handler — always safe (idempotent guard).
|
|
175
|
+
try:
|
|
176
|
+
ctx.register_platform_handler("api_server", _wire_audio_routes)
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
logger.warning(
|
|
179
|
+
"[Hermes Mobile QR] Could not register api_server handler: %s", exc
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Keep-alive: spawn the detached gateway watchdog. It lives OUTSIDE the
|
|
183
|
+
# gateway process, so if the gateway dies (crash, OOM, reboot script) it
|
|
184
|
+
# restarts it and the app reconnects. Idempotent via PID file.
|
|
185
|
+
try:
|
|
186
|
+
from .supervisor import ensure_running
|
|
187
|
+
ensure_running()
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
logger.warning("[Hermes Mobile QR] Watchdog spawn failed: %s", exc)
|
|
190
|
+
|
|
191
|
+
# Fire-and-forget QR generation so we don't block loader. Scheduling
|
|
192
|
+
# only ever happens on an ALREADY-running loop: a run_until_complete
|
|
193
|
+
# fallback here could block the plugin loader (or start a stray loop
|
|
194
|
+
# beside the gateway's). No loop at register-time -> QR skipped until
|
|
195
|
+
# the next startup; the CLI covers the manual path.
|
|
196
|
+
try:
|
|
197
|
+
asyncio.get_running_loop().create_task(_maybe_generate_qr(ctx))
|
|
198
|
+
except RuntimeError:
|
|
199
|
+
logger.debug("[hermes-mobile-qr] no running loop at register(); QR gen deferred")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Legacy entry point (kept for backward compatibility)
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def create_plugin(config: dict, plugin_dir: Path) -> HermesMobileQRPlugin:
|
|
208
|
+
"""Factory function called by the legacy Hermes plugin loader."""
|
|
209
|
+
return HermesMobileQRPlugin(config, plugin_dir)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def startup_hook(config: dict, plugin_dir: Path) -> None:
|
|
213
|
+
"""Standalone startup hook if Hermes loader expects a function."""
|
|
214
|
+
plugin = create_plugin(config, plugin_dir)
|
|
215
|
+
await plugin.startup()
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Voice backend: STT (Whisper) + TTS (Edge/OpenAI/ElevenLabs) via hermes-agent.
|
|
2
|
+
|
|
3
|
+
Wraps the existing ``tools.voice_mode.transcribe_recording`` and
|
|
4
|
+
``tools.tts_tool.text_to_speech_tool`` so the mobile app can use the same
|
|
5
|
+
provider chain the desktop voice mode uses, without re-implementing any
|
|
6
|
+
provider logic.
|
|
7
|
+
|
|
8
|
+
Provider chain is configured in ``~/.hermes/config.yaml`` under ``tts.`` and
|
|
9
|
+
``voice.`` — this module just delegates.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import binascii
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
19
|
+
from contextlib import contextmanager, nullcontext
|
|
20
|
+
from typing import Any, Iterator, Optional
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# Mirrors hermes-agent's gateway MAX_REQUEST_BYTES (25 MB, api_server.py); a
|
|
25
|
+
# 25 MB recording base64-encodes to ~33 MB and dies at the gateway first.
|
|
26
|
+
_MAX_TRANSCRIPTION_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB
|
|
27
|
+
_AUDIO_MIME_EXTENSIONS = {
|
|
28
|
+
"audio/webm": ".webm",
|
|
29
|
+
"audio/ogg": ".ogg",
|
|
30
|
+
"audio/opus": ".opus",
|
|
31
|
+
"audio/mpeg": ".mp3",
|
|
32
|
+
"audio/mp3": ".mp3",
|
|
33
|
+
"audio/mp4": ".mp4",
|
|
34
|
+
"audio/wav": ".wav",
|
|
35
|
+
"audio/x-wav": ".wav",
|
|
36
|
+
"audio/flac": ".flac",
|
|
37
|
+
"audio/aac": ".aac",
|
|
38
|
+
"audio/x-m4a": ".m4a",
|
|
39
|
+
"video/webm": ".webm",
|
|
40
|
+
}
|
|
41
|
+
_AUDIO_EXT_TO_MIME = {
|
|
42
|
+
".mp3": "audio/mpeg",
|
|
43
|
+
".ogg": "audio/ogg",
|
|
44
|
+
".opus": "audio/ogg",
|
|
45
|
+
".wav": "audio/wav",
|
|
46
|
+
".flac": "audio/flac",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _audio_extension_for_mime(mime_type: str) -> str:
|
|
51
|
+
normalized = (mime_type or "").split(";", 1)[0].strip().lower()
|
|
52
|
+
return _AUDIO_MIME_EXTENSIONS.get(normalized, ".webm")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_supported_audio_mime(mime_type: str) -> bool:
|
|
56
|
+
if not mime_type:
|
|
57
|
+
return False
|
|
58
|
+
normalized = mime_type.split(";", 1)[0].strip().lower()
|
|
59
|
+
return normalized.startswith("audio/") or normalized == "video/webm"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AudioBackendError(Exception):
|
|
63
|
+
"""Raised when STT or TTS fails. Carries an HTTP status for the route handler."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, message: str, status: int = 500):
|
|
66
|
+
super().__init__(message)
|
|
67
|
+
self.status = status
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _resolve_profile_scope(profile: Optional[str]) -> Any:
|
|
71
|
+
"""Return a context manager that scopes config resolution to *profile*.
|
|
72
|
+
|
|
73
|
+
None / "" / "current" => no scope (use default home).
|
|
74
|
+
Mirrors ``_config_profile_scope`` in hermes_cli/web_server.py.
|
|
75
|
+
"""
|
|
76
|
+
requested = (profile or "").strip()
|
|
77
|
+
if not requested or requested.lower() == "current":
|
|
78
|
+
return nullcontext()
|
|
79
|
+
|
|
80
|
+
# Imported lazily — these live inside hermes-agent and are not on the
|
|
81
|
+
# plugin's static-analysis path.
|
|
82
|
+
from hermes_constants import (
|
|
83
|
+
set_hermes_home_override,
|
|
84
|
+
reset_hermes_home_override,
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
from hermes_cli.web_server import _resolve_profile_dir
|
|
88
|
+
except ImportError:
|
|
89
|
+
# Sep-2026 decomposition moved it to the sibling module; hosts that
|
|
90
|
+
# updated hermes-agent broke every profile-scoped speak/transcribe
|
|
91
|
+
# call until the plugin followed.
|
|
92
|
+
from hermes_cli.web_server_profiles import _resolve_profile_dir
|
|
93
|
+
|
|
94
|
+
profile_dir = _resolve_profile_dir(requested)
|
|
95
|
+
|
|
96
|
+
@contextmanager
|
|
97
|
+
def _scoped() -> Iterator[Any]:
|
|
98
|
+
token = set_hermes_home_override(str(profile_dir))
|
|
99
|
+
try:
|
|
100
|
+
yield profile_dir
|
|
101
|
+
finally:
|
|
102
|
+
reset_hermes_home_override(token)
|
|
103
|
+
|
|
104
|
+
return _scoped()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def transcribe_audio(
|
|
108
|
+
audio_b64: str,
|
|
109
|
+
mime_type: str = "audio/webm",
|
|
110
|
+
profile: Optional[str] = None,
|
|
111
|
+
model: Optional[str] = None,
|
|
112
|
+
) -> str:
|
|
113
|
+
"""Decode base64 audio and return its transcript.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
audio_b64: Base64-encoded audio bytes.
|
|
117
|
+
mime_type: MIME type (e.g. ``audio/webm``).
|
|
118
|
+
profile: Optional Hermes profile name.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Transcript text. Empty string on silence (per
|
|
122
|
+
``transcribe_recording`` contract).
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
AudioBackendError: 400 for bad input, 413 for too-large, 500 for
|
|
126
|
+
provider failures, 503 if no transcription provider is available.
|
|
127
|
+
"""
|
|
128
|
+
if not audio_b64 or not audio_b64.strip():
|
|
129
|
+
raise AudioBackendError("audio_b64 is required", status=400)
|
|
130
|
+
if not _is_supported_audio_mime(mime_type):
|
|
131
|
+
raise AudioBackendError("mime_type must be audio/*", status=400)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
audio_bytes = base64.b64decode(audio_b64, validate=True)
|
|
135
|
+
except (binascii.Error, ValueError) as exc:
|
|
136
|
+
raise AudioBackendError(f"audio_b64 is not valid base64: {exc}", status=400)
|
|
137
|
+
|
|
138
|
+
if not audio_bytes:
|
|
139
|
+
raise AudioBackendError("Audio recording is empty", status=400)
|
|
140
|
+
if len(audio_bytes) > _MAX_TRANSCRIPTION_UPLOAD_BYTES:
|
|
141
|
+
raise AudioBackendError("Audio recording is too large", status=413)
|
|
142
|
+
|
|
143
|
+
suffix = _audio_extension_for_mime(mime_type)
|
|
144
|
+
temp_path = ""
|
|
145
|
+
try:
|
|
146
|
+
with tempfile.NamedTemporaryFile(
|
|
147
|
+
prefix="hermes-mobile-voice-", suffix=suffix, delete=False
|
|
148
|
+
) as tmp:
|
|
149
|
+
tmp.write(audio_bytes)
|
|
150
|
+
temp_path = tmp.name
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
from tools.voice_mode import transcribe_recording
|
|
154
|
+
except ImportError as exc:
|
|
155
|
+
raise AudioBackendError(
|
|
156
|
+
f"Transcription provider unavailable: {exc}", status=503
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
scope = _resolve_profile_scope(profile)
|
|
160
|
+
with scope:
|
|
161
|
+
try:
|
|
162
|
+
result = transcribe_recording(temp_path, model=model)
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
logger.exception("Mobile voice transcription failed")
|
|
165
|
+
raise AudioBackendError(
|
|
166
|
+
f"Transcription failed: {exc}", status=500
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if not isinstance(result, dict):
|
|
170
|
+
# Defensive: if a provider returns a raw string, accept it.
|
|
171
|
+
return (str(result) if result else "").strip()
|
|
172
|
+
|
|
173
|
+
if not result.get("success"):
|
|
174
|
+
raise AudioBackendError(
|
|
175
|
+
result.get("error") or "Transcription failed",
|
|
176
|
+
status=400,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return (result.get("transcript") or "").strip()
|
|
180
|
+
finally:
|
|
181
|
+
if temp_path:
|
|
182
|
+
try:
|
|
183
|
+
os.unlink(temp_path)
|
|
184
|
+
except OSError:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def synthesize_speech(
|
|
189
|
+
text: str,
|
|
190
|
+
profile: Optional[str] = None,
|
|
191
|
+
) -> tuple[bytes, str, Optional[str]]:
|
|
192
|
+
"""Synthesize text to audio bytes.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
text: Text to speak.
|
|
196
|
+
profile: Optional Hermes profile name.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Tuple of ``(audio_bytes, mime_type, provider_name)``.
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
AudioBackendError: 400 for empty text, 500 for provider failures,
|
|
203
|
+
503 if no TTS provider is available.
|
|
204
|
+
"""
|
|
205
|
+
text = (text or "").strip()
|
|
206
|
+
if not text:
|
|
207
|
+
raise AudioBackendError("text is required", status=400)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
from tools.tts_tool import text_to_speech_tool
|
|
211
|
+
except ImportError as exc:
|
|
212
|
+
raise AudioBackendError(
|
|
213
|
+
f"TTS provider unavailable: {exc}", status=503
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
import json as _json
|
|
217
|
+
|
|
218
|
+
scope = _resolve_profile_scope(profile)
|
|
219
|
+
with scope:
|
|
220
|
+
try:
|
|
221
|
+
result_json = text_to_speech_tool(text)
|
|
222
|
+
result = _json.loads(result_json) if isinstance(result_json, str) else result_json
|
|
223
|
+
except Exception as exc:
|
|
224
|
+
logger.exception("Mobile voice TTS failed")
|
|
225
|
+
raise AudioBackendError(
|
|
226
|
+
f"Speech synthesis failed: {exc}", status=500
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if not result.get("success"):
|
|
230
|
+
raise AudioBackendError(
|
|
231
|
+
result.get("error") or "Speech synthesis failed",
|
|
232
|
+
status=400,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
file_path = result.get("file_path")
|
|
236
|
+
if not file_path or not os.path.isfile(file_path):
|
|
237
|
+
raise AudioBackendError("Audio file missing", status=500)
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
with open(file_path, "rb") as fh:
|
|
241
|
+
audio_bytes = fh.read()
|
|
242
|
+
except OSError as exc:
|
|
243
|
+
raise AudioBackendError(f"Could not read audio: {exc}", status=500)
|
|
244
|
+
finally:
|
|
245
|
+
try:
|
|
246
|
+
os.unlink(file_path)
|
|
247
|
+
except OSError:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
251
|
+
mime_type = _AUDIO_EXT_TO_MIME.get(ext, "audio/mpeg")
|
|
252
|
+
provider = result.get("provider")
|
|
253
|
+
return audio_bytes, mime_type, provider
|