""" MOSS-TTS-Realtime — Hugging Face Space Adapted from the official app.py with proper streaming via MossTTSRealtimeStreamingSession + AudioStreamDecoder + Web Audio API. """ import base64 import functools import importlib.util import json import os import subprocess import sys import time from collections import OrderedDict from dataclasses import dataclass from pathlib import Path from typing import Callable, Iterator, Sequence # ── spaces (ZeroGPU — must come before other imports) ───────────────────────── try: import spaces except ImportError: class _SpacesFallback: @staticmethod def GPU(*_args, **_kwargs): def _decorator(func): return func return _decorator spaces = _SpacesFallback() # ── Repo setup ───────────────────────────────────────────────────────────────── REPO_DIR = Path("/home/user/moss-tts") REALTIME_DIR = REPO_DIR / "moss_tts_realtime" def _run(*cmd, **kw): subprocess.check_call(list(cmd), **kw) if not REPO_DIR.exists(): print("[INFO] Cloning MOSS-TTS repository…") _run("git", "clone", "--depth=1", "https://github.com/OpenMOSS/MOSS-TTS.git", str(REPO_DIR)) print("[INFO] Clone complete.") for p in [str(REALTIME_DIR), str(REPO_DIR)]: if p not in sys.path: sys.path.insert(0, p) # ── Core imports ─────────────────────────────────────────────────────────────── import gradio as gr import numpy as np import torch import torch._dynamo torch._dynamo.config.cache_size_limit = 64 torch._dynamo.config.suppress_errors = True os.environ.setdefault("TORCHDYNAMO_DISABLE", "1") # disable compile globally — avoids torch.device ConstantVariable bug import torchaudio from transformers import AutoTokenizer, AutoModel from mossttsrealtime import MossTTSRealtime, MossTTSRealtimeProcessor from mossttsrealtime.streaming_mossttsrealtime import ( AudioStreamDecoder, MossTTSRealtimeInference, MossTTSRealtimeStreamingSession, ) torch.backends.cuda.enable_cudnn_sdp(False) torch.backends.cuda.enable_flash_sdp(True) torch.backends.cuda.enable_mem_efficient_sdp(True) torch.backends.cuda.enable_math_sdp(True) # ── Constants ────────────────────────────────────────────────────────────────── MODEL_PATH = "OpenMOSS-Team/MOSS-TTS-Realtime" TOKENIZER_PATH = "OpenMOSS-Team/MOSS-TTS-Realtime" CODEC_MODEL_PATH = "OpenMOSS-Team/MOSS-Audio-Tokenizer" SAMPLE_RATE = 24_000 SUPPORTED_LANGUAGES = ( "Chinese, English, Turkish, Korean, Japanese, German, French, " "Spanish, Portuguese, Italian, Russian, Arabic, Hindi, " "Indonesian, Vietnamese, Dutch, Polish, Swedish, Danish, Norwegian" ) # ── Dataclasses (mirrors official app.py) ───────────────────────────────────── @dataclass(frozen=True) class GenerationConfig: temperature: float top_p: float top_k: int repetition_penalty: float repetition_window: int do_sample: bool max_length: int @dataclass(frozen=True) class StreamingConfig: text_chunk_tokens: int decode_chunk_frames: int decode_overlap_frames: int chunk_duration: float @dataclass(frozen=True) class StreamEvent: message: str audio: "tuple[int, np.ndarray] | None" = None # ── Backend loader ───────────────────────────────────────────────────────────── @functools.lru_cache(maxsize=1) def _load_backend(model_path: str, tokenizer_path: str, codec_path: str, device_str: str, attn_impl: str): if not torch.cuda.is_available(): raise RuntimeError("CUDA is required.") device = torch.device(device_str) tokenizer = AutoTokenizer.from_pretrained(tokenizer_path) processor = MossTTSRealtimeProcessor(tokenizer) dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16 model = MossTTSRealtime.from_pretrained( model_path, attn_implementation=attn_impl, torch_dtype=dtype ).to(device).eval() # Disable torch.compile on the local transformer — torch.device causes # a ConstantVariable assertion error in torch 2.9.1+cu128 dynamo. codec = AutoModel.from_pretrained( codec_path, trust_remote_code=True ).eval().to(device) print("[INFO] Backend loaded.") return model, tokenizer, processor, codec, device def resolve_attn(device_str: str) -> str: if not torch.cuda.is_available(): return "eager" dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16 if (importlib.util.find_spec("flash_attn") is not None and dtype in {torch.float16, torch.bfloat16}): major, _ = torch.cuda.get_device_capability() if major >= 8: return "flash_attention_2" return "sdpa" # ── Audio helpers ────────────────────────────────────────────────────────────── _audio_token_cache: OrderedDict = OrderedDict() _CACHE_SIZE = 8 def _load_audio(path: Path) -> torch.Tensor: wav, sr = torchaudio.load(path) if sr != SAMPLE_RATE: wav = torchaudio.functional.resample(wav, sr, SAMPLE_RATE) if wav.shape[0] > 1: wav = wav.mean(dim=0, keepdim=True) return wav def _extract_codes(encode_result): if isinstance(encode_result, dict): codes = encode_result["audio_codes"] elif isinstance(encode_result, (list, tuple)) and encode_result: codes = encode_result[0] else: codes = encode_result if isinstance(codes, np.ndarray): codes = torch.from_numpy(codes) if isinstance(codes, torch.Tensor) and codes.dim() == 3: codes = codes[:, 0, :] if codes.shape[1] == 1 else codes[0] return codes def _encode_audio_tokens(path: Path, codec, device: torch.device, chunk_duration: float) -> np.ndarray: key = (str(path.resolve()), int(path.stat().st_mtime_ns), chunk_duration) if key in _audio_token_cache: _audio_token_cache.move_to_end(key) return _audio_token_cache[key] with torch.inference_mode(): wav = _load_audio(path).to(device) if wav.dim() == 2: wav = wav.unsqueeze(0) result = codec.encode(wav, chunk_duration=chunk_duration) tokens = _extract_codes(result) if isinstance(tokens, torch.Tensor): tokens = tokens.detach().cpu().numpy() _audio_token_cache[key] = tokens _audio_token_cache.move_to_end(key) while len(_audio_token_cache) > _CACHE_SIZE: _audio_token_cache.popitem(last=False) return tokens def _sanitize_tokens(tokens: torch.Tensor, codebook_size: int, audio_eos_token: int) -> tuple: if tokens.dim() == 1: tokens = tokens.unsqueeze(0) if tokens.numel() == 0: return tokens, False eos_rows = (tokens[:, 0] == audio_eos_token).nonzero(as_tuple=False) invalid_rows = ((tokens < 0) | (tokens >= codebook_size)).any(dim=1) stop_idx = None if eos_rows.numel() > 0: stop_idx = int(eos_rows[0].item()) if invalid_rows.any(): inv = int(invalid_rows.nonzero(as_tuple=False)[0].item()) stop_idx = inv if stop_idx is None else min(stop_idx, inv) if stop_idx is not None: return tokens[:stop_idx], True return tokens, False def _decode_frames(audio_frames, decoder: AudioStreamDecoder, codebook_size: int, audio_eos_token: int) -> Iterator[np.ndarray]: for frame in audio_frames: tokens = frame if tokens.dim() == 3: tokens = tokens[0] if tokens.dim() != 2: continue tokens, _ = _sanitize_tokens(tokens, codebook_size, audio_eos_token) if tokens.numel() == 0: continue decoder.push_tokens(tokens.detach()) for wav in decoder.audio_chunks(): if wav.numel() > 0: yield wav.detach().cpu().numpy().reshape(-1) def _flush_decoder(decoder: AudioStreamDecoder) -> Iterator[np.ndarray]: final = decoder.flush() if final is not None and final.numel() > 0: yield final.detach().cpu().numpy().reshape(-1) def _encode_chunk(sr: int, chunk: np.ndarray, idx: int) -> str: chunk = chunk.astype(np.float32).reshape(-1) return json.dumps({ "sr": int(sr), "idx": int(idx), "data": base64.b64encode(chunk.tobytes()).decode("ascii"), }) # ── Core streaming pipeline ──────────────────────────────────────────────────── def _build_text_only_turn(processor, user_text: str, prompt_tokens) -> np.ndarray: system_prompt = processor.make_ensemble(prompt_tokens) user_text_str = "<|im_end|>\n<|im_start|>user\n" + user_text + \ "<|im_end|>\n<|im_start|>assistant\n" user_tok = processor.tokenizer(user_text_str)["input_ids"] user_arr = np.full((len(user_tok), processor.channels + 1), fill_value=processor.audio_channel_pad, dtype=np.int64) user_arr[:, 0] = np.asarray(user_tok, dtype=np.int64) return np.concatenate([system_prompt, user_arr], axis=0) def run_stream( assistant_text: str, prompt_audio_path: "str | None", user_text: str, gen: GenerationConfig, streaming: StreamingConfig, attn_impl: str, ) -> Iterator[StreamEvent]: model, tokenizer, processor, codec, device = _load_backend( MODEL_PATH, TOKENIZER_PATH, CODEC_MODEL_PATH, "cuda:0", attn_impl ) prompt_tokens = None if prompt_audio_path and Path(prompt_audio_path).exists(): prompt_tokens = _encode_audio_tokens( Path(prompt_audio_path), codec, device, streaming.chunk_duration ) inferencer = MossTTSRealtimeInference( model, tokenizer, max_length=gen.max_length ) inferencer.reset_generation_state(keep_cache=False) session = MossTTSRealtimeStreamingSession( inferencer, processor, codec=codec, codec_sample_rate=SAMPLE_RATE, codec_encode_kwargs={"chunk_duration": streaming.chunk_duration}, prefill_text_len=processor.delay_tokens_len, temperature=gen.temperature, top_p=gen.top_p, top_k=gen.top_k, do_sample=gen.do_sample, repetition_penalty=gen.repetition_penalty, repetition_window=gen.repetition_window, ) if prompt_tokens is not None: session.set_voice_prompt_tokens(prompt_tokens) else: session.clear_voice_prompt() turn_input = _build_text_only_turn(processor, user_text, prompt_tokens) session.reset_turn( input_ids=turn_input, include_system_prompt=True, reset_cache=True ) decoder = AudioStreamDecoder( codec, chunk_frames=streaming.decode_chunk_frames, overlap_frames=streaming.decode_overlap_frames, decode_kwargs={"chunk_duration": -1}, device=device, ) codebook_size = int(getattr(codec, "codebook_size", 1024)) audio_eos_token = int(getattr(inferencer, "audio_eos_token", 1026)) text_tokens = tokenizer.encode(assistant_text, add_special_tokens=False) if not text_tokens: raise RuntimeError("No tokens from assistant text.") chunk_size = max(1, streaming.text_chunk_tokens) token_chunks = [text_tokens[i:i+chunk_size] for i in range(0, len(text_tokens), chunk_size)] with codec.streaming(batch_size=1): for chunk in token_chunks: audio_frames = session.push_text_tokens(chunk) yield from ( StreamEvent(message="Streaming", audio=(SAMPLE_RATE, w)) for w in _decode_frames(audio_frames, decoder, codebook_size, audio_eos_token) ) audio_frames = session.end_text() yield from ( StreamEvent(message="Finalizing", audio=(SAMPLE_RATE, w)) for w in _decode_frames(audio_frames, decoder, codebook_size, audio_eos_token) ) while True: audio_frames = session.drain(max_steps=1) if not audio_frames: break yield from ( StreamEvent(message="Draining", audio=(SAMPLE_RATE, w)) for w in _decode_frames(audio_frames, decoder, codebook_size, audio_eos_token) ) if session.inferencer.is_finished: break yield from ( StreamEvent(message="Flushing", audio=(SAMPLE_RATE, w)) for w in _flush_decoder(decoder) ) yield StreamEvent(message="Done") # ── Gradio GPU wrapper ───────────────────────────────────────────────────────── @spaces.GPU(duration=120) def generate( user_text: str, assistant_text: str, prompt_audio, temperature: float, top_p: float, top_k: int, repetition_penalty: float, repetition_window: int, text_chunk_tokens: int, decode_chunk_frames: int, decode_overlap_frames: int, chunk_duration: float, ): if not assistant_text.strip(): gr.Warning("Please enter assistant text to synthesize.") return attn_impl = resolve_attn("cuda:0") gen = GenerationConfig( temperature=temperature, top_p=top_p, top_k=int(top_k), repetition_penalty=repetition_penalty, repetition_window=int(repetition_window), do_sample=True, max_length=3000, ) streaming = StreamingConfig( text_chunk_tokens=int(text_chunk_tokens), decode_chunk_frames=int(decode_chunk_frames), decode_overlap_frames=int(decode_overlap_frames), chunk_duration=float(chunk_duration), ) started_at = time.monotonic() first_chunk_at = None full_chunks = [] chunk_index = 0 stream_reset = json.dumps({"reset": True}) yield stream_reset, None, "Starting…" try: for event in run_stream( assistant_text=assistant_text, prompt_audio_path=prompt_audio, user_text=user_text or "Hello!", gen=gen, streaming=streaming, attn_impl=attn_impl, ): if event.audio is None: yield gr.update(), gr.update(), event.message continue sr, chunk = event.audio chunk = np.asarray(chunk, dtype=np.float32).reshape(-1) if chunk.size == 0: continue if first_chunk_at is None: first_chunk_at = time.monotonic() full_chunks.append(chunk) chunk_index += 1 ttfb_ms = (first_chunk_at - started_at) * 1000 if first_chunk_at else float("nan") payload = _encode_chunk(sr, chunk, chunk_index) yield payload, gr.update(), f"Streaming… chunks={chunk_index} | TTFB={ttfb_ms:.0f}ms" if full_chunks: full_audio = np.concatenate(full_chunks) elapsed = time.monotonic() - started_at audio_sec = full_audio.size / SAMPLE_RATE rtf = elapsed / audio_sec if audio_sec > 0 else float("inf") ttfb_ms = (first_chunk_at - started_at) * 1000 if first_chunk_at else float("nan") done_msg = ( f"Done | chunks={chunk_index} | audio={audio_sec:.2f}s | " f"elapsed={elapsed:.2f}s | RTF={rtf:.3f} | TTFB={ttfb_ms:.0f}ms" f"{' ✅' if rtf < 1.0 else ' ⚠️'}" ) yield gr.update(), (SAMPLE_RATE, full_audio), done_msg else: yield gr.update(), gr.update(), "Done | no audio chunks emitted" except Exception as exc: import traceback; traceback.print_exc() yield gr.update(), gr.update(), f"Error: {exc}" # ── Gradio UI ────────────────────────────────────────────────────────────────── STREAM_PLAYER_HTML = """