import os import sys import io import re import argparse import numpy as np import soundfile as sf import librosa import torch import nltk # Add the project root to sys.path so we can run the script from any directory project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) if project_root not in sys.path: sys.path.insert(0, project_root) from datasets import load_dataset, Audio, load_from_disk, concatenate_datasets, Dataset from transformers import Wav2Vec2Processor from src.g2p.g2p_utils import G2PManager from src.utils.audio_utils import AudioPreprocessor # Global variables to cache model/processor/G2P manager instances per worker process PREPROCESSOR = None PROCESSOR = None G2P_MANAGER = None def init_worker(processor_dir, dict_path): global PREPROCESSOR, PROCESSOR, G2P_MANAGER if PREPROCESSOR is None: # Prevent PyTorch core oversubscription across multi-process workers torch.set_num_threads(1) PREPROCESSOR = AudioPreprocessor(sr=16000) if PROCESSOR is None: PROCESSOR = Wav2Vec2Processor.from_pretrained(processor_dir) if G2P_MANAGER is None: G2P_MANAGER = G2PManager(dict_path=dict_path) def preprocess_batch(batch, processor_dir, dict_path): # Initialize worker-local resources if not already done init_worker(processor_dir, dict_path) input_values_list = [] labels_list = [] audios = batch["audio"] # Find the text column dynamically text_key = None for key in ["text", "transcription", "sentence", "normalized_text"]: if key in batch: text_key = key break texts = batch[text_key] if text_key is not None else [""] * len(audios) for i in range(len(audios)): try: audio_data = audios[i] text = texts[i] if i < len(texts) else "" # 1. Decode audio bytes if isinstance(audio_data, dict) and "bytes" in audio_data and audio_data["bytes"] is not None: audio_array, sr = sf.read(io.BytesIO(audio_data["bytes"])) elif isinstance(audio_data, dict) and "array" in audio_data and audio_data["array"] is not None: audio_array = np.array(audio_data["array"]) sr = audio_data.get("sampling_rate", 16000) elif isinstance(audio_data, dict) and "path" in audio_data and audio_data["path"] is not None: audio_array, sr = sf.read(audio_data["path"]) else: raise ValueError("Invalid audio format or missing audio content.") # 2. Resample to 16kHz if needed if sr != 16000: try: from scipy.signal import resample_poly import math gcd = math.gcd(sr, 16000) up = 16000 // gcd down = sr // gcd audio_array = resample_poly(audio_array, up, down) except Exception: audio_array = librosa.resample(audio_array, orig_sr=sr, target_sr=16000, res_type="kaiser_fast") # 3. Preprocess Audio (FFT Filter + VAD Trim) clean_audio = PREPROCESSOR.preprocess(audio_array) if len(clean_audio) == 0: raise ValueError("Audio clip is empty after FFT filtering and VAD silence trimming.") # 4. Extract Features input_values = PROCESSOR(clean_audio, sampling_rate=16000).input_values[0] # 5. Text to Phonemes phonemes = G2P_MANAGER.convert_sentence(text) if len(phonemes) == 0: raise ValueError("Phoneme sequence is empty after G2P conversion.") labels = PROCESSOR.tokenizer.convert_tokens_to_ids(phonemes) input_values_list.append(input_values) labels_list.append(labels) except Exception as e: # We can log exceptions, but to avoid spam, we pass silently. pass return {"input_values": input_values_list, "labels": labels_list} def is_valid_english_script(text): """Exclude non-English script (e.g. Devanagari, Bengali ranges). Only Latin ASCII script is valid.""" if not text: return False # Ensure text is purely Latin/ASCII script and spaces/punctuations try: text.encode('ascii') # Check that it contains at least some alphanumeric characters return bool(re.search(r"[A-Za-z]", text)) except UnicodeEncodeError: return False _VOCAB_CACHE = None def lexical_filter(text, g2p_manager, tokenizer): """Verify that all tokens exist in dictionary or neural fallback. Drop if all map to .""" global _VOCAB_CACHE words = g2p_manager.tokenize(text) if not words: return False if _VOCAB_CACHE is None: _VOCAB_CACHE = tokenizer.get_vocab() vocab = _VOCAB_CACHE valid_words = 0 for word in words: phonemes = g2p_manager.convert_word(word) if len(phonemes) == 0: continue # If all phonemes for the word map to (not in vocabulary), skip it if all(p not in vocab for p in phonemes): continue valid_words += 1 return valid_words > 0 def load_mixed_dataset(processor, g2p_manager, token=None, local_openslr_dir="local_openslr_104"): """Loads and samples the datasets dynamically using memory-mapped Arrow tables (0-RAM).""" datasets_list = [] # Helper function to load, filter, and standardize a dataset using Arrow operations (0-RAM) def load_and_standardize(path, split, name=None, config=None, text_keys=None, source_label=None, trust_remote_code=False): print(f"Loading {path} (config={config}, split={split})...") try: if config: ds = load_dataset(path, config, split=split, token=token, trust_remote_code=trust_remote_code) else: ds = load_dataset(path, split=split, token=token, trust_remote_code=trust_remote_code) # Prevent audio decoding into memory ds = ds.cast_column("audio", Audio(decode=False)) def filter_fn(example): text = "" if text_keys: for k in text_keys: if example.get(k): text = example[k] break if not text: text = example.get("sentence") or example.get("text") or example.get("transcription") or example.get("normalized_text") or "" text = str(text).strip() return is_valid_english_script(text) and lexical_filter(text, g2p_manager, processor.tokenizer) ds_filtered = ds.filter(filter_fn, desc=f"Filtering {source_label}") def map_fn(example): text = "" if text_keys: for k in text_keys: if example.get(k): text = example[k] break if not text: text = example.get("sentence") or example.get("text") or example.get("transcription") or example.get("normalized_text") or "" return { "audio": example["audio"], "text": str(text).strip(), "source_dataset": source_label } columns_to_remove = [col for col in ds_filtered.column_names if col not in ["audio", "text", "source_dataset"]] ds_standardized = ds_filtered.map( map_fn, remove_columns=columns_to_remove, desc=f"Standardizing {source_label}" ) datasets_list.append(ds_standardized) print(f"✓ Loaded and standardized {len(ds_standardized)} samples from {path}.") except Exception as e: print(f"⚠️ Error loading {path}: {e}") # 1. Common Voice India Accent load_and_standardize("WillHeld/india_accent_cv", "train", text_keys=["sentence"], source_label="common_voice") # 2. theothertom/indian_english_extended load_and_standardize("theothertom/indian_english_extended", "train", text_keys=["transcription", "sentence"], source_label="theothertom_extended") # 3. theothertom/indian_english_bigger load_and_standardize("theothertom/indian_english_bigger", "train", text_keys=["transcription", "sentence"], source_label="theothertom_bigger") # 4. theothertom/indian_english_audio_2 load_and_standardize("theothertom/indian_english_audio_2", "train", text_keys=["transcription", "sentence"], source_label="theothertom_audio_2") # 5. Svarah (loads 'test' split because 'train' split doesn't exist) load_and_standardize("ai4bharat/Svarah", "test", text_keys=["transcription"], source_label="svarah") # 6. OpenSLR 104 (bypasses loading script error if local folder exists) local_openslr_loaded = False if os.path.exists(local_openslr_dir): print(f"Loading local OpenSLR 104 from disk ('{local_openslr_dir}')...") try: local_ds = load_from_disk(local_openslr_dir) local_ds = local_ds.cast_column("audio", Audio(decode=False)) def filter_fn(example): text = example.get("sentence") or example.get("text") or example.get("transcription") or "" text = str(text).strip() return is_valid_english_script(text) and lexical_filter(text, g2p_manager, processor.tokenizer) ds_filtered = local_ds.filter(filter_fn, desc="Filtering openslr_104") def map_fn(example): text = example.get("sentence") or example.get("text") or example.get("transcription") or "" return { "audio": example["audio"], "text": str(text).strip(), "source_dataset": "openslr_104" } columns_to_remove = [col for col in ds_filtered.column_names if col not in ["audio", "text", "source_dataset"]] ds_standardized = ds_filtered.map(map_fn, remove_columns=columns_to_remove, desc="Standardizing openslr_104") datasets_list.append(ds_standardized) local_openslr_loaded = True print(f"✓ Loaded local OpenSLR 104: {len(ds_standardized)} samples.") except Exception as e: print(f"⚠️ Error loading local OpenSLR 104: {e}") if not local_openslr_loaded: load_and_standardize("openslr", "train", config="104", text_keys=["transcription"], source_label="openslr_104", trust_remote_code=True) # 7. Eka Care (Medical ASR) print("Loading Eka Care Medical ASR...") try: eka_ds = load_dataset("eka-care/medical-asr", split="train", token=token) eka_ds = eka_ds.filter(lambda x: not x.get("is_synthetic", False)) eka_ds = eka_ds.cast_column("audio", Audio(decode=False)) def filter_fn(example): text = example.get("transcription") or example.get("text") or "" text = str(text).strip() return is_valid_english_script(text) and lexical_filter(text, g2p_manager, processor.tokenizer) ds_filtered = eka_ds.filter(filter_fn, desc="Filtering eka_care") def map_fn(example): text = example.get("transcription") or example.get("text") or "" return { "audio": example["audio"], "text": str(text).strip(), "source_dataset": "eka_care" } columns_to_remove = [col for col in ds_filtered.column_names if col not in ["audio", "text", "source_dataset"]] ds_standardized = ds_filtered.map(map_fn, remove_columns=columns_to_remove, desc="Standardizing eka_care") datasets_list.append(ds_standardized) print(f"✓ Loaded Eka Care: {len(ds_standardized)} samples.") except Exception as e: print(f"⚠️ Error loading Eka Care: {e}") if not datasets_list: raise ValueError("No non-NPTEL datasets were loaded successfully!") other_datasets = concatenate_datasets(datasets_list) n_others = len(other_datasets) print(f"Total non-NPTEL samples gathered: {n_others}") print(f"Streaming exactly {n_others} samples from NPTEL to balance...") try: # Load NPTEL in streaming mode nptel_ds = load_dataset("skbose/indian-english-nptel-v0", split="train", streaming=True, token=token) nptel_ds = nptel_ds.cast_column("audio", Audio(decode=False)) nptel_samples = [] loaded = 0 checked = 0 for sample in nptel_ds: checked += 1 if checked % 1000 == 0: print(f" [NPTEL Stream] Processed {checked} stream records, matched and balanced {loaded}/{n_others} samples...", flush=True) text = sample.get("text") or sample.get("transcription") or "" text = str(text).strip() if is_valid_english_script(text) and lexical_filter(text, g2p_manager, processor.tokenizer): nptel_samples.append({ "audio": sample["audio"], "text": text, "source_dataset": "nptel" }) loaded += 1 if loaded >= n_others: break print(f"✓ Balanced with {loaded} NPTEL samples (checked {checked} stream items total).") nptel_dataset = Dataset.from_list(nptel_samples, features=other_datasets.features) final_dataset = concatenate_datasets([other_datasets, nptel_dataset]) except Exception as e: print(f"⚠️ Error loading or processing NPTEL: {e}") final_dataset = other_datasets print(f"✓ Concatenated and balanced dataset. Total samples: {len(final_dataset)}") # Shuffle out-of-core final_dataset = final_dataset.shuffle(seed=42) return final_dataset def build_and_apply_vocab_patch(dataset, processor, g2p_manager, patch_path): """Verify vocabulary against tokenizer. Log any unmapped OOV words to patch_vocab.dict.""" print("Running G2P vocabulary verification check...") unk_id = processor.tokenizer.unk_token_id or 1 new_patches = {} # We only check words from non-NPTEL datasets as specified words_to_check = set() for sample in dataset: source = sample.get("source_dataset", "nptel") if source != "nptel": text = sample.get("text") or sample.get("transcription") or sample.get("sentence") or "" words_to_check.update(g2p_manager.tokenize(text)) print(f"Analyzing {len(words_to_check)} unique words from non-NPTEL datasets...") vocab = processor.tokenizer.get_vocab() for word in words_to_check: phonemes = g2p_manager.convert_word(word) if len(phonemes) == 0: continue ids = processor.tokenizer.convert_tokens_to_ids(phonemes) if any(i == unk_id for i in ids): # Clean/approximate phonemes that mapped to unk using valid tokenizer tokens cleaned_phonemes = [] for p in phonemes: if p in vocab: cleaned_phonemes.append(p) else: closest = "" for char in p: if char in vocab: closest += char if closest: cleaned_phonemes.append(closest) if cleaned_phonemes: new_patches[word] = cleaned_phonemes if new_patches: print(f"Writing {len(new_patches)} new vocabulary patches to {patch_path}...") existing_patches = {} if os.path.exists(patch_path): with open(patch_path, "r", encoding="utf-8") as f: for line in f: parts = line.strip().split("\t") if len(parts) >= 2: existing_patches[parts[0]] = parts[1].split() existing_patches.update(new_patches) os.makedirs(os.path.dirname(patch_path), exist_ok=True) with open(patch_path, "w", encoding="utf-8") as f: for w, phs in sorted(existing_patches.items()): f.write(f"{w}\t{' '.join(phs)}\n") g2p_manager.phoneme_dict.update(new_patches) print("✅ Vocabulary patch successfully updated and merged!") else: print("✓ No vocabulary patches needed. All words mapped successfully.") def main(): parser = argparse.ArgumentParser(description="Download, balance, and preprocess CDAC datasets offline") parser.add_argument("--processor_dir", default="models/processor_dir", help="Path to local processor config") parser.add_argument("--dict_path", default="src/g2p/output_v2_detailed.dict", help="Path to MFA dictionary for G2P") parser.add_argument("--save_dir", default="local_nptel_processed", help="Path to save the preprocessed dataset") parser.add_argument("--local_openslr_dir", default="local_openslr_104", help="Path to local processed OpenSLR 104 dataset directory") parser.add_argument("--num_proc", type=int, default=40, help="Number of processes to use for preprocessing (default: 40)") parser.add_argument("--batch_size", type=int, default=100, help="Batch size for map function") parser.add_argument("--hf_token", default=None, help="Hugging Face authorization token") args = parser.parse_args() hf_token = args.hf_token or os.environ.get("HF_TOKEN") if isinstance(hf_token, str) and hf_token.strip().lower() in ["none", ""]: hf_token = None print("Checking NLTK resources...") for res in ['averaged_perceptron_tagger', 'averaged_perceptron_tagger_eng', 'cmudict']: nltk.download(res, quiet=True) # VAD Warmup sequentially in main process before spawning child processes print("Warming up Silero VAD cache sequentially in main process...") _ = AudioPreprocessor(sr=16000) # Initialize G2P and Processor for verification checks processor = Wav2Vec2Processor.from_pretrained(args.processor_dir) g2p_manager = G2PManager(dict_path=args.dict_path) # 1. Load balanced mixture print("Loading datasets dynamically...") mixed_dataset = load_mixed_dataset(processor, g2p_manager, token=hf_token, local_openslr_dir=args.local_openslr_dir) # 2. Extract and log OOV vocabulary patches patch_path = os.path.join(os.path.dirname(args.dict_path), "patch_vocab.dict") build_and_apply_vocab_patch(mixed_dataset, processor, g2p_manager, patch_path) # 3. Perform Preprocessing Map print(f"Starting preprocessing map with {args.num_proc} processes, batch_size={args.batch_size}...") original_columns = mixed_dataset.column_names processed_dataset = mixed_dataset.map( preprocess_batch, fn_kwargs={"processor_dir": args.processor_dir, "dict_path": args.dict_path}, batched=True, batch_size=args.batch_size, num_proc=args.num_proc, remove_columns=original_columns, desc="Preprocessing audio and text offline" ) print(f"Splitting dataset into train and test (10% test size)...") dataset_dict = processed_dataset.train_test_split(test_size=0.1, seed=42) print(f"Saving preprocessed DatasetDict to disk at '{args.save_dir}'...") dataset_dict.save_to_disk(args.save_dir) print("✅ Preprocessing, train-test split, and save completed successfully!") if __name__ == "__main__": main()