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.
- speaker_helper/__init__.py +98 -0
- speaker_helper/api.py +294 -0
- speaker_helper/cli.py +561 -0
- speaker_helper/client.py +597 -0
- speaker_helper/cloning.py +213 -0
- speaker_helper/config.py +246 -0
- speaker_helper/engine.py +330 -0
- speaker_helper/eval/__init__.py +90 -0
- speaker_helper/eval/data/en_reference.jsonl +10 -0
- speaker_helper/eval/data/es_reference.jsonl +10 -0
- speaker_helper/eval/data/fr_reference.jsonl +12 -0
- speaker_helper/eval/dataset.py +146 -0
- speaker_helper/eval/deepeval_metrics.py +141 -0
- speaker_helper/eval/metrics.py +313 -0
- speaker_helper/eval/multilang.py +127 -0
- speaker_helper/eval/priors.py +119 -0
- speaker_helper/eval/runner.py +344 -0
- speaker_helper/eval/thresholds.py +92 -0
- speaker_helper/eval/thresholds.yaml +17 -0
- speaker_helper/profiles.py +189 -0
- speaker_helper/sources.py +306 -0
- speaker_helper/speaker.py +410 -0
- speaker_helper/text.py +106 -0
- speaker_helper/transcription.py +189 -0
- speaker_helper/types.py +189 -0
- speaker_helper-0.7.4.dist-info/METADATA +365 -0
- speaker_helper-0.7.4.dist-info/RECORD +31 -0
- speaker_helper-0.7.4.dist-info/WHEEL +5 -0
- speaker_helper-0.7.4.dist-info/entry_points.txt +2 -0
- speaker_helper-0.7.4.dist-info/licenses/LICENSE +29 -0
- speaker_helper-0.7.4.dist-info/top_level.txt +1 -0
|
@@ -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__
|