Skip to content

Commit a270a6a

Browse files
DavidTbilisiclaude
andcommitted
feat(ai): add --live stdin mode and MCP server for AI clients
Two new entry points for piping/embedding TTS_ka into AI workflows: * `--live` / `--live-idle-ms`: read stdin incrementally, speak each sentence as it lands. `SentenceBuffer` flushes on `[.!?]+\s`, paragraph break, or idle timeout (default 800 ms). Code fences are held open until closed so `not_reading.replace_not_readable` can collapse them to a placeholder. Per-sentence MP3s feed `StreamingAudioPlayer` with chunk_index ordering. * MCP server (`TTS_ka-mcp`, new `[mcp]` extra): FastMCP stdio server with speak / stream_open / stream_append / stream_close / session_status / list_sessions / stop / list_voices. `_run_with_preserved_stdout` uses os.dup/dup2 so library print() calls go to stderr while JSON-RPC keeps the real stdout fd — naive `sys.stdout = sys.stderr` would silently reroute the protocol stream. Tests: 28 for SentenceBuffer/live_loop, 22 in-process MCP unit tests, 2 subprocess-based E2E tests (slow-marked, 15s timeout guard). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5bd62bd commit a270a6a

8 files changed

Lines changed: 1448 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ Environment variables (see `fast_audio.py`, `readme.md`):
6060
| `ultra_fast.py` | Parallel async generation, auto-optimization |
6161
| `fast_audio.py` | Per-chunk TTS (HTTP + edge-tts), merge, playback helpers |
6262
| `streaming_player.py` | Queue-based background playback thread |
63+
| `live_stream.py` | `--live` mode: read stdin incrementally, speak each sentence as it lands (for piping LLM output) |
64+
| `mcp_server.py` | MCP server (`TTS_ka-mcp`): exposes `speak` / `stream_open` / `stream_append` / `stream_close` / `stop` / `list_voices` over stdio for AI clients |
6365
| `chunking.py` | WPM-based text splitting |
6466
| `not_reading.py` | Text sanitization before generation |
6567
| `constants.py` | `VOICE_MAP` (`ka`, `ka-m`, `ru`, `en`, `en-US`), `SSML_LANG_MAP`, HTTP/stream limits |
@@ -73,3 +75,6 @@ Environment variables (see `fast_audio.py`, `readme.md`):
7375
- **Layered fallbacks**: optional Bing HTTP POST → `edge-tts`; merge: `soundfile` → PyDub → FFmpeg.
7476
- **uvloop on Unix**: used in `ultra_fast.py` when available for faster event-loop I/O.
7577
- **Georgian voices**: `--lang ka` (Eka), `--lang ka-m` (Giorgi); SSML `xml:lang` uses `ka-GE` for those codes on the HTTP path.
78+
- **`--live` AI-streaming mode**: `tts-ka --live -l en` reads stdin line-by-line, accumulates in `SentenceBuffer`, flushes on `[.!?]+\s`, paragraph break, or idle (default 800 ms via `--live-idle-ms`). Code fences (` ``` `) are held open until closed so `not_reading.replace_not_readable` can collapse them; per-sentence MP3s feed into `StreamingAudioPlayer` with `chunk_index` ordering. Use case: `claude --print | tts-ka --live`.
79+
- **MCP server (`TTS_ka-mcp`)**: stdio JSON-RPC server (`pip install -e ".[mcp]"`). Tools: `speak`, `stream_open`, `stream_append`, `stream_close`, `session_status`, `list_sessions`, `stop`, `list_voices`. `_LiveSession` tracks two counters: `_idx` (output-file numbering, ticks inside `_speak`) and `_queued` (status-visible, ticks when `feed` extracts a sentence — so an agent can see backed-up synths via `synths_pending = _queued - done_tasks`). `build_server(sessions=dict)` factory lets tests inject a session dict for inspection. Configure in Claude Code: `{"mcpServers": {"tts-ka": {"command": "TTS_ka-mcp"}}}`.
80+
- **MCP stdout duality** (`_run_with_preserved_stdout`): naively swapping `sys.stdout = sys.stderr` to silence library prints ALSO kills MCP framing because `mcp.server.stdio` reads `sys.stdout.buffer` at handshake time. The fix is fd-level: `os.dup(1)` saves the original stdout fd, `os.dup2(2, 1)` redirects Python-level stdout (and any subprocess inheriting fd 1) to stderr, and the saved fd is wrapped and handed to `stdio_server(stdout=...)`. Without this, the E2E test hangs on `session.initialize()` because the server's reply lands on stderr.

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Changelog = "https://github.com/DavidTbilisi/TTS/releases"
5252
TTS_ka = "TTS_ka.main:main"
5353
TTS_ka-gui = "TTS_ka.gui:main"
5454
TTS_ka-hotkeys = "TTS_ka.native_hotkeys:main"
55+
TTS_ka-mcp = "TTS_ka.mcp_server:main"
5556

5657
[project.optional-dependencies]
5758
hotkeys = [
@@ -73,6 +74,9 @@ server = [
7374
"fastapi>=0.110.0",
7475
"uvicorn>=0.27.0",
7576
]
77+
mcp = [
78+
"mcp>=1.0.0",
79+
]
7680
test = [
7781
"pytest>=7.0.0",
7882
"pytest-asyncio>=0.21.0",

src/TTS_ka/live_stream.py

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
"""Live streaming TTS — read stdin as it arrives, speak sentence-by-sentence.
2+
3+
Designed for piping LLM/agent output:
4+
5+
claude … | tts-ka --live --lang en
6+
7+
Each completed sentence is synthesized and queued to the existing
8+
``StreamingAudioPlayer`` so playback starts within ~1s of the first sentence
9+
landing. Sentences are detected on ``[.!?]`` + whitespace, paragraph breaks
10+
(``\\n\\n``), or an idle timeout (default 800 ms) so the buffer never gets
11+
stuck mid-thought.
12+
13+
Code fences (``\\`\\`\\``) are tracked: a sentence boundary inside an open
14+
fence is held back until the closing fence arrives so the sanitizer can
15+
collapse the whole block to "omitted fenced code block".
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import asyncio
21+
import os
22+
import re
23+
import sys
24+
import tempfile
25+
import threading
26+
import time
27+
from typing import AsyncIterator, Callable, List, Optional, Tuple
28+
29+
from .fast_audio import cleanup_http, fast_generate_audio
30+
from .not_reading import replace_not_readable
31+
from .streaming_player import StreamingAudioPlayer
32+
33+
DEFAULT_IDLE_FLUSH_MS = 800
34+
DEFAULT_MAX_CONCURRENT = 4
35+
36+
# `[.!?]+` optionally followed by a closing quote/bracket, then whitespace.
37+
# Trailing whitespace is required so "Hello." mid-stream does not flush before
38+
# the user finishes typing "Hello.com" — the space/newline is the real signal.
39+
_SENTENCE_END_RE = re.compile(r"[.!?]+[\"')\]]?\s")
40+
_PARAGRAPH_END_RE = re.compile(r"\n\s*\n")
41+
42+
43+
class SentenceBuffer:
44+
"""Accumulates streaming text and yields complete sentences.
45+
46+
The buffer is fence-aware: while inside an unclosed ``\\`\\`\\`` fenced
47+
code block, no new sentences after the open fence are released until the
48+
closing fence arrives. Sentences before the open fence still flow.
49+
"""
50+
51+
def __init__(self, idle_flush_ms: int = DEFAULT_IDLE_FLUSH_MS) -> None:
52+
self._buf: str = ""
53+
self._last_input_at: float = time.monotonic()
54+
self.idle_flush_ms = idle_flush_ms
55+
56+
@property
57+
def buffer(self) -> str:
58+
return self._buf
59+
60+
def _fence_positions(self) -> List[int]:
61+
"""Indices of every ``\\`\\`\\`` run in the buffer, in order."""
62+
out: List[int] = []
63+
start = 0
64+
while True:
65+
idx = self._buf.find("```", start)
66+
if idx == -1:
67+
break
68+
out.append(idx)
69+
start = idx + 3
70+
return out
71+
72+
def _first_unmatched_fence(self) -> int:
73+
"""Index of the first opened ``\\`\\`\\`` with no matching close, else -1."""
74+
positions = self._fence_positions()
75+
if len(positions) % 2 == 1:
76+
return positions[-1]
77+
return -1
78+
79+
def _closed_fence_ranges(self) -> List[Tuple[int, int]]:
80+
"""``[(open_start, close_end), ...]`` for every matched fence pair."""
81+
positions = self._fence_positions()
82+
pairs: List[Tuple[int, int]] = []
83+
for i in range(0, len(positions) - 1, 2):
84+
pairs.append((positions[i], positions[i + 1] + 3))
85+
return pairs
86+
87+
@staticmethod
88+
def _inside_fence(pos: int, ranges: List[Tuple[int, int]]) -> int:
89+
"""If *pos* lies inside any (start, end), return that end; else -1."""
90+
for start, end in ranges:
91+
if start <= pos < end:
92+
return end
93+
return -1
94+
95+
def feed(self, text: str) -> List[str]:
96+
"""Append *text*, return any complete sentences ready to speak."""
97+
if not text:
98+
return []
99+
self._buf += text
100+
self._last_input_at = time.monotonic()
101+
return self._extract()
102+
103+
def _next_boundary(self, start: int = 0) -> Optional[int]:
104+
"""Return the end-index of the next sentence/paragraph boundary
105+
outside any closed fence and before any open fence, or ``None``."""
106+
open_at = self._first_unmatched_fence()
107+
ranges = self._closed_fence_ranges()
108+
pos = start
109+
n = len(self._buf)
110+
while pos < n:
111+
m_sent = _SENTENCE_END_RE.search(self._buf, pos)
112+
m_para = _PARAGRAPH_END_RE.search(self._buf, pos)
113+
candidates = [m for m in (m_sent, m_para) if m is not None]
114+
if not candidates:
115+
return None
116+
m = min(candidates, key=lambda x: x.end())
117+
if open_at >= 0 and m.start() >= open_at:
118+
return None # past an unclosed fence — hold
119+
jump = self._inside_fence(m.start(), ranges)
120+
if jump != -1:
121+
pos = jump # boundary fell inside a closed fence; look past it
122+
continue
123+
return m.end()
124+
return None
125+
126+
def _extract(self) -> List[str]:
127+
out: List[str] = []
128+
while True:
129+
cut = self._next_boundary()
130+
if cut is None:
131+
break
132+
piece = self._buf[:cut].strip()
133+
self._buf = self._buf[cut:]
134+
if piece:
135+
out.append(piece)
136+
return out
137+
138+
def time_since_last_input_ms(self, now: Optional[float] = None) -> float:
139+
now = now if now is not None else time.monotonic()
140+
return (now - self._last_input_at) * 1000.0
141+
142+
def should_idle_flush(self, now: Optional[float] = None) -> bool:
143+
"""True when the buffer has non-empty content older than ``idle_flush_ms``.
144+
145+
Does not fire while a code fence is open — that text isn't speakable
146+
until the close arrives. EOF drain (``drain(force=True)``) handles the
147+
truly-stuck case.
148+
"""
149+
if self._first_unmatched_fence() >= 0:
150+
return False
151+
if not self._buf.strip():
152+
return False
153+
return self.time_since_last_input_ms(now) >= self.idle_flush_ms
154+
155+
def drain(self, force: bool = False) -> Optional[str]:
156+
"""Return the buffered remainder, clearing it. Returns ``None`` if empty.
157+
158+
Without *force*, holds back when a code fence is still open. With
159+
*force=True* (EOF), returns whatever is in the buffer.
160+
"""
161+
if not force and self._first_unmatched_fence() >= 0:
162+
return None
163+
text = self._buf.strip()
164+
self._buf = ""
165+
if not text:
166+
return None
167+
return text
168+
169+
170+
# Async iterator yielding either a text chunk or ``None`` to signal EOF.
171+
InputReader = AsyncIterator[Optional[str]]
172+
# Coroutine: (text, lang, output_path, *, voice, prosody) -> None
173+
AudioGenerator = Callable
174+
175+
176+
async def _stdin_pump(queue: "asyncio.Queue[Optional[str]]") -> None:
177+
"""Forward ``sys.stdin`` to *queue* line-by-line via a daemon thread.
178+
179+
Pushes ``None`` on EOF. Used in production; tests inject ``input_reader``
180+
or pre-populated queues instead.
181+
"""
182+
loop = asyncio.get_running_loop()
183+
184+
def _reader() -> None:
185+
try:
186+
for line in sys.stdin:
187+
asyncio.run_coroutine_threadsafe(queue.put(line), loop)
188+
finally:
189+
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
190+
191+
threading.Thread(target=_reader, daemon=True).start()
192+
193+
194+
async def live_loop(
195+
*,
196+
lang: str = "en",
197+
voice: Optional[str] = None,
198+
prosody=None,
199+
idle_flush_ms: int = DEFAULT_IDLE_FLUSH_MS,
200+
show_player_gui: bool = False,
201+
sanitize: bool = True,
202+
max_concurrent: int = DEFAULT_MAX_CONCURRENT,
203+
input_reader: Optional[InputReader] = None,
204+
audio_generator: Optional[AudioGenerator] = None,
205+
player: Optional[StreamingAudioPlayer] = None,
206+
tmp_dir: Optional[str] = None,
207+
) -> None:
208+
"""Read text and speak it sentence-by-sentence as it arrives.
209+
210+
Production: pass nothing (stdin + StreamingAudioPlayer + fast_generate_audio).
211+
Tests: inject ``input_reader``, ``audio_generator``, and/or ``player``.
212+
"""
213+
buf = SentenceBuffer(idle_flush_ms=idle_flush_ms)
214+
owns_tmp = tmp_dir is None
215+
tmp_dir = tmp_dir or tempfile.mkdtemp(prefix="ttska-live-")
216+
owns_player = player is None
217+
if player is None:
218+
player = StreamingAudioPlayer(show_gui=show_player_gui)
219+
gen = audio_generator or fast_generate_audio
220+
221+
chunk_index = 0
222+
pending: List[asyncio.Task] = []
223+
sem = asyncio.Semaphore(max_concurrent)
224+
225+
player.start()
226+
227+
async def speak(sentence: str) -> None:
228+
nonlocal chunk_index
229+
idx = chunk_index
230+
chunk_index += 1
231+
text = replace_not_readable(sentence) if sanitize else sentence
232+
if not text.strip():
233+
return
234+
async with sem:
235+
path = os.path.join(tmp_dir, f".part_{idx:04d}.mp3")
236+
try:
237+
await gen(text, lang, path, voice=voice, prosody=prosody)
238+
player.add_chunk(path, chunk_index=idx)
239+
except Exception as exc: # noqa: BLE001 — surface to user, keep going
240+
print(f"⚠️ Could not speak sentence {idx}: {exc}",
241+
file=sys.stderr)
242+
243+
def _spawn(s: str) -> None:
244+
pending.append(asyncio.create_task(speak(s)))
245+
246+
# Build the input source.
247+
if input_reader is None:
248+
queue: "asyncio.Queue[Optional[str]]" = asyncio.Queue()
249+
await _stdin_pump(queue)
250+
251+
async def _iter() -> InputReader: # type: ignore[misc]
252+
timeout_s = idle_flush_ms / 1000.0
253+
while True:
254+
try:
255+
item = await asyncio.wait_for(queue.get(), timeout=timeout_s)
256+
except asyncio.TimeoutError:
257+
yield "" # idle marker
258+
continue
259+
yield item
260+
if item is None:
261+
return
262+
263+
input_reader = _iter() # type: ignore[assignment]
264+
265+
try:
266+
async for chunk in input_reader:
267+
if chunk is None:
268+
break
269+
if chunk == "":
270+
# Idle marker from the production pump.
271+
if buf.should_idle_flush():
272+
tail = buf.drain()
273+
if tail:
274+
_spawn(tail)
275+
continue
276+
for s in buf.feed(chunk):
277+
_spawn(s)
278+
279+
# EOF: drain remainder, force past any unclosed fence.
280+
tail = buf.drain(force=True)
281+
if tail:
282+
_spawn(tail)
283+
284+
if pending:
285+
await asyncio.gather(*pending, return_exceptions=True)
286+
finally:
287+
try:
288+
player.finish_generation()
289+
except Exception:
290+
pass
291+
if owns_player:
292+
try:
293+
player.wait_for_completion()
294+
except Exception:
295+
pass
296+
try:
297+
await cleanup_http()
298+
except Exception:
299+
pass
300+
if owns_tmp:
301+
try:
302+
import shutil
303+
shutil.rmtree(tmp_dir, ignore_errors=True)
304+
except Exception:
305+
pass

src/TTS_ka/main.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,18 @@ def main() -> None:
449449
default=None,
450450
help="Print a shell completion script and exit.",
451451
)
452+
parser.add_argument(
453+
"--live",
454+
action="store_true",
455+
help="Read stdin incrementally and speak each sentence as it lands. "
456+
"For piping AI/LLM output (e.g. `claude … | tts-ka --live -l en`).",
457+
)
458+
parser.add_argument(
459+
"--live-idle-ms",
460+
type=int,
461+
default=800,
462+
help="In --live mode, flush a partial sentence after this many ms of stdin silence (default 800).",
463+
)
452464

453465
args = parser.parse_args(argv_rest)
454466

@@ -537,6 +549,24 @@ def main() -> None:
537549
sys.stdout = sys.stderr
538550
emit = _make_emitter(args.json, json_stream)
539551

552+
# --live: incremental stdin → per-sentence speak. Branches before the
553+
# blocking sys.stdin.read() below so we read line-by-line instead.
554+
if args.live:
555+
prosody_opts = _prosody.build_opts(args.rate, args.pitch, args.volume)
556+
from .live_stream import live_loop as _live_loop
557+
try:
558+
asyncio.run(_live_loop(
559+
lang=args.lang,
560+
voice=args.voice,
561+
prosody=prosody_opts,
562+
idle_flush_ms=args.live_idle_ms,
563+
show_player_gui=show_player,
564+
))
565+
except KeyboardInterrupt:
566+
stop_active_streaming_player()
567+
emit({"event": "error", "message": "cancelled"})
568+
return
569+
540570
# Stdin handling: if no text arg and stdin is piped, or text == "-", read stdin.
541571
if args.text == "-" or (not args.text and not sys.stdin.isatty()):
542572
try:

0 commit comments

Comments
 (0)