feat: add expressive offline Russian book TTS

This commit is contained in:
Курнат Андрей
2026-07-21 21:28:48 +03:00
parent 28312e3fbc
commit cf82ab61eb
47 changed files with 127843 additions and 124 deletions
+4
View File
@@ -38,6 +38,10 @@ Before accepting the model, synthesize a held-out Russian validation list at mul
`noise_scale`, and `noise_scale_w` settings, measure real-time factor on the target phone, and listen for skipped
words, unstable stress, clicks, and repeated phonemes. A successful export alone is not a quality gate.
The epoch-500 book-reading profile selected by listening comparison is stored in
`aletheia_book_profile.json`. Keep its duration, noise, and sentence-pause values together when evaluating the ONNX
model or integrating it into the reader.
On native Windows, first run the one-batch CUDA check:
```powershell
+50
View File
@@ -33,3 +33,53 @@ before producing WAV files.
`train_piper_windows.ps1` provides the equivalent native Windows path and a `-SmokeTest` mode that executes one
training and validation batch on CUDA before a long run. See `GPU_TRAINING.md` for host prerequisites and the
acceptance gate.
## Vosk BookTTS compression experiment
`vosk_booktts_experiment.py` reproduces the Android Vosk frontend, prepares real acoustic-model feeds from
`booktts_calibration_ru.txt`, performs calibrated QDQ int8 quantization of convolution layers, and renders
deterministic baseline/candidate WAV pairs. Keep generated `.npz`, candidate ONNX, and WAV files outside tracked
source directories, for example under `.codex-temp`. Do not replace the application asset until the candidate
loads with ONNX Runtime, runs on the target phone, and passes listening comparison.
Use `prepare --bert-model` plus `compare-feeds` to isolate a BERT replacement while keeping the acoustic model
identical in both WAV branches. `compare-feeds --resume` preserves existing WAVs and atomically updates its
report after every pair; `run_booktts_ab_windows.ps1` is the persistent Windows entry point.
`booktts_student.py` builds token-level teacher targets from the current int8 BERT and trains a four-layer,
256-dimensional Transformer student with a 768-dimensional drop-in output. Its small calibration corpus is only
for validating the training/export pipeline; a candidate for the application requires a much larger licensed
literary corpus, held-out evaluation, ONNX quantization, and listening tests. The optional
`--max-training-batches` and `--max-validation-batches` limits are for CUDA memory smoke tests only.
Each completed epoch atomically replaces `checkpoint.latest.pt`; pass `--resume` with the same output directory
to continue an interrupted run.
`run_booktts_student_windows.ps1` is the native Windows CUDA entry point for a full or resumed student run.
The exported student includes the dataset's original-to-compact token map so its `input_ids` remain compatible
with the existing Vosk vocabulary. `patch_booktts_student_token_map.py` adds the same lookup to an older export
without retraining.
`compare_booktts_bert.py` measures teacher/student and FP32/INT8 embedding error, inference time, and unseen-token
coverage on a held-out text file.
`build_booktts_prosody_corpus.py` creates a deterministic balance of questions, exclamations, quotations,
ellipsis, and neutral prose. Build its teacher dataset with `--reuse-token-map`, then fine-tune from the prior
`student.pt` with `--initial-model`; the held-out listening phrases must be passed through `--exclude`.
`run_booktts_prosody_dataset_windows.ps1` builds this reused-vocabulary teacher dataset persistently on Windows.
After auditing that dataset, `run_booktts_prosody_finetune_windows.ps1` starts the low-learning-rate CUDA run.
`extract_wikisource_corpus.py` streams an official Russian Wikisource XML/BZip2 dump and writes a deduplicated,
filtered sentence corpus plus provenance metadata. The source URL and dump size must be retained. Wikisource is
not a blanket rights clearance for every included work, so review the resulting corpus and applicable source
licenses before distributing a trained model.
`run_booktts_corpus_windows.ps1` completes the resumable BITS download on the CUDA host, verifies the official
dump size and SHA-1, extracts the configured number of sentences, and builds the streamed float16 teacher
dataset. It intentionally stops before training so vocabulary coverage and a held-out evaluation plan can be
reviewed first.
`audit_booktts_text_corpus.py` records the corpus SHA-256, sentence-length and word-count distributions,
residual wiki-markup counters, and deterministic review samples. Run it before training; its report is a
technical quality check and does not replace a rights review of the source works.
`audit_booktts_dataset.py` opens every generated teacher shard and verifies sample counts, tensor shapes,
token ranges, binary masks, and finite teacher values. It also prints a reproducible SHA-256 over shard bytes.
+12
View File
@@ -0,0 +1,12 @@
{
"id": "aletheia-book-moderate-v1",
"model_milestone": 500,
"length_scale": 1.5,
"noise_scale": 0.75,
"noise_w_scale": 0.95,
"sentence_silence_seconds": 0.25,
"selection": {
"method": "human_listening_test",
"result": "preferred_over_pause_only_and_stronger_variation"
}
}
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Validate every shard of a BookTTS teacher dataset."""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from pathlib import Path
import numpy as np
def audit(args: argparse.Namespace) -> None:
metadata_path = args.dataset / "dataset.json"
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
token_map = np.load(args.dataset / "token_id_map.npy")
samples = 0
tokens = 0
bytes_total = metadata_path.stat().st_size + (args.dataset / "token_id_map.npy").stat().st_size
digest = hashlib.sha256()
for record in metadata.get("shards", []):
path = args.dataset / record["file"]
digest.update(path.read_bytes())
bytes_total += path.stat().st_size
with np.load(path) as shard:
ids = shard["original_input_ids"]
mask = shard["attention_mask"]
teacher = shard["teacher"]
if ids.shape != mask.shape or teacher.shape[:2] != ids.shape or teacher.shape[2:] != (768,):
raise ValueError(f"Incompatible shapes in {path.name}: {ids.shape}, {mask.shape}, {teacher.shape}")
if ids.shape[0] != record["samples"]:
raise ValueError(f"Sample count mismatch in {path.name}")
if ids.size and (ids.min() < 0 or ids.max() >= token_map.shape[0]):
raise ValueError(f"Token id outside token map in {path.name}")
if not np.isfinite(teacher).all():
raise ValueError(f"Non-finite teacher value in {path.name}")
if not np.all((mask == 0) | (mask == 1)):
raise ValueError(f"Non-binary attention mask in {path.name}")
samples += ids.shape[0]
tokens += int(mask.sum())
if samples != metadata["samples"]:
raise ValueError(f"Dataset sample count is {samples}, metadata says {metadata['samples']}")
report = {
"dataset": str(args.dataset),
"samples": samples,
"shards": len(metadata.get("shards", [])),
"tokens": tokens,
"vocab_size": metadata["vocab_size"],
"token_map_entries": int(token_map.shape[0]),
"bytes": bytes_total,
"shards_sha256": digest.hexdigest().upper(),
"teacher_dtype": metadata.get("teacher_dtype"),
}
print(json.dumps(report, ensure_ascii=False, indent=2))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--dataset", type=Path, required=True)
return parser.parse_args()
if __name__ == "__main__":
sys.exit(audit(parse_args()) or 0)
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Audit a BookTTS text corpus and emit reproducible statistics and samples."""
from __future__ import annotations
import argparse
import hashlib
import json
import random
import re
import sys
from pathlib import Path
MARKUP_PATTERNS = ("{{", "}}", "[[", "]]", "<ref", "</", "{|", "|}", "http://", "https://")
RUSSIAN = re.compile(r"[А-Яа-яЁё]")
LETTERS = re.compile(r"[^\W\d_]", re.UNICODE)
def percentile(sorted_values: list[int], fraction: float) -> int:
if not sorted_values:
return 0
return sorted_values[round((len(sorted_values) - 1) * fraction)]
def audit(args: argparse.Namespace) -> None:
rng = random.Random(args.seed)
lengths: list[int] = []
words: list[int] = []
samples: list[str] = []
suspicious = {pattern: 0 for pattern in MARKUP_PATTERNS}
low_russian_ratio = 0
with_digits = 0
with_dialogue_dash = 0
digest = hashlib.sha256()
with args.corpus.open("rb") as binary:
for raw in binary:
digest.update(raw)
line = raw.decode("utf-8").rstrip("\r\n")
index = len(lengths)
lengths.append(len(line)); words.append(len(line.split()))
if any(char.isdigit() for char in line):
with_digits += 1
if line.startswith(("", "", "-")):
with_dialogue_dash += 1
letters = LETTERS.findall(line)
russian = RUSSIAN.findall(line)
if letters and len(russian) / len(letters) < 0.75:
low_russian_ratio += 1
for pattern in MARKUP_PATTERNS:
if pattern in line:
suspicious[pattern] += 1
if len(samples) < args.samples:
samples.append(line)
else:
replacement = rng.randint(0, index)
if replacement < args.samples:
samples[replacement] = line
ordered_lengths = sorted(lengths); ordered_words = sorted(words)
report = {
"corpus": str(args.corpus), "sha256": digest.hexdigest().upper(), "lines": len(lengths),
"bytes": args.corpus.stat().st_size,
"characters": {"min": min(lengths, default=0), "mean": sum(lengths) / len(lengths) if lengths else 0, "p50": percentile(ordered_lengths, 0.50), "p95": percentile(ordered_lengths, 0.95), "max": max(lengths, default=0)},
"words": {"min": min(words, default=0), "mean": sum(words) / len(words) if words else 0, "p50": percentile(ordered_words, 0.50), "p95": percentile(ordered_words, 0.95), "max": max(words, default=0)},
"with_digits": with_digits, "with_dialogue_dash": with_dialogue_dash,
"below_75_percent_russian_letters": low_russian_ratio, "suspicious_markup": suspicious,
"sample_seed": args.seed, "samples": samples,
}
rendered = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
if args.output:
if args.output.exists():
raise FileExistsError(f"Refusing to overwrite {args.output}")
args.output.write_text(rendered, encoding="utf-8")
print(rendered, end="")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--corpus", type=Path, required=True)
parser.add_argument("--output", type=Path)
parser.add_argument("--samples", type=int, default=20)
parser.add_argument("--seed", type=int, default=20260721)
return parser.parse_args()
if __name__ == "__main__":
sys.exit(audit(parse_args()) or 0)
+100
View File
@@ -0,0 +1,100 @@
— Что-то случилось? — тихо спросил он, остановившись у двери.
Кое-кто торопится, кое-что забывает, а кто-то всё-таки возвращается.
«Послушай, — сказала Ксения, — нам необходимо поговорить серьёзно».
Он открыл письмо и прочитал: решение принято; отступать уже поздно.
В комнате было тихо... Слишком тихо, чтобы не заметить чужого дыхания.
Почему вы молчите? Неужели ответ оказался настолько неожиданным?
Стой! Не делай ни шага, пока я не объясню происходящее.
На столе лежали старые часы (они давно остановились), ключ и записка.
Северо-западный ветер постепенно стих, но море оставалось беспокойным.
Едва заметная улыбка появилась на её лице и сразу исчезла.
Во-первых, нужно проверить дорогу; во-вторых, дождаться остальных.
Он говорил медленно, отчётливо и спокойно, не проглатывая окончания слов.
Слова «замок», «мука» и «атлас» меняют ударение вместе со значением.
У ворот стоял старый замок, а вдали виднелся заброшенный замок.
В 2026 году экспедиция прошла 12 345 километров за 21 день.
Температура опустилась до -12,05 градуса, а давление выросло на +7 единиц.
Поезд номер 5 отправляется в 07:30; посадка закончится через 10 минут.
Глава вторая. Ночная встреча у реки.
Господин Петров, ул. Лесная, дом 8 — так было написано на конверте.
Когда предложение переходит на следующую страницу, оно должно звучать непрерывно.
Рассказчик сделал короткую паузу — ровно настолько, чтобы сохранить напряжение.
После двоеточия пауза короче: мысль продолжается без завершения фразы.
После точки, вопросительного и восклицательного знаков нужна ясная граница.
Чёткая дикция важнее скорости, особенно в длинных книжных предложениях.
За окном, где ещё недавно шумел дождь, медленно светлело утреннее небо.
— Подождите меня здесь, — произнёс проводник, — и никуда не уходите.
Она хотела возразить, однако передумала: спор всё равно ничего бы не изменил.
Если дверь заперта, постучи трижды; если никто не ответит — возвращайся.
Где-то далеко прокричала ночная птица, и лес снова погрузился в тишину.
Как странно: знакомый дом казался теперь меньше, темнее и гораздо старше.
«Я вернусь до рассвета!» — крикнул он и исчез за поворотом.
Неужели всё это — лишь совпадение, которому мы напрасно придали значение?
Ветер перебирал сухие листья — шорох за шорохом, вздох за вздохом.
Она подняла глаза (в них блестели слёзы), но голос её остался твёрдым.
Старик помолчал; затем, словно решившись, заговорил совсем другим тоном.
Ни света, ни звука, ни единого следа — только холодная пустая дорога.
Прежде чем ответить, он медленно сложил письмо и убрал его во внутренний карман.
— Вы уверены? — Да. — Тогда начинайте, времени у нас почти не осталось.
По ту сторону реки поднимался древний лес, окутанный сизым предрассветным туманом.
Всё-таки, по-моему, кто-нибудь должен был предупредить нас заранее.
Из-за полуоткрытой двери донёсся чей-то приглушённый, едва различимый голос.
В письме стояло одно слово: «Жди»; ни подписи, ни даты не было.
Она замерла на полуслове, будто внезапно услышала то, чего не слышали остальные.
Гром прогремел совсем близко — окна задрожали, а свеча внезапно погасла.
Мальчик нёс в руках старинный атлас, а на плечах у него лежал атласный плащ.
Мука́ закончилась к полудню, но му́ка ожидания продолжалась до самого вечера.
На берегу белели старые и́рисы, а в письме лежал неоплаченный ири́с.
Острый клино́к сверкнул в темноте, и сухой кли́нок дерева хрустнул под ногой.
Мы обошли весь квартал, но нужный дом так и не нашли.
Он поставил подпись под договором, хотя каждый пункт вызывал у него сомнения.
В первой главе герой уезжает; во второй — возвращается под чужим именем.
Запомни главное: нельзя перебивать фразу только потому, что закончилась страница.
Последние слова предложения должны прозвучать на следующем экране без повторения начала.
Она читала неторопливо, оставляя между словами едва заметное пространство.
Слишком быстрый темп съедает согласные, а чрезмерно медленный разрушает смысл фразы.
На отметке 3,5 километра дорога раздваивается: налево — к озеру, направо — к селу.
В архиве значились тома № 7, 12 и 18; восьмого тома в описи не было.
Экспедиция началась 14 июля 1897 года и завершилась лишь через восемь месяцев.
В 6 часов 45 минут колокол ударил дважды, хотя должен был ударить шесть раз.
Расстояние составляло около 1 250 метров, то есть чуть больше одной версты.
Цена книги — 19 рублей 90 копеек; на обороте карандашом написано: «Не продавать».
Глава двенадцатая. Письмо, которого никто не ждал.
Часть III. Возвращение домой после двадцати лет странствий.
— Кто там? — Это я. — Кто «я»? — Откройте, и вы всё поймёте.
«Нет, нет и ещё раз нет», — отчётливо повторила она.
Он прошептал: «Тише… нас могут услышать», — и указал на окно.
Сначала послышались шаги; потом скрипнула лестница; наконец открылась дверь.
Дорога была трудна: снег слепил глаза, ветер сбивал с ног, силы иссякали.
Он не ответил — не потому, что не знал ответа, а потому, что боялся его произнести.
Кто бы мог подумать, что маленькая находка изменит судьбу целого города!
Когда часы пробили полночь, незнакомец снял шляпу и назвал своё настоящее имя.
Утром всё выглядело обыкновенно; только следы у калитки напоминали о ночном госте.
Я хотел было уйти, но она сказала: «Останьтесь ещё на одну минуту».
Голос звучал спокойно, почти равнодушно, и от этого становилось ещё тревожнее.
Вдали показалась станция — низкая платформа, жёлтый фонарь и одинокий смотритель.
Он читал старую рукопись строка за строкой, боясь пропустить хотя бы одну букву.
Некоторые слова были стёрты; другие — исправлены чужой рукой много лет спустя.
В примечании значилось: см. главу 4, стр. 217, абзац второй.
На табличке было написано: «Вход воспрещён с 22:00 до 06:00».
Температура воды равнялась 18,4 °C, скорость течения — 2,1 метра в секунду.
Вероятность ошибки оценили в 0,03 процента, но случай всё-таки произошёл.
Координаты точки: 55°45′ северной широты, 37°37′ восточной долготы.
В комнате находились А. П. Чехов, Л. Н. Толстой и ещё двое гостей.
Иван Сергеевич, будьте добры, прочтите последний абзац ещё раз.
Мать-и-мачеха росла у дороги, а где-то рядом кричала птица-пересмешник.
Поезд шёл с северо-востока на юго-запад, постепенно набирая скорость.
Что-либо менять было поздно; кое-как собрав вещи, они отправились в путь.
Кто-нибудь видел серо-зелёную папку, лежавшую здесь полчаса назад?
Из-под земли доносился глухой рокот, то усиливаясь, то почти исчезая.
Он всё повторял одно и то же, словно заученное заклинание: «Нельзя опаздывать».
Свет погас на мгновение — и именно в это мгновение картина исчезла со стены.
Слово за словом, страница за страницей перед читателем раскрывалась чужая жизнь.
Её ответ был прост, но окончателен: она не вернётся ни завтра, ни через год.
Вопрос состоял не в том, кто виноват, а в том, можно ли ещё что-нибудь исправить.
Не открывая глаз, он прислушался: рядом потрескивал огонь, за стеной шумела вода.
«Вы опоздали на семь минут», — заметил человек в сером пальто.
На мгновение ей показалось, будто портрет улыбнулся; разумеется, этого не могло быть.
Туман рассеивался медленно, открывая то крышу, то колокольню, то дальний берег.
Никто не произнёс ни слова — прощание и без того затянулось.
И всё же где-то в глубине души оставалась слабая, почти невозможная надежда.
+4
View File
@@ -0,0 +1,4 @@
Почему вы молчите? Неужели ответ оказался настолько неожиданным?
В 2026 году экспедиция прошла 12 345 километров за 21 день.
Мука́ закончилась к полудню, но му́ка ожидания продолжалась до самого вечера.
Мать-и-мачеха росла у дороги, а где-то рядом кричала птица-пересмешник.
+1
View File
@@ -0,0 +1 @@
Мука́ закончилась к полудню, но му́ка ожидания продолжалась до самого вечера.
+5
View File
@@ -0,0 +1,5 @@
Почему вы молчите? Неужели ответ оказался настолько неожиданным?
— Что-то случилось? — тихо спросил он, остановившись у двери.
Чёткая дикция важнее скорости, особенно в длинных книжных предложениях.
Неужели всё это — лишь совпадение, которому мы напрасно придали значение?
И всё же где-то в глубине души оставалась слабая, почти невозможная надежда.
@@ -0,0 +1,3 @@
— Что-то случилось?
Почему вы молчите?
Неужели ответ оказался настолько неожиданным?
+435
View File
@@ -0,0 +1,435 @@
#!/usr/bin/env python3
"""Build and train a compact drop-in BERT student for Aletheia BookTTS."""
from __future__ import annotations
import argparse
import json
import random
import shutil
import sys
import time
from collections import Counter
from pathlib import Path
import numpy as np
from vosk_booktts_experiment import WordPieceTokenizer, load_corpus, normalize_text
def build_dataset(args: argparse.Namespace) -> None:
import onnxruntime as ort
args.output.mkdir(parents=True, exist_ok=False)
tokenizer = WordPieceTokenizer(args.assets / "vocab.txt")
options = ort.SessionOptions()
options.log_severity_level = 3
teacher = ort.InferenceSession(
str(args.assets / "bert.int8.onnx"),
sess_options=options,
providers=["CPUExecutionProvider"],
)
token_counts: Counter[int] = Counter()
pending: list[tuple[str, np.ndarray]] = []
shard_samples: list[tuple[np.ndarray, np.ndarray]] = []
shard_records = []
sample_index = 0
shard_index = 0
teacher_dtype = np.float16 if args.teacher_dtype == "float16" else np.float32
def flush_shard() -> None:
nonlocal shard_index
if not shard_samples:
return
longest = max(ids.size for ids, _ in shard_samples)
shard_ids = np.zeros((len(shard_samples), longest), dtype=np.int64)
shard_mask = np.zeros_like(shard_ids)
shard_teacher = np.zeros((len(shard_samples), longest, 768), dtype=teacher_dtype)
for row, (ids, embeddings) in enumerate(shard_samples):
shard_ids[row, : ids.size] = ids
shard_mask[row, : ids.size] = 1
shard_teacher[row, : ids.size] = embeddings
filename = f"shard-{shard_index:05d}.npz"
np.savez(
args.output / filename,
original_input_ids=shard_ids,
attention_mask=shard_mask,
teacher=shard_teacher,
)
shard_records.append({"file": filename, "samples": len(shard_samples), "max_tokens": longest})
shard_samples.clear()
shard_index += 1
def flush_batch(manifest) -> None:
nonlocal sample_index
if not pending:
return
longest = max(ids.size for _, ids in pending)
batch_ids = np.zeros((len(pending), longest), dtype=np.int64)
batch_mask = np.zeros_like(batch_ids)
for row, (_, ids) in enumerate(pending):
batch_ids[row, : ids.size] = ids
batch_mask[row, : ids.size] = 1
outputs = teacher.run(None, {
"input_ids": batch_ids,
"attention_mask": batch_mask,
"token_type_ids": np.zeros_like(batch_ids),
})[0]
if outputs.ndim == 2:
outputs = outputs[np.newaxis, :, :]
for row, (text, ids) in enumerate(pending):
embeddings = outputs[row, : ids.size].astype(teacher_dtype, copy=False)
if embeddings.shape != (ids.size, 768):
raise ValueError(f"Unexpected teacher shape {embeddings.shape}")
shard_samples.append((ids.copy(), embeddings.copy()))
manifest.write(json.dumps({"sample": sample_index, "text": text, "tokens": int(ids.size)}, ensure_ascii=False) + "\n")
sample_index += 1
if len(shard_samples) >= args.shard_size:
flush_shard()
pending.clear()
with (args.output / "manifest.jsonl").open("w", encoding="utf-8", newline="\n") as manifest:
for text in load_corpus(args.corpus):
ids, _ = tokenizer.encode(normalize_text(text))
token_counts.update(int(value) for value in ids)
pending.append((text, ids))
if len(pending) >= args.teacher_batch_size:
flush_batch(manifest)
if sample_index % 1_000 == 0:
print(f"teacher_samples={sample_index}", flush=True)
flush_batch(manifest)
flush_shard()
observed = set(token_counts)
if args.reuse_token_map:
source_metadata = json.loads((args.reuse_token_map / "dataset.json").read_text(encoding="utf-8"))
source_map = np.load(args.reuse_token_map / "token_id_map.npy")
if source_map.size != len(tokenizer.tokens):
raise ValueError("Reused token map and teacher vocabulary have different sizes")
shutil.copy2(args.reuse_token_map / "token_id_map.npy", args.output / "token_id_map.npy")
shutil.copy2(args.reuse_token_map / "vocab.txt", args.output / "vocab.txt")
vocab_size = int(source_metadata["vocab_size"])
unknown_new = int(source_map[tokenizer.vocabulary["[UNK]"]])
replaced_occurrences = sum(
count for token, count in token_counts.items()
if token != tokenizer.vocabulary["[UNK]"] and int(source_map[token]) == unknown_new
)
else:
special_ids = [tokenizer.vocabulary[name] for name in ("[PAD]", "[UNK]", "[CLS]", "[SEP]")]
if args.max_vocab and len(observed) > args.max_vocab:
retained = set(special_ids)
retained.update(token for token, _ in token_counts.most_common(args.max_vocab - len(retained)))
else:
retained = observed | set(special_ids)
ordered_old_ids = special_ids + sorted(retained - set(special_ids))
old_to_new = {old: new for new, old in enumerate(ordered_old_ids)}
unknown_new = old_to_new[tokenizer.vocabulary["[UNK]"]]
reduced_tokens = [tokenizer.tokens[index] for index in ordered_old_ids]
(args.output / "vocab.txt").write_text("\n".join(reduced_tokens) + "\n", encoding="utf-8")
token_id_map = np.full(len(tokenizer.tokens), unknown_new, dtype=np.int64)
for old, new in old_to_new.items():
token_id_map[old] = new
np.save(args.output / "token_id_map.npy", token_id_map)
vocab_size = len(reduced_tokens)
replaced_occurrences = sum(count for token, count in token_counts.items() if token not in retained)
metadata = {
"samples": sample_index,
"vocab_size": vocab_size,
"observed_original_tokens": len(observed),
"replaced_token_occurrences": replaced_occurrences,
"reused_token_map": str(args.reuse_token_map) if args.reuse_token_map else None,
"teacher_dtype": args.teacher_dtype,
"teacher_batch_size": args.teacher_batch_size,
"shard_size": args.shard_size,
"shards": shard_records,
}
(args.output / "dataset.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(metadata, indent=2))
def train_student(args: argparse.Namespace) -> None:
import torch
from torch import nn
from torch.utils.data import DataLoader, Dataset, IterableDataset, Subset
class DistillationDataset(Dataset):
def __init__(self, root: Path):
self.files = sorted(root.glob("sample-*.npz"))
if not self.files:
raise ValueError(f"No samples in {root}")
mapping = root / "token_id_map.npy"
self.token_id_map = np.load(mapping) if mapping.exists() else None
def __len__(self) -> int:
return len(self.files)
def __getitem__(self, index: int) -> tuple[torch.Tensor, torch.Tensor]:
with np.load(self.files[index]) as data:
if "original_input_ids" in data:
original = data["original_input_ids"]
if self.token_id_map is None:
raise ValueError("token_id_map.npy is required for original_input_ids")
input_ids = self.token_id_map[original]
else:
input_ids = data["input_ids"]
teacher = data["teacher"].astype(np.float32)
return torch.from_numpy(input_ids.copy()), torch.from_numpy(teacher.copy())
class ShardedDataset(IterableDataset):
def __init__(self, root: Path, records: list[dict], shuffle: bool, seed: int):
self.root = root
self.records = list(records)
self.shuffle = shuffle
self.seed = seed
self.epoch = 0
self.token_id_map = np.load(root / "token_id_map.npy")
def __len__(self) -> int:
return sum(record["samples"] for record in self.records)
def set_epoch(self, epoch: int) -> None:
self.epoch = epoch
def __iter__(self):
rng = random.Random(self.seed + self.epoch)
records = list(self.records)
if self.shuffle:
rng.shuffle(records)
for record in records:
with np.load(self.root / record["file"]) as data:
original = data["original_input_ids"]
masks = data["attention_mask"]
teachers = data["teacher"]
rows = list(range(original.shape[0]))
if self.shuffle:
rng.shuffle(rows)
for row in rows:
length = int(masks[row].sum())
ids = self.token_id_map[original[row, :length]]
teacher = teachers[row, :length].astype(np.float32)
yield torch.from_numpy(ids.copy()), torch.from_numpy(teacher.copy())
def collate(batch):
longest = max(ids.size(0) for ids, _ in batch)
ids = torch.zeros((len(batch), longest), dtype=torch.long)
mask = torch.zeros((len(batch), longest), dtype=torch.long)
targets = torch.zeros((len(batch), longest, 768), dtype=torch.float32)
for row, (sample_ids, teacher) in enumerate(batch):
length = sample_ids.size(0)
ids[row, :length] = sample_ids
mask[row, :length] = 1
targets[row, :length] = teacher
return ids, mask, targets
class Student(nn.Module):
def __init__(self, vocab_size: int):
super().__init__()
self.token_embedding = nn.Embedding(vocab_size, args.hidden, padding_idx=0)
self.position_embedding = nn.Embedding(args.max_length, args.hidden)
self.type_embedding = nn.Embedding(2, args.hidden)
layer = nn.TransformerEncoderLayer(
d_model=args.hidden, nhead=args.heads, dim_feedforward=args.feed_forward,
dropout=args.dropout, activation="gelu", batch_first=True, norm_first=True,
)
self.encoder = nn.TransformerEncoder(layer, num_layers=args.layers, enable_nested_tensor=False)
self.norm = nn.LayerNorm(args.hidden)
self.projection = nn.Linear(args.hidden, 768)
def forward(self, input_ids, attention_mask, token_type_ids=None):
if token_type_ids is None:
token_type_ids = torch.zeros_like(input_ids)
positions = torch.arange(input_ids.size(1), device=input_ids.device).unsqueeze(0)
values = (
self.token_embedding(input_ids)
+ self.position_embedding(positions)
+ self.type_embedding(token_type_ids)
)
values = self.encoder(values, src_key_padding_mask=attention_mask == 0)
return self.projection(self.norm(values))
class ExportWrapper(nn.Module):
def __init__(self, model, token_id_map):
super().__init__(); self.model = model
self.register_buffer("token_id_map", token_id_map)
def forward(self, input_ids, attention_mask, token_type_ids):
mapped_input_ids = self.token_id_map[input_ids]
return self.model(mapped_input_ids, attention_mask, token_type_ids)[0]
random.seed(args.seed); np.random.seed(args.seed); torch.manual_seed(args.seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(args.seed)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
metadata = json.loads((args.dataset / "dataset.json").read_text(encoding="utf-8"))
model = Student(metadata["vocab_size"]).to(device)
if args.initial_model:
payload = torch.load(args.initial_model, map_location=device, weights_only=False)
state_dict = payload.get("state_dict") or payload.get("best_state") or payload.get("model")
if state_dict is None:
raise ValueError(f"No model state in {args.initial_model}")
model.load_state_dict(state_dict)
if metadata.get("shards"):
records = list(metadata["shards"])
rng = random.Random(args.seed); rng.shuffle(records)
validation_shards = max(1, round(len(records) * args.validation_fraction)) if len(records) > 1 else 0
validation_records = records[:validation_shards]
training_records = records[validation_shards:] or records
training_data = ShardedDataset(args.dataset, training_records, True, args.seed)
validation_data = ShardedDataset(args.dataset, validation_records, False, args.seed) if validation_records else None
loader = DataLoader(training_data, batch_size=args.batch_size, collate_fn=collate)
validation_loader = DataLoader(validation_data, batch_size=args.batch_size, collate_fn=collate) if validation_data else None
else:
all_data = DistillationDataset(args.dataset)
validation_size = max(1, round(len(all_data) * args.validation_fraction)) if len(all_data) > 1 else 0
indices = list(range(len(all_data))); random.Random(args.seed).shuffle(indices)
validation_data = Subset(all_data, indices[:validation_size]) if validation_size else None
training_data = Subset(all_data, indices[validation_size:] or indices)
loader = DataLoader(training_data, batch_size=args.batch_size, shuffle=True, collate_fn=collate)
validation_loader = DataLoader(validation_data, batch_size=args.batch_size, collate_fn=collate) if validation_data else None
optimizer = torch.optim.AdamW(model.parameters(), lr=args.learning_rate, weight_decay=0.01)
scaler = torch.amp.GradScaler("cuda", enabled=device.type == "cuda")
checkpoint_path = args.output / "checkpoint.latest.pt"
if args.resume:
if not checkpoint_path.exists():
raise FileNotFoundError(f"Cannot resume without {checkpoint_path}")
else:
args.output.mkdir(parents=True, exist_ok=False)
started = time.time(); history = []; best_loss = float("inf"); best_state = None; best_epoch = 0; stale_epochs = 0
start_epoch = 1
if args.resume:
checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
model.load_state_dict(checkpoint["model"])
optimizer.load_state_dict(checkpoint["optimizer"])
scaler.load_state_dict(checkpoint["scaler"])
history = checkpoint["history"]
best_loss = checkpoint["best_loss"]
best_state = checkpoint["best_state"]
best_epoch = checkpoint["best_epoch"]
stale_epochs = checkpoint["stale_epochs"]
start_epoch = checkpoint["epoch"] + 1
def calculate_loss(prediction, target, mask):
active = mask.bool().unsqueeze(-1).expand_as(prediction)
mse = torch.mean((prediction[active] - target[active]) ** 2)
cosine = 1.0 - torch.nn.functional.cosine_similarity(prediction[mask.bool()], target[mask.bool()], dim=-1).mean()
return mse + args.cosine_weight * cosine
for epoch in range(start_epoch, args.epochs + 1):
if isinstance(training_data, ShardedDataset):
training_data.set_epoch(epoch)
model.train(); total = 0.0; batches = 0
for ids, mask, target in loader:
ids, mask, target = ids.to(device), mask.to(device), target.to(device)
optimizer.zero_grad(set_to_none=True)
with torch.amp.autocast("cuda", dtype=torch.float16, enabled=device.type == "cuda"):
prediction = model(ids, mask)
loss = calculate_loss(prediction, target, mask)
scaler.scale(loss).backward(); scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(optimizer); scaler.update()
total += float(loss.detach()); batches += 1
if args.max_training_batches and batches >= args.max_training_batches:
break
training_loss = total / batches
validation_loss = training_loss
if validation_loader is not None:
model.eval(); validation_total = 0.0; validation_batches = 0
with torch.no_grad():
for ids, mask, target in validation_loader:
ids, mask, target = ids.to(device), mask.to(device), target.to(device)
with torch.amp.autocast("cuda", dtype=torch.float16, enabled=device.type == "cuda"):
validation_total += float(calculate_loss(model(ids, mask), target, mask))
validation_batches += 1
if args.max_validation_batches and validation_batches >= args.max_validation_batches:
break
validation_loss = validation_total / validation_batches
history.append({"epoch": epoch, "training_loss": training_loss, "validation_loss": validation_loss})
print(f"epoch={epoch} training_loss={training_loss:.8f} validation_loss={validation_loss:.8f}")
if validation_loss < best_loss:
best_loss = validation_loss; best_epoch = epoch; stale_epochs = 0
best_state = {name: value.detach().cpu().clone() for name, value in model.state_dict().items()}
else:
stale_epochs += 1
checkpoint = {
"epoch": epoch, "model": model.state_dict(), "optimizer": optimizer.state_dict(),
"scaler": scaler.state_dict(), "history": history, "best_loss": best_loss,
"best_state": best_state, "best_epoch": best_epoch, "stale_epochs": stale_epochs,
}
temporary_checkpoint = checkpoint_path.with_suffix(".tmp")
torch.save(checkpoint, temporary_checkpoint); temporary_checkpoint.replace(checkpoint_path)
if stale_epochs >= args.early_stopping_patience:
print(f"early_stop epoch={epoch} best_epoch={best_epoch}")
break
if best_state is not None:
model.load_state_dict(best_state)
config = {key: str(value) if isinstance(value, Path) else value for key, value in vars(args).items() if key != "handler"}
torch.save({"state_dict": model.state_dict(), "metadata": metadata, "config": config}, args.output / "student.pt")
model.eval()
export_token_map = torch.from_numpy(np.load(args.dataset / "token_id_map.npy")).long()
wrapper = ExportWrapper(model, export_token_map).cpu().eval()
example_ids = torch.tensor([[2, 3]], dtype=torch.long)
example_mask = torch.ones_like(example_ids); example_types = torch.zeros_like(example_ids)
sequence = torch.export.Dim("sequence", min=2, max=args.max_length)
torch.onnx.export(
wrapper, (example_ids, example_mask, example_types), args.output / "bert.student.fp32.onnx",
input_names=["input_ids", "attention_mask", "token_type_ids"], output_names=["logits"],
dynamic_shapes=({1: sequence}, {1: sequence}, {1: sequence}),
opset_version=18, dynamo=True, external_data=False,
)
report = {
"device": str(device), "parameters": sum(parameter.numel() for parameter in model.parameters()),
"training_samples": len(training_data), "validation_samples": len(validation_data) if validation_data is not None else 0,
"best_epoch": best_epoch, "best_validation_loss": best_loss,
"peak_cuda_bytes": torch.cuda.max_memory_allocated() if device.type == "cuda" else 0,
"seconds": time.time() - started, "onnx_bytes": (args.output / "bert.student.fp32.onnx").stat().st_size,
"history": history, "training_batches_last_epoch": batches,
"validation_batches_last_epoch": validation_batches if validation_loader is not None else 0,
}
(args.output / "training.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(json.dumps({key: value for key, value in report.items() if key != "history"}, indent=2))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
commands = parser.add_subparsers(dest="command", required=True)
dataset = commands.add_parser("dataset")
dataset.add_argument("--assets", type=Path, required=True)
dataset.add_argument("--corpus", type=Path, required=True)
dataset.add_argument("--output", type=Path, required=True)
dataset.add_argument("--max-vocab", type=int, default=0)
dataset.add_argument("--teacher-batch-size", type=int, default=16)
dataset.add_argument("--teacher-dtype", choices=("float16", "float32"), default="float16")
dataset.add_argument("--shard-size", type=int, default=256)
dataset.add_argument("--reuse-token-map", type=Path)
dataset.set_defaults(handler=build_dataset)
train = commands.add_parser("train")
train.add_argument("--dataset", type=Path, required=True)
train.add_argument("--output", type=Path, required=True)
train.add_argument("--epochs", type=int, default=10)
train.add_argument("--batch-size", type=int, default=8)
train.add_argument("--hidden", type=int, default=256)
train.add_argument("--heads", type=int, default=8)
train.add_argument("--feed-forward", type=int, default=768)
train.add_argument("--layers", type=int, default=4)
train.add_argument("--max-length", type=int, default=256)
train.add_argument("--dropout", type=float, default=0.1)
train.add_argument("--learning-rate", type=float, default=3e-4)
train.add_argument("--cosine-weight", type=float, default=0.1)
train.add_argument("--validation-fraction", type=float, default=0.05)
train.add_argument("--early-stopping-patience", type=int, default=5)
train.add_argument("--max-training-batches", type=int, default=0)
train.add_argument("--max-validation-batches", type=int, default=0)
train.add_argument("--resume", action="store_true")
train.add_argument("--initial-model", type=Path)
train.add_argument("--seed", type=int, default=20260721)
train.set_defaults(handler=train_student)
return parser.parse_args()
def main() -> int:
args = parse_args(); args.handler(args); return 0
if __name__ == "__main__":
sys.exit(main())
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Build a deterministic punctuation-balanced fine-tuning corpus."""
from __future__ import annotations
import argparse
import hashlib
import heapq
import json
import sys
from pathlib import Path
CATEGORIES = {
"question": lambda text: "?" in text,
"exclamation": lambda text: "!" in text,
"quotes": lambda text: "«" in text or "»" in text,
"ellipsis": lambda text: "" in text or "..." in text,
"neutral": lambda text: not any(mark in text for mark in ("?", "!", "«", "»", "", "...")),
}
def build(args: argparse.Namespace) -> None:
if args.output.exists() or args.metadata.exists():
raise FileExistsError("Refusing to overwrite output or metadata")
excluded = set()
if args.exclude:
excluded = {line.strip() for line in args.exclude.read_text(encoding="utf-8-sig").splitlines() if line.strip()}
heaps: dict[str, list[tuple[int, str]]] = {name: [] for name in CATEGORIES}
scanned = 0
for text in args.source.read_text(encoding="utf-8").splitlines():
text = text.strip()
if not text or text in excluded:
continue
scanned += 1
for name, predicate in CATEGORIES.items():
if not predicate(text):
continue
score = int.from_bytes(hashlib.blake2b(f"{name}\0{text}".encode("utf-8"), digest_size=8).digest(), "big")
heap = heaps[name]
if len(heap) < args.per_category:
heapq.heappush(heap, (-score, text))
elif score < -heap[0][0]:
heapq.heapreplace(heap, (-score, text))
selected: dict[str, set[str]] = {
name: {text for _, text in heap} for name, heap in heaps.items()
}
combined = sorted(set().union(*selected.values()), key=lambda text: hashlib.sha256(text.encode("utf-8")).digest())
args.output.write_text("\n".join(combined) + "\n", encoding="utf-8")
metadata = {
"source": str(args.source), "exclude": str(args.exclude) if args.exclude else None,
"scanned": scanned, "per_category_limit": args.per_category,
"category_counts": {name: len(values) for name, values in selected.items()},
"unique_sentences": len(combined), "output_bytes": args.output.stat().st_size,
}
args.metadata.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(metadata, ensure_ascii=False, indent=2))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--exclude", type=Path)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--metadata", type=Path, required=True)
parser.add_argument("--per-category", type=int, default=4000)
return parser.parse_args()
if __name__ == "__main__":
sys.exit(build(parse_args()) or 0)
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Compare teacher, FP32 student, and INT8 student embeddings on real text."""
from __future__ import annotations
import argparse
import json
import math
import sys
import time
from pathlib import Path
import numpy as np
import onnxruntime as ort
from vosk_booktts_experiment import WordPieceTokenizer, load_corpus, normalize_text
def cosine_rows(left: np.ndarray, right: np.ndarray) -> np.ndarray:
numerator = np.sum(left * right, axis=-1)
denominator = np.linalg.norm(left, axis=-1) * np.linalg.norm(right, axis=-1)
return numerator / np.maximum(denominator, 1e-12)
def compare(args: argparse.Namespace) -> None:
tokenizer = WordPieceTokenizer(args.vocab)
token_map = np.load(args.token_map)
compact_unk = int(token_map[tokenizer.vocabulary["[UNK]"]])
sessions = {
"teacher": ort.InferenceSession(str(args.teacher), providers=["CPUExecutionProvider"]),
"student_fp32": ort.InferenceSession(str(args.student_fp32), providers=["CPUExecutionProvider"]),
"student_int8": ort.InferenceSession(str(args.student_int8), providers=["CPUExecutionProvider"]),
}
timings = {name: 0.0 for name in sessions}
teacher_cosines: list[np.ndarray] = []
quantized_cosines: list[np.ndarray] = []
teacher_squared_error = 0.0
quantized_squared_error = 0.0
elements = 0
tokens = 0
unseen_tokens = 0
phrases = load_corpus(args.corpus)[: args.limit or None]
for text in phrases:
ids, _ = tokenizer.encode(normalize_text(text))
shape = (1, ids.size)
feeds = {
"input_ids": ids.reshape(shape),
"attention_mask": np.ones(shape, dtype=np.int64),
"token_type_ids": np.zeros(shape, dtype=np.int64),
}
outputs: dict[str, np.ndarray] = {}
for name, session in sessions.items():
started = time.perf_counter()
outputs[name] = session.run(None, feeds)[0].astype(np.float32, copy=False)
timings[name] += time.perf_counter() - started
teacher = outputs["teacher"]
fp32 = outputs["student_fp32"]
int8 = outputs["student_int8"]
if teacher.shape != fp32.shape or fp32.shape != int8.shape:
raise ValueError(f"Output shape mismatch for {text!r}: {teacher.shape}, {fp32.shape}, {int8.shape}")
teacher_cosines.append(cosine_rows(teacher, fp32))
quantized_cosines.append(cosine_rows(fp32, int8))
teacher_squared_error += float(np.sum((teacher - fp32) ** 2))
quantized_squared_error += float(np.sum((fp32 - int8) ** 2))
elements += teacher.size
tokens += ids.size
unseen_tokens += int(np.sum((token_map[ids] == compact_unk) & (ids != tokenizer.vocabulary["[UNK]"])))
teacher_cosine = np.concatenate(teacher_cosines)
quantized_cosine = np.concatenate(quantized_cosines)
report = {
"phrases": len(phrases),
"tokens": tokens,
"unseen_tokens": unseen_tokens,
"unseen_percent": 100.0 * unseen_tokens / tokens,
"teacher_vs_student_fp32": {
"mean_token_cosine": float(np.mean(teacher_cosine)),
"p05_token_cosine": float(np.percentile(teacher_cosine, 5)),
"mse": teacher_squared_error / elements,
},
"student_fp32_vs_int8": {
"mean_token_cosine": float(np.mean(quantized_cosine)),
"p05_token_cosine": float(np.percentile(quantized_cosine, 5)),
"mse": quantized_squared_error / elements,
},
"total_inference_seconds": timings,
"milliseconds_per_phrase": {name: seconds * 1000.0 / len(phrases) for name, seconds in timings.items()},
}
if not all(math.isfinite(value) for value in (teacher_squared_error, quantized_squared_error)):
raise ValueError("Non-finite comparison result")
rendered = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
if args.output:
if args.output.exists():
raise FileExistsError(f"Refusing to overwrite {args.output}")
args.output.write_text(rendered, encoding="utf-8")
print(rendered, end="")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--teacher", type=Path, required=True)
parser.add_argument("--student-fp32", type=Path, required=True)
parser.add_argument("--student-int8", type=Path, required=True)
parser.add_argument("--vocab", type=Path, required=True)
parser.add_argument("--token-map", type=Path, required=True)
parser.add_argument("--corpus", type=Path, required=True)
parser.add_argument("--output", type=Path)
parser.add_argument("--limit", type=int, default=0)
return parser.parse_args()
if __name__ == "__main__":
sys.exit(compare(parse_args()) or 0)
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Extract clean Russian sentences from an official Wikisource XML dump."""
from __future__ import annotations
import argparse
import bz2
import heapq
import hashlib
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from xml.etree import ElementTree
SENTENCE_BOUNDARY = re.compile(r"(?<=[.!?…])\s+")
WHITESPACE = re.compile(r"\s+")
RUSSIAN_LETTER = re.compile(r"[А-Яа-яЁё]")
ANY_LETTER = re.compile(r"[^\W\d_]", re.UNICODE)
BAD_MARKUP = ("{{", "}}", "[[", "]]", "http://", "https://", "<math", "</", "{|", "|}")
def local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
def descendant_text(element, name: str) -> str:
for child in element.iter():
if local_name(child.tag) == name:
return child.text or ""
return ""
def clean_page_text(source: str) -> str:
import mwparserfromhell
source = re.sub(r"<ref\b[^>]*>.*?</ref>|<ref\b[^>]*/>", " ", source, flags=re.IGNORECASE | re.DOTALL)
source = re.sub(r"\{\|.*?\|\}", " ", source, flags=re.DOTALL)
plain = mwparserfromhell.parse(source).strip_code(normalize=True, collapse=True)
return WHITESPACE.sub(" ", plain).strip()
def acceptable(sentence: str) -> bool:
if not 40 <= len(sentence) <= 240 or any(marker in sentence for marker in BAD_MARKUP):
return False
if sentence[-1:] not in ".!?…":
return False
words = sentence.split()
if not 6 <= len(words) <= 45:
return False
letters = ANY_LETTER.findall(sentence)
if not letters:
return False
russian = RUSSIAN_LETTER.findall(sentence)
return len(russian) / len(letters) >= 0.75
def extract(args: argparse.Namespace) -> None:
temporary = args.output.with_suffix(args.output.suffix + ".part")
if args.output.exists() or args.metadata.exists() or temporary.exists():
raise FileExistsError("Refusing to overwrite output, metadata, or partial output")
args.output.parent.mkdir(parents=True, exist_ok=True)
seen: set[bytes] = set()
sample_heap: list[tuple[int, bytes, str]] = []
pages = 0
accepted = 0
candidates = 0
with bz2.open(args.dump, "rb") as source, temporary.open("w", encoding="utf-8", newline="\n") as output:
for _, element in ElementTree.iterparse(source, events=("end",)):
if local_name(element.tag) != "page":
continue
pages += 1
namespace = descendant_text(element, "ns")
title = descendant_text(element, "title")
wiki_text = descendant_text(element, "text")
if namespace == "0" and wiki_text and not title.startswith(("Категория:", "Шаблон:", "Справка:")):
for sentence in SENTENCE_BOUNDARY.split(clean_page_text(wiki_text)):
# Leading dashes carry dialogue/prosody information and must survive extraction.
sentence = sentence.strip(" \t\r\n")
if not acceptable(sentence):
continue
digest = hashlib.blake2b(sentence.encode("utf-8"), digest_size=16).digest()
candidates += 1
if args.sampling == "first":
if digest in seen:
continue
seen.add(digest)
output.write(sentence + "\n")
accepted += 1
if accepted % 10_000 == 0:
print(f"sentences={accepted} pages={pages}", flush=True)
if accepted >= args.max_sentences:
break
else:
score = int.from_bytes(digest, "big")
if digest in seen:
continue
if len(sample_heap) < args.max_sentences:
heapq.heappush(sample_heap, (-score, digest, sentence)); seen.add(digest)
elif score < -sample_heap[0][0]:
_, removed_digest, _ = heapq.heapreplace(sample_heap, (-score, digest, sentence))
seen.remove(removed_digest); seen.add(digest)
element.clear()
if args.sampling == "first" and accepted >= args.max_sentences:
break
if pages % 10_000 == 0:
print(f"pages={pages} candidates={candidates} retained={len(sample_heap)}", flush=True)
if args.sampling == "hash":
selected = sorted((-negative_score, sentence) for negative_score, _, sentence in sample_heap)
for _, sentence in selected:
output.write(sentence + "\n")
accepted = len(selected)
temporary.replace(args.output)
metadata = {
"source_url": args.source_url,
"dump_file": args.dump.name,
"dump_bytes": args.dump.stat().st_size,
"pages_scanned": pages,
"candidate_sentences": candidates,
"sentences": accepted,
"output_bytes": args.output.stat().st_size,
"created_utc": datetime.now(timezone.utc).isoformat(),
"sampling": args.sampling,
"filters": {"min_chars": 40, "max_chars": 240, "min_words": 6, "max_words": 45, "min_russian_letter_ratio": 0.75},
}
args.metadata.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(metadata, ensure_ascii=False, indent=2))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--dump", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--metadata", type=Path, required=True)
parser.add_argument("--max-sentences", type=int, default=200_000)
parser.add_argument(
"--sampling", choices=("first", "hash"), default="hash",
help="hash scans the complete dump and keeps a deterministic min-hash sample",
)
parser.add_argument(
"--source-url",
default="https://dumps.wikimedia.org/ruwikisource/latest/ruwikisource-latest-pages-articles-multistream.xml.bz2",
)
return parser.parse_args()
if __name__ == "__main__":
sys.exit(extract(parse_args()) or 0)
@@ -0,0 +1,198 @@
[CmdletBinding()]
param(
[int[]] $Milestones = @(100, 300, 500),
[int] $PollSeconds = 20
)
$ErrorActionPreference = 'Stop'
$root = $PSScriptRoot
$trainingDir = Join-Path $root 'training'
$checkpointRoot = Join-Path $trainingDir 'checkpoints'
$configSource = Join-Path $trainingDir 'aletheia_ru.onnx.json'
$python = Join-Path $root '.venv\Scripts\python.exe'
$outputRoot = Join-Path $trainingDir 'milestones'
$monitorLog = Join-Path $outputRoot 'monitor.log'
New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null
function Write-MonitorLog([string] $Message) {
$line = '{0} {1}' -f (Get-Date).ToUniversalTime().ToString('o'), $Message
Add-Content -LiteralPath $monitorLog -Value $line -Encoding utf8
}
function Invoke-LoggedProcess(
[string] $FilePath,
[string[]] $Arguments,
[string] $StdoutPath,
[string] $StderrPath
) {
$process = Start-Process -FilePath $FilePath `
-ArgumentList $Arguments `
-RedirectStandardOutput $StdoutPath `
-RedirectStandardError $StderrPath `
-WindowStyle Hidden `
-Wait `
-PassThru
if ($process.ExitCode -ne 0) {
throw "Process failed with exit code $($process.ExitCode): $FilePath"
}
}
function Get-LatestCheckpoint {
Get-ChildItem -LiteralPath $checkpointRoot -Filter '*.ckpt' -File -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^epoch=(\d+)-step=(\d+)\.ckpt$' } |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
}
function Show-TrainingStatus {
$trainingLog = Join-Path $trainingDir 'scheduled-training.stdout.log'
$trainingTask = Get-ScheduledTask -TaskName 'AletheiaTTS-Training' -ErrorAction SilentlyContinue
$progressLine = ''
if (Test-Path -LiteralPath $trainingLog -PathType Leaf) {
$progressLine = Get-Content -LiteralPath $trainingLog -Tail 30 |
Where-Object { $_ -match '^Epoch\s+\d+:' } |
Select-Object -Last 1
}
$checkpoint = Get-LatestCheckpoint
$gpu = (& nvidia-smi --query-gpu=name,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw --format=csv,noheader,nounits 2>$null) -join [Environment]::NewLine
try {
$Host.UI.RawUI.WindowTitle = 'Aletheia TTS - training monitor'
Clear-Host
} catch {
# The monitor also works when no interactive console is attached.
}
Write-Host 'Aletheia Russian TTS training'
Write-Host ('Updated: {0}' -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'))
Write-Host ('Training task: {0}' -f $(if ($trainingTask) { $trainingTask.State } else { 'Not found' }))
Write-Host ('Progress: {0}' -f $(if ($progressLine) { $progressLine.Trim() } else { 'Waiting for log data' }))
Write-Host ('GPU: {0}' -f $(if ($gpu) { $gpu.Trim() } else { 'nvidia-smi unavailable' }))
if ($checkpoint) {
Write-Host ('Latest checkpoint: {0} ({1:N0} bytes)' -f $checkpoint.Name, $checkpoint.Length)
} else {
Write-Host 'Latest checkpoint: waiting'
}
Write-Host ''
Write-Host 'Evaluation milestones:'
foreach ($milestone in $Milestones) {
$completePath = Join-Path $outputRoot ('epoch-{0:d4}\complete.json' -f $milestone)
$state = if (Test-Path -LiteralPath $completePath -PathType Leaf) { 'READY' } else { 'waiting' }
Write-Host (' {0,4} epochs: {1}' -f $milestone, $state)
}
Write-Host ''
Write-Host 'This window may be minimized. Closing it stops milestone exports only.'
}
function Save-Milestone([int] $Milestone, [IO.FileInfo] $SourceCheckpoint) {
$milestoneName = 'epoch-{0:d4}' -f $Milestone
$milestoneDir = Join-Path $outputRoot $milestoneName
$completePath = Join-Path $milestoneDir 'complete.json'
if (Test-Path -LiteralPath $completePath -PathType Leaf) {
return
}
New-Item -ItemType Directory -Force -Path $milestoneDir | Out-Null
$checkpointPath = Join-Path $milestoneDir "aletheia_ru_$milestoneName.ckpt"
if (-not (Test-Path -LiteralPath $checkpointPath -PathType Leaf)) {
$firstLength = $SourceCheckpoint.Length
Start-Sleep -Seconds 10
$refreshed = Get-Item -LiteralPath $SourceCheckpoint.FullName
if ($firstLength -ne $refreshed.Length -or $refreshed.Length -le 0) {
throw "Checkpoint is not stable yet: $($SourceCheckpoint.FullName)"
}
$partialPath = "$checkpointPath.partial"
Copy-Item -LiteralPath $refreshed.FullName -Destination $partialPath -Force
Move-Item -LiteralPath $partialPath -Destination $checkpointPath -Force
}
$modelPath = Join-Path $milestoneDir "aletheia_ru_$milestoneName.onnx"
$configPath = "$modelPath.json"
if (-not (Test-Path -LiteralPath $modelPath -PathType Leaf)) {
Invoke-LoggedProcess $python @(
'-m', 'piper.train.export_onnx',
'--checkpoint', $checkpointPath,
'--output-file', $modelPath
) (Join-Path $milestoneDir 'export.stdout.log') (Join-Path $milestoneDir 'export.stderr.log')
}
Copy-Item -LiteralPath $configSource -Destination $configPath -Force
$inputPath = Join-Path $milestoneDir 'test_sentences.txt'
$testText = @(
'0JIg0YLQuNGI0LjQvdC1INCy0LXRh9C10YDQvdC10Lkg0LHQuNCx0LvQuNC+0YLQtdC60Lgg0YjQtdC70LXRgdGC0LXQu9C4INGB0YLRgNCw0L3QuNGG0YssINC4INC60LDQttC00LDRjyDQvdC+0LLQsNGPINCz0LvQsNCy0LAg0L7RgtC60YDRi9Cy0LDQu9CwINGD0LTQuNCy0LjRgtC10LvRjNC90YvQuSDQvNC40YAu',
'0JrQvtCz0LTQsCDRh9C10LvQvtCy0LXQuiDQtNC10LvQsNC10YIg0LLRi9Cx0L7RgCwg0L7QvSDQvdC1INCy0YHQtdCz0LTQsCDQt9Cw0YDQsNC90LXQtSDQt9C90LDQtdGCLCDQuiDQutCw0LrQuNC8INC/0L7RgdC70LXQtNGB0YLQstC40Y/QvCDQv9GA0LjQstC10LTRkdGCINC10LPQviDRgNC10YjQtdC90LjQtS4=',
'0JfQsCDQvtC60L3QvtC8INC80LXQtNC70LXQvdC90L4g0L3QsNGH0LjQvdCw0LvRgdGPINC00L7QttC00YwsINC90L4g0L/Rg9GC0LXRiNC10YHRgtCy0LjQtSDQs9C10YDQvtC10LIg0YLQvtC70YzQutC+INC90LDQsdC40YDQsNC70L4g0YHQuNC70YMu'
) | ForEach-Object { [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($_)) }
$testText = $testText -join [Environment]::NewLine
[IO.File]::WriteAllText($inputPath, $testText, [Text.UTF8Encoding]::new($false))
$samples = @(
@{ Name = 'neutral'; Length = '1.0'; Noise = '0.667'; Width = '0.8'; Silence = '0.0' },
@{ Name = 'book'; Length = '1.5'; Noise = '0.75'; Width = '0.95'; Silence = '0.25' },
@{ Name = 'fast'; Length = '0.82'; Noise = '0.70'; Width = '0.85'; Silence = '0.0' }
)
foreach ($sample in $samples) {
$wavPath = Join-Path $milestoneDir ("sample_{0}.wav" -f $sample.Name)
if (Test-Path -LiteralPath $wavPath -PathType Leaf) {
continue
}
Invoke-LoggedProcess $python @(
'-m', 'piper',
'--model', $modelPath,
'--config', $configPath,
'--input-file', $inputPath,
'--output-file', $wavPath,
'--length-scale', $sample.Length,
'--noise-scale', $sample.Noise,
'--noise-w-scale', $sample.Width,
'--sentence-silence', $sample.Silence
) (Join-Path $milestoneDir "$($sample.Name).stdout.log") (Join-Path $milestoneDir "$($sample.Name).stderr.log")
}
$sourceMatch = [regex]::Match($SourceCheckpoint.Name, '^epoch=(\d+)-step=(\d+)\.ckpt$')
$artifacts = Get-ChildItem -LiteralPath $milestoneDir -File |
Where-Object { $_.Extension -in @('.ckpt', '.onnx', '.json', '.wav') } |
ForEach-Object {
[ordered]@{
name = $_.Name
bytes = $_.Length
sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
}
}
[ordered]@{
requested_completed_epochs = $Milestone
source_epoch_zero_based = [int]$sourceMatch.Groups[1].Value
source_step = [int]$sourceMatch.Groups[2].Value
created_utc = (Get-Date).ToUniversalTime().ToString('o')
artifacts = @($artifacts)
} | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $completePath -Encoding utf8
Write-MonitorLog "Completed milestone $Milestone from $($SourceCheckpoint.Name)"
}
Write-MonitorLog "Monitor started for milestones: $($Milestones -join ', ')"
while ($true) {
Show-TrainingStatus
$pending = @($Milestones | Where-Object {
-not (Test-Path -LiteralPath (Join-Path $outputRoot ('epoch-{0:d4}\complete.json' -f $_)) -PathType Leaf)
})
if ($pending.Count -eq 0) {
Write-MonitorLog 'All milestones completed'
exit 0
}
try {
$checkpoint = Get-LatestCheckpoint
if ($checkpoint -and $checkpoint.Name -match '^epoch=(\d+)-step=(\d+)\.ckpt$') {
$completedEpochs = [int]$Matches[1] + 1
foreach ($milestone in $pending) {
if ($completedEpochs -ge $milestone) {
Save-Milestone $milestone $checkpoint
}
}
}
} catch {
Write-MonitorLog ("Retryable error: " + ($_ | Out-String).Trim())
}
Start-Sleep -Seconds $PollSeconds
}
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Add the original-to-compact token lookup to an already exported student ONNX."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
import numpy as np
import onnx
from onnx import helper, numpy_helper
def patch(args: argparse.Namespace) -> None:
if args.output.exists():
raise FileExistsError(f"Refusing to overwrite {args.output}")
model = onnx.load(str(args.model))
if not any(value.name == "input_ids" for value in model.graph.input):
raise ValueError("Model has no input_ids graph input")
if any(item.name == "original_to_compact_token_id" for item in model.graph.initializer):
raise ValueError("Model already contains original_to_compact_token_id")
consumers = 0
for node in model.graph.node:
for index, name in enumerate(node.input):
if name == "input_ids":
node.input[index] = "mapped_input_ids"
consumers += 1
if consumers == 0:
raise ValueError("No input_ids consumers found")
token_map = np.load(args.token_map).astype(np.int64, copy=False)
model.graph.initializer.append(numpy_helper.from_array(token_map, "original_to_compact_token_id"))
model.graph.node.insert(
0,
helper.make_node(
"Gather", ["original_to_compact_token_id", "input_ids"], ["mapped_input_ids"],
axis=0, name="MapOriginalTokenIds",
),
)
onnx.checker.check_model(model)
onnx.save(model, str(args.output))
print(f"token_map_entries={token_map.size} consumers={consumers} output_bytes={args.output.stat().st_size}")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--model", type=Path, required=True)
parser.add_argument("--token-map", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
return parser.parse_args()
if __name__ == "__main__":
sys.exit(patch(parse_args()) or 0)
+11
View File
@@ -0,0 +1,11 @@
diff --git a/src/piper/train/export_onnx.py b/src/piper/train/export_onnx.py
index fe9be60..abfeeb0 100644
--- a/src/piper/train/export_onnx.py
+++ b/src/piper/train/export_onnx.py
@@ -103,5 +103,6 @@ def main() -> None:
"input_lengths": {0: "batch_size"},
"output": {0: "batch_size", 2: "time"},
},
+ dynamo=False,
)
_LOGGER.info("Exported model to %s", output_path)
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Prepare a Piper ONNX export for sherpa-onnx mobile inference."""
import argparse
import json
import shutil
from pathlib import Path
import onnx
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--model", type=Path, required=True)
parser.add_argument("--config", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
args = parser.parse_args()
config = json.loads(args.config.read_text(encoding="utf-8"))
output_dir: Path = args.output_dir
output_dir.mkdir(parents=True, exist_ok=True)
output_model = output_dir / "aletheia_ru.onnx"
output_config = output_dir / "aletheia_ru.onnx.json"
output_tokens = output_dir / "tokens.txt"
shutil.copy2(args.model, output_model)
shutil.copy2(args.config, output_config)
id_map = config["phoneme_id_map"]
# sherpa-onnx's Piper lexicon maps one Unicode code point to one model ID.
# Newer Piper configs may also contain English diphthong aliases such as
# "aɪ". eSpeak emits their component code points, and sherpa rejects the
# multi-code-point aliases, so only the single-code-point table is mobile-safe.
token_rows = sorted(
((ids[0], symbol) for symbol, ids in id_map.items() if len(symbol) == 1),
key=lambda row: row[0],
)
actual_ids = [row[0] for row in token_rows]
if len(actual_ids) != len(set(actual_ids)):
raise RuntimeError("phoneme_id_map contains duplicate token identifiers")
if not actual_ids or min(actual_ids) < 0 or max(actual_ids) >= config["num_symbols"]:
raise RuntimeError("phoneme_id_map contains a token outside the model symbol range")
output_tokens.write_text(
"".join(f"{symbol} {token_id}\n" for token_id, symbol in token_rows),
encoding="utf-8",
newline="\n",
)
model = onnx.load(str(output_model))
metadata = {
"model_type": "vits",
"comment": "piper",
"language": "Russian",
"voice": config["espeak"]["voice"],
"has_espeak": "1",
"n_speakers": str(config["num_speakers"]),
"sample_rate": str(config["audio"]["sample_rate"]),
}
existing = {item.key: item for item in model.metadata_props}
for key, value in metadata.items():
if key in existing:
existing[key].value = value
else:
item = model.metadata_props.add()
item.key = key
item.value = value
onnx.save(model, str(output_model))
print(json.dumps({
"model": str(output_model),
"model_bytes": output_model.stat().st_size,
"tokens": len(token_rows),
"metadata": metadata,
}, ensure_ascii=False))
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
param([string]$Root = 'C:\Users\seven\AletheiaBookTTS')
$ErrorActionPreference = 'Stop'
$env:PYTHONUTF8 = '1'
$env:PYTHONIOENCODING = 'utf-8'
$python = Join-Path $Root '.venv\Scripts\python.exe'
$work = Join-Path $Root 'work'
$candidate = Join-Path $work 'student-100k-v1'
& $python (Join-Path $work 'vosk_booktts_experiment.py') compare-feeds `
--model (Join-Path $Root 'teacher\model.onnx') `
--baseline-calibration (Join-Path $candidate 'feeds-baseline-100-v2') `
--candidate-calibration (Join-Path $candidate 'feeds-student-int8-100-v2') `
--output (Join-Path $candidate 'wav-ab-100-v1') `
--resume
exit $LASTEXITCODE
+89
View File
@@ -0,0 +1,89 @@
param(
[string]$Root = 'C:\Users\seven\AletheiaBookTTS',
[int]$MaxSentences = 100000,
[int]$TeacherBatchSize = 16,
[ValidateSet('first', 'hash')]
[string]$Sampling = 'hash'
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$env:PYTHONUTF8 = '1'
$env:PYTHONIOENCODING = 'utf-8'
$displayName = 'AletheiaBookTTS-RuWikisource'
$corpusDir = Join-Path $Root 'corpus'
$workDir = Join-Path $Root 'work'
$teacherDir = Join-Path $Root 'teacher'
$python = Join-Path $Root '.venv\Scripts\python.exe'
$dump = Join-Path $corpusDir 'ruwikisource-latest-pages-articles-multistream.xml.bz2'
$sentences = Join-Path $corpusDir "ruwikisource-booktts-$MaxSentences-$Sampling.txt"
$metadata = Join-Path $corpusDir "ruwikisource-booktts-$MaxSentences-$Sampling.json"
$dataset = Join-Path $workDir "student-dataset-$MaxSentences-$Sampling"
$datasetPartial = "$dataset.partial"
$expectedBytes = 2104816879L
$checksumUrl = 'https://dumps.wikimedia.org/ruwikisource/latest/ruwikisource-latest-sha1sums.txt'
Import-Module BitsTransfer
while (-not (Test-Path -LiteralPath $dump)) {
$job = Get-BitsTransfer -ErrorAction SilentlyContinue |
Where-Object DisplayName -eq $displayName |
Select-Object -First 1
if (-not $job) {
throw "BITS job $displayName is absent and dump is not downloaded"
}
if ($job.JobState -eq 'Transferred') {
Complete-BitsTransfer -BitsJob $job
break
}
if ($job.JobState -eq 'Error') {
throw "BITS download failed: $($job.ErrorDescription)"
}
$percent = if ($job.BytesTotal -gt 0 -and $job.BytesTotal -lt [uint64]::MaxValue) {
[math]::Round(100 * $job.BytesTransferred / $job.BytesTotal, 2)
} else { 0 }
Write-Output "download state=$($job.JobState) bytes=$($job.BytesTransferred)/$($job.BytesTotal) percent=$percent"
Start-Sleep -Seconds 20
}
$dumpItem = Get-Item -LiteralPath $dump
if ($dumpItem.Length -ne $expectedBytes) {
throw "Unexpected dump size: $($dumpItem.Length), expected $expectedBytes"
}
$checksumText = (Invoke-WebRequest -UseBasicParsing -Uri $checksumUrl).Content
$checksumLine = ($checksumText -split "`n" | Where-Object { $_ -match 'pages-articles-multistream\.xml\.bz2\s*$' } | Select-Object -First 1).Trim()
if (-not $checksumLine) {
throw 'Cannot find multistream dump in official SHA-1 list'
}
$expectedSha1 = ($checksumLine -split '\s+')[0].ToUpperInvariant()
$actualSha1 = (Get-FileHash -LiteralPath $dump -Algorithm SHA1).Hash
if ($actualSha1 -ne $expectedSha1) {
throw "SHA-1 mismatch: actual=$actualSha1 expected=$expectedSha1"
}
Write-Output "dump verified bytes=$($dumpItem.Length) sha1=$actualSha1"
if (-not (Test-Path -LiteralPath $sentences)) {
& $python (Join-Path $workDir 'extract_wikisource_corpus.py') `
--dump $dump `
--output $sentences `
--metadata $metadata `
--max-sentences $MaxSentences `
--sampling $Sampling
if ($LASTEXITCODE -ne 0) { throw "Corpus extraction failed with exit code $LASTEXITCODE" }
}
if (-not (Test-Path -LiteralPath $dataset)) {
if (Test-Path -LiteralPath $datasetPartial) {
throw "Partial dataset already exists: $datasetPartial"
}
& $python (Join-Path $workDir 'booktts_student.py') dataset `
--assets $teacherDir `
--corpus $sentences `
--output $datasetPartial `
--teacher-batch-size $TeacherBatchSize `
--teacher-dtype float16
if ($LASTEXITCODE -ne 0) { throw "Teacher dataset failed with exit code $LASTEXITCODE" }
Move-Item -LiteralPath $datasetPartial -Destination $dataset
}
Write-Output "pipeline complete corpus=$sentences dataset=$dataset"
@@ -0,0 +1,16 @@
param([string]$Root = 'C:\Users\seven\AletheiaBookTTS')
$ErrorActionPreference = 'Stop'
$env:PYTHONUTF8 = '1'
$env:PYTHONIOENCODING = 'utf-8'
$python = Join-Path $Root '.venv\Scripts\python.exe'
$work = Join-Path $Root 'work'
$output = Join-Path $work 'student-dataset-prosody-v1'
& $python (Join-Path $work 'booktts_student.py') dataset `
--assets (Join-Path $Root 'teacher') `
--corpus (Join-Path $Root 'corpus\booktts-prosody-balanced-v1.txt') `
--output $output `
--teacher-batch-size 16 `
--teacher-dtype float16 `
--reuse-token-map (Join-Path $work 'student-dataset-100000')
exit $LASTEXITCODE
@@ -0,0 +1,17 @@
param([string]$Root = 'C:\Users\seven\AletheiaBookTTS')
$ErrorActionPreference = 'Stop'
$env:PYTHONUTF8 = '1'
$env:PYTHONIOENCODING = 'utf-8'
$python = Join-Path $Root '.venv\Scripts\python.exe'
$work = Join-Path $Root 'work'
& $python (Join-Path $work 'booktts_student.py') train `
--dataset (Join-Path $work 'student-dataset-prosody-v1') `
--initial-model (Join-Path $work 'student-100k-v1\student.pt') `
--output (Join-Path $work 'student-100k-prosody-v1') `
--epochs 8 `
--batch-size 32 `
--learning-rate 0.00005 `
--validation-fraction 0.1 `
--early-stopping-patience 3
exit $LASTEXITCODE
+27
View File
@@ -0,0 +1,27 @@
param(
[string]$Root = 'C:\Users\seven\AletheiaBookTTS',
[string]$DatasetName = 'student-dataset-100000',
[string]$OutputName = 'student-100k-v1',
[int]$Epochs = 20,
[int]$BatchSize = 32,
[double]$ValidationFraction = 0.05,
[int]$EarlyStoppingPatience = 4,
[switch]$Resume
)
$ErrorActionPreference = 'Stop'
$env:PYTHONUTF8 = '1'
$env:PYTHONIOENCODING = 'utf-8'
$python = Join-Path $Root '.venv\Scripts\python.exe'
$trainer = Join-Path $Root 'work\booktts_student.py'
$dataset = Join-Path $Root "work\$DatasetName"
$output = Join-Path $Root "work\$OutputName"
$arguments = @(
$trainer, 'train', '--dataset', $dataset, '--output', $output,
'--epochs', $Epochs, '--batch-size', $BatchSize,
'--validation-fraction', $ValidationFraction,
'--early-stopping-patience', $EarlyStoppingPatience
)
if ($Resume) { $arguments += '--resume' }
& $python @arguments
exit $LASTEXITCODE
+10
View File
@@ -15,6 +15,13 @@ if (Test-Path -LiteralPath $exitPath) {
$exitCode = 1
try {
$latestCheckpoint = Get-ChildItem -LiteralPath (Join-Path $runDir 'checkpoints') `
-Filter '*.ckpt' `
-File `
-Recurse `
-ErrorAction SilentlyContinue |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
$arguments = @(
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
'-File', (Join-Path $root 'train_piper_windows.ps1'),
@@ -24,6 +31,9 @@ try {
'-PythonExe', (Join-Path $root '.venv\Scripts\python.exe'),
'-BatchSize', '16', '-NumWorkers', '0', '-MaxEpochs', '2000'
)
if ($latestCheckpoint) {
$arguments += @('-CheckpointPath', $latestCheckpoint.FullName)
}
$process = Start-Process -FilePath 'powershell.exe' `
-ArgumentList $arguments `
-RedirectStandardOutput $stdoutPath `
+8
View File
@@ -7,6 +7,7 @@ param(
[Parameter(Mandatory = $true)]
[string] $PythonExe,
[string] $CacheDir,
[string] $CheckpointPath,
[int] $BatchSize = 4,
[int] $NumWorkers = 2,
[int] $MaxEpochs = 2000,
@@ -61,6 +62,13 @@ $fitArgs = @(
if ($SmokeTest) {
$fitArgs += @('--trainer.fast_dev_run', 'true', '--trainer.num_sanity_val_steps', '0')
}
if ($CheckpointPath) {
$checkpointToResume = [IO.Path]::GetFullPath($CheckpointPath)
if (-not (Test-Path -LiteralPath $checkpointToResume -PathType Leaf)) {
throw "Resume checkpoint not found: $checkpointToResume"
}
$fitArgs += @('--ckpt_path', $checkpointToResume)
}
& $python @fitArgs
if ($LASTEXITCODE -ne 0) {
+568
View File
@@ -0,0 +1,568 @@
#!/usr/bin/env python3
"""Prepare real Vosk feeds, quantize acoustic ONNX, and render A/B WAVs.
The frontend mirrors VoskSpeechEngine.kt: number normalization, WordPiece
positions, dictionary lookup and all five phone feature channels.
"""
from __future__ import annotations
import argparse
import bisect
import json
import math
import re
import sys
import time
import wave
from collections import OrderedDict
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator
import numpy as np
SAMPLE_RATE = 22_050
SPEAKER_ID = 4
BERT_DIMENSIONS = 768
MAX_WORD_PIECE_CHARS = 100
CACHE_SIZE = 4_096
MULTISTREAM_PUNCTUATION = set("!(),-.:;?")
BERT_EXCLUDED_PUNCTUATION = {"-", ",", ".", "?", "!", ";", ":", '"'}
PHONEMES = [
"_", "^", "$", " ", "!", "'", "(", ")", ",", "-", ".", "...", ":", ";", "?",
"a0", "a1", "b", "bj", "c", "ch", "d", "dj", "e0", "e1", "f", "fj", "g", "gj",
"h", "hj", "i0", "i1", "j", "k", "kj", "l", "lj", "m", "mj", "n", "nj", "o0",
"o1", "p", "pj", "r", "rj", "s", "sch", "sh", "sj", "t", "tj", "u0", "u1", "v",
"vj", "y0", "y1", "z", "zh", "zj",
]
PHONEME_IDS = {phone: index for index, phone in enumerate(PHONEMES)}
DIGITS = ["ноль", "один", "два", "три", "четыре", "пять", "шесть", "семь", "восемь", "девять"]
TEENS = [
"десять", "одиннадцать", "двенадцать", "тринадцать", "четырнадцать",
"пятнадцать", "шестнадцать", "семнадцать", "восемнадцать", "девятнадцать",
]
TENS = ["", "десять", "двадцать", "тридцать", "сорок", "пятьдесят", "шестьдесят", "семьдесят", "восемьдесят", "девяносто"]
HUNDREDS = ["", "сто", "двести", "триста", "четыреста", "пятьсот", "шестьсот", "семьсот", "восемьсот", "девятьсот"]
SCALES = [
None,
("тысяча", "тысячи", "тысяч"),
("миллион", "миллиона", "миллионов"),
("миллиард", "миллиарда", "миллиардов"),
("триллион", "триллиона", "триллионов"),
("квадриллион", "квадриллиона", "квадриллионов"),
]
NUMBER_PATTERN = re.compile(r"(?<![^\W_])([+-]?)(\d{1,18})(?:[,.](\d+))?(?![^\W_])", re.UNICODE)
YEAR_IN_PREPOSITIONAL_PATTERN = re.compile(r"(?<![^\W_])(\d{4})\s+(году|г\.)", re.IGNORECASE | re.UNICODE)
SOFT_LETTERS = set("яёюиье")
SYLLABLE_STARTS = set("#ъьаёуюэеиы-")
SOFT_HARD_CONSONANTS = {
"б": "b", "в": "v", "г": "g", "д": "d", "з": "z", "к": "k", "л": "l",
"м": "m", "н": "n", "п": "p", "р": "r", "с": "s", "т": "t", "ф": "f", "х": "h",
}
OTHER_CONSONANTS = {"ж": "zh", "ц": "c", "ч": "ch", "ш": "sh", "щ": "sch", "й": "j"}
VOWELS = {"а": "a", "я": "a", "у": "u", "ю": "u", "о": "o", "ё": "o", "э": "e", "е": "e", "и": "i", "ы": "y"}
def scale_form(value: int, forms: tuple[str, str, str]) -> str:
if 11 <= value % 100 <= 14:
return forms[2]
return forms[0] if value % 10 == 1 else forms[1] if value % 10 in (2, 3, 4) else forms[2]
def under_thousand_to_words(value: int, feminine: bool) -> str:
words: list[str] = []
if value // 100:
words.append(HUNDREDS[value // 100])
tail = value % 100
if 10 <= tail <= 19:
words.append(TEENS[tail - 10])
else:
if tail // 10:
words.append(TENS[tail // 10])
units = tail % 10
if units:
words.append("одна" if feminine and units == 1 else "две" if feminine and units == 2 else DIGITS[units])
return " ".join(words)
def integer_to_words(value: int) -> str:
if value == 0:
return DIGITS[0]
groups: list[int] = []
while value:
groups.append(value % 1_000)
value //= 1_000
result: list[str] = []
for group_index in range(len(groups) - 1, -1, -1):
group = groups[group_index]
if not group:
continue
words = under_thousand_to_words(group, group_index == 1)
forms = SCALES[group_index] if group_index < len(SCALES) else None
result.append(words if forms is None else f"{words} {scale_form(group, forms)}")
return " ".join(result)
def normalize_numbers(text: str) -> str:
prepared = re.sub(r"(?<=\d)[\u00a0\u202f](?=\d{3}(?:\D|$))", "", text)
prepared = re.sub(r"\s*(?=\d)", "номер ", prepared)
def replace(match: re.Match[str]) -> str:
sign = "минус " if match.group(1) == "-" else "плюс " if match.group(1) == "+" else ""
integer_digits = match.group(2)
if len(integer_digits) > 1 and integer_digits.startswith("0"):
integer = " ".join(DIGITS[int(char)] for char in integer_digits)
else:
integer = integer_to_words(int(integer_digits))
fraction = match.group(3)
return f"{sign}{integer}" if not fraction else f"{sign}{integer} запятая {' '.join(DIGITS[int(char)] for char in fraction)}"
prepared = YEAR_IN_PREPOSITIONAL_PATTERN.sub(
lambda match: f"{year_in_prepositional_words(int(match.group(1)))} {match.group(2)}",
prepared,
)
return NUMBER_PATTERN.sub(replace, prepared)
ORDINAL_UNITS_PREPOSITIONAL = ("", "первом", "втором", "третьем", "четвёртом", "пятом", "шестом", "седьмом", "восьмом", "девятом")
ORDINAL_TEENS_PREPOSITIONAL = ("десятом", "одиннадцатом", "двенадцатом", "тринадцатом", "четырнадцатом", "пятнадцатом", "шестнадцатом", "семнадцатом", "восемнадцатом", "девятнадцатом")
ORDINAL_TENS_PREPOSITIONAL = ("", "десятом", "двадцатом", "тридцатом", "сороковом", "пятидесятом", "шестидесятом", "семидесятом", "восьмидесятом", "девяностом")
ORDINAL_HUNDREDS_PREPOSITIONAL = ("", "сотом", "двухсотом", "трёхсотом", "четырёхсотом", "пятисотом", "шестисотом", "семисотом", "восьмисотом", "девятисотом")
EXACT_THOUSANDTH_PREPOSITIONAL = ("", "тысячном", "двухтысячном", "трёхтысячном", "четырёхтысячном", "пятитысячном", "шеститысячном", "семитысячном", "восьмитысячном", "девятитысячном")
def ordinal_under_thousand_prepositional(value: int) -> str:
last_two = value % 100
units = value % 10
if units and not 11 <= last_two <= 19:
return f"{integer_to_words(value - units) if value > units else ''} {ORDINAL_UNITS_PREPOSITIONAL[units]}".strip()
if 11 <= last_two <= 19:
return f"{integer_to_words(value - last_two) if value > last_two else ''} {ORDINAL_TEENS_PREPOSITIONAL[last_two - 10]}".strip()
if last_two >= 20:
return f"{integer_to_words(value - last_two) if value > last_two else ''} {ORDINAL_TENS_PREPOSITIONAL[last_two // 10]}".strip()
return ORDINAL_HUNDREDS_PREPOSITIONAL[value // 100]
def year_in_prepositional_words(value: int) -> str:
if not 1000 <= value <= 9999:
raise ValueError(value)
if value % 1000 == 0:
return EXACT_THOUSANDTH_PREPOSITIONAL[value // 1000]
thousands = value // 1000
thousands_words = under_thousand_to_words(thousands, True)
thousands_prefix = "" if thousands_words == "одна" else thousands_words + " "
return f"{thousands_prefix}{scale_form(thousands, SCALES[1])} {ordinal_under_thousand_prepositional(value % 1000)}"
def normalize_text(text: str) -> str:
stressed = re.sub(r"([А-Яа-яЁё])\u0301", lambda match: "+" + match.group(1), text)
translated = normalize_numbers(stressed).lower().replace("", "-").replace("", "-").replace("", "...")
translated = translated.translate(str.maketrans({"«": '"', "»": '"', "": '"', "": '"'}))
return re.sub(r"\s+", " ", translated).strip()
class WordPieceTokenizer:
def __init__(self, path: Path):
self.tokens = path.read_text(encoding="utf-8").splitlines()
self.vocabulary = {token: index for index, token in enumerate(self.tokens)}
for special in ("[CLS]", "[SEP]", "[UNK]"):
if special not in self.vocabulary:
raise ValueError(f"Missing {special} in {path}")
def encode(self, text: str) -> tuple[np.ndarray, list[int]]:
ids = [self.vocabulary["[CLS]"]]
positions = [0]
for token in self._basic_tokens(text):
punctuation = token in BERT_EXCLUDED_PUNCTUATION
pieces = [self.vocabulary.get(token, self.vocabulary["[UNK]"])] if punctuation else self._word_pieces(token)
if not punctuation:
positions.append(len(ids))
ids.extend(pieces)
ids.append(self.vocabulary["[SEP]"])
positions.append(len(ids) - 1)
return np.asarray(ids, dtype=np.int64), positions
@staticmethod
def _basic_tokens(text: str) -> list[str]:
result: list[str] = []
word: list[str] = []
for char in text:
if char.isspace():
if word:
result.append("".join(word)); word.clear()
elif char.isalnum():
word.append(char.lower())
elif char == "+":
continue
else:
if word:
result.append("".join(word)); word.clear()
result.append(char)
if word:
result.append("".join(word))
return result
def _word_pieces(self, token: str) -> list[int]:
if len(token) > MAX_WORD_PIECE_CHARS:
return [self.vocabulary["[UNK]"]]
result: list[int] = []
start = 0
while start < len(token):
found: int | None = None
found_end = start
for end in range(len(token), start, -1):
piece = token[start:end] if start == 0 else f"##{token[start:end]}"
if piece in self.vocabulary:
found = self.vocabulary[piece]; found_end = end; break
if found is None:
return [self.vocabulary["[UNK]"]]
result.append(found); start = found_end
return result
class PronunciationDictionary:
def __init__(self, dictionary_path: Path, index_path: Path):
self.source = dictionary_path.open("rb")
entries = []
for line in index_path.read_text(encoding="utf-8").splitlines():
word, offset = line.rsplit("\t", 1)
entries.append((word, int(offset)))
self.words = [entry[0] for entry in entries]
self.offsets = [entry[1] for entry in entries]
self.length = dictionary_path.stat().st_size
self.cache: OrderedDict[str, str | None] = OrderedDict()
def close(self) -> None:
self.source.close()
def find(self, word: str) -> str | None:
if word in self.cache:
value = self.cache.pop(word); self.cache[word] = value; return value
index = max(0, bisect.bisect_right(self.words, word) - 1)
end = self.offsets[index + 1] if index + 1 < len(self.offsets) else self.length
self.source.seek(self.offsets[index])
value: str | None = None
while self.source.tell() < end:
raw = self.source.readline()
if not raw:
break
entry_word, _, pronunciation = raw.decode("utf-8").rstrip("\r\n").partition("\t")
if entry_word < word:
continue
if entry_word == word:
value = pronunciation.strip() or None
break
self.cache[word] = value
if len(self.cache) > CACHE_SIZE:
self.cache.popitem(last=False)
return value
def russian_g2p(word: str) -> str:
marked: list[tuple[str, int]] = []
stress = 0
for char in f"#{word}#":
if char == "+":
stress = 1
else:
marked.append((char, stress)); stress = 0
phones: list[str] = []
for index, (char, accent) in enumerate(marked):
previous = marked[index - 1][0] if index else "#"
following = marked[index + 1][0] if index + 1 < len(marked) else None
if char in SOFT_HARD_CONSONANTS:
phones.append(SOFT_HARD_CONSONANTS[char] + ("j" if following in SOFT_LETTERS else ""))
elif char in OTHER_CONSONANTS:
phones.append(OTHER_CONSONANTS[char])
elif char in VOWELS:
if previous in SYLLABLE_STARTS and char in set("яюеё"):
phones.append("j")
phones.append(f"{VOWELS[char]}{accent}")
return " ".join(phones)
@dataclass
class RawPhone:
phone: str
punctuation: list[str]
in_quote: int
bert_word_index: int
def build_multistream_phones(text: str, dictionary: PronunciationDictionary) -> tuple[np.ndarray, list[int]]:
raw = [RawPhone("^", [], 0, 0)]
word: list[str] = []
pending: list[str] = []
in_quote = 0
bert_word_index = 1
def flush_word() -> None:
nonlocal bert_word_index
if not word:
return
value = "".join(word)
pronunciation = dictionary.find(value) or russian_g2p(value)
raw.extend(RawPhone(phone, [], in_quote, bert_word_index) for phone in pronunciation.split() if phone)
word.clear(); bert_word_index += 1
def append_space() -> None:
raw.append(RawPhone(" ", list(pending), in_quote, bert_word_index)); pending.clear()
index = 0
while index < len(text):
if text.startswith("...", index):
flush_word(); pending.append("..."); index += 3; continue
char = text[index]
if char in {'"', "«", "»", "", ""}:
flush_word(); in_quote = 1 - in_quote
elif char.isspace():
flush_word(); append_space()
elif char == "-" and index > 0 and index + 1 < len(text) and text[index - 1].isalnum() and text[index + 1].isalnum():
flush_word()
elif char in MULTISTREAM_PUNCTUATION:
flush_word(); pending.append(char)
elif char.isalnum() or char == "+":
word.append(char)
index += 1
flush_word(); append_space(); raw.append(RawPhone("$", [], 0, bert_word_index))
last_punctuation = " "
last_sentence_punctuation = " "
reversed_features: list[list[int]] = []
reversed_positions: list[int] = []
for phone in reversed(raw):
for candidate in ("...", ".", "!", "?", "-"):
if candidate in phone.punctuation:
last_sentence_punctuation = candidate; break
if phone.punctuation:
last_punctuation = phone.punctuation[0]
current = phone.punctuation[0] if phone.punctuation else "_"
values = [PHONEME_IDS[phone.phone], PHONEME_IDS[current], phone.in_quote, PHONEME_IDS[last_punctuation], PHONEME_IDS[last_sentence_punctuation]]
reversed_features.append(values); reversed_positions.append(phone.bert_word_index)
features = np.asarray(list(reversed(reversed_features)), dtype=np.int64).T[np.newaxis, :, :]
return features, list(reversed(reversed_positions))
class Frontend:
def __init__(self, assets: Path, bert_model: Path | None = None):
import onnxruntime as ort
self.tokenizer = WordPieceTokenizer(assets / "vocab.txt")
self.dictionary = PronunciationDictionary(assets / "dict.tsv", assets / "dictionary.index")
self.bert = ort.InferenceSession(str(bert_model or assets / "bert.int8.onnx"), providers=["CPUExecutionProvider"])
def close(self) -> None:
self.dictionary.close()
def prepare(self, text: str, articulation: float = 1.0) -> dict[str, np.ndarray]:
normalized = normalize_text(text)
ids, embedding_positions = self.tokenizer.encode(normalized)
shape = (1, ids.size)
token_embeddings = self.bert.run(None, {
"input_ids": ids.reshape(shape),
"attention_mask": np.ones(shape, dtype=np.int64),
"token_type_ids": np.zeros(shape, dtype=np.int64),
})[0]
selected = token_embeddings[np.asarray(embedding_positions, dtype=np.int64)]
features, phone_positions = build_multistream_phones(normalized, self.dictionary)
phone_embeddings = selected[np.clip(np.asarray(phone_positions), 0, len(selected) - 1)]
bert = phone_embeddings.T[np.newaxis, :, :].astype(np.float32, copy=False)
time_steps = features.shape[2]
if bert.shape != (1, BERT_DIMENSIONS, time_steps):
raise ValueError(f"Unexpected BERT shape {bert.shape} for {text!r}")
return {
"input": features,
"input_lengths": np.asarray([time_steps], dtype=np.int64),
"scales": np.asarray([0.8, articulation, 0.8], dtype=np.float32),
"sid": np.asarray([SPEAKER_ID], dtype=np.int64),
"bert": bert,
}
def load_corpus(path: Path) -> list[str]:
lines = [line.strip() for line in path.read_text(encoding="utf-8-sig").splitlines()]
result = [line for line in lines if line and not line.startswith("#")]
if not result:
raise ValueError(f"No phrases in {path}")
return result
def command_prepare(args: argparse.Namespace) -> None:
args.output.mkdir(parents=True, exist_ok=False)
frontend = Frontend(args.assets, args.bert_model)
manifest = []
try:
for index, text in enumerate(load_corpus(args.corpus)):
feeds = frontend.prepare(text, args.articulation)
name = f"sample-{index:04d}.npz"
np.savez_compressed(args.output / name, **feeds)
manifest.append({"file": name, "text": text, "time_steps": int(feeds["input_lengths"][0])})
print(f"prepared {index + 1}: phones={manifest[-1]['time_steps']} text={text}")
finally:
frontend.close()
(args.output / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"samples={len(manifest)} output={args.output}")
class NpzCalibrationReader:
def __init__(self, directory: Path):
self.files = sorted(directory.glob("sample-*.npz"))
self.iterator: Iterator[Path] | None = None
if not self.files:
raise ValueError(f"No sample-*.npz in {directory}")
def get_next(self) -> dict[str, np.ndarray] | None:
if self.iterator is None:
self.iterator = iter(self.files)
try:
path = next(self.iterator)
except StopIteration:
return None
with np.load(path) as data:
return {name: data[name] for name in data.files}
def rewind(self) -> None:
self.iterator = iter(self.files)
def command_quantize(args: argparse.Namespace) -> None:
from onnxruntime.quantization import QuantFormat, QuantType, quantize_static
if args.output.exists():
raise FileExistsError(f"Refusing to overwrite {args.output}")
started = time.time()
quantize_static(
str(args.model), str(args.output), NpzCalibrationReader(args.calibration),
quant_format=QuantFormat.QDQ, op_types_to_quantize=["Conv"], per_channel=True,
activation_type=QuantType.QInt8, weight_type=QuantType.QInt8,
)
source_size = args.model.stat().st_size
target_size = args.output.stat().st_size
print(json.dumps({
"source_bytes": source_size, "target_bytes": target_size,
"saving_bytes": source_size - target_size,
"saving_percent": round((source_size - target_size) * 100 / source_size, 4),
"seconds": round(time.time() - started, 3),
}, indent=2))
def write_wav(path: Path, samples: np.ndarray) -> None:
pcm = (np.clip(samples.reshape(-1), -1.0, 1.0) * 32767.0).astype("<i2").tobytes()
with wave.open(str(path), "wb") as output:
output.setnchannels(1); output.setsampwidth(2); output.setframerate(SAMPLE_RATE); output.writeframes(pcm)
def command_compare(args: argparse.Namespace) -> None:
import onnxruntime as ort
args.output.mkdir(parents=True, exist_ok=False)
options = ort.SessionOptions(); options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL
baseline = ort.InferenceSession(str(args.baseline), sess_options=options, providers=["CPUExecutionProvider"])
candidate = ort.InferenceSession(str(args.candidate), sess_options=options, providers=["CPUExecutionProvider"])
manifest = json.loads((args.calibration / "manifest.json").read_text(encoding="utf-8"))
report = []
for item in manifest[: args.limit]:
with np.load(args.calibration / item["file"]) as data:
feeds = {name: data[name] for name in data.files}
feeds["scales"] = np.asarray([0.0, float(feeds["scales"][1]), 0.0], dtype=np.float32)
base_wav, base_length = baseline.run(None, feeds)
test_wav, test_length = candidate.run(None, feeds)
common = min(base_wav.size, test_wav.size)
difference = base_wav.reshape(-1)[:common] - test_wav.reshape(-1)[:common]
entry = {
"text": item["text"], "baseline_samples": int(base_wav.size), "candidate_samples": int(test_wav.size),
"baseline_length": np.asarray(base_length).tolist(), "candidate_length": np.asarray(test_length).tolist(),
"mae": float(np.mean(np.abs(difference))), "rmse": float(math.sqrt(np.mean(difference * difference))),
}
report.append(entry)
number = len(report)
write_wav(args.output / f"{number:02d}-baseline.wav", base_wav)
write_wav(args.output / f"{number:02d}-candidate.wav", test_wav)
print(f"compared {number}: mae={entry['mae']:.6f} rmse={entry['rmse']:.6f}")
(args.output / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def command_compare_feeds(args: argparse.Namespace) -> None:
import onnxruntime as ort
args.output.mkdir(parents=True, exist_ok=args.resume)
options = ort.SessionOptions(); options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL
acoustic = ort.InferenceSession(str(args.model), sess_options=options, providers=["CPUExecutionProvider"])
baseline_manifest = json.loads((args.baseline_calibration / "manifest.json").read_text(encoding="utf-8"))
candidate_manifest = json.loads((args.candidate_calibration / "manifest.json").read_text(encoding="utf-8"))
if [item["text"] for item in baseline_manifest] != [item["text"] for item in candidate_manifest]:
raise ValueError("Baseline and candidate manifests contain different texts")
report = []
for baseline_item, candidate_item in zip(baseline_manifest[: args.limit or None], candidate_manifest[: args.limit or None]):
with np.load(args.baseline_calibration / baseline_item["file"]) as data:
baseline_feeds = {name: data[name] for name in data.files}
with np.load(args.candidate_calibration / candidate_item["file"]) as data:
candidate_feeds = {name: data[name] for name in data.files}
baseline_feeds["scales"] = np.asarray([0.0, float(baseline_feeds["scales"][1]), 0.0], dtype=np.float32)
candidate_feeds["scales"] = np.asarray([0.0, float(candidate_feeds["scales"][1]), 0.0], dtype=np.float32)
baseline_wav, baseline_length = acoustic.run(None, baseline_feeds)
candidate_wav, candidate_length = acoustic.run(None, candidate_feeds)
common = min(baseline_wav.size, candidate_wav.size)
difference = baseline_wav.reshape(-1)[:common] - candidate_wav.reshape(-1)[:common]
entry = {
"text": baseline_item["text"],
"baseline_samples": int(baseline_wav.size), "candidate_samples": int(candidate_wav.size),
"baseline_length": np.asarray(baseline_length).tolist(),
"candidate_length": np.asarray(candidate_length).tolist(),
"mae": float(np.mean(np.abs(difference))),
"rmse": float(math.sqrt(np.mean(difference * difference))),
}
report.append(entry)
number = len(report)
baseline_path = args.output / f"{number:03d}-baseline.wav"
candidate_path = args.output / f"{number:03d}-student-int8.wav"
if not baseline_path.exists():
write_wav(baseline_path, baseline_wav)
if not candidate_path.exists():
write_wav(candidate_path, candidate_wav)
temporary_report = args.output / "report.json.tmp"
temporary_report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary_report.replace(args.output / "report.json")
print(f"rendered {number}: mae={entry['mae']:.6f} rmse={entry['rmse']:.6f}", flush=True)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
commands = parser.add_subparsers(dest="command", required=True)
prepare = commands.add_parser("prepare")
prepare.add_argument("--assets", type=Path, required=True)
prepare.add_argument("--corpus", type=Path, required=True)
prepare.add_argument("--output", type=Path, required=True)
prepare.add_argument("--articulation", type=float, default=1.0)
prepare.add_argument("--bert-model", type=Path)
prepare.set_defaults(handler=command_prepare)
quantize = commands.add_parser("quantize")
quantize.add_argument("--model", type=Path, required=True)
quantize.add_argument("--calibration", type=Path, required=True)
quantize.add_argument("--output", type=Path, required=True)
quantize.set_defaults(handler=command_quantize)
compare = commands.add_parser("compare")
compare.add_argument("--baseline", type=Path, required=True)
compare.add_argument("--candidate", type=Path, required=True)
compare.add_argument("--calibration", type=Path, required=True)
compare.add_argument("--output", type=Path, required=True)
compare.add_argument("--limit", type=int, default=3)
compare.set_defaults(handler=command_compare)
compare_feeds = commands.add_parser("compare-feeds")
compare_feeds.add_argument("--model", type=Path, required=True)
compare_feeds.add_argument("--baseline-calibration", type=Path, required=True)
compare_feeds.add_argument("--candidate-calibration", type=Path, required=True)
compare_feeds.add_argument("--output", type=Path, required=True)
compare_feeds.add_argument("--limit", type=int, default=0)
compare_feeds.add_argument("--resume", action="store_true")
compare_feeds.set_defaults(handler=command_compare_feeds)
return parser.parse_args()
def main() -> int:
args = parse_args()
args.handler(args)
return 0
if __name__ == "__main__":
sys.exit(main())