Обновить библиотеку, читалку и выпуск до 2.31
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user