Обновить библиотеку, читалку и выпуск до 2.31

This commit is contained in:
Курнат Андрей
2026-07-17 14:38:49 +03:00
parent 2f40569031
commit c880395341
188 changed files with 15734 additions and 3702 deletions
+51
View File
@@ -0,0 +1,51 @@
# Aletheia Russian Piper/VITS training
The selected deployment format is a single-speaker Piper/VITS model exported to ONNX. Piper's current official
training interface consumes `wav-file|text` CSV rows, supports Russian through the `ru` espeak-ng voice, and
exports a checkpoint with `python3 -m piper.train.export_onnx`.
## GPU host prerequisites
- Windows or Linux with an NVIDIA CUDA GPU and a working `nvidia-smi`.
- Git, Python 3, build-essential, CMake, and Ninja.
- A checkout of `https://github.com/OHF-Voice/piper1-gpl` with the `[train]` dependencies installed and
`build_monotonic_align.sh` completed.
- The generated Aletheia dataset directory containing `metadata.csv` and `wav/`.
## Scratch training
```bash
git clone https://github.com/OHF-Voice/piper1-gpl.git
cd piper1-gpl
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install -e '.[train]'
./build_monotonic_align.sh
python3 setup.py build_ext --inplace
/path/to/Aletheia/tools/tts/train_piper.sh \
/path/to/tts-dataset \
/path/to/tts-training \
"$PWD" \
2000
```
No `--ckpt_path` is passed: the Aletheia acoustic model is initialized from scratch. This is intentionally slower
than Piper's recommended checkpoint fine-tuning. The exported deliverables are `aletheia_ru.onnx`,
`aletheia_ru.onnx.json`, and `artifacts.json` with byte counts and SHA-256 hashes.
Before accepting the model, synthesize a held-out Russian validation list at multiple `length_scale`,
`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.
On native Windows, first run the one-batch CUDA check:
```powershell
.\train_piper_windows.ps1 `
-DatasetDir C:\path\to\tts-dataset `
-OutputDir C:\path\to\tts-smoke `
-PythonExe C:\path\to\.venv\Scripts\python.exe `
-SmokeTest
```
The default Windows batch size is 4 so the smoke test can establish actual memory use before increasing it.
+35
View File
@@ -0,0 +1,35 @@
# Aletheia Russian TTS corpus tools
`audit_corpus.py` verifies that an audiobook and EPUB contain the same text before any training data is produced.
It extracts the EPUB spine in reading order and compares local Whisper transcripts from the beginning, middle,
and end of the audiobook with the normalized book text.
Generated audio, transcripts, downloaded ASR models, and future training checkpoints belong under
`.codex-temp/tts-*`; they are working artifacts and must not be committed.
Example on Windows PowerShell:
```powershell
$env:PYTHONPATH = 'C:\Repos\Aletheia\.codex-temp\tts-python'
python tools\tts\audit_corpus.py `
--source-dir 'C:\path\to\audiobook' `
--output-dir '.codex-temp\tts-audit' `
--model base
```
The audit is not a copyright or voice-consent check. Training and distributing a voice model requires
separate confirmation that the recordings and narrator's voice may be used for that purpose.
After the audit passes, `transcribe_corpus.py` creates one resumable JSON sidecar per MP3 with segment and
word timestamps. Existing sidecars are skipped unless `--force` is supplied. These transcripts are alignment
anchors only; the final Piper labels must come from the exact EPUB text.
`build_piper_dataset.py` globally aligns all ASR tokens to the EPUB in monotonic reading order, rejects weak
matches, merges adjacent short segments, and writes 22.05 kHz mono PCM WAV files plus Piper's
`filename.wav|Exact book text` metadata. Use `--dry-run` first and inspect the reported exact-token match ratio
before producing WAV files.
`train_piper.sh` is the CUDA/Linux entry point for scratch training and ONNX export.
`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.
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""Audit an audiobook/EPUB pair before building a single-speaker TTS corpus."""
from __future__ import annotations
import argparse
import collections
import difflib
import json
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path, PurePosixPath
WORD_RE = re.compile(r"[а-яёa-z0-9]+", re.IGNORECASE)
WHITESPACE_RE = re.compile(r"\s+")
def normalized_words(text: str) -> list[str]:
return [word.replace("ё", "е") for word in WORD_RE.findall(text.lower())]
def element_text(root: ET.Element) -> str:
ignored = {"script", "style", "svg", "math"}
parts: list[str] = []
def visit(node: ET.Element) -> None:
tag = node.tag.rsplit("}", 1)[-1].lower()
if tag in ignored:
return
if node.text:
parts.append(node.text)
for child in node:
visit(child)
if child.tail:
parts.append(child.tail)
if tag in {"p", "div", "section", "h1", "h2", "h3", "h4", "h5", "h6", "li", "br"}:
parts.append("\n")
visit(root)
lines = [WHITESPACE_RE.sub(" ", line).strip() for line in "".join(parts).splitlines()]
return "\n".join(line for line in lines if line)
def extract_epub(epub_path: Path, output_dir: Path) -> tuple[str, list[dict[str, object]]]:
with zipfile.ZipFile(epub_path) as archive:
container = ET.fromstring(archive.read("META-INF/container.xml"))
rootfile = next(
node.attrib["full-path"]
for node in container.iter()
if node.tag.rsplit("}", 1)[-1] == "rootfile"
)
opf = ET.fromstring(archive.read(rootfile))
opf_dir = PurePosixPath(rootfile).parent
manifest = {
node.attrib["id"]: node.attrib["href"]
for node in opf.iter()
if node.tag.rsplit("}", 1)[-1] == "item" and "id" in node.attrib and "href" in node.attrib
}
spine_ids = [
node.attrib["idref"]
for node in opf.iter()
if node.tag.rsplit("}", 1)[-1] == "itemref" and "idref" in node.attrib
]
sections: list[dict[str, object]] = []
texts: list[str] = []
for index, item_id in enumerate(spine_ids):
href = manifest.get(item_id)
if not href:
continue
member = str(opf_dir / PurePosixPath(href))
try:
root = ET.fromstring(archive.read(member))
except (KeyError, ET.ParseError):
continue
text = element_text(root)
if not text:
continue
headings = [
WHITESPACE_RE.sub(" ", "".join(node.itertext())).strip()
for node in root.iter()
if node.tag.rsplit("}", 1)[-1].lower() in {"h1", "h2", "h3"}
]
sections.append(
{
"index": index,
"href": member,
"characters": len(text),
"words": len(normalized_words(text)),
"headings": [heading for heading in headings if heading],
}
)
texts.append(text)
book_text = "\n\n".join(texts)
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / "book.txt").write_text(book_text, encoding="utf-8")
(output_dir / "spine.json").write_text(
json.dumps(sections, ensure_ascii=False, indent=2), encoding="utf-8"
)
return book_text, sections
def best_text_match(transcript: str, book_words: list[str]) -> dict[str, object]:
spoken = normalized_words(transcript)
if not spoken or not book_words:
return {"ratio": 0.0, "book_excerpt": "", "word_offset": None}
positions: dict[str, list[int]] = collections.defaultdict(list)
for index, word in enumerate(book_words):
if len(word) >= 4:
positions[word].append(index)
votes: collections.Counter[int] = collections.Counter()
for spoken_index, word in enumerate(spoken):
candidates = positions.get(word, ())
if len(candidates) <= 200:
votes.update(book_index - spoken_index for book_index in candidates)
offsets = [offset for offset, _ in votes.most_common(30)] or [0]
best_ratio = 0.0
best_offset = 0
best_window: list[str] = []
for offset in offsets:
start = max(0, offset - 8)
window = book_words[start : start + len(spoken) + 16]
ratio = difflib.SequenceMatcher(None, spoken, window, autojunk=False).ratio()
if ratio > best_ratio:
best_ratio, best_offset, best_window = ratio, start, window
return {
"ratio": round(best_ratio, 4),
"book_excerpt": " ".join(best_window),
"word_offset": best_offset,
}
def transcribe_samples(
audio_files: list[Path],
book_text: str,
output_dir: Path,
model_name: str,
sample_seconds: int,
) -> list[dict[str, object]]:
try:
import av
import imageio_ffmpeg
from faster_whisper import WhisperModel
except ImportError as error:
raise SystemExit(
"Install audit dependencies into PYTHONPATH: faster-whisper imageio-ffmpeg"
) from error
chosen = [audio_files[0], audio_files[len(audio_files) // 2], audio_files[-1]]
samples_dir = output_dir / "samples"
samples_dir.mkdir(parents=True, exist_ok=True)
ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
model = WhisperModel(
model_name,
device="cpu",
compute_type="int8",
download_root=str(output_dir / "models"),
)
book_words = normalized_words(book_text)
results: list[dict[str, object]] = []
for source in chosen:
with av.open(str(source)) as media:
stream = media.streams.audio[0]
duration = float(stream.duration * stream.time_base) if stream.duration is not None else 0.0
start = min(max(45.0, duration * 0.35), max(0.0, duration - sample_seconds - 5.0))
wav_path = samples_dir / f"{source.stem[:4]}-{int(start):05d}.wav"
subprocess.run(
[
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-ss",
f"{start:.3f}",
"-i",
str(source),
"-t",
str(sample_seconds),
"-ac",
"1",
"-ar",
"16000",
str(wav_path),
],
check=True,
)
segments, info = model.transcribe(
str(wav_path),
language="ru",
beam_size=5,
vad_filter=True,
condition_on_previous_text=True,
)
transcript = " ".join(segment.text.strip() for segment in segments).strip()
result = {
"source": source.name,
"source_duration_seconds": round(duration, 3),
"sample_start_seconds": round(start, 3),
"sample_duration_seconds": sample_seconds,
"detected_language": info.language,
"language_probability": round(info.language_probability, 4),
"transcript": transcript,
}
result.update(best_text_match(transcript, book_words))
results.append(result)
(output_dir / "alignment_samples.json").write_text(
json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8"
)
return results
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--source-dir", type=Path, required=True)
parser.add_argument("--epub", type=Path)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--model", default="base")
parser.add_argument("--sample-seconds", type=int, default=75)
parser.add_argument("--skip-asr", action="store_true")
args = parser.parse_args()
audio_files = sorted(args.source_dir.glob("*.mp3"))
epub_path = args.epub or next(args.source_dir.glob("*.epub"), None)
if not audio_files:
parser.error("No MP3 files found")
if epub_path is None or not epub_path.is_file():
parser.error("EPUB file not found")
book_text, sections = extract_epub(epub_path, args.output_dir)
report: dict[str, object] = {
"source_dir": str(args.source_dir.resolve()),
"epub": str(epub_path.resolve()),
"audio_files": [str(path.resolve()) for path in audio_files],
"audio_file_count": len(audio_files),
"epub_section_count": len(sections),
"book_characters": len(book_text),
"book_words": len(normalized_words(book_text)),
}
if not args.skip_asr:
report["alignment_samples"] = transcribe_samples(
audio_files, book_text, args.output_dir, args.model, args.sample_seconds
)
(args.output_dir / "audit.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
+364
View File
@@ -0,0 +1,364 @@
#!/usr/bin/env python3
"""Align ASR sidecars to exact EPUB text and create a Piper WAV/metadata dataset."""
from __future__ import annotations
import argparse
import csv
import difflib
import json
import re
import subprocess
import sys
import wave
from dataclasses import dataclass
from pathlib import Path
WORD_RE = re.compile(r"[а-яёa-z0-9]+", re.IGNORECASE)
def normalize_word(value: str) -> str:
return value.lower().replace("ё", "е")
@dataclass(frozen=True)
class BookWord:
value: str
start: int
end: int
@dataclass(frozen=True)
class AsrToken:
value: str
track: str
segment_id: int
start: float
end: float
@dataclass
class AlignedSegment:
track: str
source: Path
start: float
end: float
book_start: int
book_end: int
matched_tokens: int
total_tokens: int
avg_logprob: float
no_speech_prob: float
@property
def duration(self) -> float:
return self.end - self.start
@property
def coverage(self) -> float:
return self.matched_tokens / max(1, self.total_tokens)
def load_book(path: Path) -> tuple[str, list[BookWord]]:
text = path.read_text(encoding="utf-8")
words = [
BookWord(normalize_word(match.group()), match.start(), match.end())
for match in WORD_RE.finditer(text)
]
return text, words
def load_asr(sidecars: list[Path]) -> tuple[list[AsrToken], list[dict[str, object]], dict[str, Path]]:
tokens: list[AsrToken] = []
segments: list[dict[str, object]] = []
sources: dict[str, Path] = {}
for sidecar in sidecars:
payload = json.loads(sidecar.read_text(encoding="utf-8"))
track = sidecar.stem
source = Path(payload["source"])
sources[track] = source
for segment in payload["segments"]:
token_start = len(tokens)
for word in segment.get("words", []):
normalized = [normalize_word(match.group()) for match in WORD_RE.finditer(word["word"])]
for value in normalized:
tokens.append(
AsrToken(
value=value,
track=track,
segment_id=int(segment["id"]),
start=float(word["start"]),
end=float(word["end"]),
)
)
segments.append(
{
"track": track,
"source": source,
"segment_id": int(segment["id"]),
"start": float(segment["start"]),
"end": float(segment["end"]),
"avg_logprob": float(segment["avg_logprob"]),
"no_speech_prob": float(segment["no_speech_prob"]),
"token_start": token_start,
"token_end": len(tokens),
}
)
return tokens, segments, sources
def token_mapping(asr_tokens: list[AsrToken], book_words: list[BookWord]) -> dict[int, int]:
matcher = difflib.SequenceMatcher(
None,
[token.value for token in asr_tokens],
[word.value for word in book_words],
autojunk=True,
)
mapping: dict[int, int] = {}
for block in matcher.get_matching_blocks():
for offset in range(block.size):
mapping[block.a + offset] = block.b + offset
return mapping
def align_segments(
segments: list[dict[str, object]],
mapping: dict[int, int],
book_words: list[BookWord],
) -> list[AlignedSegment]:
aligned: list[AlignedSegment] = []
previous_book_end = -1
for segment in segments:
start_index = int(segment["token_start"])
end_index = int(segment["token_end"])
mapped = [mapping[index] for index in range(start_index, end_index) if index in mapping]
if not mapped:
continue
first, last = min(mapped), max(mapped)
if first < previous_book_end:
continue
asr_count = max(1, end_index - start_index)
book_count = last - first + 1
if book_count > asr_count * 1.8 + 8:
continue
item = AlignedSegment(
track=str(segment["track"]),
source=Path(segment["source"]),
start=float(segment["start"]),
end=float(segment["end"]),
book_start=book_words[first].start,
book_end=book_words[last].end,
matched_tokens=len(mapped),
total_tokens=asr_count,
avg_logprob=float(segment["avg_logprob"]),
no_speech_prob=float(segment["no_speech_prob"]),
)
aligned.append(item)
previous_book_end = last
return aligned
def merge_segments(items: list[AlignedSegment], book_text: str, max_duration: float) -> list[AlignedSegment]:
merged: list[AlignedSegment] = []
current: AlignedSegment | None = None
for item in items:
eligible = item.coverage >= 0.55 and item.avg_logprob >= -1.2 and item.no_speech_prob <= 0.5
if not eligible or item.duration <= 0:
if current is not None:
merged.append(current)
current = None
continue
if current is None:
current = item
continue
gap = item.start - current.end
combined_duration = item.end - current.start
same_track = item.track == current.track and item.source == current.source
near_in_book = 0 <= item.book_start - current.book_end <= 120
if same_track and gap <= 0.8 and near_in_book and combined_duration <= max_duration:
total = current.total_tokens + item.total_tokens
current.end = item.end
current.book_end = item.book_end
current.matched_tokens += item.matched_tokens
current.avg_logprob = (
current.avg_logprob * current.total_tokens + item.avg_logprob * item.total_tokens
) / total
current.no_speech_prob = max(current.no_speech_prob, item.no_speech_prob)
current.total_tokens = total
text = book_text[current.book_start : current.book_end].rstrip()
if current.duration >= 3.0 and text.endswith((".", "!", "?", "", ":", ";")):
merged.append(current)
current = None
else:
merged.append(current)
current = item
if current is not None:
merged.append(current)
return merged
def clean_label(value: str) -> str:
return re.sub(r"\s+", " ", value).strip(" —–-\t\r\n")
def is_valid_wav(path: Path, sample_rate: int) -> bool:
if not path.is_file():
return False
try:
with wave.open(str(path), "rb") as audio:
return (
audio.getnchannels() == 1
and audio.getsampwidth() == 2
and audio.getframerate() == sample_rate
and audio.getnframes() > 0
)
except (EOFError, wave.Error):
return False
def create_dataset(
items: list[AlignedSegment],
book_text: str,
output_dir: Path,
ffmpeg: str,
sample_rate: int,
min_duration: float,
max_duration: float,
min_coverage: float,
) -> dict[str, object]:
wav_dir = output_dir / "wav"
wav_dir.mkdir(parents=True, exist_ok=True)
metadata_path = output_dir / "metadata.csv"
rows: list[tuple[str, str]] = []
accepted_seconds = 0.0
rejected = 0
details: list[dict[str, object]] = []
for index, item in enumerate(items):
text = clean_label(book_text[item.book_start : item.book_end])
duration = item.duration
if not (min_duration <= duration <= max_duration) or item.coverage < min_coverage:
rejected += 1
continue
if len(text) < 8 or len(text) > 320 or "|" in text:
rejected += 1
continue
clip_name = f"aletheia_ru_{len(rows):06d}.wav"
clip_path = wav_dir / clip_name
start = max(0.0, item.start - 0.06)
end = item.end + 0.08
if not is_valid_wav(clip_path, sample_rate):
subprocess.run(
[
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-ss",
f"{start:.3f}",
"-i",
str(item.source),
"-t",
f"{end - start:.3f}",
"-ac",
"1",
"-ar",
str(sample_rate),
"-sample_fmt",
"s16",
str(clip_path),
],
check=True,
)
rows.append((clip_name, text))
accepted_seconds += duration
details.append(
{
"clip": clip_name,
"source": str(item.source),
"start": round(item.start, 3),
"end": round(item.end, 3),
"duration": round(duration, 3),
"coverage": round(item.coverage, 4),
"text": text,
}
)
with metadata_path.open("w", encoding="utf-8", newline="") as output:
writer = csv.writer(output, delimiter="|", lineterminator="\n")
writer.writerows(rows)
report = {
"schema": 1,
"clips": len(rows),
"accepted_seconds": round(accepted_seconds, 3),
"accepted_hours": round(accepted_seconds / 3600, 4),
"rejected_candidates": rejected,
"sample_rate": sample_rate,
"min_duration": min_duration,
"max_duration": max_duration,
"min_coverage": min_coverage,
"items": details,
}
(output_dir / "dataset_report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
return report
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--book-text", type=Path, required=True)
parser.add_argument("--transcripts-dir", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--sample-rate", type=int, default=22050)
parser.add_argument("--min-duration", type=float, default=2.0)
parser.add_argument("--max-duration", type=float, default=12.0)
parser.add_argument("--min-coverage", type=float, default=0.62)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
try:
import imageio_ffmpeg
except ImportError as error:
raise SystemExit("imageio-ffmpeg is missing from PYTHONPATH") from error
sidecars = sorted(args.transcripts_dir.glob("*.json"))
if not sidecars:
parser.error("No transcript sidecars found")
book_text, book_words = load_book(args.book_text)
asr_tokens, segments, _ = load_asr(sidecars)
mapping = token_mapping(asr_tokens, book_words)
aligned = align_segments(segments, mapping, book_words)
merged = merge_segments(aligned, book_text, args.max_duration)
summary = {
"transcript_files": len(sidecars),
"book_words": len(book_words),
"asr_tokens": len(asr_tokens),
"exact_token_matches": len(mapping),
"exact_token_match_ratio": round(len(mapping) / max(1, len(asr_tokens)), 4),
"aligned_segments": len(aligned),
"merged_candidates": len(merged),
}
if args.dry_run:
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0
report = create_dataset(
merged,
book_text,
args.output_dir,
imageio_ffmpeg.get_ffmpeg_exe(),
args.sample_rate,
args.min_duration,
args.max_duration,
args.min_coverage,
)
report.update(summary)
(args.output_dir / "dataset_report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(json.dumps({key: value for key, value in report.items() if key != "items"}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
+45
View File
@@ -0,0 +1,45 @@
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$root = $PSScriptRoot
$runDir = Join-Path $root 'training'
$stdoutPath = Join-Path $runDir 'scheduled-training.stdout.log'
$stderrPath = Join-Path $runDir 'scheduled-training.stderr.log'
$exitPath = Join-Path $runDir 'scheduled-training-exit.json'
New-Item -ItemType Directory -Force -Path $runDir | Out-Null
if (Test-Path -LiteralPath $exitPath) {
Remove-Item -LiteralPath $exitPath -Force
}
$exitCode = 1
try {
$arguments = @(
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
'-File', (Join-Path $root 'train_piper_windows.ps1'),
'-DatasetDir', (Join-Path $root 'dataset'),
'-OutputDir', $runDir,
'-CacheDir', (Join-Path $root 'smoke\cache'),
'-PythonExe', (Join-Path $root '.venv\Scripts\python.exe'),
'-BatchSize', '16', '-NumWorkers', '0', '-MaxEpochs', '2000'
)
$process = Start-Process -FilePath 'powershell.exe' `
-ArgumentList $arguments `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath `
-WindowStyle Hidden `
-Wait `
-PassThru
$exitCode = $process.ExitCode
} catch {
$_ | Out-String | Add-Content -LiteralPath $stderrPath -Encoding utf8
$exitCode = 1
} finally {
[ordered]@{
exit_code = $exitCode
finished_utc = (Get-Date).ToUniversalTime().ToString('o')
} | ConvertTo-Json | Set-Content -LiteralPath $exitPath -Encoding utf8
}
exit $exitCode
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 3 ]]; then
echo "Usage: $0 DATASET_DIR OUTPUT_DIR PIPER_REPO [EPOCHS]" >&2
exit 2
fi
dataset_dir="$(realpath "$1")"
output_dir="$(mkdir -p "$2" && realpath "$2")"
piper_repo="$(realpath "$3")"
epochs="${4:-2000}"
metadata="$dataset_dir/metadata.csv"
audio_dir="$dataset_dir/wav"
if [[ ! -f "$metadata" || ! -d "$audio_dir" ]]; then
echo "Dataset must contain metadata.csv and wav/" >&2
exit 2
fi
if [[ ! -f "$piper_repo/src/piper/train/__main__.py" ]]; then
echo "Piper training sources not found at $piper_repo" >&2
exit 2
fi
python3 -m piper.train fit \
--data.voice_name aletheia_ru \
--data.csv_path "$metadata" \
--data.audio_dir "$audio_dir" \
--data.espeak_voice ru \
--data.cache_dir "$output_dir/cache" \
--data.config_path "$output_dir/aletheia_ru.onnx.json" \
--data.batch_size 16 \
--data.num_workers 4 \
--model.sample_rate 22050 \
--trainer.accelerator gpu \
--trainer.devices 1 \
--trainer.precision 16-mixed \
--trainer.max_epochs "$epochs" \
--trainer.default_root_dir "$output_dir/checkpoints"
checkpoint="$(find "$output_dir/checkpoints" -type f -name '*.ckpt' -printf '%T@ %p\n' | sort -nr | head -n 1 | cut -d' ' -f2-)"
if [[ -z "$checkpoint" ]]; then
echo "Training finished without a checkpoint" >&2
exit 1
fi
python3 -m piper.train.export_onnx \
--checkpoint "$checkpoint" \
--output-file "$output_dir/aletheia_ru.onnx"
python3 - "$output_dir" <<'PY'
import hashlib
import json
import pathlib
import sys
root = pathlib.Path(sys.argv[1])
artifacts = {}
for name in ("aletheia_ru.onnx", "aletheia_ru.onnx.json"):
path = root / name
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts[name] = {"bytes": path.stat().st_size, "sha256": digest}
(root / "artifacts.json").write_text(json.dumps(artifacts, indent=2), encoding="utf-8")
print(json.dumps(artifacts, indent=2))
PY
+102
View File
@@ -0,0 +1,102 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string] $DatasetDir,
[Parameter(Mandatory = $true)]
[string] $OutputDir,
[Parameter(Mandatory = $true)]
[string] $PythonExe,
[string] $CacheDir,
[int] $BatchSize = 4,
[int] $NumWorkers = 2,
[int] $MaxEpochs = 2000,
[switch] $SmokeTest
)
$ErrorActionPreference = 'Stop'
$dataset = [IO.Path]::GetFullPath($DatasetDir)
$output = [IO.Path]::GetFullPath($OutputDir)
$python = [IO.Path]::GetFullPath($PythonExe)
$metadata = Join-Path $dataset 'metadata.csv'
$audioDir = Join-Path $dataset 'wav'
$configPath = Join-Path $output 'aletheia_ru.onnx.json'
$cacheDir = if ($CacheDir) {
[IO.Path]::GetFullPath($CacheDir)
} else {
Join-Path $output 'cache'
}
$checkpointDir = Join-Path $output 'checkpoints'
if (-not (Test-Path -LiteralPath $python -PathType Leaf)) {
throw "Python executable not found: $python"
}
if (-not (Test-Path -LiteralPath $metadata -PathType Leaf)) {
throw "Piper metadata not found: $metadata"
}
if (-not (Test-Path -LiteralPath $audioDir -PathType Container)) {
throw "Piper audio directory not found: $audioDir"
}
New-Item -ItemType Directory -Force -Path $output, $cacheDir, $checkpointDir | Out-Null
$fitArgs = @(
'-m', 'piper.train', 'fit',
'--data.voice_name', 'aletheia_ru',
'--data.csv_path', $metadata,
'--data.audio_dir', $audioDir,
'--data.espeak_voice', 'ru',
'--data.cache_dir', $cacheDir,
'--data.config_path', $configPath,
'--data.batch_size', $BatchSize,
'--data.num_workers', $NumWorkers,
'--model.sample_rate', '22050',
'--trainer.accelerator', 'gpu',
'--trainer.devices', '1',
'--trainer.precision', '16-mixed',
'--trainer.max_epochs', $MaxEpochs,
'--trainer.default_root_dir', $checkpointDir
)
if ($SmokeTest) {
$fitArgs += @('--trainer.fast_dev_run', 'true', '--trainer.num_sanity_val_steps', '0')
}
& $python @fitArgs
if ($LASTEXITCODE -ne 0) {
throw "Piper training exited with code $LASTEXITCODE"
}
if ($SmokeTest) {
Write-Output 'PIPER_CUDA_SMOKE_TEST_OK'
exit 0
}
$checkpoint = Get-ChildItem -LiteralPath $checkpointDir -Filter '*.ckpt' -File -Recurse |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if (-not $checkpoint) {
throw "Training finished without a checkpoint under $checkpointDir"
}
$onnxPath = Join-Path $output 'aletheia_ru.onnx'
& $python -m piper.train.export_onnx --checkpoint $checkpoint.FullName --output-file $onnxPath
if ($LASTEXITCODE -ne 0) {
throw "Piper ONNX export exited with code $LASTEXITCODE"
}
$artifacts = [ordered]@{}
foreach ($artifactPath in @($onnxPath, $configPath)) {
if (-not (Test-Path -LiteralPath $artifactPath -PathType Leaf)) {
throw "Expected artifact not found: $artifactPath"
}
$item = Get-Item -LiteralPath $artifactPath
$artifacts[$item.Name] = [ordered]@{
bytes = $item.Length
sha256 = (Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
}
}
$manifestPath = Join-Path $output 'artifacts.json'
$artifacts | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifestPath -Encoding utf8
$artifacts | ConvertTo-Json -Depth 4
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Create resumable word-timestamp ASR sidecars for audiobook tracks."""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
import time
from pathlib import Path
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def serialize_segment(segment) -> dict[str, object]:
return {
"id": segment.id,
"start": round(segment.start, 3),
"end": round(segment.end, 3),
"text": segment.text.strip(),
"avg_logprob": round(segment.avg_logprob, 5),
"compression_ratio": round(segment.compression_ratio, 5),
"no_speech_prob": round(segment.no_speech_prob, 5),
"words": [
{
"start": round(word.start, 3),
"end": round(word.end, 3),
"word": word.word,
"probability": round(word.probability, 5),
}
for word in (segment.words or ())
],
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--source-dir", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--model", default="base")
parser.add_argument("--model-cache", type=Path, required=True)
parser.add_argument("--max-files", type=int)
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
try:
from faster_whisper import WhisperModel
except ImportError as error:
raise SystemExit("faster-whisper is missing from PYTHONPATH") from error
files = sorted(args.source_dir.glob("*.mp3"))
if args.max_files is not None:
files = files[: max(0, args.max_files)]
if not files:
parser.error("No MP3 files found")
args.output_dir.mkdir(parents=True, exist_ok=True)
model = WhisperModel(
args.model,
device="cpu",
compute_type="int8",
download_root=str(args.model_cache),
local_files_only=True,
)
completed = 0
for index, audio_path in enumerate(files, 1):
output_path = args.output_dir / f"{audio_path.stem[:4]}.json"
if output_path.is_file() and not args.force:
print(f"[{index}/{len(files)}] skip {audio_path.name}", flush=True)
completed += 1
continue
started = time.monotonic()
print(f"[{index}/{len(files)}] transcribe {audio_path.name}", flush=True)
segments_iter, info = model.transcribe(
str(audio_path),
language="ru",
beam_size=5,
vad_filter=True,
word_timestamps=True,
condition_on_previous_text=True,
)
segments = [serialize_segment(segment) for segment in segments_iter]
payload = {
"schema": 1,
"source": str(audio_path.resolve()),
"source_bytes": audio_path.stat().st_size,
"source_sha256": sha256(audio_path),
"language": info.language,
"language_probability": round(info.language_probability, 5),
"duration": round(info.duration, 3),
"duration_after_vad": round(info.duration_after_vad, 3),
"elapsed_seconds": round(time.monotonic() - started, 3),
"segments": segments,
}
temporary = output_path.with_suffix(".json.tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(output_path)
completed += 1
print(
f"[{index}/{len(files)}] wrote {output_path.name}: "
f"{len(segments)} segments in {payload['elapsed_seconds']}s",
flush=True,
)
print(json.dumps({"files": len(files), "completed": completed}, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())