#!/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())