Russian narration for the Enshrouded notes: mod 0.4.0, installer, generation pipeline

This commit is contained in:
2026-10-18 15:56:52 +03:00
commit 73dee2d786
557 changed files with 4051 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
"""TTS backends: speak(items=[(text, out_wav)], seed) in the cloned narrator voice.
local — qwen-tts in this process on a CUDA GPU (any machine, the default);
media_node — an MCP server with generate_speech (the author's home GPU node; see media_node.py).
"""
def backend(cfg, ref_wav, ref_text):
name = cfg.get("tts", "backend", "local")
if name == "local":
from .local import LocalTTS
return LocalTTS(cfg, ref_wav, ref_text)
if name == "media_node":
from .media_node import MediaNodeTTS
return MediaNodeTTS(cfg)
raise SystemExit(f"unknown tts.backend {name!r}")
+33
View File
@@ -0,0 +1,33 @@
"""qwen-tts (Qwen3-TTS 1.7B, fine-tune siriusfreak/qwen3-tts-12hz-1.7b-ru-stress-cf) on a local GPU.
ICL voice clone: the reference clip and its exact text are encoded once; texts are spoken in batches
of tts.batch with torch.manual_seed(seed + batch start), so a seed is repeatable for the same segments
in the same order. fp16 on Volta/Turing, bfloat16 on Ampere and newer (tts.dtype).
"""
import soundfile as sf
import torch
class LocalTTS:
def __init__(self, cfg, ref_wav, ref_text):
from qwen_tts import Qwen3TTSModel
t = cfg["tts"]
self.model = Qwen3TTSModel.from_pretrained(t.get("model", "siriusfreak/qwen3-tts-12hz-1.7b-ru-stress-cf"),
device_map=t.get("device", "cuda:0"),
dtype=getattr(torch, t.get("dtype", "bfloat16")))
self.batch = int(t.get("batch", 8))
self.set_voice(ref_wav, ref_text)
def set_voice(self, ref_wav, ref_text):
self.prompt = self.model.create_voice_clone_prompt(ref_audio=str(ref_wav), ref_text=ref_text)
def speak(self, items, seed, log=print):
items = sorted(items, key=lambda x: len(x[0])) # similar lengths in one batch: less padding
for i in range(0, len(items), self.batch):
b = items[i:i + self.batch]
torch.manual_seed(seed + i)
wavs, sr = self.model.generate_voice_clone(text=[t for t, _ in b], language=["Russian"] * len(b),
voice_clone_prompt=self.prompt * len(b))
for (_, out), w in zip(b, wavs):
sf.write(out, w, sr)
log(f"seed {seed}: {min(i + self.batch, len(items))}/{len(items)}")
+75
View File
@@ -0,0 +1,75 @@
"""generate_speech over MCP streamable HTTP (media-node, the author's home GPU node with qwentts.cpp).
Needs a server with the ru-stress model and a saved project voice (tts.media_node.voice, e.g. made with
its save_voice tool from the reference clip). Batches of up to 64 texts / 29000 characters per call.
"""
import itertools
import json
import urllib.request
_ids = itertools.count(1)
class MCP:
def __init__(self, url):
self.url, self.sid = url, None
self._post({"jsonrpc": "2.0", "id": next(_ids), "method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "ensvoice", "version": "0.4"}}})
self._post({"jsonrpc": "2.0", "method": "notifications/initialized"}, expect=False)
def _post(self, msg, expect=True, timeout=7200):
h = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
if self.sid:
h["Mcp-Session-Id"] = self.sid
with urllib.request.urlopen(urllib.request.Request(self.url, json.dumps(msg).encode(), h), timeout=timeout) as r:
self.sid = r.headers.get("Mcp-Session-Id", self.sid)
body = r.read().decode()
if not expect:
return None
if body.lstrip().startswith("{"):
return json.loads(body)
last = None
for line in body.splitlines():
if line.startswith("data:"):
d = json.loads(line[5:])
if d.get("id") == msg.get("id"):
last = d
return last
def call(self, name, args):
r = self._post({"jsonrpc": "2.0", "id": next(_ids), "method": "tools/call",
"params": {"name": name, "arguments": args}})
if "error" in r:
raise RuntimeError(r["error"])
return r["result"]
class MediaNodeTTS:
def __init__(self, cfg):
c = cfg["tts"]["media_node"]
self.url, self.voice = c["url"], c["voice"]
self.rewrite = c.get("download_host_rewrite") # ["from", "to"] if file URLs name another host
self.m = MCP(self.url)
def speak(self, items, seed, log=print):
batches, cur, c = [], [], 0
for it in items:
if cur and (len(cur) == 64 or c + len(it[0]) > 29000):
batches.append(cur); cur, c = [], 0
cur.append(it); c += len(it[0])
if cur:
batches.append(cur)
for bi, b in enumerate(batches):
for attempt in range(3):
try:
r = self.m.call("generate_speech", {"model": "ru-stress", "voice": self.voice, "language": "Russian",
"seed": seed, "texts": [{"text": t} for t, _ in b]})
break
except Exception as ex: # a backend timeout: once more with a new session
log(f"retry {bi}: {ex}"); self.m = MCP(self.url)
files = sorted(r["structuredContent"]["files"], key=lambda f: f["item"])
for (_, out), f in zip(b, files):
url = f["url"] if not self.rewrite else f["url"].replace(*self.rewrite)
urllib.request.urlretrieve(url, out)
log(f"seed {seed}: batch {bi + 1}/{len(batches)}")