| |
| """ |
| A tiny WebSocket TTS server for VoXtream. |
| |
| Protocol (very simple): |
| |
| Client connects to ws://HOST:PORT/voxtream and first sends a JSON text frame: |
| |
| { |
| "event": "init", |
| // Either provide a path on the server or a base64-encoded wav/ogg data string: |
| "prompt_audio_path": "/path/to/prompt.wav", // optional if prompt_audio_b64 given |
| "prompt_audio_b64": "data:audio/wav;base64,...", // optional if prompt_audio_path given |
| // Provide text *either* as a single string: |
| "text": "Hello world, streaming back now!", |
| // Or stream text afterwards using {"event":"text","chunk":"..."} messages. |
| // Optional knobs: |
| "sample_rate": null, // if null, use config.mimi_sr |
| "full_stream": true // when true, we'll treat following "text" events as streaming input |
| } |
| |
| Then, if full_stream=true and no "text" field was provided in init: |
| - send any number of JSON text frames: {"event":"text","chunk":"next words..."} |
| - when done, send {"event":"eot"} (end of text) |
| |
| Server responses: |
| - First a JSON text frame with synthesis config: |
| {"type":"config","sample_rate":24000,"dtype":"float32","channels":1} |
| |
| - Then many binary frames, each = one audio frame (float32 PCM, mono, little-endian). |
| You can play them as they arrive. |
| |
| - Final JSON text frame: |
| {"type":"eos"} |
| """ |
|
|
| import asyncio |
| import base64 |
| import json |
| import os |
| import re |
| import tempfile |
| from contextlib import asynccontextmanager |
| from pathlib import Path |
| from typing import Iterator, Optional |
|
|
| import numpy as np |
| import uvicorn |
| from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect |
| from fastapi.responses import HTMLResponse |
|
|
| from voxtream.generator import SpeechGenerator, SpeechGeneratorConfig |
| from voxtream.utils.generator import set_seed |
|
|
| |
|
|
| DATA_URL_RE = re.compile(r"^data:.*?;base64,(.*)$", re.IGNORECASE) |
|
|
|
|
| def _b64_to_bytes(s: str) -> bytes: |
| m = DATA_URL_RE.match(s) |
| payload = m.group(1) if m else s |
| return base64.b64decode(payload) |
|
|
|
|
| def _ensure_prompt_audio_file( |
| prompt_audio_path: Optional[str], prompt_audio_b64: Optional[str] |
| ) -> Path: |
| """ |
| Returns a filesystem Path to the prompt audio. |
| If base64 is provided, writes a temp file with the decoded bytes (wav/ogg input supported by voxtream). |
| """ |
| if prompt_audio_path: |
| return Path(prompt_audio_path) |
| if not prompt_audio_b64: |
| raise ValueError( |
| "Either 'prompt_audio_path' or 'prompt_audio_b64' must be provided." |
| ) |
| raw = _b64_to_bytes(prompt_audio_b64) |
| |
| suffix = ".wav" |
| if prompt_audio_b64.lower().startswith("data:audio/ogg"): |
| suffix = ".ogg" |
| fd, tmp = tempfile.mkstemp(prefix="voxtream_prompt_", suffix=suffix) |
| with os.fdopen(fd, "wb") as f: |
| f.write(raw) |
| return Path(tmp) |
|
|
|
|
| def get_generator_from_state( |
| app: FastAPI, |
| ) -> tuple[SpeechGenerator, SpeechGeneratorConfig]: |
| try: |
| return app.state.speech_generator, app.state.config |
| except AttributeError as err: |
| raise HTTPException( |
| status_code=503, detail="Model is initializing, try again" |
| ) from err |
|
|
|
|
| @asynccontextmanager |
| async def lifespan(app: FastAPI): |
| set_seed() |
| config_path = "configs/generator.json" |
| with open(config_path) as f: |
| config = SpeechGeneratorConfig(**json.load(f)) |
| spk_rate_config_path = "configs/speaking_rate.json" |
| with open(spk_rate_config_path) as f: |
| spk_rate_config = json.load(f) |
|
|
| app.state.config = config |
| app.state.speech_generator = SpeechGenerator(config, spk_rate_config) |
|
|
| try: |
| yield |
| finally: |
| |
| pass |
|
|
|
|
| class _QueueIterator(Iterator[str]): |
| """ |
| Iterator backed by an asyncio.Queue, to be consumed from a non-async thread. |
| We capture the main asyncio loop and use run_coroutine_threadsafe(q.get()). |
| Putting None into the queue signals StopIteration. |
| """ |
|
|
| def __init__( |
| self, q: "asyncio.Queue[Optional[str]]", loop: asyncio.AbstractEventLoop |
| ): |
| self._q = q |
| self._loop = loop |
|
|
| def __iter__(self): |
| return self |
|
|
| def __next__(self) -> str: |
| item = asyncio.run_coroutine_threadsafe(self._q.get(), self._loop).result() |
| if item is None: |
| raise StopIteration |
| return item |
|
|
|
|
| |
|
|
| app = FastAPI(title="VoXtream WebSocket TTS", lifespan=lifespan) |
|
|
|
|
| @app.get("/") |
| def index(): |
| return HTMLResponse( |
| """ |
| <html> |
| <body> |
| <h2>VoXtream WebSocket is running.</h2> |
| <p>Connect a client to <code>/voxtream</code> and follow the protocol in server.py docstring.</p> |
| </body> |
| </html> |
| """ |
| ) |
|
|
|
|
| @app.websocket("/voxtream") |
| async def synthesis(ws: WebSocket): |
| await ws.accept() |
| loop = asyncio.get_running_loop() |
|
|
| try: |
| speech_generator, config = get_generator_from_state(ws.app) |
| except AttributeError: |
| |
| await ws.close(code=1013, reason="Service unavailable, initializing") |
| return |
|
|
| try: |
| |
| init_msg = await ws.receive_text() |
| try: |
| init = json.loads(init_msg) |
| except Exception: |
| await ws.send_text( |
| json.dumps( |
| {"type": "error", "message": "First message must be JSON init."} |
| ) |
| ) |
| await ws.close() |
| return |
|
|
| if init.get("event") != "init": |
| await ws.send_text( |
| json.dumps( |
| { |
| "type": "error", |
| "message": "First message must be {'event':'init',...}.", |
| } |
| ) |
| ) |
| await ws.close() |
| return |
|
|
| prompt_audio_path: Optional[str] = init.get("prompt_audio_path") |
| prompt_audio_b64: Optional[str] = init.get("prompt_audio_b64") |
| text_initial: Optional[str] = init.get("text") |
| full_stream: bool = bool(init.get("full_stream", False)) |
|
|
| |
| sample_rate = config.mimi_sr |
|
|
| try: |
| prompt_path = _ensure_prompt_audio_file(prompt_audio_path, prompt_audio_b64) |
| except Exception as e: |
| await ws.send_text( |
| json.dumps({"type": "error", "message": f"Invalid prompt audio: {e}"}) |
| ) |
| await ws.close() |
| return |
|
|
| |
| await ws.send_text( |
| json.dumps( |
| { |
| "type": "config", |
| "sample_rate": sample_rate, |
| "dtype": "float32", |
| "channels": 1, |
| } |
| ) |
| ) |
|
|
| |
| |
| text_source: str | Iterator[str] |
| queue: Optional["asyncio.Queue[Optional[str]]"] = None |
|
|
| |
| if full_stream: |
| queue: "asyncio.Queue[Optional[str]]" = asyncio.Queue() |
| feeder_done = asyncio.Event() |
|
|
| async def recv_text_chunks(): |
| try: |
| if text_initial: |
| await queue.put(text_initial) |
| while True: |
| msg = await ws.receive() |
| if msg["type"] == "websocket.disconnect": |
| break |
| if "text" in msg: |
| try: |
| payload = json.loads(msg["text"]) |
| except json.JSONDecodeError: |
| |
| await queue.put(msg["text"]) |
| continue |
|
|
| ev = payload.get("event") |
| if ev == "text": |
| chunk = payload.get("chunk", "") |
| if chunk: |
| await queue.put(chunk) |
| elif ev == "eot": |
| break |
| |
| finally: |
| await queue.put(None) |
| feeder_done.set() |
|
|
| asyncio.create_task(recv_text_chunks()) |
| text_source = _QueueIterator(queue, loop) |
| else: |
| |
| text_source = text_initial or "" |
|
|
| |
| audio_q: "asyncio.Queue[tuple[Optional[np.ndarray], Optional[str]]]" = ( |
| asyncio.Queue(maxsize=8) |
| ) |
| done_evt = asyncio.Event() |
|
|
| def _run_generator(): |
| return speech_generator.generate_stream( |
| prompt_audio_path=prompt_path, |
| text=text_source, |
| ) |
|
|
| def _worker(): |
| err: Optional[str] = None |
| try: |
| for audio_frame, _meta in _run_generator(): |
| asyncio.run_coroutine_threadsafe( |
| audio_q.put((audio_frame, None)), loop |
| ).result() |
| except Exception as e: |
| err = str(e) |
| finally: |
| |
| asyncio.run_coroutine_threadsafe( |
| audio_q.put((None, err)), loop |
| ).result() |
| |
| loop.call_soon_threadsafe(done_evt.set) |
|
|
| import threading |
|
|
| threading.Thread(target=_worker, daemon=True).start() |
|
|
| |
| await ws.send_text( |
| json.dumps( |
| { |
| "type": "config", |
| "sample_rate": config.mimi_sr, |
| "dtype": "float32", |
| "channels": 1, |
| } |
| ) |
| ) |
|
|
| |
| while True: |
| frame, err = await audio_q.get() |
| if frame is None: |
| if err: |
| |
| try: |
| await ws.send_text( |
| json.dumps({"type": "error", "message": err}) |
| ) |
| except Exception: |
| pass |
| break |
| if frame.dtype != np.float32: |
| frame = frame.astype(np.float32, copy=False) |
| frame = np.ascontiguousarray(frame) |
| await ws.send_bytes(frame.tobytes()) |
|
|
| |
| try: |
| await ws.send_text(json.dumps({"type": "eos"})) |
| except Exception: |
| pass |
| try: |
| await ws.close() |
| except Exception: |
| pass |
|
|
| except WebSocketDisconnect: |
| return |
| except Exception as e: |
| |
| try: |
| await ws.send_text(json.dumps({"type": "error", "message": str(e)})) |
| await ws.close() |
| except Exception: |
| pass |
|
|
|
|
| def main(): |
| uvicorn.run(app, host="0.0.0.0", port=7860, reload=False) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|