Download scripts/sanity_check.py from Moeeldouma/arabic-tts-xtts-v2: direct link, hf CLI and curl.
- Browser
- Download file 16.1 kB
-
https://huggingface.co/Moeeldouma/arabic-tts-xtts-v2/resolve/main/scripts/sanity_check.py
- Command line
-
hf download hf://Moeeldouma/arabic-tts-xtts-v2/scripts/sanity_check.py
-
curl -L -o sanity_check.py https://huggingface.co/Moeeldouma/arabic-tts-xtts-v2/resolve/main/scripts/sanity_check.py
16.1 kB
| """ | |
| Data sanity check pipeline for Arabic TTS training data. | |
| Clean data is crucial for TTS fine-tuning. Unlike ASR (speech recognition), | |
| where the model can tolerate some noise and still learn to transcribe, | |
| TTS models learn to *reproduce* exactly what they hear. If the training | |
| data contains: | |
| - Misaligned text (text doesn't match audio) โ model learns wrong pronunciations | |
| - Noisy audio โ model learns to generate noise | |
| - Silence or dead air โ model learns to produce silence mid-speech | |
| - Corrupted text โ model learns to mispronounce words | |
| A single bad clip won't ruin the model, but hundreds of bad clips in a | |
| 5,000-clip dataset will noticeably degrade output quality. This script | |
| applies three layers of automated verification to catch problems before | |
| they reach the training pipeline. | |
| Three-layer verification: | |
| Layer 1 โ Text quality: character validation, length ratios, pattern detection | |
| Layer 2 โ Audio quality: SNR, silence ratio, clipping detection | |
| Layer 3 โ Alignment verification: Whisper cross-check (independent transcript) | |
| Usage: | |
| conda activate new-arabic-tts | |
| python scripts/sanity_check.py | |
| Output: | |
| data/egyptian/metadata_train.csv (cleaned, bad clips removed) | |
| data/egyptian/metadata_eval.csv (cleaned, bad clips removed) | |
| data/egyptian/rejected/ (moved bad clips for review) | |
| docs/benchmarks/sanity_check.json (full report) | |
| """ | |
| import os | |
| import re | |
| import sys | |
| import csv | |
| import json | |
| import time | |
| import shutil | |
| import numpy as np | |
| import soundfile as sf | |
| from pathlib import Path | |
| from difflib import SequenceMatcher | |
| PROJECT_ROOT = Path(__file__).resolve().parent.parent | |
| DATA_DIR = PROJECT_ROOT / "data" / "egyptian" | |
| WAVS_DIR = DATA_DIR / "wavs" | |
| REJECTED_DIR = DATA_DIR / "rejected" | |
| BENCHMARKS_DIR = PROJECT_ROOT / "docs" / "benchmarks" | |
| # --- Layer 1: Text Quality Thresholds --- | |
| MIN_ARABIC_RATIO = 0.60 # At least 60% Arabic characters | |
| MAX_LATIN_CHARS = 3 # Allow max 3 Latin chars (typos) | |
| MAX_REPEATED_WORDS = 3 # Flag if same word appears 3+ times in a row | |
| MIN_CHARS_PER_SEC = 3.0 # Minimum characters per second (too few = wrong text) | |
| MAX_CHARS_PER_SEC = 25.0 # Maximum characters per second (too many = wrong text) | |
| # --- Layer 2: Audio Quality Thresholds --- | |
| MIN_SNR_DB = 10.0 # Minimum signal-to-noise ratio | |
| MAX_SILENCE_RATIO = 0.50 # Max 50% silence | |
| CLIPPING_THRESHOLD = 0.99 # Samples above this are clipped | |
| MAX_CLIPPING_RATIO = 0.01 # Max 1% of samples can be clipped | |
| # --- Layer 3: Alignment Thresholds --- | |
| MIN_SIMILARITY = 0.40 # Minimum text similarity between original and Whisper | |
| def is_arabic(char): | |
| """Check if character is Arabic.""" | |
| return '\u0600' <= char <= '\u06FF' or '\u0750' <= char <= '\u077F' or '\uFB50' <= char <= '\uFDFF' or '\uFE70' <= char <= '\uFEFF' | |
| def arabic_ratio(text): | |
| """Fraction of characters that are Arabic.""" | |
| text_no_space = text.replace(" ", "") | |
| if not text_no_space: | |
| return 0 | |
| return sum(1 for c in text_no_space if is_arabic(c)) / len(text_no_space) | |
| def count_latin(text): | |
| """Count Latin alphabet characters.""" | |
| return sum(1 for c in text if c.isascii() and c.isalpha()) | |
| def has_repeated_words(text, threshold=MAX_REPEATED_WORDS): | |
| """Detect stuttered/repeated words.""" | |
| words = text.split() | |
| for i in range(len(words) - threshold + 1): | |
| if len(set(words[i:i+threshold])) == 1: | |
| return True | |
| return False | |
| def measure_snr(wav, sr): | |
| """Estimate SNR from frame-level RMS.""" | |
| frame_size = int(sr * 0.025) | |
| hop = frame_size // 2 | |
| frames = [wav[i:i+frame_size] for i in range(0, len(wav) - frame_size, hop)] | |
| if not frames: | |
| return 0 | |
| rms_values = [np.sqrt(np.mean(f**2)) for f in frames] | |
| rms_values.sort() | |
| n = len(rms_values) | |
| noise_floor = np.mean(rms_values[:max(1, n // 10)]) | |
| signal_level = np.mean(rms_values[n // 2:]) | |
| if noise_floor < 1e-10: | |
| return 100 | |
| return 20 * np.log10(signal_level / noise_floor) | |
| def silence_ratio(wav, sr, threshold=0.01): | |
| """Fraction of audio that is silence.""" | |
| return np.mean(np.abs(wav) < threshold) | |
| def clipping_ratio(wav): | |
| """Fraction of samples that are clipped.""" | |
| return np.mean(np.abs(wav) >= CLIPPING_THRESHOLD) | |
| def normalize_arabic(text): | |
| """Normalize Arabic text for comparison.""" | |
| # Remove diacritics | |
| text = re.sub(r'[\u064B-\u065F\u0670]', '', text) | |
| # Normalize alef variants | |
| text = re.sub(r'[ุฅุฃุขุง]', 'ุง', text) | |
| # Normalize taa marbuta | |
| text = text.replace('ุฉ', 'ู') | |
| # Remove punctuation | |
| text = re.sub(r'[^\w\s]', '', text) | |
| # Collapse whitespace | |
| text = re.sub(r'\s+', ' ', text).strip() | |
| return text | |
| def text_similarity(text1, text2): | |
| """Compare two Arabic texts after normalization.""" | |
| t1 = normalize_arabic(text1) | |
| t2 = normalize_arabic(text2) | |
| return SequenceMatcher(None, t1, t2).ratio() | |
| def load_metadata(csv_path): | |
| """Load pipe-delimited metadata CSV.""" | |
| rows = [] | |
| with open(csv_path, "r", encoding="utf-8") as f: | |
| reader = csv.DictReader(f, delimiter="|") | |
| for row in reader: | |
| rows.append(row) | |
| return rows | |
| def save_metadata(rows, csv_path): | |
| """Save pipe-delimited metadata CSV.""" | |
| with open(csv_path, "w", encoding="utf-8") as f: | |
| f.write("audio_file|text|speaker_name\n") | |
| for row in rows: | |
| f.write(f"{row['audio_file']}|{row['text']}|{row['speaker_name']}\n") | |
| def main(): | |
| print("=" * 70) | |
| print(" Data Sanity Check Pipeline") | |
| print(" Three-layer verification for TTS training data") | |
| print("=" * 70) | |
| t_start = time.time() | |
| REJECTED_DIR.mkdir(parents=True, exist_ok=True) | |
| # Load both splits | |
| train_rows = load_metadata(DATA_DIR / "metadata_train.csv") | |
| eval_rows = load_metadata(DATA_DIR / "metadata_eval.csv") | |
| all_rows = train_rows + eval_rows | |
| total_clips = len(all_rows) | |
| print(f"\nLoaded {len(train_rows)} train + {len(eval_rows)} eval = {total_clips} clips") | |
| rejected = {} # clip -> reason | |
| # ===== LAYER 1: TEXT QUALITY ===== | |
| print(f"\n{'โ'*50}") | |
| print(" Layer 1: Text Quality Check") | |
| print(f"{'โ'*50}") | |
| l1_issues = {"low_arabic_ratio": 0, "too_many_latin": 0, | |
| "repeated_words": 0, "bad_char_rate": 0} | |
| for row in all_rows: | |
| clip = row["audio_file"] | |
| text = row["text"] | |
| if clip in rejected: | |
| continue | |
| # Arabic ratio | |
| ar = arabic_ratio(text) | |
| if ar < MIN_ARABIC_RATIO: | |
| rejected[clip] = f"Layer 1: Low Arabic ratio ({ar:.2f})" | |
| l1_issues["low_arabic_ratio"] += 1 | |
| continue | |
| # Latin characters | |
| latin = count_latin(text) | |
| if latin > MAX_LATIN_CHARS: | |
| rejected[clip] = f"Layer 1: Too many Latin chars ({latin})" | |
| l1_issues["too_many_latin"] += 1 | |
| continue | |
| # Repeated words | |
| if has_repeated_words(text): | |
| rejected[clip] = "Layer 1: Repeated words detected" | |
| l1_issues["repeated_words"] += 1 | |
| continue | |
| # Characters per second (need audio duration) | |
| wav_path = DATA_DIR / clip | |
| if wav_path.exists(): | |
| info = sf.info(str(wav_path)) | |
| duration = info.duration | |
| chars_per_sec = len(text) / max(duration, 0.1) | |
| if chars_per_sec < MIN_CHARS_PER_SEC or chars_per_sec > MAX_CHARS_PER_SEC: | |
| rejected[clip] = f"Layer 1: Abnormal char rate ({chars_per_sec:.1f} chars/s)" | |
| l1_issues["bad_char_rate"] += 1 | |
| l1_total = sum(l1_issues.values()) | |
| print(f" Low Arabic ratio: {l1_issues['low_arabic_ratio']}") | |
| print(f" Too many Latin: {l1_issues['too_many_latin']}") | |
| print(f" Repeated words: {l1_issues['repeated_words']}") | |
| print(f" Abnormal char rate: {l1_issues['bad_char_rate']}") | |
| print(f" Total rejected: {l1_total}") | |
| # ===== LAYER 2: AUDIO QUALITY ===== | |
| print(f"\n{'โ'*50}") | |
| print(" Layer 2: Audio Quality Check") | |
| print(f"{'โ'*50}") | |
| l2_issues = {"low_snr": 0, "high_silence": 0, "clipping": 0, "missing_file": 0} | |
| for i, row in enumerate(all_rows): | |
| clip = row["audio_file"] | |
| if clip in rejected: | |
| continue | |
| wav_path = DATA_DIR / clip | |
| if not wav_path.exists(): | |
| rejected[clip] = "Layer 2: File missing" | |
| l2_issues["missing_file"] += 1 | |
| continue | |
| wav, sr = sf.read(str(wav_path)) | |
| # SNR | |
| snr = measure_snr(wav, sr) | |
| if snr < MIN_SNR_DB: | |
| rejected[clip] = f"Layer 2: Low SNR ({snr:.1f} dB)" | |
| l2_issues["low_snr"] += 1 | |
| continue | |
| # Silence ratio | |
| sil = silence_ratio(wav, sr) | |
| if sil > MAX_SILENCE_RATIO: | |
| rejected[clip] = f"Layer 2: Too much silence ({sil:.0%})" | |
| l2_issues["high_silence"] += 1 | |
| continue | |
| # Clipping | |
| clip_r = clipping_ratio(wav) | |
| if clip_r > MAX_CLIPPING_RATIO: | |
| rejected[clip] = f"Layer 2: Audio clipping ({clip_r:.1%})" | |
| l2_issues["clipping"] += 1 | |
| if (i + 1) % 1000 == 0: | |
| print(f" [{i+1:,}/{total_clips}] checked...") | |
| l2_total = sum(l2_issues.values()) | |
| print(f" Low SNR: {l2_issues['low_snr']}") | |
| print(f" Too much silence: {l2_issues['high_silence']}") | |
| print(f" Audio clipping: {l2_issues['clipping']}") | |
| print(f" Missing files: {l2_issues['missing_file']}") | |
| print(f" Total rejected: {l2_total}") | |
| # ===== LAYER 3: ALIGNMENT VERIFICATION (WHISPER) ===== | |
| print(f"\n{'โ'*50}") | |
| print(" Layer 3: Alignment Verification (Whisper)") | |
| print(f"{'โ'*50}") | |
| print(" Loading Whisper model (this may take a minute)...") | |
| import whisper | |
| whisper_model = whisper.load_model("large-v3", device="cuda") | |
| l3_issues = {"low_similarity": 0} | |
| l3_checked = 0 | |
| t0 = time.time() | |
| for i, row in enumerate(all_rows): | |
| clip = row["audio_file"] | |
| if clip in rejected: | |
| continue | |
| wav_path = str(DATA_DIR / clip) | |
| original_text = row["text"] | |
| try: | |
| result = whisper_model.transcribe(wav_path, language="ar", fp16=True) | |
| whisper_text = result["text"].strip() | |
| sim = text_similarity(original_text, whisper_text) | |
| if sim < MIN_SIMILARITY: | |
| rejected[clip] = f"Layer 3: Low alignment ({sim:.2f}) | Original: {original_text[:50]} | Whisper: {whisper_text[:50]}" | |
| l3_issues["low_similarity"] += 1 | |
| except Exception as e: | |
| rejected[clip] = f"Layer 3: Whisper error ({str(e)[:50]})" | |
| l3_issues["low_similarity"] += 1 | |
| l3_checked += 1 | |
| if (i + 1) % 500 == 0: | |
| rate = l3_checked / (time.time() - t0) | |
| remaining = sum(1 for r in all_rows[i+1:] if r["audio_file"] not in rejected) | |
| eta = remaining / max(rate, 1) / 60 | |
| print(f" [{i+1:,}/{total_clips}] {l3_checked} verified, " | |
| f"{l3_issues['low_similarity']} misaligned, " | |
| f"{rate:.0f} clips/s, ETA {eta:.0f}min") | |
| l3_total = sum(l3_issues.values()) | |
| print(f" Low alignment: {l3_issues['low_similarity']}") | |
| print(f" Total rejected: {l3_total}") | |
| # ===== APPLY REJECTIONS ===== | |
| print(f"\n{'โ'*50}") | |
| print(" Applying rejections") | |
| print(f"{'โ'*50}") | |
| # Move rejected WAVs | |
| moved = 0 | |
| for clip, reason in rejected.items(): | |
| src = DATA_DIR / clip | |
| dst = REJECTED_DIR / Path(clip).name | |
| if src.exists(): | |
| shutil.move(str(src), str(dst)) | |
| moved += 1 | |
| # Filter metadata | |
| clean_train = [r for r in train_rows if r["audio_file"] not in rejected] | |
| clean_eval = [r for r in eval_rows if r["audio_file"] not in rejected] | |
| save_metadata(clean_train, DATA_DIR / "metadata_train.csv") | |
| save_metadata(clean_eval, DATA_DIR / "metadata_eval.csv") | |
| # ===== SUMMARY ===== | |
| total_rejected = len(rejected) | |
| total_kept = total_clips - total_rejected | |
| print(f"\n{'='*70}") | |
| print(f" Sanity Check Complete!") | |
| print(f" Total time: {(time.time() - t_start)/60:.1f} minutes") | |
| print(f"{'='*70}") | |
| print(f"\n Results:") | |
| print(f" Original clips: {total_clips:,}") | |
| print(f" Rejected: {total_rejected:,} ({total_rejected/total_clips:.1%})") | |
| print(f" Layer 1 (text): {l1_total:,}") | |
| print(f" Layer 2 (audio): {l2_total:,}") | |
| print(f" Layer 3 (align): {l3_total:,}") | |
| print(f" Kept: {total_kept:,} ({total_kept/total_clips:.1%})") | |
| print(f" Train: {len(clean_train):,}") | |
| print(f" Eval: {len(clean_eval):,}") | |
| print(f"\n Files:") | |
| print(f" Clean train: {DATA_DIR / 'metadata_train.csv'}") | |
| print(f" Clean eval: {DATA_DIR / 'metadata_eval.csv'}") | |
| print(f" Rejected WAVs: {REJECTED_DIR}/ ({moved} files)") | |
| # Save review file for Layer 3 rejections (compare original vs Whisper) | |
| review_path = DATA_DIR / "rejected" / "alignment_review.csv" | |
| l3_review_count = 0 | |
| with open(review_path, "w", encoding="utf-8") as f: | |
| f.write("audio_file|original_text|whisper_text|similarity|status\n") | |
| for clip, reason in sorted(rejected.items()): | |
| if reason.startswith("Layer 3: Low alignment"): | |
| # Parse the reason string to extract texts and similarity | |
| parts = reason.split(" | ") | |
| sim_str = parts[0].split("(")[1].rstrip(")") | |
| orig = parts[1].replace("Original: ", "") if len(parts) > 1 else "" | |
| whis = parts[2].replace("Whisper: ", "") if len(parts) > 2 else "" | |
| f.write(f"{clip}|{orig}|{whis}|{sim_str}|REJECTED\n") | |
| l3_review_count += 1 | |
| print(f"\n Review file for Layer 3 rejections ({l3_review_count} clips):") | |
| print(f" {review_path}") | |
| print(f" Open this file to compare original text vs Whisper text") | |
| print(f" and decide if the rejection was correct.") | |
| # Save report | |
| BENCHMARKS_DIR.mkdir(parents=True, exist_ok=True) | |
| report = { | |
| "date": time.strftime("%Y-%m-%d"), | |
| "total_clips": total_clips, | |
| "total_rejected": total_rejected, | |
| "total_kept": total_kept, | |
| "layer_1_text": { | |
| "rejected": l1_total, | |
| "details": l1_issues, | |
| "thresholds": { | |
| "min_arabic_ratio": MIN_ARABIC_RATIO, | |
| "max_latin_chars": MAX_LATIN_CHARS, | |
| "max_repeated_words": MAX_REPEATED_WORDS, | |
| "min_chars_per_sec": MIN_CHARS_PER_SEC, | |
| "max_chars_per_sec": MAX_CHARS_PER_SEC, | |
| } | |
| }, | |
| "layer_2_audio": { | |
| "rejected": l2_total, | |
| "details": l2_issues, | |
| "thresholds": { | |
| "min_snr_db": MIN_SNR_DB, | |
| "max_silence_ratio": MAX_SILENCE_RATIO, | |
| "max_clipping_ratio": MAX_CLIPPING_RATIO, | |
| } | |
| }, | |
| "layer_3_alignment": { | |
| "rejected": l3_total, | |
| "details": l3_issues, | |
| "whisper_model": "large-v3 (OpenAI)", | |
| "min_similarity": MIN_SIMILARITY, | |
| "note": "Original metadata text is NEVER modified. Whisper is only used to verify alignment. Review alignment_review.csv to compare.", | |
| }, | |
| "output": { | |
| "train_clips": len(clean_train), | |
| "eval_clips": len(clean_eval), | |
| }, | |
| "rejected_clips": {clip: reason for clip, reason in sorted(rejected.items())}, | |
| "pipeline_time_min": round((time.time() - t_start) / 60, 1), | |
| } | |
| report_path = BENCHMARKS_DIR / "sanity_check.json" | |
| with open(report_path, "w", encoding="utf-8") as f: | |
| json.dump(report, f, ensure_ascii=False, indent=2) | |
| print(f" Report: {report_path}") | |
| if __name__ == "__main__": | |
| main() | |