openspeechapi 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.
- openspeech/__init__.py +75 -0
- openspeech/__main__.py +5 -0
- openspeech/cli.py +413 -0
- openspeech/client/__init__.py +4 -0
- openspeech/client/client.py +145 -0
- openspeech/config.py +212 -0
- openspeech/core/__init__.py +0 -0
- openspeech/core/base.py +75 -0
- openspeech/core/enums.py +39 -0
- openspeech/core/models.py +61 -0
- openspeech/core/registry.py +37 -0
- openspeech/core/settings.py +8 -0
- openspeech/demo.py +675 -0
- openspeech/dispatch/__init__.py +0 -0
- openspeech/dispatch/context.py +34 -0
- openspeech/dispatch/dispatcher.py +661 -0
- openspeech/dispatch/executors/__init__.py +0 -0
- openspeech/dispatch/executors/base.py +34 -0
- openspeech/dispatch/executors/in_process.py +66 -0
- openspeech/dispatch/executors/remote.py +64 -0
- openspeech/dispatch/executors/subprocess_exec.py +446 -0
- openspeech/dispatch/fanout.py +95 -0
- openspeech/dispatch/filters.py +73 -0
- openspeech/dispatch/lifecycle.py +178 -0
- openspeech/dispatch/watcher.py +82 -0
- openspeech/engine_catalog.py +236 -0
- openspeech/engine_registry.yaml +347 -0
- openspeech/exceptions.py +51 -0
- openspeech/factory.py +325 -0
- openspeech/local_engines/__init__.py +12 -0
- openspeech/local_engines/aim_resolver.py +91 -0
- openspeech/local_engines/backends/__init__.py +1 -0
- openspeech/local_engines/backends/docker_backend.py +490 -0
- openspeech/local_engines/backends/native_backend.py +902 -0
- openspeech/local_engines/base.py +30 -0
- openspeech/local_engines/engines/__init__.py +1 -0
- openspeech/local_engines/engines/faster_whisper.py +36 -0
- openspeech/local_engines/engines/fish_speech.py +33 -0
- openspeech/local_engines/engines/sherpa_onnx.py +56 -0
- openspeech/local_engines/engines/whisper.py +41 -0
- openspeech/local_engines/engines/whisperlivekit.py +60 -0
- openspeech/local_engines/manager.py +208 -0
- openspeech/local_engines/models.py +50 -0
- openspeech/local_engines/progress.py +69 -0
- openspeech/local_engines/registry.py +19 -0
- openspeech/local_engines/task_store.py +52 -0
- openspeech/local_engines/tasks.py +71 -0
- openspeech/logging_config.py +607 -0
- openspeech/observe/__init__.py +0 -0
- openspeech/observe/base.py +79 -0
- openspeech/observe/debug.py +44 -0
- openspeech/observe/latency.py +19 -0
- openspeech/observe/metrics.py +47 -0
- openspeech/observe/tracing.py +44 -0
- openspeech/observe/usage.py +27 -0
- openspeech/providers/__init__.py +0 -0
- openspeech/providers/_template.py +101 -0
- openspeech/providers/stt/__init__.py +0 -0
- openspeech/providers/stt/alibaba.py +86 -0
- openspeech/providers/stt/assemblyai.py +135 -0
- openspeech/providers/stt/azure_speech.py +99 -0
- openspeech/providers/stt/baidu.py +135 -0
- openspeech/providers/stt/deepgram.py +311 -0
- openspeech/providers/stt/elevenlabs.py +385 -0
- openspeech/providers/stt/faster_whisper.py +211 -0
- openspeech/providers/stt/google_cloud.py +106 -0
- openspeech/providers/stt/iflytek.py +427 -0
- openspeech/providers/stt/macos_speech.py +226 -0
- openspeech/providers/stt/openai.py +84 -0
- openspeech/providers/stt/sherpa_onnx.py +353 -0
- openspeech/providers/stt/tencent.py +212 -0
- openspeech/providers/stt/volcengine.py +107 -0
- openspeech/providers/stt/whisper.py +153 -0
- openspeech/providers/stt/whisperlivekit.py +530 -0
- openspeech/providers/stt/windows_speech.py +249 -0
- openspeech/providers/tts/__init__.py +0 -0
- openspeech/providers/tts/alibaba.py +95 -0
- openspeech/providers/tts/azure_speech.py +123 -0
- openspeech/providers/tts/baidu.py +143 -0
- openspeech/providers/tts/coqui.py +64 -0
- openspeech/providers/tts/cosyvoice.py +90 -0
- openspeech/providers/tts/deepgram.py +174 -0
- openspeech/providers/tts/elevenlabs.py +311 -0
- openspeech/providers/tts/fish_speech.py +158 -0
- openspeech/providers/tts/google_cloud.py +107 -0
- openspeech/providers/tts/iflytek.py +209 -0
- openspeech/providers/tts/macos_say.py +251 -0
- openspeech/providers/tts/minimax.py +122 -0
- openspeech/providers/tts/openai.py +104 -0
- openspeech/providers/tts/piper.py +104 -0
- openspeech/providers/tts/tencent.py +189 -0
- openspeech/providers/tts/volcengine.py +117 -0
- openspeech/providers/tts/windows_sapi.py +234 -0
- openspeech/server/__init__.py +1 -0
- openspeech/server/app.py +72 -0
- openspeech/server/auth.py +42 -0
- openspeech/server/middleware.py +75 -0
- openspeech/server/routes/__init__.py +1 -0
- openspeech/server/routes/management.py +848 -0
- openspeech/server/routes/stt.py +121 -0
- openspeech/server/routes/tts.py +159 -0
- openspeech/server/routes/webui.py +29 -0
- openspeech/server/webui/app.js +2649 -0
- openspeech/server/webui/index.html +216 -0
- openspeech/server/webui/styles.css +617 -0
- openspeech/server/ws/__init__.py +1 -0
- openspeech/server/ws/stt_stream.py +263 -0
- openspeech/server/ws/tts_stream.py +207 -0
- openspeech/telemetry/__init__.py +21 -0
- openspeech/telemetry/perf.py +307 -0
- openspeech/utils/__init__.py +5 -0
- openspeech/utils/audio_converter.py +406 -0
- openspeech/utils/audio_playback.py +156 -0
- openspeech/vendor_registry.yaml +74 -0
- openspeechapi-0.1.0.dist-info/METADATA +101 -0
- openspeechapi-0.1.0.dist-info/RECORD +118 -0
- openspeechapi-0.1.0.dist-info/WHEEL +4 -0
- openspeechapi-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""WebSocket STT streaming endpoint."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
from openspeech.logging_config import logger
|
|
7
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
8
|
+
|
|
9
|
+
from openspeech.core.enums import Capability
|
|
10
|
+
from openspeech.logging_config import bind_context
|
|
11
|
+
from openspeech.telemetry.perf import Event, PerfTimer, milestone
|
|
12
|
+
|
|
13
|
+
router = APIRouter()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.websocket("/stream")
|
|
17
|
+
async def stt_stream(
|
|
18
|
+
websocket: WebSocket,
|
|
19
|
+
provider: str = "faster-whisper",
|
|
20
|
+
language: str | None = None,
|
|
21
|
+
sample_rate: int = 16000,
|
|
22
|
+
):
|
|
23
|
+
# Establish request_id early so all log lines from this connection are
|
|
24
|
+
# correlated, including auth-failure paths.
|
|
25
|
+
request_id = (
|
|
26
|
+
websocket.query_params.get("request_id")
|
|
27
|
+
or websocket.headers.get("x-request-id")
|
|
28
|
+
or uuid.uuid4().hex[:12]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
with bind_context(request_id=request_id, provider=provider, engine=provider):
|
|
32
|
+
# WebSocket auth via query param
|
|
33
|
+
server_config = getattr(websocket.app.state, "server_config", None)
|
|
34
|
+
if server_config is not None and server_config.auth_enabled:
|
|
35
|
+
token = websocket.query_params.get("token", "")
|
|
36
|
+
if token not in server_config.api_keys:
|
|
37
|
+
milestone(Event.WS_ERROR, reason="unauthorized", scope="stt")
|
|
38
|
+
await websocket.close(code=4001, reason="Unauthorized")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
with PerfTimer(Event.WS_TOTAL, scope="stt", provider=provider) as ws_timer:
|
|
42
|
+
await websocket.accept()
|
|
43
|
+
milestone(
|
|
44
|
+
Event.WS_ACCEPT,
|
|
45
|
+
scope="stt",
|
|
46
|
+
provider=provider,
|
|
47
|
+
language=language,
|
|
48
|
+
sample_rate=sample_rate,
|
|
49
|
+
)
|
|
50
|
+
dispatcher = websocket.app.state.dispatcher
|
|
51
|
+
|
|
52
|
+
# Determine if the provider supports real streaming
|
|
53
|
+
handle = dispatcher._handles.get(provider)
|
|
54
|
+
provider_cls = handle.provider_cls if handle else None
|
|
55
|
+
supports_streaming = (
|
|
56
|
+
provider_cls is not None
|
|
57
|
+
and Capability.STREAMING in getattr(provider_cls, "capabilities", set())
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Send meta message so frontend knows batch vs streaming mode
|
|
61
|
+
await websocket.send_json({
|
|
62
|
+
"type": "meta",
|
|
63
|
+
"streaming": supports_streaming,
|
|
64
|
+
"provider": provider,
|
|
65
|
+
"request_id": request_id,
|
|
66
|
+
})
|
|
67
|
+
milestone(
|
|
68
|
+
Event.WS_META_SENT,
|
|
69
|
+
level="verbose",
|
|
70
|
+
scope="stt",
|
|
71
|
+
streaming=supports_streaming,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
if supports_streaming:
|
|
76
|
+
await _run_streaming(
|
|
77
|
+
websocket=websocket,
|
|
78
|
+
dispatcher=dispatcher,
|
|
79
|
+
provider=provider,
|
|
80
|
+
language=language,
|
|
81
|
+
ws_timer=ws_timer,
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
await _run_batch(
|
|
85
|
+
websocket=websocket,
|
|
86
|
+
dispatcher=dispatcher,
|
|
87
|
+
provider=provider,
|
|
88
|
+
language=language,
|
|
89
|
+
sample_rate=sample_rate,
|
|
90
|
+
ws_timer=ws_timer,
|
|
91
|
+
)
|
|
92
|
+
except WebSocketDisconnect:
|
|
93
|
+
milestone(Event.WS_CLOSED, scope="stt", reason="client_disconnect")
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
milestone(
|
|
96
|
+
Event.WS_ERROR,
|
|
97
|
+
scope="stt",
|
|
98
|
+
error_type=type(exc).__name__,
|
|
99
|
+
error_message=str(exc),
|
|
100
|
+
)
|
|
101
|
+
logger.exception("STT WS error")
|
|
102
|
+
try:
|
|
103
|
+
await websocket.send_json({"type": "error", "detail": str(exc)})
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _run_streaming(
|
|
109
|
+
*,
|
|
110
|
+
websocket: WebSocket,
|
|
111
|
+
dispatcher,
|
|
112
|
+
provider: str,
|
|
113
|
+
language: str | None,
|
|
114
|
+
ws_timer: PerfTimer,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Pipe real-time audio chunks through ``transcribe_stream``."""
|
|
117
|
+
audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
|
118
|
+
first_audio_logged = False
|
|
119
|
+
first_response_logged = False
|
|
120
|
+
|
|
121
|
+
async def audio_source():
|
|
122
|
+
while True:
|
|
123
|
+
chunk = await audio_queue.get()
|
|
124
|
+
if chunk is None:
|
|
125
|
+
break
|
|
126
|
+
yield chunk
|
|
127
|
+
|
|
128
|
+
async def receive_audio():
|
|
129
|
+
"""Receive WebSocket messages and push audio into the queue."""
|
|
130
|
+
nonlocal first_audio_logged
|
|
131
|
+
frame_count = 0
|
|
132
|
+
total_bytes = 0
|
|
133
|
+
try:
|
|
134
|
+
while True:
|
|
135
|
+
message = await websocket.receive()
|
|
136
|
+
if message.get("type") == "websocket.disconnect":
|
|
137
|
+
break
|
|
138
|
+
if message.get("bytes") is not None:
|
|
139
|
+
if not first_audio_logged:
|
|
140
|
+
first_audio_logged = True
|
|
141
|
+
ws_timer.emit_milestone(
|
|
142
|
+
Event.WS_FIRST_AUDIO_FRAME,
|
|
143
|
+
scope="stt",
|
|
144
|
+
bytes=len(message["bytes"]),
|
|
145
|
+
)
|
|
146
|
+
frame_count += 1
|
|
147
|
+
total_bytes += len(message["bytes"])
|
|
148
|
+
await audio_queue.put(message["bytes"])
|
|
149
|
+
elif message.get("text") is not None:
|
|
150
|
+
data = json.loads(message["text"])
|
|
151
|
+
if data.get("type") == "stop":
|
|
152
|
+
break
|
|
153
|
+
finally:
|
|
154
|
+
ws_timer.add(frames_received=frame_count, bytes_received=total_bytes)
|
|
155
|
+
await audio_queue.put(None)
|
|
156
|
+
|
|
157
|
+
recv_task = asyncio.create_task(receive_audio())
|
|
158
|
+
|
|
159
|
+
# Ensure provider is started before streaming (lazy-load)
|
|
160
|
+
await dispatcher._lifecycle.ensure_ready(provider)
|
|
161
|
+
|
|
162
|
+
# Stream via executor.invoke_stream
|
|
163
|
+
handle = dispatcher._handles[provider]
|
|
164
|
+
partial_count = 0
|
|
165
|
+
final_count = 0
|
|
166
|
+
async for transcription in handle.executor.invoke_stream(
|
|
167
|
+
"transcribe_stream", stream=audio_source()
|
|
168
|
+
):
|
|
169
|
+
is_partial = getattr(transcription, "is_partial", True)
|
|
170
|
+
msg_type = "final" if not is_partial else "partial"
|
|
171
|
+
if not first_response_logged:
|
|
172
|
+
first_response_logged = True
|
|
173
|
+
ws_timer.mark_ttfb()
|
|
174
|
+
ws_timer.emit_milestone(
|
|
175
|
+
Event.WS_FIRST_RESPONSE,
|
|
176
|
+
scope="stt",
|
|
177
|
+
msg_type=msg_type,
|
|
178
|
+
)
|
|
179
|
+
if is_partial:
|
|
180
|
+
partial_count += 1
|
|
181
|
+
else:
|
|
182
|
+
final_count += 1
|
|
183
|
+
ws_timer.emit_milestone(
|
|
184
|
+
Event.WS_FINAL_SENT,
|
|
185
|
+
scope="stt",
|
|
186
|
+
text_preview=(transcription.text or "")[:80],
|
|
187
|
+
)
|
|
188
|
+
await websocket.send_json({
|
|
189
|
+
"type": msg_type,
|
|
190
|
+
"text": transcription.text,
|
|
191
|
+
"confidence": transcription.confidence,
|
|
192
|
+
"language": transcription.language,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
# Give receive_audio a chance to finish (client may have already
|
|
196
|
+
# disconnected after receiving "final"); cancel if it doesn't complete
|
|
197
|
+
# promptly so we don't hang forever.
|
|
198
|
+
recv_task.cancel()
|
|
199
|
+
try:
|
|
200
|
+
await recv_task
|
|
201
|
+
except asyncio.CancelledError:
|
|
202
|
+
pass
|
|
203
|
+
ws_timer.add(partials=partial_count, finals=final_count, mode="streaming")
|
|
204
|
+
await websocket.send_json({"type": "closed"})
|
|
205
|
+
milestone(Event.WS_CLOSED, scope="stt", mode="streaming")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def _run_batch(
|
|
209
|
+
*,
|
|
210
|
+
websocket: WebSocket,
|
|
211
|
+
dispatcher,
|
|
212
|
+
provider: str,
|
|
213
|
+
language: str | None,
|
|
214
|
+
sample_rate: int,
|
|
215
|
+
ws_timer: PerfTimer,
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Accumulate all audio then transcribe in one batch call (fallback)."""
|
|
218
|
+
audio_buffer = bytearray()
|
|
219
|
+
logger.info("STT WS batch mode start")
|
|
220
|
+
|
|
221
|
+
while True:
|
|
222
|
+
message = await websocket.receive()
|
|
223
|
+
if message.get("type") == "websocket.disconnect":
|
|
224
|
+
break
|
|
225
|
+
if message.get("bytes") is not None:
|
|
226
|
+
audio_buffer.extend(message["bytes"])
|
|
227
|
+
elif message.get("text") is not None:
|
|
228
|
+
data = json.loads(message["text"])
|
|
229
|
+
if data.get("type") == "stop":
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
if audio_buffer:
|
|
233
|
+
from openspeech.core.enums import AudioFormat
|
|
234
|
+
from openspeech.core.models import AudioData, STTOptions
|
|
235
|
+
|
|
236
|
+
audio_data = AudioData(
|
|
237
|
+
data=bytes(audio_buffer),
|
|
238
|
+
sample_rate=sample_rate,
|
|
239
|
+
channels=1,
|
|
240
|
+
format=AudioFormat.PCM_16K,
|
|
241
|
+
)
|
|
242
|
+
opts = STTOptions(language=language)
|
|
243
|
+
ws_timer.add(bytes_received=len(audio_buffer), mode="batch")
|
|
244
|
+
with PerfTimer(Event.DISPATCH_TOTAL, scope="stt", provider=provider, method="transcribe"):
|
|
245
|
+
result = await dispatcher.stt.transcribe(provider, audio_data, opts)
|
|
246
|
+
ws_timer.mark_ttfb()
|
|
247
|
+
if result:
|
|
248
|
+
ws_timer.emit_milestone(
|
|
249
|
+
Event.WS_FINAL_SENT,
|
|
250
|
+
scope="stt",
|
|
251
|
+
text_preview=(result.text or "")[:80],
|
|
252
|
+
)
|
|
253
|
+
await websocket.send_json({
|
|
254
|
+
"type": "final",
|
|
255
|
+
"text": result.text,
|
|
256
|
+
"confidence": result.confidence,
|
|
257
|
+
"language": result.language,
|
|
258
|
+
})
|
|
259
|
+
else:
|
|
260
|
+
logger.warning("STT WS batch: empty audio buffer, skipping transcription")
|
|
261
|
+
|
|
262
|
+
await websocket.send_json({"type": "closed"})
|
|
263
|
+
milestone(Event.WS_CLOSED, scope="stt", mode="batch")
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""WebSocket TTS streaming endpoint."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from openspeech.logging_config import logger
|
|
6
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
7
|
+
|
|
8
|
+
from openspeech.core.enums import Capability
|
|
9
|
+
from openspeech.logging_config import bind_context
|
|
10
|
+
from openspeech.telemetry.perf import Event, PerfTimer, milestone
|
|
11
|
+
|
|
12
|
+
router = APIRouter()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.websocket("/stream")
|
|
16
|
+
async def tts_stream(websocket: WebSocket):
|
|
17
|
+
request_id = (
|
|
18
|
+
websocket.query_params.get("request_id")
|
|
19
|
+
or websocket.headers.get("x-request-id")
|
|
20
|
+
or uuid.uuid4().hex[:12]
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
with bind_context(request_id=request_id):
|
|
24
|
+
# WebSocket auth via query param
|
|
25
|
+
server_config = getattr(websocket.app.state, "server_config", None)
|
|
26
|
+
if server_config is not None and server_config.auth_enabled:
|
|
27
|
+
token = websocket.query_params.get("token", "")
|
|
28
|
+
if token not in server_config.api_keys:
|
|
29
|
+
milestone(Event.WS_ERROR, reason="unauthorized", scope="tts")
|
|
30
|
+
await websocket.close(code=4001, reason="Unauthorized")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
with PerfTimer(Event.WS_TOTAL, scope="tts") as ws_timer:
|
|
34
|
+
await websocket.accept()
|
|
35
|
+
milestone(Event.WS_ACCEPT, scope="tts")
|
|
36
|
+
dispatcher = websocket.app.state.dispatcher
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
# Receive synthesis request
|
|
40
|
+
message = await websocket.receive_text()
|
|
41
|
+
data = json.loads(message)
|
|
42
|
+
|
|
43
|
+
provider = data.get("provider", "openai-tts")
|
|
44
|
+
text = data.get("text", "")
|
|
45
|
+
voice = data.get("voice")
|
|
46
|
+
speed = data.get("speed", 1.0)
|
|
47
|
+
model = data.get("model")
|
|
48
|
+
stream_transport = data.get("stream_transport")
|
|
49
|
+
|
|
50
|
+
# Bind provider now that we know it.
|
|
51
|
+
with bind_context(provider=provider, engine=provider):
|
|
52
|
+
ws_timer.add(provider=provider, text_len=len(text))
|
|
53
|
+
|
|
54
|
+
from openspeech.core.models import TTSOptions
|
|
55
|
+
opts = TTSOptions(
|
|
56
|
+
voice=voice,
|
|
57
|
+
speed=float(speed),
|
|
58
|
+
model=str(model).strip() if model else None,
|
|
59
|
+
stream_transport=str(stream_transport).strip() if stream_transport else None,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Check if provider supports true streaming
|
|
63
|
+
handle = dispatcher._get_handle(provider)
|
|
64
|
+
provider_cls = handle.provider_cls
|
|
65
|
+
has_streaming = (
|
|
66
|
+
Capability.STREAMING in getattr(provider_cls, "capabilities", set())
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Detect output format from provider settings when possible
|
|
70
|
+
# (fallback to wav for PCM-like providers).
|
|
71
|
+
audio_format = "wav"
|
|
72
|
+
try:
|
|
73
|
+
settings = getattr(handle, "settings_dict", {}) or {}
|
|
74
|
+
if "output_format" in settings and settings["output_format"]:
|
|
75
|
+
# e.g. mp3_44100_128 -> mp3
|
|
76
|
+
audio_format = str(settings["output_format"]).split("_", 1)[0]
|
|
77
|
+
elif hasattr(provider_cls, "name") and "iflytek" in provider_cls.name:
|
|
78
|
+
audio_format = "mp3"
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
if has_streaming:
|
|
83
|
+
await _run_streaming(
|
|
84
|
+
websocket=websocket,
|
|
85
|
+
dispatcher=dispatcher,
|
|
86
|
+
provider=provider,
|
|
87
|
+
text=text,
|
|
88
|
+
opts=opts,
|
|
89
|
+
audio_format=audio_format,
|
|
90
|
+
ws_timer=ws_timer,
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
await _run_batch(
|
|
94
|
+
websocket=websocket,
|
|
95
|
+
dispatcher=dispatcher,
|
|
96
|
+
provider=provider,
|
|
97
|
+
text=text,
|
|
98
|
+
opts=opts,
|
|
99
|
+
ws_timer=ws_timer,
|
|
100
|
+
)
|
|
101
|
+
except WebSocketDisconnect:
|
|
102
|
+
milestone(Event.WS_CLOSED, scope="tts", reason="client_disconnect")
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
milestone(
|
|
105
|
+
Event.WS_ERROR,
|
|
106
|
+
scope="tts",
|
|
107
|
+
error_type=type(exc).__name__,
|
|
108
|
+
error_message=str(exc),
|
|
109
|
+
)
|
|
110
|
+
logger.exception("TTS WS error")
|
|
111
|
+
try:
|
|
112
|
+
await websocket.send_json({"type": "error", "detail": str(exc)})
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def _run_streaming(
|
|
118
|
+
*,
|
|
119
|
+
websocket: WebSocket,
|
|
120
|
+
dispatcher,
|
|
121
|
+
provider: str,
|
|
122
|
+
text: str,
|
|
123
|
+
opts,
|
|
124
|
+
audio_format: str,
|
|
125
|
+
ws_timer: PerfTimer,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""True streaming: yield chunks as they're produced."""
|
|
128
|
+
await websocket.send_json({
|
|
129
|
+
"type": "meta",
|
|
130
|
+
"streaming": True,
|
|
131
|
+
"audio_format": audio_format,
|
|
132
|
+
})
|
|
133
|
+
milestone(Event.WS_META_SENT, level="verbose", scope="tts", streaming=True)
|
|
134
|
+
|
|
135
|
+
total_bytes = 0
|
|
136
|
+
chunk_seq = 0
|
|
137
|
+
async for chunk in dispatcher.tts.synthesize_stream(provider, text, opts):
|
|
138
|
+
if chunk.data:
|
|
139
|
+
if chunk_seq == 0:
|
|
140
|
+
ws_timer.mark_ttfb()
|
|
141
|
+
ws_timer.emit_milestone(
|
|
142
|
+
Event.WS_FIRST_RESPONSE,
|
|
143
|
+
scope="tts",
|
|
144
|
+
chunk_bytes=len(chunk.data),
|
|
145
|
+
)
|
|
146
|
+
await websocket.send_bytes(chunk.data)
|
|
147
|
+
total_bytes += len(chunk.data)
|
|
148
|
+
chunk_seq += 1
|
|
149
|
+
if chunk.is_final:
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
ws_timer.add(chunks_sent=chunk_seq, bytes_sent=total_bytes, mode="streaming")
|
|
153
|
+
await websocket.send_json({
|
|
154
|
+
"type": "done",
|
|
155
|
+
"total_bytes": total_bytes,
|
|
156
|
+
"streaming": True,
|
|
157
|
+
"audio_format": audio_format,
|
|
158
|
+
})
|
|
159
|
+
milestone(Event.WS_CLOSED, scope="tts", mode="streaming")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def _run_batch(
|
|
163
|
+
*,
|
|
164
|
+
websocket: WebSocket,
|
|
165
|
+
dispatcher,
|
|
166
|
+
provider: str,
|
|
167
|
+
text: str,
|
|
168
|
+
opts,
|
|
169
|
+
ws_timer: PerfTimer,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Batch synthesize, then stream chunks to client."""
|
|
172
|
+
await dispatcher._lifecycle.ensure_ready(provider)
|
|
173
|
+
with PerfTimer(Event.DISPATCH_TOTAL, scope="tts", method="synthesize"):
|
|
174
|
+
result = await dispatcher.tts.synthesize(provider, text, opts)
|
|
175
|
+
|
|
176
|
+
if not result:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
ws_timer.mark_ttfb()
|
|
180
|
+
fmt = getattr(result, "format", "wav") or "wav"
|
|
181
|
+
await websocket.send_json({
|
|
182
|
+
"type": "meta",
|
|
183
|
+
"sample_rate": result.sample_rate,
|
|
184
|
+
"channels": result.channels,
|
|
185
|
+
"duration_ms": result.duration_ms or 0,
|
|
186
|
+
"total_bytes": len(result.data),
|
|
187
|
+
"audio_format": str(fmt),
|
|
188
|
+
})
|
|
189
|
+
milestone(Event.WS_META_SENT, level="verbose", scope="tts", streaming=False)
|
|
190
|
+
|
|
191
|
+
chunk_size = 4096
|
|
192
|
+
total_bytes = len(result.data)
|
|
193
|
+
chunk_seq = 0
|
|
194
|
+
for i in range(0, total_bytes, chunk_size):
|
|
195
|
+
chunk_data = result.data[i : i + chunk_size]
|
|
196
|
+
await websocket.send_bytes(chunk_data)
|
|
197
|
+
chunk_seq += 1
|
|
198
|
+
|
|
199
|
+
ws_timer.add(chunks_sent=chunk_seq, bytes_sent=total_bytes, mode="batch")
|
|
200
|
+
await websocket.send_json({
|
|
201
|
+
"type": "done",
|
|
202
|
+
"total_bytes": total_bytes,
|
|
203
|
+
"sample_rate": result.sample_rate,
|
|
204
|
+
"streaming": False,
|
|
205
|
+
"audio_format": str(fmt),
|
|
206
|
+
})
|
|
207
|
+
milestone(Event.WS_CLOSED, scope="tts", mode="batch")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Telemetry helpers for structured performance logging."""
|
|
2
|
+
|
|
3
|
+
from openspeech.telemetry.perf import (
|
|
4
|
+
PerfTimer,
|
|
5
|
+
Event,
|
|
6
|
+
milestone,
|
|
7
|
+
perf_event,
|
|
8
|
+
perf_enabled,
|
|
9
|
+
timed,
|
|
10
|
+
timed_async,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Event",
|
|
15
|
+
"PerfTimer",
|
|
16
|
+
"milestone",
|
|
17
|
+
"perf_enabled",
|
|
18
|
+
"perf_event",
|
|
19
|
+
"timed",
|
|
20
|
+
"timed_async",
|
|
21
|
+
]
|