speaker-helper 0.7.4__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,98 @@
1
+ """
2
+ speaker-helper — professional text-to-speech, offline and streaming.
3
+
4
+ Module summary
5
+ --------------
6
+ speaker-helper is the inverse of ``vocal-helper``: it turns **text into
7
+ speech**. It wraps the local `Voicebox <https://github.com/jamiepine/voicebox>`_
8
+ engine behind a small, typed API with two modes — **offline** (whole text →
9
+ one audio) and **streaming** (sentence-split, low time-to-first-audio) — and
10
+ ships a CLI and a REST API. It runs locally (conda + pip) or as a Docker
11
+ server.
12
+
13
+ The default engine is ``kokoro``, which synthesises faster than real time on
14
+ CPU (real-time factor well below 1.0).
15
+
16
+ Usage example
17
+ -------------
18
+ >>> from speaker_helper import Speaker, Settings
19
+ >>> spk = Speaker(Settings.from_mapping({"engine": "kokoro", "language": "fr"}))
20
+ >>> # spk.save("Bonjour le monde.", "hello.wav") # requires a running Voicebox
21
+
22
+ See ``EXAMPLES.md`` for a runnable cookbook.
23
+
24
+ Author
25
+ ------
26
+ Warith HARCHAOUI — https://linkedin.com/in/warith-harchaoui
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ # Low-level HTTP client that talks to the Voicebox REST service.
32
+ from speaker_helper.client import VoiceboxClient, VoiceboxError
33
+
34
+ # Typed configuration (connection details plus voice/language/mode).
35
+ from speaker_helper.config import Settings, VoiceboxConfig
36
+
37
+ # Backend registry and the engine protocol the rest of the package targets.
38
+ from speaker_helper.engine import (
39
+ MockEngine,
40
+ TTSEngine,
41
+ available_backends,
42
+ create_engine,
43
+ register_backend,
44
+ )
45
+
46
+ # Per-language operating profiles and their tuning helpers.
47
+ from speaker_helper.profiles import (
48
+ DEFAULT_PROFILES,
49
+ LanguageProfile,
50
+ profile_for,
51
+ tune_profiles,
52
+ )
53
+
54
+ # High-level entry point most callers use.
55
+ from speaker_helper.speaker import Speaker
56
+
57
+ # Deterministic sentence splitter used for streaming synthesis.
58
+ from speaker_helper.text import chunk_for_streaming, split_sentences
59
+
60
+ # Dataclasses exchanged across the public surface.
61
+ from speaker_helper.types import (
62
+ AudioResult,
63
+ Mode,
64
+ StreamChunk,
65
+ Voice,
66
+ VoiceList,
67
+ VoiceSample,
68
+ )
69
+
70
+ __version__ = "0.7.4"
71
+
72
+ # Explicit, sorted public API: only these names are re-exported from the
73
+ # package root; the sub-modules above hold the implementation.
74
+ __all__ = [
75
+ "DEFAULT_PROFILES",
76
+ "AudioResult",
77
+ "LanguageProfile",
78
+ "Mode",
79
+ "MockEngine",
80
+ "Settings",
81
+ "Speaker",
82
+ "StreamChunk",
83
+ "TTSEngine",
84
+ "Voice",
85
+ "VoiceList",
86
+ "VoiceSample",
87
+ "VoiceboxClient",
88
+ "VoiceboxConfig",
89
+ "VoiceboxError",
90
+ "__version__",
91
+ "available_backends",
92
+ "chunk_for_streaming",
93
+ "create_engine",
94
+ "profile_for",
95
+ "register_backend",
96
+ "split_sentences",
97
+ "tune_profiles",
98
+ ]
speaker_helper/api.py ADDED
@@ -0,0 +1,294 @@
1
+ """
2
+ REST API server for speaker-helper.
3
+
4
+ Module summary
5
+ --------------
6
+ A thin FastAPI façade over :class:`~speaker_helper.speaker.Speaker`, suitable
7
+ for running as a Docker service. It exposes:
8
+
9
+ * ``GET /health`` — liveness plus the upstream engine health.
10
+ * ``GET /voices`` — preset voices for the configured (or requested) engine.
11
+ * ``POST /synth`` — synthesise ``{"text": ..., "language": ...}`` and return a
12
+ ``audio/wav`` body (offline: whole text in one response).
13
+ * ``POST /synth/stream`` — same body, but stream Server-Sent Events, one per
14
+ sentence-sized chunk, as soon as each is ready (low time-to-first-audio).
15
+ Each event's ``data`` is JSON with ``seq``, ``duration_s``, ``rtf``,
16
+ ``ttfa_s`` (first chunk only), ``is_final``, and base64 ``audio`` (WAV).
17
+ * ``POST /clone`` — multipart upload of reference audio (plus optional
18
+ transcripts) to clone a voice; missing transcripts come from ``vocal-helper``.
19
+ The clone becomes the server's active voice for subsequent ``/synth`` calls.
20
+
21
+ When ``fastapi-mcp`` is installed, a Model Context Protocol server is also
22
+ mounted at ``/mcp``, exposing these endpoints as MCP tools so an assistant can
23
+ synthesise, stream, clone, and list voices directly.
24
+
25
+ This module imports FastAPI/pydantic/uvicorn at import time, so it is only ever
26
+ imported behind the ``server`` extra (the CLI imports it lazily, and the core
27
+ package never imports it). Keeping the request model at module scope lets
28
+ FastAPI correctly resolve it as a request **body** rather than query params.
29
+
30
+ Usage example
31
+ -------------
32
+ .. code-block:: bash
33
+
34
+ speaker-helper serve --host 0.0.0.0 --port 8080
35
+ curl -s -X POST localhost:8080/synth -H 'content-type: application/json' \\
36
+ -d '{"text": "Bonjour."}' -o out.wav
37
+
38
+ Author
39
+ ------
40
+ Warith HARCHAOUI — https://linkedin.com/in/warith-harchaoui
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import base64
46
+ import json
47
+ from collections.abc import AsyncIterator
48
+ from contextlib import asynccontextmanager
49
+
50
+ import os_helper as osh
51
+ from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
52
+ from fastapi.responses import Response, StreamingResponse
53
+ from pydantic import BaseModel, Field
54
+
55
+ from speaker_helper.config import Settings
56
+ from speaker_helper.speaker import Speaker
57
+ from speaker_helper.types import VoiceSample
58
+
59
+
60
+ class SynthRequest(BaseModel):
61
+ """Request body for ``POST /synth``.
62
+
63
+ Parameters
64
+ ----------
65
+ text : str
66
+ Text to synthesise (non-empty).
67
+ language : str or None
68
+ Optional override of the configured target language.
69
+ """
70
+
71
+ text: str = Field(..., min_length=1, description="Text to synthesise.")
72
+ language: str | None = Field(None, description="Override target language.")
73
+
74
+
75
+ def create_app(settings: Settings | None = None, *, enable_mcp: bool = True) -> FastAPI:
76
+ """Build the FastAPI application.
77
+
78
+ Parameters
79
+ ----------
80
+ settings : Settings or None
81
+ Configuration; defaults to :meth:`Settings.load`.
82
+ enable_mcp : bool
83
+ When ``True`` (default) and ``fastapi-mcp`` is installed, mount a Model
84
+ Context Protocol server at ``/mcp`` that exposes the REST endpoints as
85
+ MCP tools — so an MCP client (an assistant) can synthesise, clone, and
86
+ list voices directly. Silently skipped if ``fastapi-mcp`` is absent.
87
+
88
+ Returns
89
+ -------
90
+ fastapi.FastAPI
91
+ The configured application. A single shared :class:`Speaker` is created
92
+ at startup and closed at shutdown.
93
+ """
94
+ # Resolve configuration once and build the single shared Speaker that every
95
+ # request handler below closes over (one pooled HTTP client for the app).
96
+ settings = settings or Settings.load()
97
+ speaker = Speaker(settings)
98
+
99
+ @asynccontextmanager
100
+ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
101
+ """Manage the shared engine's startup/shutdown for the app's lifetime.
102
+
103
+ Yields
104
+ ------
105
+ None
106
+ Control is yielded to the running application between warmup and
107
+ teardown; nothing is passed back.
108
+ """
109
+ # One Speaker (and its pooled HTTP client) for the app's lifetime.
110
+ # Warm the engine up front so the first client request is not the one
111
+ # that pays the model download / load.
112
+ await speaker.warmup()
113
+ yield
114
+ # On shutdown, release the pooled HTTP client and engine resources.
115
+ await speaker.aclose()
116
+
117
+ # Wire the lifespan into the app so warmup/teardown run around serving.
118
+ app = FastAPI(title="speaker-helper", version=_version(), lifespan=lifespan)
119
+
120
+ @app.get("/health", operation_id="health")
121
+ async def health() -> dict:
122
+ """Return server liveness and upstream Voicebox health."""
123
+ try:
124
+ # Liveness is trivially true (we answered); the interesting signal is
125
+ # whether the upstream engine is reachable, so probe it here.
126
+ upstream = await speaker.engine.health()
127
+ except Exception as exc: # noqa: BLE001 - report upstream failure verbatim
128
+ # We are up but the engine is not: report "degraded" rather than 5xx
129
+ # so callers can distinguish "server dead" from "engine dead".
130
+ return {"status": "degraded", "backend": settings.backend, "engine_error": str(exc)}
131
+ return {"status": "ok", "backend": settings.backend, "engine": upstream}
132
+
133
+ @app.get("/voices", operation_id="list_voices")
134
+ async def voices(engine: str | None = Query(None)) -> dict:
135
+ """List preset voices for an engine."""
136
+ listing = await speaker.voices(engine)
137
+ return {"engine": listing.engine, "voices": [vars(v) for v in listing.voices]}
138
+
139
+ @app.post("/synth", operation_id="synth")
140
+ async def synth(req: SynthRequest) -> Response:
141
+ """Synthesise text and return an ``audio/wav`` body."""
142
+ try:
143
+ # Whole-text (offline) synthesis: one WAV back in one response.
144
+ result = await speaker.say(req.text, language=req.language)
145
+ # Bad input (e.g. empty text) is the client's fault -> 400.
146
+ except ValueError as exc:
147
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
148
+ # Anything else is an upstream engine failure -> 502 (bad gateway).
149
+ except Exception as exc: # noqa: BLE001 - surface engine failures as 502
150
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
151
+ # Expose duration/RTF as headers so callers can measure without decoding.
152
+ headers = {
153
+ "X-Audio-Duration-S": f"{result.duration_s:.3f}",
154
+ "X-Audio-RTF": f"{result.rtf:.3f}",
155
+ }
156
+ return Response(content=result.wav_bytes, media_type="audio/wav", headers=headers)
157
+
158
+ @app.post("/synth/stream", operation_id="synth_stream")
159
+ async def synth_stream(req: SynthRequest) -> StreamingResponse:
160
+ """Stream synthesis as Server-Sent Events, one per sentence-sized chunk.
161
+
162
+ Emits each chunk's audio (base64 WAV) and metadata as soon as it is
163
+ ready, so a client can start playing before the whole text is
164
+ synthesised. A terminal ``event: error`` is sent if synthesis fails
165
+ mid-stream (the HTTP status is already 200 by then).
166
+ """
167
+
168
+ async def events() -> AsyncIterator[bytes]:
169
+ """Yield one SSE ``data:`` frame per synthesised chunk.
170
+
171
+ Yields
172
+ ------
173
+ bytes
174
+ A Server-Sent-Events frame: normally a ``data:`` line carrying
175
+ the chunk metadata and base64 WAV; on failure, a terminal
176
+ ``event: error`` frame with the error detail.
177
+ """
178
+ try:
179
+ # Forward each streaming chunk to the client the moment it is
180
+ # ready, so playback can start before the whole text is done.
181
+ async for chunk in speaker.stream(req.text, language=req.language):
182
+ # Serialise chunk metadata + audio into the SSE payload.
183
+ # ttfa_s is only meaningful on the first chunk (else None).
184
+ payload = {
185
+ "seq": chunk.seq,
186
+ "duration_s": round(chunk.audio.duration_s, 3),
187
+ "rtf": round(chunk.audio.rtf, 3),
188
+ "ttfa_s": (round(chunk.ttfa_s, 3) if chunk.ttfa_s is not None else None),
189
+ "is_final": chunk.is_final,
190
+ "audio": base64.b64encode(chunk.audio.wav_bytes).decode("ascii"),
191
+ }
192
+ # SSE frames are terminated by a blank line.
193
+ yield f"data: {json.dumps(payload)}\n\n".encode()
194
+ # Bad input surfaces as an error frame (status is already 200 here).
195
+ except ValueError as exc:
196
+ yield f"event: error\ndata: {json.dumps({'detail': str(exc)})}\n\n".encode()
197
+ # Engine failures mid-stream: log and tell the client via an error frame.
198
+ except Exception as exc: # noqa: BLE001 - surface engine failures to the client
199
+ osh.warning("stream failed: %s", exc)
200
+ yield f"event: error\ndata: {json.dumps({'detail': str(exc)})}\n\n".encode()
201
+
202
+ return StreamingResponse(events(), media_type="text/event-stream")
203
+
204
+ @app.post("/clone", operation_id="clone_voice")
205
+ async def clone(
206
+ name: str = Form(..., description="Name for the cloned voice profile."),
207
+ files: list[UploadFile] = File(..., description="Reference audio recordings."),
208
+ reference_texts: list[str] = Form(
209
+ default=[], description="Transcripts, one per file (auto-transcribed if omitted)."
210
+ ),
211
+ ) -> dict:
212
+ """Clone a voice from uploaded reference audio and return its id.
213
+
214
+ Any file without a matching ``reference_texts`` entry is transcribed
215
+ with ``vocal-helper``. The cloned voice becomes the server's active
216
+ voice, so subsequent ``/synth`` calls use it.
217
+ """
218
+ # Pair each uploaded file with its transcript by position; a file with no
219
+ # matching transcript gets "" and is auto-transcribed downstream.
220
+ samples = [
221
+ VoiceSample(
222
+ audio=await f.read(),
223
+ reference_text=(reference_texts[i] if i < len(reference_texts) else ""),
224
+ )
225
+ for i, f in enumerate(files)
226
+ ]
227
+ try:
228
+ # Register the clone; it becomes the server's active voice on success.
229
+ voice_id = await speaker.clone_voice(name, samples)
230
+ except ValueError as exc:
231
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
232
+ except Exception as exc: # noqa: BLE001 - surface engine/clone failures as 502
233
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
234
+ return {"voice_id": voice_id}
235
+
236
+ # Optionally expose every endpoint above as an MCP tool for assistants.
237
+ if enable_mcp:
238
+ _mount_mcp(app)
239
+
240
+ return app
241
+
242
+
243
+ def _mount_mcp(app: FastAPI) -> None:
244
+ """Mount an MCP server at ``/mcp`` exposing the REST endpoints as tools.
245
+
246
+ Uses ``fastapi-mcp`` to turn each operation (``synth``, ``synth_stream``,
247
+ ``clone_voice``, ``list_voices``, ``health``) into a Model Context Protocol
248
+ tool, so an assistant can drive speaker-helper directly. A no-op (with a
249
+ warning) when ``fastapi-mcp`` is not installed.
250
+ """
251
+ try:
252
+ # Import lazily: fastapi-mcp lives behind the optional 'mcp' extra.
253
+ from fastapi_mcp import FastApiMCP
254
+ except ImportError:
255
+ # Absent extra is not an error — the REST API works fine without MCP.
256
+ osh.warning(
257
+ "fastapi-mcp not installed; MCP server not mounted "
258
+ "(install the 'mcp' extra to enable /mcp)"
259
+ )
260
+ return
261
+ mcp = FastApiMCP(
262
+ app,
263
+ name="speaker-helper",
264
+ description="Text-to-speech: synthesise, stream, clone voices, list voices.",
265
+ )
266
+ mcp.mount_http()
267
+ osh.info("MCP server mounted at /mcp")
268
+
269
+
270
+ def serve(settings: Settings | None = None, *, host: str = "127.0.0.1", port: int = 8080) -> None:
271
+ """Run the API server with uvicorn (blocking).
272
+
273
+ Parameters
274
+ ----------
275
+ settings : Settings or None
276
+ Configuration; defaults to :meth:`Settings.load`.
277
+ host : str
278
+ Bind address.
279
+ port : int
280
+ Bind port.
281
+ """
282
+ # Import uvicorn lazily so the core package never requires the server extra.
283
+ import uvicorn
284
+
285
+ osh.info("starting speaker-helper API on %s:%d", host, port)
286
+ # Blocking call: hands control to uvicorn's event loop until interrupted.
287
+ uvicorn.run(create_app(settings), host=host, port=port)
288
+
289
+
290
+ def _version() -> str:
291
+ """Return the package version without importing the whole package eagerly."""
292
+ from speaker_helper import __version__
293
+
294
+ return __version__