agent-core-voice 0.8.2__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.
- agent_core_voice/__init__.py +1 -0
- agent_core_voice/audit.py +54 -0
- agent_core_voice/endpoint.py +699 -0
- agent_core_voice/envelopes.py +85 -0
- agent_core_voice/lifecycle.py +144 -0
- agent_core_voice/mcp.py +121 -0
- agent_core_voice/plugin.py +123 -0
- agent_core_voice/protocol.py +46 -0
- agent_core_voice-0.8.2.dist-info/METADATA +17 -0
- agent_core_voice-0.8.2.dist-info/RECORD +12 -0
- agent_core_voice-0.8.2.dist-info/WHEEL +4 -0
- agent_core_voice-0.8.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""agent-core-voice — Qwen3-TTS voice synthesis endpoint for agent_core."""
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Append-only JSONL audit log for voice synthesis calls.
|
|
2
|
+
|
|
3
|
+
One line per ``synthesize_speech`` call (success or failure). Schema is
|
|
4
|
+
documented in the design spec. Audit-write failures are swallowed so an
|
|
5
|
+
audit problem never breaks a synthesis call.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from agent_core.audit import JsonlAuditLog
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class AuditEvent:
|
|
20
|
+
"""One line in the voice audit log."""
|
|
21
|
+
|
|
22
|
+
timestamp: datetime
|
|
23
|
+
agent: str
|
|
24
|
+
voice_id: str
|
|
25
|
+
text_len: int
|
|
26
|
+
seed: int
|
|
27
|
+
duration_s: float | None
|
|
28
|
+
generation_s: float | None
|
|
29
|
+
wav_path: str | None
|
|
30
|
+
error: str | None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuditLog(JsonlAuditLog[AuditEvent]):
|
|
34
|
+
"""Append-only JSONL audit log."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, path: Path) -> None:
|
|
37
|
+
super().__init__(path)
|
|
38
|
+
|
|
39
|
+
def _serialize(self, event: AuditEvent) -> str:
|
|
40
|
+
payload = {
|
|
41
|
+
"ts": event.timestamp.isoformat(),
|
|
42
|
+
"agent": event.agent,
|
|
43
|
+
"voice_id": event.voice_id,
|
|
44
|
+
"text_len": event.text_len,
|
|
45
|
+
"seed": event.seed,
|
|
46
|
+
"duration_s": event.duration_s,
|
|
47
|
+
"generation_s": event.generation_s,
|
|
48
|
+
"wav_path": event.wav_path,
|
|
49
|
+
"error": event.error,
|
|
50
|
+
}
|
|
51
|
+
return json.dumps(payload, ensure_ascii=False)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = ["AuditEvent", "AuditLog"]
|
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
"""VoiceEndpoint — bus endpoint exposing per-agent synthesis.
|
|
2
|
+
|
|
3
|
+
Implements the standard Endpoint protocol. ``deliver`` accepts
|
|
4
|
+
``Event`` envelopes whose ``payload.type == "SynthesisRequest"``,
|
|
5
|
+
spawns a background task to run synthesis via ``madrigal.generate``,
|
|
6
|
+
and publishes a correlated ``SynthesisReady`` or ``SynthesisFailed``
|
|
7
|
+
envelope back to the caller. Non-synthesis envelopes are acked and
|
|
8
|
+
ignored.
|
|
9
|
+
|
|
10
|
+
The endpoint holds the warm TTS backend, the registry of configured
|
|
11
|
+
voices, an agent → voice_id mapping (populated by plugin.py at wire
|
|
12
|
+
time so callers cannot spoof voice ids on the wire), the output
|
|
13
|
+
directory, and the audit log.
|
|
14
|
+
|
|
15
|
+
Construction wiring:
|
|
16
|
+
|
|
17
|
+
* Production: bus runner calls ``VoiceEndpoint(name=..., **yaml_params)``
|
|
18
|
+
which constructs ``madrigal.engine.QwenTTSBackend`` internally.
|
|
19
|
+
* Tests: ``VoiceEndpoint.for_test(backend=fake, voices=..., ...)`` skips
|
|
20
|
+
the real backend and injects a fake (typically
|
|
21
|
+
``madrigal.engine.FakeTTSBackend``).
|
|
22
|
+
|
|
23
|
+
Synthesis routes through ``madrigal.generate()`` with chunked + parallel
|
|
24
|
+
batching enabled. The endpoint does not call ``backend.synthesize``
|
|
25
|
+
directly; the voice library owns chunking, batching, and wav
|
|
26
|
+
concatenation.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
import hashlib
|
|
33
|
+
import io
|
|
34
|
+
import logging
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from datetime import UTC, datetime
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import TYPE_CHECKING, Any
|
|
39
|
+
|
|
40
|
+
import soundfile as sf
|
|
41
|
+
|
|
42
|
+
from agent_core.bus.protocol import EndpointUnavailable
|
|
43
|
+
from agent_core_voice.audit import AuditEvent, AuditLog
|
|
44
|
+
from agent_core_voice.lifecycle import transcode_audio
|
|
45
|
+
from agent_core_voice.protocol import (
|
|
46
|
+
EmptyTextError,
|
|
47
|
+
GPUOOMError,
|
|
48
|
+
TextTooLongError,
|
|
49
|
+
TTSBackend,
|
|
50
|
+
VoiceError,
|
|
51
|
+
VoiceInfo,
|
|
52
|
+
VoiceNotPreparedError,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if TYPE_CHECKING:
|
|
56
|
+
from agent_core.bus.envelope import Envelope
|
|
57
|
+
from agent_core.bus.handle import BusHandle
|
|
58
|
+
|
|
59
|
+
log = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class SynthesisSuccess:
|
|
64
|
+
"""Successful synthesis result."""
|
|
65
|
+
|
|
66
|
+
path: str
|
|
67
|
+
duration_s: float
|
|
68
|
+
generation_s: float
|
|
69
|
+
sample_rate: int
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class SynthesisError:
|
|
74
|
+
"""Failed synthesis — message is the agent-readable string."""
|
|
75
|
+
|
|
76
|
+
message: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _WavPhaseError(Exception):
|
|
80
|
+
"""Internal marker for failures in the wav decode/write phase.
|
|
81
|
+
|
|
82
|
+
Wraps the underlying OSError/RuntimeError so ``_error_message`` can
|
|
83
|
+
distinguish wav-phase failures from arbitrary backend RuntimeErrors.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, wrapped: BaseException) -> None:
|
|
87
|
+
super().__init__(str(wrapped))
|
|
88
|
+
self.wrapped = wrapped
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class VoiceEndpoint:
|
|
92
|
+
"""Tool-only bus endpoint backing the voice MCP tool surface."""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
name: str,
|
|
98
|
+
backend: TTSBackend | None = None,
|
|
99
|
+
voices: dict[str, VoiceInfo] | dict[str, dict] | None = None,
|
|
100
|
+
output_dir: Path | str,
|
|
101
|
+
audit_path: Path | str,
|
|
102
|
+
max_text_len: int = 2000,
|
|
103
|
+
# Real-backend params, only used when backend is None:
|
|
104
|
+
model_path: str | None = None,
|
|
105
|
+
device: str = "cuda:0",
|
|
106
|
+
attn_implementation: str = "sdpa",
|
|
107
|
+
) -> None:
|
|
108
|
+
self._name = name
|
|
109
|
+
self._handle: BusHandle | None = None
|
|
110
|
+
self._max_text_len = max_text_len
|
|
111
|
+
# Plugin.py populates this at wire time; envelope handler uses it to
|
|
112
|
+
# resolve envelope.from_ → voice_id without trusting caller-supplied
|
|
113
|
+
# voice ids on the wire.
|
|
114
|
+
self._agent_to_voice: dict[str, str] = {}
|
|
115
|
+
|
|
116
|
+
# Fail-fast on misconfiguration: production path needs model_path.
|
|
117
|
+
if backend is None and model_path is None:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
"VoiceEndpoint requires either backend=... (tests) or "
|
|
120
|
+
"model_path=... (production with QwenTTSBackend)"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Store the pre-built backend (test path) or None (production path, built in start()).
|
|
124
|
+
self._backend: TTSBackend | None = backend
|
|
125
|
+
|
|
126
|
+
# Store production-path params for lazy use in start().
|
|
127
|
+
self._model_path = model_path
|
|
128
|
+
self._device = device
|
|
129
|
+
self._attn_implementation = attn_implementation
|
|
130
|
+
|
|
131
|
+
# Normalize voices: yaml gives dict[str, dict]; tests give dict[str, VoiceInfo].
|
|
132
|
+
# Expand ~ in all paths so yaml configs can use "~/.agent-core/..." cleanly.
|
|
133
|
+
normalized: dict[str, VoiceInfo] = {}
|
|
134
|
+
for vid, raw in (voices or {}).items():
|
|
135
|
+
if isinstance(raw, VoiceInfo):
|
|
136
|
+
normalized[vid] = raw
|
|
137
|
+
else:
|
|
138
|
+
normalized[vid] = VoiceInfo(
|
|
139
|
+
voice_id=vid,
|
|
140
|
+
ref_wav=Path(raw["ref_wav"]).expanduser(),
|
|
141
|
+
ref_text=raw["ref_text"],
|
|
142
|
+
blend=raw.get("blend"),
|
|
143
|
+
)
|
|
144
|
+
self._voices = normalized
|
|
145
|
+
|
|
146
|
+
self._output_dir = Path(output_dir).expanduser()
|
|
147
|
+
self._audit = AuditLog(Path(audit_path).expanduser())
|
|
148
|
+
self._output_dir.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
|
|
150
|
+
# Backend construction and voice warming are deferred to start().
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def for_test(
|
|
154
|
+
cls,
|
|
155
|
+
*,
|
|
156
|
+
backend: TTSBackend,
|
|
157
|
+
voices: dict[str, VoiceInfo],
|
|
158
|
+
output_dir: Path | str,
|
|
159
|
+
audit_path: Path | str,
|
|
160
|
+
name: str = "voice_test",
|
|
161
|
+
max_text_len: int = 2000,
|
|
162
|
+
) -> VoiceEndpoint:
|
|
163
|
+
"""Test seam — same constructor, explicit name default."""
|
|
164
|
+
return cls(
|
|
165
|
+
name=name,
|
|
166
|
+
backend=backend,
|
|
167
|
+
voices=voices,
|
|
168
|
+
output_dir=output_dir,
|
|
169
|
+
audit_path=audit_path,
|
|
170
|
+
max_text_len=max_text_len,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def name(self) -> str:
|
|
175
|
+
return self._name
|
|
176
|
+
|
|
177
|
+
def voice_ids(self) -> set[str]:
|
|
178
|
+
return set(self._voices.keys())
|
|
179
|
+
|
|
180
|
+
def register_agent(self, agent_name: str, voice_id: str) -> None:
|
|
181
|
+
"""Bind an agent name → voice_id mapping.
|
|
182
|
+
|
|
183
|
+
Plugin.py calls this at wire time (before bus.start). The envelope
|
|
184
|
+
handler uses the mapping to resolve which voice an inbound
|
|
185
|
+
``envelope.from_`` is allowed to use — voice_id is endpoint-closed
|
|
186
|
+
and never trusted from the wire.
|
|
187
|
+
"""
|
|
188
|
+
if voice_id not in self._voices:
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"voice_id={voice_id!r} not configured; "
|
|
191
|
+
f"available: {sorted(self._voices.keys())}"
|
|
192
|
+
)
|
|
193
|
+
self._agent_to_voice[agent_name] = voice_id
|
|
194
|
+
|
|
195
|
+
def voice_info(self, voice_id: str) -> dict[str, Any]:
|
|
196
|
+
info = self._voices[voice_id]
|
|
197
|
+
return {
|
|
198
|
+
"voice_id": info.voice_id,
|
|
199
|
+
"ref_clip": str(info.ref_wav),
|
|
200
|
+
"ref_text": info.ref_text,
|
|
201
|
+
"blend": info.blend,
|
|
202
|
+
"sample_rate": 24000,
|
|
203
|
+
"mode": "1.7B Base + ICL voice clone",
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async def cleanup_tick(self) -> int:
|
|
207
|
+
"""Sweep expired WAVs from the output directory. Returns count removed.
|
|
208
|
+
|
|
209
|
+
Called periodically (every 5 min by the scheduler endpoint) to enforce
|
|
210
|
+
the file lifecycle policy. Safe to call manually for tests or ops.
|
|
211
|
+
|
|
212
|
+
NOTE: Wire this in production by adding a scheduler job to
|
|
213
|
+
``~/.agent-core/jobs.yaml`` that targets the voice endpoint and calls
|
|
214
|
+
``cleanup_tick()`` every 5 minutes. Exact yaml shape depends on the
|
|
215
|
+
scheduler endpoint's job format — check
|
|
216
|
+
``agent_core/endpoints/scheduler.py`` for the current schema.
|
|
217
|
+
"""
|
|
218
|
+
from agent_core_voice.lifecycle import cleanup_expired
|
|
219
|
+
|
|
220
|
+
return await asyncio.to_thread(cleanup_expired, root=self._output_dir)
|
|
221
|
+
|
|
222
|
+
async def synthesize_safe(
|
|
223
|
+
self,
|
|
224
|
+
*,
|
|
225
|
+
agent_name: str,
|
|
226
|
+
voice_id: str,
|
|
227
|
+
text: str,
|
|
228
|
+
seed: int,
|
|
229
|
+
) -> SynthesisSuccess | SynthesisError:
|
|
230
|
+
"""Synthesize via ``madrigal.generate()`` (chunked + parallel-batched).
|
|
231
|
+
|
|
232
|
+
Never raises. All failures land as ``SynthesisError(message=...)``.
|
|
233
|
+
"""
|
|
234
|
+
# Lazy-import to keep module import cheap and to honor the
|
|
235
|
+
# backend-swap boundary: madrigal is the runtime dep, not a hard
|
|
236
|
+
# import-time dep for tests that only touch protocol/audit code.
|
|
237
|
+
from madrigal import Spec, generate
|
|
238
|
+
|
|
239
|
+
now = datetime.now(UTC)
|
|
240
|
+
|
|
241
|
+
# Endpoint-level text validation. The chunker would also raise on
|
|
242
|
+
# empty input, but doing the pre-check here keeps the error path
|
|
243
|
+
# uniform (single error class, audited identically) regardless of
|
|
244
|
+
# whether chunking is enabled.
|
|
245
|
+
if not text or not text.strip():
|
|
246
|
+
return await self._record_error(
|
|
247
|
+
now, agent_name, voice_id, text, seed, EmptyTextError("text is empty")
|
|
248
|
+
)
|
|
249
|
+
if len(text) > self._max_text_len:
|
|
250
|
+
return await self._record_error(
|
|
251
|
+
now,
|
|
252
|
+
agent_name,
|
|
253
|
+
voice_id,
|
|
254
|
+
text,
|
|
255
|
+
seed,
|
|
256
|
+
TextTooLongError(
|
|
257
|
+
f"text length {len(text)} exceeds endpoint budget {self._max_text_len}"
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
result = await asyncio.to_thread(
|
|
263
|
+
generate,
|
|
264
|
+
text,
|
|
265
|
+
Spec(
|
|
266
|
+
voice_id=voice_id,
|
|
267
|
+
seed=seed,
|
|
268
|
+
chunk_strategy="sentence",
|
|
269
|
+
parallel=True,
|
|
270
|
+
),
|
|
271
|
+
backend=self._backend,
|
|
272
|
+
)
|
|
273
|
+
except VoiceError as exc:
|
|
274
|
+
return await self._record_error(now, agent_name, voice_id, text, seed, exc)
|
|
275
|
+
except Exception as exc: # defensive — unknown backend/library failure
|
|
276
|
+
log.exception("madrigal.generate raised unexpected exception")
|
|
277
|
+
return await self._record_error(now, agent_name, voice_id, text, seed, exc)
|
|
278
|
+
|
|
279
|
+
wav_bytes = result.audio
|
|
280
|
+
if wav_bytes is None:
|
|
281
|
+
return await self._record_error(
|
|
282
|
+
now,
|
|
283
|
+
agent_name,
|
|
284
|
+
voice_id,
|
|
285
|
+
text,
|
|
286
|
+
seed,
|
|
287
|
+
_WavPhaseError(RuntimeError("madrigal returned no audio bytes")),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
duration_s, sample_rate = self._wav_duration(wav_bytes)
|
|
292
|
+
path = self._next_output_path(agent_name, seed, text, now)
|
|
293
|
+
await asyncio.to_thread(path.write_bytes, wav_bytes)
|
|
294
|
+
except (OSError, RuntimeError) as exc:
|
|
295
|
+
return await self._record_error(
|
|
296
|
+
now, agent_name, voice_id, text, seed, _WavPhaseError(exc)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# generation_s = sum of per-chunk wall times reported by the
|
|
300
|
+
# backend (madrigal exposes these in result.timings). When timings
|
|
301
|
+
# are absent (cache hit, sequential single-call path on some
|
|
302
|
+
# backends) we fall back to 0.0; callers should treat
|
|
303
|
+
# generation_s as a hint, not a guarantee.
|
|
304
|
+
generation_s = float(sum(result.timings)) if result.timings else 0.0
|
|
305
|
+
|
|
306
|
+
await self._audit.write(
|
|
307
|
+
AuditEvent(
|
|
308
|
+
timestamp=now,
|
|
309
|
+
agent=agent_name,
|
|
310
|
+
voice_id=voice_id,
|
|
311
|
+
text_len=len(text),
|
|
312
|
+
seed=seed,
|
|
313
|
+
duration_s=duration_s,
|
|
314
|
+
generation_s=generation_s,
|
|
315
|
+
wav_path=str(path),
|
|
316
|
+
error=None,
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
return SynthesisSuccess(
|
|
320
|
+
path=str(path),
|
|
321
|
+
duration_s=duration_s,
|
|
322
|
+
generation_s=generation_s,
|
|
323
|
+
sample_rate=sample_rate,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
async def _record_error(
|
|
327
|
+
self,
|
|
328
|
+
now: datetime,
|
|
329
|
+
agent_name: str,
|
|
330
|
+
voice_id: str,
|
|
331
|
+
text: str,
|
|
332
|
+
seed: int,
|
|
333
|
+
exc: BaseException,
|
|
334
|
+
) -> SynthesisError:
|
|
335
|
+
message = self._error_message(exc)
|
|
336
|
+
await self._audit.write(
|
|
337
|
+
AuditEvent(
|
|
338
|
+
timestamp=now,
|
|
339
|
+
agent=agent_name,
|
|
340
|
+
voice_id=voice_id,
|
|
341
|
+
text_len=len(text),
|
|
342
|
+
seed=seed,
|
|
343
|
+
duration_s=None,
|
|
344
|
+
generation_s=None,
|
|
345
|
+
wav_path=None,
|
|
346
|
+
error=message,
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
return SynthesisError(message=message)
|
|
350
|
+
|
|
351
|
+
@staticmethod
|
|
352
|
+
def _error_message(exc: BaseException) -> str:
|
|
353
|
+
if isinstance(exc, EmptyTextError):
|
|
354
|
+
return "text is empty"
|
|
355
|
+
if isinstance(exc, TextTooLongError):
|
|
356
|
+
return f"text exceeds model budget ({exc})"
|
|
357
|
+
if isinstance(exc, GPUOOMError):
|
|
358
|
+
return "GPU is out of memory; try again in a moment"
|
|
359
|
+
if isinstance(exc, VoiceNotPreparedError):
|
|
360
|
+
return str(exc)
|
|
361
|
+
if isinstance(exc, _WavPhaseError):
|
|
362
|
+
wrapped = exc.wrapped
|
|
363
|
+
if isinstance(wrapped, OSError):
|
|
364
|
+
return f"output directory is not writable: {wrapped}"
|
|
365
|
+
return f"wav decode/write failed: {wrapped}"
|
|
366
|
+
if isinstance(exc, OSError):
|
|
367
|
+
return f"output directory is not writable: {exc}"
|
|
368
|
+
return f"{type(exc).__name__}: {exc}"
|
|
369
|
+
|
|
370
|
+
def _next_output_path(self, agent_name: str, seed: int, text: str, now: datetime) -> Path:
|
|
371
|
+
day = now.strftime("%Y-%m-%d")
|
|
372
|
+
ts = now.strftime("%Y%m%dT%H%M%S_%f")
|
|
373
|
+
text_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()[:8]
|
|
374
|
+
dir_ = self._output_dir / agent_name / day
|
|
375
|
+
dir_.mkdir(parents=True, exist_ok=True)
|
|
376
|
+
return dir_ / f"{ts}-{seed}-{text_hash}.wav"
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def _wav_duration(wav_bytes: bytes) -> tuple[float, int]:
|
|
380
|
+
with sf.SoundFile(io.BytesIO(wav_bytes)) as f:
|
|
381
|
+
return f.frames / float(f.samplerate), int(f.samplerate)
|
|
382
|
+
|
|
383
|
+
# Endpoint protocol implementation.
|
|
384
|
+
async def start(self, bus: BusHandle) -> None:
|
|
385
|
+
self._handle = bus
|
|
386
|
+
if self._backend is None:
|
|
387
|
+
# Production path: build the GPU/CPU backend off the event loop thread.
|
|
388
|
+
# asyncio.to_thread keeps the loop responsive during the (potentially
|
|
389
|
+
# multi-second) model load.
|
|
390
|
+
from madrigal.engine import QwenTTSBackend
|
|
391
|
+
|
|
392
|
+
self._backend = await asyncio.to_thread(
|
|
393
|
+
QwenTTSBackend,
|
|
394
|
+
model_path=self._model_path,
|
|
395
|
+
device=self._device,
|
|
396
|
+
attn_implementation=self._attn_implementation,
|
|
397
|
+
)
|
|
398
|
+
# Warm all configured voices (no-op cost for fake backends; off-thread
|
|
399
|
+
# for real backends so WAV encoding doesn't block the loop).
|
|
400
|
+
for voice_id, info in self._voices.items():
|
|
401
|
+
await asyncio.to_thread(
|
|
402
|
+
self._backend.prepare_voice, voice_id, Path(info.ref_wav), info.ref_text
|
|
403
|
+
)
|
|
404
|
+
log.info("voice %r prepared (ref_wav=%s)", voice_id, info.ref_wav)
|
|
405
|
+
log.info("VoiceEndpoint(name=%s) started; output_dir=%s", self._name, self._output_dir)
|
|
406
|
+
|
|
407
|
+
async def deliver(self, envelope: Envelope) -> None:
|
|
408
|
+
"""Route SynthesisRequest envelopes to a background task; ack others.
|
|
409
|
+
|
|
410
|
+
We return from ``deliver`` promptly (per Endpoint protocol contract)
|
|
411
|
+
and let the synthesis run in an asyncio task. The bus is then free
|
|
412
|
+
to dispatch to other endpoints while the GPU is busy.
|
|
413
|
+
"""
|
|
414
|
+
if self._backend is None:
|
|
415
|
+
# start() has not yet completed. This should not happen in production
|
|
416
|
+
# (bus awaits start() before draining mail), but fail safe rather than crash.
|
|
417
|
+
raise EndpointUnavailable(
|
|
418
|
+
f"VoiceEndpoint(name={self._name!r}): backend not ready — start() not yet complete"
|
|
419
|
+
)
|
|
420
|
+
if (
|
|
421
|
+
envelope.kind != "Event"
|
|
422
|
+
or not hasattr(envelope.payload, "type")
|
|
423
|
+
or envelope.payload.type != "SynthesisRequest"
|
|
424
|
+
):
|
|
425
|
+
log.debug(
|
|
426
|
+
"VoiceEndpoint(name=%s) ignoring envelope %s (kind=%s, payload_type=%s)",
|
|
427
|
+
self._name,
|
|
428
|
+
envelope.id,
|
|
429
|
+
envelope.kind,
|
|
430
|
+
getattr(envelope.payload, "type", None),
|
|
431
|
+
)
|
|
432
|
+
if self._handle is not None:
|
|
433
|
+
await self._handle.ack(envelope.id)
|
|
434
|
+
return
|
|
435
|
+
|
|
436
|
+
from agent_core_voice.envelopes import SynthesisRequestPayload
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
req = SynthesisRequestPayload.model_validate(envelope.payload.data)
|
|
440
|
+
except Exception as exc:
|
|
441
|
+
log.warning(
|
|
442
|
+
"VoiceEndpoint(name=%s) rejected malformed SynthesisRequest %s: %s",
|
|
443
|
+
self._name,
|
|
444
|
+
envelope.id,
|
|
445
|
+
exc,
|
|
446
|
+
)
|
|
447
|
+
await self._publish_failed(
|
|
448
|
+
envelope,
|
|
449
|
+
reason="INTERNAL_ERROR",
|
|
450
|
+
message=f"invalid SynthesisRequest payload: {exc}",
|
|
451
|
+
retryable=False,
|
|
452
|
+
)
|
|
453
|
+
if self._handle is not None:
|
|
454
|
+
await self._handle.ack(envelope.id)
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
# Ack the request envelope NOW (before spawning the worker) so the
|
|
458
|
+
# bus doesn't treat synthesis-wall-time as an in-flight timeout.
|
|
459
|
+
# The Ready/Failed envelopes we emit later are separate envelopes
|
|
460
|
+
# correlated via in_reply_to=<request.id>, not the same envelope.
|
|
461
|
+
if self._handle is not None:
|
|
462
|
+
await self._handle.ack(envelope.id)
|
|
463
|
+
|
|
464
|
+
# Spawn-and-forget — synthesis can take ~60s; the bus must not block.
|
|
465
|
+
self._handle.spawn(
|
|
466
|
+
self._handle_synthesis_request(envelope, req),
|
|
467
|
+
name=f"voice-synthesis-{envelope.id}",
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
async def stop(self) -> None:
|
|
471
|
+
self._handle = None
|
|
472
|
+
log.info("VoiceEndpoint(name=%s) stopped", self._name)
|
|
473
|
+
|
|
474
|
+
async def _handle_synthesis_request(
|
|
475
|
+
self,
|
|
476
|
+
envelope: Envelope,
|
|
477
|
+
req: Any, # SynthesisRequestPayload — typed via local import to avoid module-level cycle
|
|
478
|
+
) -> None:
|
|
479
|
+
"""Run synthesis in a thread, publish Ready/Failed, then ack."""
|
|
480
|
+
from madrigal import Spec, generate
|
|
481
|
+
|
|
482
|
+
from agent_core_voice.envelopes import SynthesisReadyPayload
|
|
483
|
+
from agent_core_voice.lifecycle import retain_until_iso, write_addressed
|
|
484
|
+
|
|
485
|
+
agent_name = envelope.from_
|
|
486
|
+
voice_id = self._agent_to_voice.get(agent_name)
|
|
487
|
+
if voice_id is None:
|
|
488
|
+
await self._publish_failed(
|
|
489
|
+
envelope,
|
|
490
|
+
reason="VOICE_NOT_PREPARED",
|
|
491
|
+
message=(
|
|
492
|
+
f"no voice configured for agent {agent_name!r}; "
|
|
493
|
+
f"call VoiceEndpoint.register_agent at wire time"
|
|
494
|
+
),
|
|
495
|
+
retryable=False,
|
|
496
|
+
)
|
|
497
|
+
return
|
|
498
|
+
|
|
499
|
+
timeout_s = req.timeout_s if req.timeout_s is not None else 300.0
|
|
500
|
+
retain_s = req.retain_s if req.retain_s is not None else 3600.0
|
|
501
|
+
options = req.options or {}
|
|
502
|
+
spec = Spec(
|
|
503
|
+
voice_id=voice_id,
|
|
504
|
+
seed=int(options.get("seed", 42)),
|
|
505
|
+
chunk_strategy=options.get("chunk_strategy", "sentence"),
|
|
506
|
+
parallel=bool(options.get("parallel", True)),
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
result = await asyncio.wait_for(
|
|
511
|
+
asyncio.to_thread(generate, req.text, spec, backend=self._backend),
|
|
512
|
+
timeout=timeout_s,
|
|
513
|
+
)
|
|
514
|
+
except TimeoutError:
|
|
515
|
+
await self._publish_failed(
|
|
516
|
+
envelope,
|
|
517
|
+
reason="TIMEOUT",
|
|
518
|
+
message=f"synthesis exceeded timeout_s={timeout_s}",
|
|
519
|
+
retryable=True,
|
|
520
|
+
)
|
|
521
|
+
return
|
|
522
|
+
except VoiceError as exc:
|
|
523
|
+
await self._publish_failed_from_voiceerror(envelope, exc)
|
|
524
|
+
return
|
|
525
|
+
except Exception as exc:
|
|
526
|
+
log.exception("madrigal.generate raised unexpected exception")
|
|
527
|
+
await self._publish_failed(
|
|
528
|
+
envelope,
|
|
529
|
+
reason="INTERNAL_ERROR",
|
|
530
|
+
message=f"{type(exc).__name__}: {exc}",
|
|
531
|
+
retryable=False,
|
|
532
|
+
)
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
wav_bytes = result.audio
|
|
536
|
+
if wav_bytes is None:
|
|
537
|
+
await self._publish_failed(
|
|
538
|
+
envelope,
|
|
539
|
+
reason="INTERNAL_ERROR",
|
|
540
|
+
message="madrigal.generate returned no audio bytes",
|
|
541
|
+
retryable=False,
|
|
542
|
+
)
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
audio_format = getattr(req, "format", "wav") or "wav"
|
|
546
|
+
|
|
547
|
+
try:
|
|
548
|
+
# Measure duration on the original WAV bytes (soundfile reads WAV reliably).
|
|
549
|
+
duration_s, _sr_from_wav = self._wav_duration(wav_bytes)
|
|
550
|
+
# Transcode to the requested format (no-op for wav).
|
|
551
|
+
audio_bytes = await asyncio.to_thread(
|
|
552
|
+
transcode_audio, wav_bytes, target_format=audio_format
|
|
553
|
+
)
|
|
554
|
+
wav_path, _sha = await asyncio.to_thread(
|
|
555
|
+
write_addressed,
|
|
556
|
+
audio_bytes,
|
|
557
|
+
root=self._output_dir,
|
|
558
|
+
retain_s=retain_s,
|
|
559
|
+
ext=audio_format,
|
|
560
|
+
)
|
|
561
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
562
|
+
await self._publish_failed(
|
|
563
|
+
envelope,
|
|
564
|
+
reason="INTERNAL_ERROR",
|
|
565
|
+
message=f"audio write/transcode failed: {exc}",
|
|
566
|
+
retryable=False,
|
|
567
|
+
)
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
# Reply-emitting tail. Everything here — stat(), model_dump(), envelope
|
|
571
|
+
# construction, publish — must still yield a terminal reply on failure:
|
|
572
|
+
# this runs in a spawned task, so an unhandled exception dies silently
|
|
573
|
+
# and the requesting agent waits forever. Route any failure through
|
|
574
|
+
# _publish_failed, and guard (don't assert) a torn-down handle.
|
|
575
|
+
try:
|
|
576
|
+
# Derive metadata from the madrigal Result. Timings list gives us
|
|
577
|
+
# per-chunk wall times — sum for elapsed_s, count for chunks.
|
|
578
|
+
timings = result.timings or []
|
|
579
|
+
elapsed_s = float(sum(timings))
|
|
580
|
+
sample_rate = int(result.sample_rate_hz)
|
|
581
|
+
chunks = max(1, len(timings)) # at least one chunk even on cache hit
|
|
582
|
+
|
|
583
|
+
ready_payload = SynthesisReadyPayload(
|
|
584
|
+
wav_path=str(wav_path),
|
|
585
|
+
file_size_bytes=wav_path.stat().st_size,
|
|
586
|
+
duration_s=duration_s,
|
|
587
|
+
elapsed_s=elapsed_s,
|
|
588
|
+
sample_rate_hz=sample_rate,
|
|
589
|
+
cache_hit=bool(result.cache_hit),
|
|
590
|
+
chunks=chunks,
|
|
591
|
+
retain_until=retain_until_iso(retain_s=retain_s),
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
import uuid as _uuid
|
|
595
|
+
|
|
596
|
+
from agent_core.bus.envelope import Envelope as BusEnvelope
|
|
597
|
+
from agent_core.bus.envelope import EventPayload
|
|
598
|
+
|
|
599
|
+
ready = BusEnvelope(
|
|
600
|
+
id=_uuid.uuid4().hex,
|
|
601
|
+
correlation_id=envelope.correlation_id,
|
|
602
|
+
in_reply_to=envelope.id,
|
|
603
|
+
to=envelope.from_,
|
|
604
|
+
kind="Event",
|
|
605
|
+
payload=EventPayload(
|
|
606
|
+
type="SynthesisReady",
|
|
607
|
+
data=ready_payload.model_dump(),
|
|
608
|
+
),
|
|
609
|
+
created_at=datetime.now(UTC),
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
if self._handle is None:
|
|
613
|
+
# The endpoint was stopped before we could reply; there is no
|
|
614
|
+
# channel left to publish on. Log rather than raise.
|
|
615
|
+
log.warning(
|
|
616
|
+
"voice handle torn down before SynthesisReady for %s; cannot reply",
|
|
617
|
+
envelope.id,
|
|
618
|
+
)
|
|
619
|
+
return
|
|
620
|
+
await self._handle.publish(ready)
|
|
621
|
+
except Exception as exc:
|
|
622
|
+
log.exception("failed to emit SynthesisReady")
|
|
623
|
+
await self._publish_failed(
|
|
624
|
+
envelope,
|
|
625
|
+
reason="INTERNAL_ERROR",
|
|
626
|
+
message=f"failed to emit SynthesisReady: {type(exc).__name__}: {exc}",
|
|
627
|
+
retryable=False,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
async def _publish_failed(
|
|
631
|
+
self,
|
|
632
|
+
request_env: Envelope,
|
|
633
|
+
*,
|
|
634
|
+
reason: str,
|
|
635
|
+
message: str,
|
|
636
|
+
retryable: bool,
|
|
637
|
+
) -> None:
|
|
638
|
+
"""Build + publish a SynthesisFailed envelope correlated with the request."""
|
|
639
|
+
from agent_core.bus.envelope import Envelope as BusEnvelope
|
|
640
|
+
from agent_core.bus.envelope import EventPayload
|
|
641
|
+
from agent_core_voice.envelopes import SynthesisFailedPayload
|
|
642
|
+
|
|
643
|
+
if self._handle is None:
|
|
644
|
+
log.error(
|
|
645
|
+
"VoiceEndpoint(name=%s) cannot publish failure (handle unset) "
|
|
646
|
+
"for request %s: %s",
|
|
647
|
+
self._name,
|
|
648
|
+
request_env.id,
|
|
649
|
+
message,
|
|
650
|
+
)
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
import uuid as _uuid
|
|
654
|
+
|
|
655
|
+
failed = BusEnvelope(
|
|
656
|
+
id=_uuid.uuid4().hex,
|
|
657
|
+
correlation_id=request_env.correlation_id,
|
|
658
|
+
in_reply_to=request_env.id,
|
|
659
|
+
to=request_env.from_,
|
|
660
|
+
kind="Event",
|
|
661
|
+
payload=EventPayload(
|
|
662
|
+
type="SynthesisFailed",
|
|
663
|
+
data=SynthesisFailedPayload(
|
|
664
|
+
reason=reason, # type: ignore[arg-type]
|
|
665
|
+
message=message,
|
|
666
|
+
retryable=retryable,
|
|
667
|
+
).model_dump(),
|
|
668
|
+
),
|
|
669
|
+
created_at=datetime.now(UTC),
|
|
670
|
+
)
|
|
671
|
+
await self._handle.publish(failed)
|
|
672
|
+
|
|
673
|
+
async def _publish_failed_from_voiceerror(
|
|
674
|
+
self,
|
|
675
|
+
request_env: Envelope,
|
|
676
|
+
exc: VoiceError,
|
|
677
|
+
) -> None:
|
|
678
|
+
"""Map a VoiceError subclass onto the failure-reason taxonomy."""
|
|
679
|
+
reason = "INTERNAL_ERROR"
|
|
680
|
+
retryable = False
|
|
681
|
+
if isinstance(exc, GPUOOMError):
|
|
682
|
+
reason, retryable = "GPU_OOM", True
|
|
683
|
+
elif isinstance(exc, TextTooLongError):
|
|
684
|
+
reason = "TEXT_TOO_LONG"
|
|
685
|
+
elif isinstance(exc, VoiceNotPreparedError):
|
|
686
|
+
reason = "VOICE_NOT_PREPARED"
|
|
687
|
+
await self._publish_failed(
|
|
688
|
+
request_env,
|
|
689
|
+
reason=reason,
|
|
690
|
+
message=str(exc),
|
|
691
|
+
retryable=retryable,
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
__all__ = [
|
|
696
|
+
"SynthesisError",
|
|
697
|
+
"SynthesisSuccess",
|
|
698
|
+
"VoiceEndpoint",
|
|
699
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Pydantic payload models for voice endpoint envelopes.
|
|
2
|
+
|
|
3
|
+
Three event types ride on top of agent_core's Event kind:
|
|
4
|
+
|
|
5
|
+
- SynthesisRequest (caller → voice)
|
|
6
|
+
- SynthesisReady (voice → caller, success)
|
|
7
|
+
- SynthesisFailed (voice → caller, failure)
|
|
8
|
+
|
|
9
|
+
Validation runs at envelope publish/parse time so malformed requests
|
|
10
|
+
surface as ValidationError to the publisher rather than as opaque
|
|
11
|
+
runtime failures downstream.
|
|
12
|
+
|
|
13
|
+
See spec §4 for the wire-level contract.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
21
|
+
|
|
22
|
+
_TIMEOUT_S_CAP = 600.0
|
|
23
|
+
_RETAIN_S_CAP = 86400.0
|
|
24
|
+
|
|
25
|
+
AudioFormat = Literal["wav", "mp3", "ogg"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SynthesisRequestPayload(BaseModel):
|
|
29
|
+
model_config = ConfigDict(extra="forbid")
|
|
30
|
+
text: str = Field(min_length=1)
|
|
31
|
+
timeout_s: float | None = Field(default=None, gt=0)
|
|
32
|
+
retain_s: float | None = Field(default=None, gt=0)
|
|
33
|
+
options: dict[str, Any] | None = None
|
|
34
|
+
format: AudioFormat = "wav"
|
|
35
|
+
|
|
36
|
+
@field_validator("timeout_s")
|
|
37
|
+
@classmethod
|
|
38
|
+
def _cap_timeout(cls, v: float | None) -> float | None:
|
|
39
|
+
if v is not None and v > _TIMEOUT_S_CAP:
|
|
40
|
+
raise ValueError(f"timeout_s exceeds 600s cap (got {v})")
|
|
41
|
+
return v
|
|
42
|
+
|
|
43
|
+
@field_validator("retain_s")
|
|
44
|
+
@classmethod
|
|
45
|
+
def _cap_retain(cls, v: float | None) -> float | None:
|
|
46
|
+
if v is not None and v > _RETAIN_S_CAP:
|
|
47
|
+
raise ValueError(f"retain_s exceeds 86400s cap (got {v})")
|
|
48
|
+
return v
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SynthesisReadyPayload(BaseModel):
|
|
52
|
+
model_config = ConfigDict(extra="forbid")
|
|
53
|
+
wav_path: str = Field(min_length=1)
|
|
54
|
+
file_size_bytes: int = Field(ge=0)
|
|
55
|
+
duration_s: float = Field(ge=0)
|
|
56
|
+
elapsed_s: float = Field(ge=0)
|
|
57
|
+
sample_rate_hz: int = Field(gt=0)
|
|
58
|
+
cache_hit: bool
|
|
59
|
+
chunks: int = Field(ge=1)
|
|
60
|
+
retain_until: str = Field(min_length=1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
FailureReason = Literal[
|
|
64
|
+
"GPU_OOM",
|
|
65
|
+
"TEXT_TOO_LONG",
|
|
66
|
+
"VOICE_NOT_PREPARED",
|
|
67
|
+
"INTERNAL_ERROR",
|
|
68
|
+
"TIMEOUT",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SynthesisFailedPayload(BaseModel):
|
|
73
|
+
model_config = ConfigDict(extra="forbid")
|
|
74
|
+
reason: FailureReason
|
|
75
|
+
message: str = Field(min_length=1)
|
|
76
|
+
retryable: bool
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"AudioFormat",
|
|
81
|
+
"FailureReason",
|
|
82
|
+
"SynthesisFailedPayload",
|
|
83
|
+
"SynthesisReadyPayload",
|
|
84
|
+
"SynthesisRequestPayload",
|
|
85
|
+
]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""File lifecycle for voice WAVs: content-addressed write + TTL cleanup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import subprocess
|
|
10
|
+
from datetime import UTC, datetime, timedelta
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def transcode_audio(wav_bytes: bytes, *, target_format: str) -> bytes:
|
|
17
|
+
"""Transcode WAV bytes to the requested format.
|
|
18
|
+
|
|
19
|
+
Returns ``wav_bytes`` unchanged for ``"wav"``. Encodes to OGG Vorbis
|
|
20
|
+
using ``soundfile`` for ``"ogg"``. Shells out to ``ffmpeg`` for ``"mp3"``.
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
ValueError: if ``target_format`` is not ``"wav"``, ``"mp3"``, or ``"ogg"``.
|
|
24
|
+
RuntimeError: if ffmpeg is not on PATH or exits non-zero.
|
|
25
|
+
"""
|
|
26
|
+
if target_format == "wav":
|
|
27
|
+
return wav_bytes
|
|
28
|
+
|
|
29
|
+
if target_format == "ogg":
|
|
30
|
+
import soundfile as sf
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
with sf.SoundFile(io.BytesIO(wav_bytes)) as f:
|
|
35
|
+
audio_data = f.read(dtype="float32")
|
|
36
|
+
sample_rate = f.samplerate
|
|
37
|
+
buf = io.BytesIO()
|
|
38
|
+
sf.write(buf, audio_data, sample_rate, format="OGG", subtype="VORBIS")
|
|
39
|
+
return buf.getvalue()
|
|
40
|
+
|
|
41
|
+
if target_format == "mp3":
|
|
42
|
+
try:
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
[
|
|
45
|
+
"ffmpeg",
|
|
46
|
+
"-f", "wav",
|
|
47
|
+
"-i", "pipe:0",
|
|
48
|
+
"-f", "mp3",
|
|
49
|
+
"-b:a", "128k",
|
|
50
|
+
"pipe:1",
|
|
51
|
+
],
|
|
52
|
+
input=wav_bytes,
|
|
53
|
+
stdout=subprocess.PIPE,
|
|
54
|
+
stderr=subprocess.PIPE,
|
|
55
|
+
check=False,
|
|
56
|
+
)
|
|
57
|
+
except FileNotFoundError as exc:
|
|
58
|
+
raise RuntimeError(f"ffmpeg not found on PATH: {exc}") from exc
|
|
59
|
+
|
|
60
|
+
if result.returncode != 0:
|
|
61
|
+
stderr_fragment = (result.stderr or b"")[:500].decode("utf-8", errors="replace")
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
f"ffmpeg exited with code {result.returncode}: {stderr_fragment}"
|
|
64
|
+
)
|
|
65
|
+
return result.stdout
|
|
66
|
+
|
|
67
|
+
raise ValueError(f"unknown target_format {target_format!r}; expected 'wav', 'mp3', or 'ogg'")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def write_addressed(
|
|
71
|
+
audio: bytes,
|
|
72
|
+
*,
|
|
73
|
+
root: Path,
|
|
74
|
+
retain_s: float,
|
|
75
|
+
ext: str = "wav",
|
|
76
|
+
) -> tuple[Path, str]:
|
|
77
|
+
"""Write ``audio`` to ``<root>/<sha256>.<ext>`` with a meta sidecar.
|
|
78
|
+
|
|
79
|
+
Returns (path, sha256_hex). The audio body is content-addressed and
|
|
80
|
+
only written if not already present (so identical audio dedupes).
|
|
81
|
+
The meta sidecar IS rewritten on every call, refreshing
|
|
82
|
+
``written_at_utc`` and effectively resetting the TTL window —
|
|
83
|
+
LRU-ish semantics: actively-touched files stay alive longer.
|
|
84
|
+
|
|
85
|
+
The meta JSON includes an ``"ext"`` key so ``cleanup_expired()`` can
|
|
86
|
+
locate the companion audio file regardless of format.
|
|
87
|
+
"""
|
|
88
|
+
sha = hashlib.sha256(audio).hexdigest()
|
|
89
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
audio_path = root / f"{sha}.{ext}"
|
|
91
|
+
meta_path = root / f"{sha}.meta.json"
|
|
92
|
+
|
|
93
|
+
if not audio_path.exists():
|
|
94
|
+
audio_path.write_bytes(audio)
|
|
95
|
+
|
|
96
|
+
meta = {
|
|
97
|
+
"sha256": sha,
|
|
98
|
+
"retain_s": retain_s,
|
|
99
|
+
"written_at_utc": datetime.now(UTC).isoformat(),
|
|
100
|
+
"ext": ext,
|
|
101
|
+
}
|
|
102
|
+
meta_path.write_text(json.dumps(meta))
|
|
103
|
+
return audio_path, sha
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def retain_until_iso(*, retain_s: float, now: datetime | None = None) -> str:
|
|
107
|
+
"""Compute ISO 8601 UTC timestamp of ``now + retain_s``."""
|
|
108
|
+
base = now or datetime.now(UTC)
|
|
109
|
+
return (base + timedelta(seconds=retain_s)).isoformat()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cleanup_expired(*, root: Path) -> int:
|
|
113
|
+
"""Walk ``root``, delete WAVs whose ``written_at_utc + retain_s < now``.
|
|
114
|
+
|
|
115
|
+
The deadline is read from the meta sidecar (``written_at_utc`` field),
|
|
116
|
+
not the filesystem mtime, so it survives backup/restore. Returns
|
|
117
|
+
count removed. Safe to call on a nonexistent root.
|
|
118
|
+
"""
|
|
119
|
+
if not root.exists():
|
|
120
|
+
return 0
|
|
121
|
+
now = datetime.now(UTC)
|
|
122
|
+
removed = 0
|
|
123
|
+
for meta_path in root.glob("*.meta.json"):
|
|
124
|
+
try:
|
|
125
|
+
meta = json.loads(meta_path.read_text())
|
|
126
|
+
written = datetime.fromisoformat(meta["written_at_utc"])
|
|
127
|
+
retain_s = float(meta["retain_s"])
|
|
128
|
+
except (OSError, ValueError, KeyError) as exc:
|
|
129
|
+
log.warning("skipping malformed meta %s: %s", meta_path, exc)
|
|
130
|
+
continue
|
|
131
|
+
if written + timedelta(seconds=retain_s) < now:
|
|
132
|
+
ext = meta.get("ext", "wav")
|
|
133
|
+
audio_path = meta_path.with_suffix("").with_suffix(f".{ext}")
|
|
134
|
+
try:
|
|
135
|
+
if audio_path.exists():
|
|
136
|
+
audio_path.unlink()
|
|
137
|
+
meta_path.unlink()
|
|
138
|
+
removed += 1
|
|
139
|
+
except OSError as exc:
|
|
140
|
+
log.warning("failed to remove %s: %s", audio_path, exc)
|
|
141
|
+
return removed
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
__all__ = ["cleanup_expired", "retain_until_iso", "transcode_audio", "write_addressed"]
|
agent_core_voice/mcp.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Agent-facing MCP tool surface for the voice endpoint (async-via-bus).
|
|
2
|
+
|
|
3
|
+
Two tools: ``synthesize_speech`` and ``voice_info``. Both close ``voice_id``
|
|
4
|
+
and ``agent_name`` into their callable at mount time. The agent's tool
|
|
5
|
+
surface has no ``voice_id`` parameter — by construction, an agent cannot
|
|
6
|
+
request another agent's voice.
|
|
7
|
+
|
|
8
|
+
``synthesize_speech`` is **publish-and-return**: it builds a
|
|
9
|
+
``SynthesisRequest`` envelope, publishes it to the voice endpoint via the
|
|
10
|
+
caller's ``BusHandle``, and returns ``{"request_id", "status": "queued"}``
|
|
11
|
+
immediately. The caller's inbox wakes when a correlated
|
|
12
|
+
``SynthesisReady`` (success) or ``SynthesisFailed`` (failure) arrives.
|
|
13
|
+
|
|
14
|
+
Returns a list of MCP TextContent blocks. No exceptions escape the tool —
|
|
15
|
+
client-side validation errors surface as ``{"error", "detail"}`` JSON.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import uuid
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
from mcp.types import TextContent
|
|
26
|
+
|
|
27
|
+
from agent_core_voice.envelopes import SynthesisRequestPayload
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from agent_core.bus.handle import BusHandle
|
|
31
|
+
from fastmcp import FastMCP
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def register_voice_tools(
|
|
35
|
+
*,
|
|
36
|
+
mcp: FastMCP,
|
|
37
|
+
bus_handle: BusHandle,
|
|
38
|
+
voice_endpoint_name: str,
|
|
39
|
+
voice_id: str,
|
|
40
|
+
agent_name: str,
|
|
41
|
+
voice_info: dict[str, Any],
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Mount ``synthesize_speech`` (envelope-fire) + ``voice_info`` on ``mcp``.
|
|
44
|
+
|
|
45
|
+
``voice_id``, ``agent_name``, and the target voice endpoint name are
|
|
46
|
+
closed at mount time. Synthesis is async-via-bus: the tool publishes a
|
|
47
|
+
``SynthesisRequest`` envelope and returns the envelope id immediately.
|
|
48
|
+
The caller's inbox wakes when a ``SynthesisReady`` (or
|
|
49
|
+
``SynthesisFailed``) arrives.
|
|
50
|
+
"""
|
|
51
|
+
# ``agent_name`` and ``voice_id`` are accepted for closure parity with the
|
|
52
|
+
# pre-bus-async signature (callers in plugin.py thread them through), but
|
|
53
|
+
# they're not used by the bus path: bus stamps ``from_`` on publish, and
|
|
54
|
+
# the voice endpoint resolves voice_id by looking up the agent it
|
|
55
|
+
# registered at wire time.
|
|
56
|
+
del agent_name, voice_id
|
|
57
|
+
|
|
58
|
+
@mcp.tool(
|
|
59
|
+
name="synthesize_speech",
|
|
60
|
+
description=(
|
|
61
|
+
"Synthesize text in your assigned voice. Returns {request_id, status} "
|
|
62
|
+
"immediately; the synthesized audio arrives on your inbox as a "
|
|
63
|
+
"SynthesisReady event with `wav_path`. SynthesisFailed arrives on the "
|
|
64
|
+
"same inbox if synthesis fails. "
|
|
65
|
+
"``format`` selects the output container: \"wav\" (default), \"mp3\", or \"ogg\"."
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
async def _synthesize(
|
|
69
|
+
text: str,
|
|
70
|
+
timeout_s: float | None = None,
|
|
71
|
+
retain_s: float | None = None,
|
|
72
|
+
options: dict | None = None,
|
|
73
|
+
format: str = "wav",
|
|
74
|
+
) -> list[Any]:
|
|
75
|
+
from agent_core.bus.envelope import Envelope, EventPayload
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
payload = SynthesisRequestPayload(
|
|
79
|
+
text=text,
|
|
80
|
+
timeout_s=timeout_s,
|
|
81
|
+
retain_s=retain_s,
|
|
82
|
+
options=options,
|
|
83
|
+
format=format, # type: ignore[arg-type]
|
|
84
|
+
)
|
|
85
|
+
except Exception as exc: # pydantic ValidationError or anything else
|
|
86
|
+
return [
|
|
87
|
+
TextContent(
|
|
88
|
+
type="text",
|
|
89
|
+
text=json.dumps({"error": "invalid_request", "detail": str(exc)}),
|
|
90
|
+
)
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
env_id = str(uuid.uuid4())
|
|
94
|
+
env = Envelope(
|
|
95
|
+
id=env_id,
|
|
96
|
+
correlation_id=env_id,
|
|
97
|
+
to=voice_endpoint_name,
|
|
98
|
+
kind="Event",
|
|
99
|
+
payload=EventPayload(
|
|
100
|
+
type="SynthesisRequest",
|
|
101
|
+
data=payload.model_dump(exclude_none=True),
|
|
102
|
+
),
|
|
103
|
+
created_at=datetime.now(UTC),
|
|
104
|
+
)
|
|
105
|
+
await bus_handle.publish(env)
|
|
106
|
+
return [
|
|
107
|
+
TextContent(
|
|
108
|
+
type="text",
|
|
109
|
+
text=json.dumps({"request_id": env_id, "status": "queued"}),
|
|
110
|
+
)
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
@mcp.tool(
|
|
114
|
+
name="voice_info",
|
|
115
|
+
description="Return metadata about your assigned voice.",
|
|
116
|
+
)
|
|
117
|
+
async def _voice_info() -> list[Any]:
|
|
118
|
+
return [TextContent(type="text", text=json.dumps(voice_info, ensure_ascii=False))]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
__all__ = ["register_voice_tools"]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Agent_core entry-point hook surface for the voice service.
|
|
2
|
+
|
|
3
|
+
Three hookimpls:
|
|
4
|
+
|
|
5
|
+
* ``register_endpoint_types`` — exposes ``builtin.voice`` so the bus
|
|
6
|
+
runner can construct a ``VoiceEndpoint`` from a yaml entry.
|
|
7
|
+
* ``reserved_endpoint_params`` — declares ``voice`` and ``voice_id`` so
|
|
8
|
+
the runner pops them from claude_code_mcp's params before constructing.
|
|
9
|
+
* ``wire_endpoints_after_registration`` — added in Task 10. For each
|
|
10
|
+
``ClaudeCodeMCPEndpoint`` whose yaml params name a voice, validate
|
|
11
|
+
and append a deferred mounter that registers the voice tools on the
|
|
12
|
+
MCP server with ``voice_id`` closed in.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
import pluggy
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from agent_core.bus.protocol import Endpoint
|
|
23
|
+
from agent_core.plugins.specs import RunnerServices
|
|
24
|
+
|
|
25
|
+
hookimpl = pluggy.HookimplMarker("agent_core")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@hookimpl
|
|
29
|
+
def register_endpoint_types() -> dict[str, type[Any]]:
|
|
30
|
+
"""Register ``builtin.voice`` as a bus endpoint type."""
|
|
31
|
+
from agent_core_voice.endpoint import VoiceEndpoint
|
|
32
|
+
|
|
33
|
+
return {"builtin.voice": VoiceEndpoint}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@hookimpl
|
|
37
|
+
def reserved_endpoint_params() -> list[str]:
|
|
38
|
+
"""The runner pops these keys from each endpoint's params before constructing."""
|
|
39
|
+
return ["voice", "voice_id"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_claude_code_mcp_cls() -> type[Any]:
|
|
43
|
+
"""Lazy import; overridable by tests via monkeypatch."""
|
|
44
|
+
from agent_core.endpoints.claude_code_mcp import ClaudeCodeMCPEndpoint
|
|
45
|
+
|
|
46
|
+
return ClaudeCodeMCPEndpoint
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@hookimpl
|
|
50
|
+
def wire_endpoints_after_registration(
|
|
51
|
+
endpoints: dict[str, Endpoint],
|
|
52
|
+
raw_endpoint_configs: dict[str, dict[str, Any]],
|
|
53
|
+
services: RunnerServices,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Mount voice tools on every MCP endpoint that names a voice + voice_id."""
|
|
56
|
+
del services # unused — kept for hookspec parity
|
|
57
|
+
|
|
58
|
+
from agent_core_voice.endpoint import VoiceEndpoint
|
|
59
|
+
from agent_core_voice.mcp import register_voice_tools
|
|
60
|
+
|
|
61
|
+
claude_code_mcp_cls = _resolve_claude_code_mcp_cls()
|
|
62
|
+
|
|
63
|
+
for name, endpoint in endpoints.items():
|
|
64
|
+
if not isinstance(endpoint, claude_code_mcp_cls):
|
|
65
|
+
continue
|
|
66
|
+
raw = raw_endpoint_configs.get(name) or {}
|
|
67
|
+
params = raw.get("params") or {}
|
|
68
|
+
voice_name = params.get("voice")
|
|
69
|
+
voice_id = params.get("voice_id")
|
|
70
|
+
|
|
71
|
+
if not voice_name and not voice_id:
|
|
72
|
+
continue
|
|
73
|
+
if not voice_name or not voice_id:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"endpoint {name!r} sets one of 'voice'/'voice_id' but not both; "
|
|
76
|
+
f"got voice={voice_name!r}, voice_id={voice_id!r}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
voice_ep = endpoints.get(voice_name)
|
|
80
|
+
if voice_ep is None:
|
|
81
|
+
available = sorted(n for n, e in endpoints.items() if isinstance(e, VoiceEndpoint))
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"endpoint {name!r} names voice={voice_name!r} but no endpoint with that "
|
|
84
|
+
f"name is registered. Available VoiceEndpoint names: {available}"
|
|
85
|
+
)
|
|
86
|
+
if not isinstance(voice_ep, VoiceEndpoint):
|
|
87
|
+
available = sorted(n for n, e in endpoints.items() if isinstance(e, VoiceEndpoint))
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"endpoint {name!r} names voice={voice_name!r}, but that endpoint is a "
|
|
90
|
+
f"{type(voice_ep).__name__}, not a VoiceEndpoint. "
|
|
91
|
+
f"Available VoiceEndpoint names: {available}"
|
|
92
|
+
)
|
|
93
|
+
if voice_id not in voice_ep.voice_ids():
|
|
94
|
+
available = sorted(voice_ep.voice_ids())
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"endpoint {name!r} requests voice_id={voice_id!r} but voice endpoint "
|
|
97
|
+
f"{voice_name!r} has no such voice. Available voice ids: {available}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Bind agent → voice_id at wire time so the voice endpoint's
|
|
101
|
+
# envelope handler can resolve envelope.from_ to a voice without
|
|
102
|
+
# trusting caller-supplied voice ids on the wire.
|
|
103
|
+
voice_ep.register_agent(name, voice_id)
|
|
104
|
+
|
|
105
|
+
def _mounter(
|
|
106
|
+
bus_handle,
|
|
107
|
+
*,
|
|
108
|
+
voice_ep: VoiceEndpoint = voice_ep,
|
|
109
|
+
voice_endpoint_name: str = voice_name,
|
|
110
|
+
mcp_endpoint=endpoint,
|
|
111
|
+
voice_id: str = voice_id,
|
|
112
|
+
agent_name: str = name,
|
|
113
|
+
) -> None:
|
|
114
|
+
register_voice_tools(
|
|
115
|
+
mcp=mcp_endpoint._mcp,
|
|
116
|
+
bus_handle=bus_handle,
|
|
117
|
+
voice_endpoint_name=voice_endpoint_name,
|
|
118
|
+
voice_id=voice_id,
|
|
119
|
+
agent_name=agent_name,
|
|
120
|
+
voice_info=voice_ep.voice_info(voice_id),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
endpoint.deferred_tool_mounters.append(_mounter)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""TTSBackend protocol + error taxonomy.
|
|
2
|
+
|
|
3
|
+
Single-source-of-truth for the voice-backend error taxonomy is now
|
|
4
|
+
``madrigal.engine.protocol`` (the upstream voice library). This module
|
|
5
|
+
re-exports those symbols so existing call sites continue to work
|
|
6
|
+
unchanged after the Phase 1 backend swap.
|
|
7
|
+
|
|
8
|
+
The ``TTSBackend`` Protocol and the ``VoiceError`` hierarchy come from
|
|
9
|
+
madrigal; ``VoiceInfo`` (the agent-core-voice registry record) stays
|
|
10
|
+
local because it's our wiring layer's concern, not madrigal's.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from madrigal.engine.protocol import (
|
|
19
|
+
EmptyTextError,
|
|
20
|
+
GPUOOMError,
|
|
21
|
+
TextTooLongError,
|
|
22
|
+
TTSBackend,
|
|
23
|
+
VoiceError,
|
|
24
|
+
VoiceNotPreparedError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class VoiceInfo:
|
|
30
|
+
"""One configured voice in the registry."""
|
|
31
|
+
|
|
32
|
+
voice_id: str
|
|
33
|
+
ref_wav: Path
|
|
34
|
+
ref_text: str
|
|
35
|
+
blend: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"EmptyTextError",
|
|
40
|
+
"GPUOOMError",
|
|
41
|
+
"TextTooLongError",
|
|
42
|
+
"TTSBackend",
|
|
43
|
+
"VoiceError",
|
|
44
|
+
"VoiceInfo",
|
|
45
|
+
"VoiceNotPreparedError",
|
|
46
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-core-voice
|
|
3
|
+
Version: 0.8.2
|
|
4
|
+
Summary: Voice synthesis endpoint for agent_core — per-agent Qwen3-TTS via ICL voice cloning
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: agent-core-bus<0.9,>=0.8
|
|
8
|
+
Requires-Dist: fastmcp>=2.0
|
|
9
|
+
Requires-Dist: madrigal>=0.2.0
|
|
10
|
+
Requires-Dist: pluggy>=1.6
|
|
11
|
+
Requires-Dist: soundfile>=0.13
|
|
12
|
+
Provides-Extra: cpu
|
|
13
|
+
Requires-Dist: torch>=2.11; extra == 'cpu'
|
|
14
|
+
Requires-Dist: torchaudio>=2.11; extra == 'cpu'
|
|
15
|
+
Provides-Extra: cu130
|
|
16
|
+
Requires-Dist: torch>=2.11; extra == 'cu130'
|
|
17
|
+
Requires-Dist: torchaudio>=2.11; extra == 'cu130'
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
agent_core_voice/__init__.py,sha256=CxY0N9G164KA4clICj_o8OOprcL6I01VrzeUB-OzvQU,78
|
|
2
|
+
agent_core_voice/audit.py,sha256=sBIfgQMtnkLnWeuzDGHRbNhANpPikPHiGKiyDPxcd9s,1414
|
|
3
|
+
agent_core_voice/endpoint.py,sha256=KoEUaZZVC6n7SiNmYOaYErackLlIbX2Kmy3FgN8C-c0,25825
|
|
4
|
+
agent_core_voice/envelopes.py,sha256=2ywCm0YvRIm8KDsTe8zpYE54Q7Ebvoa_aeXlVaNoIXA,2310
|
|
5
|
+
agent_core_voice/lifecycle.py,sha256=DJxe8fc3jU12BZdDU8VS1RhDFKEAOSbclxX9pWOPsO4,4863
|
|
6
|
+
agent_core_voice/mcp.py,sha256=gLPWOZRilW7fNIdRFa151vdoexYudSS2sfAOjQ8z9OI,4286
|
|
7
|
+
agent_core_voice/plugin.py,sha256=ogNYZfNUIdGS0HBp8ySgTMLdQIZawPXuqNCx_gbGALU,4632
|
|
8
|
+
agent_core_voice/protocol.py,sha256=FX6nVg23o5OGReOPD9pR4g6wQ4iyMrYdpEqBJ2q4_g4,1089
|
|
9
|
+
agent_core_voice-0.8.2.dist-info/METADATA,sha256=g6jLTkZ3Y3VfrvXIGZ2L8f4hVG1UWA3-CzYS-8Shbog,588
|
|
10
|
+
agent_core_voice-0.8.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
11
|
+
agent_core_voice-0.8.2.dist-info/entry_points.txt,sha256=64PP6ZcPSDlZLDQWBhWimKvHKRdTmdOH9KLQL1Mba90,53
|
|
12
|
+
agent_core_voice-0.8.2.dist-info/RECORD,,
|