Add offline desktop speech with Silero Xenia for Russian

This commit is contained in:
sergeych committed 2026-09-28 13:45:00 +04:00
1 parent 9cd8ebc422
commit 9dbe30b20c
28 files changed
+1865 -16

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Download the pinned Supertonic 3 CPU model. No packages or credentials required."""
import hashlib
import json
from pathlib import Path
import sys
import urllib.request
ROOT = Path(__file__).resolve().parent.parent
MANIFEST = json.loads((ROOT / "scripts/supertonic-3-model.json").read_text())
def valid(path, entry):
if not path.is_file() or path.stat().st_size != entry["size"]:
return False
digest = hashlib.sha256() if "sha256" in entry else hashlib.sha1()
if "git_blob" in entry:
digest.update(f"blob {entry['size']}\0".encode())
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest() == entry.get("sha256", entry.get("git_blob"))
def download(manifest, destination):
total = sum(entry["size"] for entry in manifest["files"])
print(f"{manifest['repository']}: {total / 1e6:.1f} MB; destination: {destination}", flush=True)
base = f"https://huggingface.co/{manifest['repository']}/resolve/{manifest['revision']}"
for entry in manifest["files"]:
target = destination / entry["path"]
if valid(target, entry):
print(f"Verified {entry['path']}", flush=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(target.suffix + ".part")
print(f"Downloading {entry['path']} ({entry['size'] / 1e6:.1f} MB)", flush=True)
try:
with urllib.request.urlopen(f"{base}/{entry['path']}", timeout=60) as source, temporary.open("wb") as output:
while chunk := source.read(1024 * 1024):
output.write(chunk)
if not valid(temporary, entry):
raise RuntimeError(f"Checksum mismatch: {entry['path']}")
temporary.replace(target)
finally:
temporary.unlink(missing_ok=True)
def main():
destination = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / ".local/tts/supertonic-3"
print("Supertonic model license: OpenRAIL-M (saved as LICENSE alongside the model).", flush=True)
download(MANIFEST, destination)
print("Ready. Run ./gradlew :composeApp:run and use Read aloud in a book.", flush=True)
if __name__ == "__main__":
main()
+231
View File
@@ -0,0 +1,231 @@
{
"repository": "ruaccent/accentuator",
"revision": "b78ae5ea1e62beaf138bed1865cd8c3b0b5ca855",
"files": [
{
"path": "README.md",
"size": 2660,
"git_blob": "7f12645cf20ed21742fd63a3b799efd8c7007734"
},
{
"path": "dictionary/accents.json.gz",
"size": 20954156,
"sha256": "aa460ebba90de00fbbf3d41d121961f605b98667e45efb7920f127473b15515e"
},
{
"path": "dictionary/accents_nn.json.gz",
"size": 845996,
"sha256": "8395664000b80c1afe09bfea3650945b0933482b8e3dee5bb9d429eb18c44935"
},
{
"path": "dictionary/omographs.json.gz",
"size": 219047,
"sha256": "04a9e81c68d65f65ba493fe0110f99e79087548c2beeec3032e2b66e28706f36"
},
{
"path": "dictionary/rule_engine/accents.json",
"size": 694782,
"git_blob": "91690536e14e133fe4017941a466fde663b96011"
},
{
"path": "dictionary/rule_engine/forms.json",
"size": 2669,
"git_blob": "6686cd615fb55c09976550aeba3ad6687ecc32dd"
},
{
"path": "dictionary/yo_homographs.json.gz",
"size": 5747,
"sha256": "c4ee777bbbab87f9eac838f370ad92974e079d02b21903e480c54b5f0c8c60d1"
},
{
"path": "dictionary/yo_words.json.gz",
"size": 548914,
"sha256": "a19fa89a964a0691d9fe4ee384783e3934904891843d8f59a1c480d67947a82a"
},
{
"path": "koziev/rulemma/rulemma.dat",
"size": 16703198,
"sha256": "bf2b3ef3ff7a0aa6e4250aa4e9c8ed568e25f825deebdb12dee1b46b785ba9fc"
},
{
"path": "koziev/rulemma/rulemma.py",
"size": 11166,
"git_blob": "a182b7d1694b13e8ac6807e2701ad17bf4064bbe"
},
{
"path": "koziev/rupostagger/__init__.py",
"size": 111,
"git_blob": "01bd02ccfc68c851542390a6d0a684481f897bb9"
},
{
"path": "koziev/rupostagger/database/ruword2tags.db",
"size": 168816640,
"sha256": "a06848e656bef642aafb4440c03554fa78f2f32dde92ea66f3f86ce9977b167e"
},
{
"path": "koziev/rupostagger/rupostagger.config",
"size": 276,
"git_blob": "4794511fc1b5ef4bc649fab5eafdf6b775206b72"
},
{
"path": "koziev/rupostagger/rupostagger.model",
"size": 2417464,
"sha256": "21b7b0bfd7427b5fdc1604052176db8aa3b139b3ce03be440cfce48536f8e5ef"
},
{
"path": "koziev/rupostagger/rupostagger.py",
"size": 7118,
"git_blob": "198a8db45678a9578dfad5bbf01d3efd8e95a7c8"
},
{
"path": "koziev/rupostagger/rusyllab.py",
"size": 18975,
"git_blob": "7f606a48ff2a5de6b6388a45caf1d41bd6089611"
},
{
"path": "koziev/rupostagger/ruword2tags.dat",
"size": 9683765,
"sha256": "dde47b5f1d48ff899887ac07812dcabd2966e48e84646f3065bfd06627c2af58"
},
{
"path": "koziev/rupostagger/ruword2tags.py",
"size": 18086,
"git_blob": "71098637747f19e44788506775e4cd1e6c5e7183"
},
{
"path": "nn/nn_accent/big.onnx",
"size": 2285217,
"sha256": "47e69d9ae19f2a82e21b1c70f6a4bbfb1abc5759e98b2e67d009c5e9d7af18c9"
},
{
"path": "nn/nn_accent/config.json",
"size": 841,
"git_blob": "0d1656a1fb93fcea726186c8bfad977c6e27c4a5"
},
{
"path": "nn/nn_accent/model.onnx",
"size": 803402,
"sha256": "4e393144e45626f6f1062a0784ef06f921b97321a8e7b87ac2a09a892286500a"
},
{
"path": "nn/nn_accent/ort_config.json",
"size": 727,
"git_blob": "5a8fc726323dda820ae4e794e4d0d514de07eee2"
},
{
"path": "nn/nn_accent/special_tokens_map.json",
"size": 99,
"git_blob": "1543e951b7c6f9c246a5e441d7162b2c65ab48a5"
},
{
"path": "nn/nn_accent/tokenizer_config.json",
"size": 257,
"git_blob": "5ea10565e634adcd37089652fbf6bb2cf05c1530"
},
{
"path": "nn/nn_accent/vocab.txt",
"size": 140,
"git_blob": "1e5a3d765a6f5a686ed3c6294efb85e9e00a431a"
},
{
"path": "nn/nn_omograph/turbo3.1/added_tokens.json",
"size": 279418,
"git_blob": "23620bf92f2ad9e1243202cff127617fe9415c9a"
},
{
"path": "nn/nn_omograph/turbo3.1/config.json",
"size": 723,
"git_blob": "2ff7dbeb01fffcb05f65ad430b08205fe6668693"
},
{
"path": "nn/nn_omograph/turbo3.1/merges.txt",
"size": 1213606,
"git_blob": "2d4658a914a3760fbf1a3a9c72b42eefcf8d8ded"
},
{
"path": "nn/nn_omograph/turbo3.1/model.onnx",
"size": 359306923,
"sha256": "2cb6a174c4cdb45bd3132b4f7c8a3779fc4b6869863180ed7d0e421bcd453dbd"
},
{
"path": "nn/nn_omograph/turbo3.1/special_tokens_map.json",
"size": 280,
"git_blob": "d5698132694f4f1bcff08fa7d937b1701812598e"
},
{
"path": "nn/nn_omograph/turbo3.1/tokenizer.json",
"size": 5532343,
"git_blob": "31ac709e3c4625c6bf80b7c97066a9cde4d9ba89"
},
{
"path": "nn/nn_omograph/turbo3.1/tokenizer_config.json",
"size": 492,
"git_blob": "15a19568f93d1b292c7ff6d4ef0fdac13bd65b53"
},
{
"path": "nn/nn_omograph/turbo3.1/vocab.json",
"size": 1555282,
"git_blob": "ede65202ec9ef483ff166bb31f76615f30048db7"
},
{
"path": "nn/nn_stress_usage_predictor/config.json",
"size": 822,
"git_blob": "df189246d87953bead249c2ee82e3985a96be8f8"
},
{
"path": "nn/nn_stress_usage_predictor/model.onnx",
"size": 116473561,
"sha256": "3d547500637b4ddfec8880ed6d1405fd50ee9d3f0131ef8a2a69dcf961dbefeb"
},
{
"path": "nn/nn_stress_usage_predictor/special_tokens_map.json",
"size": 125,
"git_blob": "a8b3208c2884c4efb86e49300fdd3dc877220cdf"
},
{
"path": "nn/nn_stress_usage_predictor/tokenizer.json",
"size": 2413536,
"git_blob": "b4d5ccba4a193e0330537378bb0bba47026bd364"
},
{
"path": "nn/nn_stress_usage_predictor/tokenizer_config.json",
"size": 368,
"git_blob": "d7d40c5e58b2c6ba58974571f617abe23221eb6c"
},
{
"path": "nn/nn_stress_usage_predictor/vocab.txt",
"size": 1080667,
"git_blob": "211012a925fc51581997b6d3ef047bcda9a842f4"
},
{
"path": "nn/nn_yo_homograph_resolver/config.json",
"size": 625,
"git_blob": "6615961d500dfe1fbbf302d1bbd4832b7e27f398"
},
{
"path": "nn/nn_yo_homograph_resolver/model.onnx",
"size": 14332169,
"sha256": "42cc85bf0c4b319dfe3d89fa17b162a92fd5e1c651a657cb3d5f44978d4e70ac"
},
{
"path": "nn/nn_yo_homograph_resolver/special_tokens_map.json",
"size": 125,
"git_blob": "a8b3208c2884c4efb86e49300fdd3dc877220cdf"
},
{
"path": "nn/nn_yo_homograph_resolver/tokenizer.json",
"size": 126952,
"git_blob": "bbab1a4bc4dfa68697338718621489e77d03e70a"
},
{
"path": "nn/nn_yo_homograph_resolver/tokenizer_config.json",
"size": 401,
"git_blob": "7d8a1ab4371909b9dcaabfcf93b9bbd56045605a"
},
{
"path": "nn/nn_yo_homograph_resolver/vocab.txt",
"size": 48576,
"git_blob": "32bc54d43a46178e70dcefb85626f52e9ddbc98b"
}
]
}
+25
View File
@@ -0,0 +1,25 @@
certifi==2026.7.22
charset-normalizer==3.5.1
filelock==4.0.4
flatbuffers==25.12.19
fsspec==2026.9.0
hf-xet==1.6.0
huggingface-hub==0.36.2
idna==3.20
numpy==2.5.3
onnxruntime==1.30.0
packaging==26.3
protobuf==7.36.2
python-crfsuite==0.9.12
pyyaml==6.0.3
razdel==0.5.0
regex==2026.9.10
requests==2.34.2
ruaccent==1.5.8.3
safetensors==0.8.0
sentencepiece==0.2.2
tokenizers==0.22.2
tqdm==4.70.1
transformers==4.57.6
typing-extensions==4.16.0
urllib3==2.8.0
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Workspace-local RuAccent CPU setup. No sudo or changes to system Python."""
import hashlib
import io
import json
import os
from pathlib import Path
import platform
import runpy
import shutil
import subprocess
import urllib.request
import zipfile
ROOT = Path(__file__).resolve().parent.parent
def main():
uv = shutil.which("uv") or str(ROOT / ".local/tools/uv")
if not Path(uv).is_file():
if platform.system() != "Linux" or platform.machine() != "x86_64":
raise SystemExit("Install uv first on this platform, then rerun this setup script.")
with urllib.request.urlopen("https://pypi.org/pypi/uv/0.12.19/json", timeout=60) as source:
metadata = json.load(source)
wheel = next(x for x in metadata["urls"] if x["filename"].endswith("manylinux_2_17_x86_64.manylinux2014_x86_64.whl"))
with urllib.request.urlopen(wheel["url"], timeout=60) as source:
content = source.read()
expected = "a63d18a0aa38ee9f21a5406afbbaeb41303bcd954be9d6b7c1b95ac275e53958"
if hashlib.sha256(content).hexdigest() != expected:
raise RuntimeError("uv checksum mismatch")
with zipfile.ZipFile(io.BytesIO(content)) as archive:
name = next(x for x in archive.namelist() if x.endswith("/uv"))
Path(uv).parent.mkdir(parents=True, exist_ok=True)
Path(uv).write_bytes(archive.read(name))
Path(uv).chmod(0o755)
environment = dict(os.environ, UV_CACHE_DIR=str(ROOT / ".local/uv-cache"),
UV_PYTHON_INSTALL_DIR=str(ROOT / ".local/python"), UV_SYSTEM_CERTS="true")
venv = ROOT / ".local/ruaccent-runtime"
python = venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
if not python.is_file():
subprocess.run([uv, "venv", "--python", "3.12.14", str(venv)], env=environment, check=True)
subprocess.run([uv, "pip", "sync", "--python", str(python), str(ROOT / "scripts/ruaccent-requirements.txt")], env=environment, check=True)
model = ROOT / ".local/tts/ruaccent"
manifest = json.loads((ROOT / "scripts/ruaccent-model.json").read_text())
runpy.run_path(str(ROOT / "scripts/download-tts-model.py"))["download"](manifest, model)
# RUAccent 1.5.8.3 expects its rule-engine files beside the installed package.
module_path = subprocess.check_output([str(python), "-c", "import importlib.util; print(importlib.util.find_spec('ruaccent').submodule_search_locations[0])"], text=True).strip()
shutil.copytree(model / "koziev", Path(module_path) / "koziev", dirs_exist_ok=True)
worker = ROOT / "composeApp/src/jvmMain/resources/tts/ruaccent-worker.py"
shutil.copyfile(worker, model / "worker.py")
# Verify a wholly offline initialization before declaring the installation ready.
subprocess.run([str(python), str(model / "worker.py"), str(model), "--check"], check=True, env=environment)
(model / "runtime.json").write_text(json.dumps({"python": str(python), "model": "turbo3.1", "revision": manifest["revision"]}, indent=2) + "\n")
print("RuAccent ready: CPU, turbo3.1, full dictionary; no network during inference.", flush=True)
if __name__ == "__main__":
main()
+10
View File
@@ -0,0 +1,10 @@
filelock==3.32.3
fsspec==2026.7.0
jinja2==3.1.6
markupsafe==3.0.3
mpmath==1.3.0
networkx==3.6.1
setuptools==78.1.0
sympy==1.14.0
torch==2.8.0+cpu
typing-extensions==4.16.0
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Audition official Silero v5_5_ru locally on CPU, without changing the reader."""
import argparse
import array
import hashlib
import json
import os
from pathlib import Path
import sys
import time
import wave
ROOT = Path(__file__).resolve().parent.parent
os.environ["CUDA_VISIBLE_DEVICES"] = ""
os.environ.setdefault("TORCH_HOME", str(ROOT / ".local/torch"))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--voices", nargs="+", default=["xenia", "baya", "aidar"])
args = parser.parse_args()
import torch
torch.set_num_threads(4)
torch.set_num_interop_threads(1)
torch.manual_seed(42)
model_path = ROOT / ".local/tts/silero-v5_5-ru/model.pt"
output = ROOT / "composeApp/build/tts-samples"
output.mkdir(parents=True, exist_ok=True)
start = time.perf_counter()
model = torch.package.PackageImporter(str(model_path)).load_pickle("tts_models", "model")
model.to(torch.device("cpu"))
loaded = time.perf_counter() - start
print(f"Silero v5_5_ru, CPU, 4 threads; load {loaded:.3f}s; voices {model.speakers}", flush=True)
samples = {
"ru": "В библиотеке было тихо. Она открыла книгу и начала читать. За окном шёл осенний дождь. На двери висел старый замок, а вдали виднелся замок на холме. Завтра, подумала она, мы наконец доберёмся до моря.",
"ru-yo": "Елка росла у дома. Ежик шел по лесу. Ребенок пьет теплое молоко. Все уже пришли. Он прочитал все. Она все еще ждет. Он узнает об этом завтра. Он узнает ее по голосу.",
}
report = {"model": "v5_5_ru", "sha256": hashlib.sha256(model_path.read_bytes()).hexdigest(),
"torch": torch.__version__, "device": "cpu", "threads": 4, "load_seconds": loaded,
"put_accent": True, "put_yo": True, "put_stress_homo": True, "put_yo_homo": True, "samples": []}
for voice in args.voices:
for label, text in samples.items():
start = time.perf_counter()
with torch.inference_mode():
audio = model.apply_tts(text=text, speaker=voice, sample_rate=48000,
put_accent=True, put_yo=True,
put_stress_homo=True, put_yo_homo=True).detach().cpu().flatten()
elapsed = time.perf_counter() - start
assert audio.numel() and torch.isfinite(audio).all() and audio.abs().max() > 0.001
duration = audio.numel() / 48000
pcm = array.array("h", (audio.clamp(-1, 1) * 32767).round().to(torch.int16).tolist())
if sys.byteorder != "little":
pcm.byteswap()
path = output / f"silero-{label}-{voice}.wav"
with wave.open(str(path), "wb") as wav:
wav.setparams((1, 2, 48000, 0, "NONE", "not compressed"))
wav.writeframes(pcm.tobytes())
path.with_suffix(".txt").write_text(text + "\n")
report["samples"].append({"file": path.name, "synthesis_seconds": elapsed,
"audio_seconds": duration, "rtf": elapsed / duration})
print(f"{path.name}: {elapsed:.3f}s synthesis, {duration:.3f}s audio, RTF {elapsed / duration:.3f}", flush=True)
(output / "silero-benchmark.json").write_text(json.dumps(report, indent=2) + "\n")
if __name__ == "__main__":
main()
+91
View File
@@ -0,0 +1,91 @@
{
"repository": "supertone-oss-archive/supertonic-3",
"revision": "aafc6e32416a594460b32413efc49d7fe4ce6d46",
"files": [
{
"path": "LICENSE",
"size": 15007,
"git_blob": "1e2cbe6cef94c8cdf3ed1fcebc0f5317ca7ad5a1"
},
{
"path": "onnx/duration_predictor.onnx",
"size": 3700147,
"sha256": "c3eb91414d5ff8a7a239b7fe9e34e7e2bf8a8140d8375ffb14718b1c639325db"
},
{
"path": "onnx/text_encoder.onnx",
"size": 36416150,
"sha256": "c7befd5ea8c3119769e8a6c1486c4edc6a3bc8365c67621c881bbb774b9902ff"
},
{
"path": "onnx/tts.json",
"size": 8253,
"git_blob": "28575b0793c92607a0f0a292550df9bd17709a79"
},
{
"path": "onnx/unicode_indexer.json",
"size": 277676,
"git_blob": "3aaf4d33024aff0be455633c24b3636b3a810150"
},
{
"path": "onnx/vector_estimator.onnx",
"size": 256534781,
"sha256": "883ac868ea0275ef0e991524dc64f16b3c0376efd7c320af6b53f5b780d7c61c"
},
{
"path": "onnx/vocoder.onnx",
"size": 101424195,
"sha256": "085de76dd8e8d5836d6ca66826601f615939218f90e519f70ee8a36ed2a4c4ba"
},
{
"path": "voice_styles/F1.json",
"size": 292046,
"git_blob": "421365f307ed1535d8da16845031d0e9fa3e60c5"
},
{
"path": "voice_styles/F2.json",
"size": 292423,
"git_blob": "b09a3a433df8a4856d1d7d64736a56ddf37a4ea3"
},
{
"path": "voice_styles/F3.json",
"size": 290794,
"git_blob": "a366f94c77c4b440404071f1a52bed3ccf83a5ff"
},
{
"path": "voice_styles/F4.json",
"size": 291808,
"git_blob": "39c78a52795320c6101b7eaf984c0e2352fbdc18"
},
{
"path": "voice_styles/F5.json",
"size": 291479,
"git_blob": "06983f8816ad00a8eeb7efd37a33d457587e16f8"
},
{
"path": "voice_styles/M1.json",
"size": 291748,
"git_blob": "bddfff85a7c4c140c11620bd7005d86681dccdac"
},
{
"path": "voice_styles/M2.json",
"size": 292055,
"git_blob": "602ff6c4d6d48cdb71c37461f7c175d71fe8b34b"
},
{
"path": "voice_styles/M3.json",
"size": 290198,
"git_blob": "000b60436b77619b933fdc33628ad737595e6794"
},
{
"path": "voice_styles/M4.json",
"size": 291522,
"git_blob": "aae253ae81185fa06a6469c3f1cdff04ed121ddf"
},
{
"path": "voice_styles/M5.json",
"size": 291469,
"git_blob": "0536b252698830f8536c077edcb94e1ce4768ab6"
}
]
}