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.
@@ -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"]
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [agent_core]
2
+ voice_aliases = agent_core_voice.plugin