import argparse import re import os from pathlib import Path import sys import time import soundfile as sf import librosa import torch import torchaudio import warnings # SUPPRESS WARNINGS (User Request) warnings.filterwarnings("ignore") try: # Ensure logs flush even when stdout is not a TTY (prevents silent crashes with buffered prints) sys.stdout.reconfigure(line_buffering=True) sys.stderr.reconfigure(line_buffering=True) except Exception: pass # --- Monkeypatch for DGX Spark Torchaudio (2.10+) --- # Pyannote expects torchaudio.AudioMetaData, which might be moved/missing in bleeding edge versions if not hasattr(torchaudio, "AudioMetaData"): from collections import namedtuple print("Monkeypatching torchaudio.AudioMetaData...") torchaudio.AudioMetaData = namedtuple( "AudioMetaData", ["sample_rate", "num_frames", "num_channels", "bits_per_sample", "encoding"] ) # Also patch backend.common if it exists, as pyannote might look there if hasattr(torchaudio, "backend") and hasattr(torchaudio.backend, "common"): torchaudio.backend.common.AudioMetaData = torchaudio.AudioMetaData if not hasattr(torchaudio, "list_audio_backends"): print("Monkeypatching torchaudio.list_audio_backends...") # Mock return value - soundfile/ffmpeg are standard torchaudio.list_audio_backends = lambda: ["ffmpeg", "soundfile"] # Monkeypatch torch.load to default weights_only=False for PyTorch 2.6+ compatibility original_torch_load = torch.load def unsafe_torch_load(*args, **kwargs): # Force weights_only=False even if present kwargs["weights_only"] = False print(f"Intercepted torch.load, forced weights_only=False. Args: {args[1:] if len(args) > 1 else '?'}") return original_torch_load(*args, **kwargs) torch.load = unsafe_torch_load print("Monkeypatched torch.load for weights_only=False (FORCED)") # Attempt to safe-list TorchVersion if possible try: # torch.torch_version.TorchVersion is the class # We need to find where it is exposed. # Usually it's not public. But let's try to locate it via the instance. from torch.torch_version import TorchVersion torch.serialization.add_safe_globals([TorchVersion]) print("Added TorchVersion to safe globals") except Exception as e: print(f"Could not add safe globals (TorchVersion): {e}") # --- Monkeypatching BEFORE Pyannote Imports --- # --- Monkeypatching BEFORE Pyannote Imports --- import semver # 1. Nuclear Option: Patch semver.VersionInfo.parse # PyTorch/Torchaudio versions on DGX Spark (e.g. 2.10.0a0+...) are not valid SemVer. original_semver_parse = semver.VersionInfo.parse def safe_semver_parse(version_str): try: return original_semver_parse(version_str) except ValueError: print(f"Warning: Bypassing invalid SemVer: {version_str}") # Return a dummy version that satisfies constraints (usually > 2.0.0) return semver.VersionInfo(3, 0, 0) # Mock as 3.0.0 semver.VersionInfo.parse = safe_semver_parse print("Monkeypatched semver.VersionInfo.parse (Nuclear Option)") # 2. Try patching pyannote check_version too for good measure try: from pyannote.audio.utils import version version.check_version = lambda library, mine, yours: None print("Monkeypatched pyannote.audio.utils.version.check_version") except Exception as e: print(f"Could not patch pyannote check_version directly: {e}") # Monkeypatch torchaudio.load to force soundfile backend (avoid torchcodec error) # Robust replacement using soundfile directly def robust_torchaudio_load(filepath, **kwargs): # Ignore backend arg if present # Directly use soundfile to load try: # soundfile.read returns (data, samplerate) # data is (frames, channels) if multichannel, or (frames,) if mono data, sr = sf.read(filepath) # Convert to torch tensor # Torchaudio expects (channels, time) if data.ndim == 1: # Mono # Must cast to float (float32) because soundfile returns float64 (Double) waveform = torch.from_numpy(data).float().unsqueeze(0) else: # Multichannel (time, channels) -> (channels, time) waveform = torch.from_numpy(data.T).float() return waveform, sr except Exception as e: print(f"Fallback load failed for {filepath}: {e}") raise e torchaudio.load = robust_torchaudio_load print("Monkeypatched torchaudio.load to use soundfile directly (ROBUST)") # Monkeypatch torchaudio.info to use soundfile (MISSING API FIX) class MockAudioInfo: def __init__(self, num_frames, sample_rate): self.num_frames = num_frames self.sample_rate = sample_rate def robust_info(filepath, **kwargs): sinfo = sf.info(filepath) return MockAudioInfo(sinfo.frames, sinfo.samplerate) torchaudio.info = robust_info print("Monkeypatched torchaudio.info to use soundfile directly (ROBUST)") # ... (Previous torchaudio hacks) ... import numpy as np import subprocess from tqdm import tqdm from transformers import AutoProcessor, pipeline # Pyannote imports MUST happen AFTER patches from pyannote.audio import Pipeline, Inference, Model from scipy.spatial.distance import cosine import json from datasets import Dataset # Re-enable TF32 (Pyannote disables it, but GB10 might need it or crash without it) import torch print("Re-enabling TF32/CuDNN benchmark to fix CUBLAS errors on GB10...") torch.backends.cuda.matmul.allow_tf32 = True torch.backends.cudnn.allow_tf32 = True torch.backends.cudnn.benchmark = True # Try ensuring optimal algo selection print("TF32 Enabled. CuDNN Benchmark Enabled.") # Robust environment check try: import pytorch_metric_learning print( f"Successfully imported pytorch_metric_learning: {pytorch_metric_learning.__version__ if hasattr(pytorch_metric_learning, '__version__') else 'unknown'}" ) except ImportError as e: print(f"CRITICAL ERROR: pytorch_metric_learning failed to import: {e}") # Don't exit yet, let the pipeline try to proceed or crash loudly later print("CUDA Architecture Check:") if torch.cuda.is_available(): print(f" System Cuda Version: {torch.version.cuda}") print(f" Device Name: {torch.cuda.get_device_name(0)}") print(f" Device Arch: {torch.cuda.get_arch_list()}") else: print(" CUDA NOT AVAILABLE") def resolve_device(requested_device): if not requested_device: return "cuda" if torch.cuda.is_available() else "cpu" req = str(requested_device).lower() if req.startswith("cuda") or req == "gpu": if torch.cuda.is_available(): return "cuda" print("Warning: CUDA requested but not available. Falling back to CPU.") return "cpu" return req def env_flag(name, default=False): val = os.environ.get(name) if val is None: return default return val.strip().lower() in ("1", "true", "yes", "y", "on") class DataPreparer: def __init__(self, output_dir, device=None, hf_token=None): # Safety: Check dependencies again pass self.device = resolve_device(device) self.device_index = 0 if self.device == "cuda" else -1 self.hf_token = hf_token or os.environ.get("HF_TOKEN") print(f"Initializing DataPreparer on {self.device} (HF Token Present: {bool(self.hf_token)})") self.output_dir = Path(output_dir) self.wavs_dir = self.output_dir / "wavs" self.wavs_dir.mkdir(parents=True, exist_ok=True) self.metadata_path = self.output_dir / "metadata.csv" # Load Models print("Loading Whisper (Transformers)...") self.transcriber = self.load_whisper_pipeline() self.whisper_language = os.environ.get("WHISPER_LANGUAGE", "spanish") self.min_asr_words = int(os.environ.get("WHISPER_MIN_WORDS", "2")) self.min_asr_alpha_ratio = float(os.environ.get("WHISPER_MIN_ALPHA_RATIO", "0.5")) print("Loading Pyannote Diarization 3.1...") # Use configured device (GPU if available) self.diarization_pipeline = Pipeline.from_pretrained( "pyannote/speaker-diarization-3.1", use_auth_token=self.hf_token ).to(torch.device(self.device)) print("Loading Pyannote Embedding Model for Verification...") self.embedding_model = Model.from_pretrained( "pyannote/wespeaker-voxceleb-resnet34-LM", use_auth_token=self.hf_token ) # Use configured device self.device_embedding = torch.device(self.device) print(f"Moving Embedding Model to {self.device_embedding}") self.embedding_model.to(self.device_embedding) self.inference = Inference(self.embedding_model, window="whole", device=self.device_embedding) self.target_sr = 24000 # For F5-TTS def _text_quality_ok(self, text: str) -> bool: words = re.findall(r"[\wáéíóúñüÁÉÍÓÚÑÜ]+", text) if len(words) < self.min_asr_words: return False alpha = sum(1 for c in text if c.isalpha()) ratio = alpha / max(1, len(text)) return ratio >= self.min_asr_alpha_ratio def _normalize_embedding(self, emb): if isinstance(emb, torch.Tensor): if emb.ndim > 1: emb = emb.mean(dim=0) return emb.detach().cpu().numpy() if hasattr(emb, "ndim") and emb.ndim > 1: emb = emb.mean(axis=0) return np.asarray(emb) def _call_diarization_pipeline(self, wav_path): try: return self.diarization_pipeline(wav_path, batch_size=1, num_workers=0) except TypeError: return self.diarization_pipeline(wav_path) def diarize_file(self, wav_path, target_sr=16000): temp_path = None try: data, sr = sf.read(str(wav_path)) if data.ndim == 1: data = data[None, :] else: data = data.T waveform = torch.from_numpy(data).float() if sr != target_sr: waveform = torchaudio.functional.resample(waveform, sr, target_sr) sr = target_sr if waveform.shape[0] > 1: waveform = torch.mean(waveform, dim=0, keepdim=True) # Trim to a multiple of 10ms to avoid padding shape mismatches frame = int(sr * 0.01) if frame > 0: trim = (waveform.shape[1] // frame) * frame if trim > 0: waveform = waveform[:, :trim] temp_path = self.output_dir / "temp_diarization" / f"{wav_path.stem}_{int(time.time() * 1000)}.wav" temp_path.parent.mkdir(parents=True, exist_ok=True) sf.write(temp_path, waveform.squeeze(0).cpu().numpy(), sr) return self._call_diarization_pipeline(str(temp_path)) finally: if temp_path and temp_path.exists(): temp_path.unlink() def load_whisper_pipeline(self): whisper_device = os.environ.get("WHISPER_DEVICE") if whisper_device: whisper_device = resolve_device(whisper_device) else: whisper_device = self.device whisper_device_index = 0 if whisper_device == "cuda" else -1 model_id = os.environ.get("WHISPER_MODEL") if not model_id: model_id = "openai/whisper-large-v3" if whisper_device == "cuda": try: total_mem_gb = torch.cuda.get_device_properties(0).total_memory / (1024**3) if total_mem_gb < 16: model_id = "openai/whisper-medium" print( f" GPU memory {total_mem_gb:.1f}GB < 16GB; using {model_id} for stability. " "Set WHISPER_MODEL to override." ) except Exception as e: print(f" Warning: could not read GPU memory ({e}); using {model_id}.") use_fast_env = os.environ.get("WHISPER_USE_FAST") processor_kwargs = {} if use_fast_env is not None: processor_kwargs["use_fast"] = use_fast_env.strip().lower() in ("1", "true", "yes", "y") dtype = torch.float16 if whisper_device == "cuda" else torch.float32 model_kwargs = { "low_cpu_mem_usage": True, "use_safetensors": True, } use_device_map = whisper_device == "cuda" print( f" Whisper model: {model_id} (device={whisper_device}, device_map={'auto' if use_device_map else 'none'})" ) processor = AutoProcessor.from_pretrained(model_id, **processor_kwargs) pipeline_kwargs = { "model": model_id, "tokenizer": processor.tokenizer, "feature_extractor": processor.feature_extractor, "torch_dtype": dtype, "model_kwargs": model_kwargs, } if use_device_map: pipeline_kwargs["device_map"] = "auto" else: pipeline_kwargs["device"] = whisper_device_index return pipeline("automatic-speech-recognition", **pipeline_kwargs) def compute_embedding(self, wav_path): """Compute embedding for a wav file using Pyannote Inference""" # Pyannote inference handles loading/resampling internally usually, # but explicit loading is safer for ensuring device emb = self.inference(str(wav_path)) return self._normalize_embedding(emb) def get_speaker_embeddings(self, audio_path, diarization, top_k=5): """ Extracts embeddings for each speaker found in the diarization. Returns generic 'speaker_label' -> averaged embedding vector. """ speaker_embeddings = {} # Group segments by speaker speaker_segments = {} for turn, _, speaker in diarization.itertracks(yield_label=True): if speaker not in speaker_segments: speaker_segments[speaker] = [] speaker_segments[speaker].append(turn) # Compute embedding for longest segments of each speaker full_audio, sr = torchaudio.load(audio_path) for speaker, segments in speaker_segments.items(): # Sort by duration, take top K longest segments.sort(key=lambda s: s.duration, reverse=True) top_segments = segments[:top_k] embeddings = [] print(f" Computing embedding for {speaker} using {len(top_segments)} segments...") for seg in top_segments: # Extract audio start_sample = int(seg.start * sr) end_sample = int(seg.end * sr) clip = full_audio[:, start_sample:end_sample] # Save temp to compute embedding (Pyannote Inference takes path or tensor, path is safer/standard api) temp_path = self.output_dir / f"temp_{speaker}_{start_sample}.wav" # Avoid torchaudio.save torchcodec dependency sf.write(temp_path, clip.squeeze().cpu().numpy(), sr) try: emb = self.compute_embedding(temp_path) embeddings.append(emb) finally: if temp_path.exists(): temp_path.unlink() if embeddings: # Average them avg_emb = np.mean(np.stack(embeddings), axis=0) speaker_embeddings[speaker] = avg_emb return speaker_embeddings def _speaker_durations(self, diarization): durations = {} total = 0.0 for turn, _, speaker in diarization.itertracks(yield_label=True): dur = float(turn.end - turn.start) durations[speaker] = durations.get(speaker, 0.0) + dur total += dur return durations, total def _select_target_speakers( self, speaker_embs, diarization, master_ref_emb, threshold, selection, min_margin, min_share, ): durations, total = self._speaker_durations(diarization) scored = [] for spk, emb in speaker_embs.items(): dist = float(cosine(emb, master_ref_emb)) scored.append( { "speaker": spk, "dist": dist, "duration": float(durations.get(spk, 0.0)), } ) scored.sort(key=lambda x: x["dist"]) if not scored: return [], scored, "no_speakers" if selection == "threshold_all": target = [s["speaker"] for s in scored if s["dist"] <= threshold] return target, scored, "threshold_all" if target else "no_match" # default: closest speaker only best = scored[0] if best["dist"] > threshold: return [], scored, "best_above_threshold" if len(scored) > 1: margin = scored[1]["dist"] - best["dist"] if margin < min_margin: return [], scored, f"ambiguous_margin_{margin:.4f}" share = (best["duration"] / total) if total > 0 else 0.0 if min_share > 0.0 and share < min_share: return [], scored, f"low_share_{share:.3f}" return [best["speaker"]], scored, "closest" def chunk_large_file(self, wav_path, chunk_duration_min=10): """Splits a large wav file into smaller chunks using ffmpeg""" try: # Check duration first using our robust info info = torchaudio.info(str(wav_path)) duration_s = info.num_frames / info.sample_rate if duration_s <= (chunk_duration_min * 60): return [wav_path] print( f"Splitting large file {wav_path.name} ({duration_s / 60:.2f} min) into {chunk_duration_min} min chunks..." ) # Create temp dir for chunks chunk_dir = self.output_dir / "temp_chunks" / wav_path.stem chunk_dir.mkdir(parents=True, exist_ok=True) # Use ffmpeg to split # segment_time is compatible with most ffmpeg versions out_pattern = str(chunk_dir / f"{wav_path.stem}_%03d.wav") cmd = [ "ffmpeg", "-y", "-i", str(wav_path), "-f", "segment", "-segment_time", str(chunk_duration_min * 60), "-c", "copy", out_pattern, ] subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) chunks = list(chunk_dir.glob("*.wav")) print(f" -> Generated {len(chunks)} chunks.") return sorted(chunks) except Exception as e: print(f"Error chunking file {wav_path}: {e}") return [wav_path] # Fallback to whole file def run( self, trusted_dir, untrusted_dir=None, threshold=0.4, skip_trusted=False, speaker_selection="closest", speaker_margin=0.05, min_speaker_share=0.4, segment_verify=False, segment_threshold=None, ): # Cosine distance threshold (lower is better) metadata_lines = [] speaker_audit_path = self.output_dir / "speaker_audit.jsonl" segment_verify = bool(segment_verify) if segment_threshold is None: segment_threshold = threshold # 1. Build Reference Embedding from Trusted Data print("--- Phase 1: Processing Trusted Data (Building Reference) ---") trusted_files = list(Path(trusted_dir).rglob("*.wav")) if not trusted_files: raise ValueError(f"No trusted wav files found in {trusted_dir}") # DEFENSIVE: Filter out huge trusted files to prevent slowdowns/OOM safe_trusted = [] MAX_TRUSTED_SIZE_BYTES = 150 * 1024 * 1024 # 150MB limit for f in trusted_files: size = f.stat().st_size if size > MAX_TRUSTED_SIZE_BYTES: print( f"WARNING: Skipping trusted file {f.name} (Size: {size / 1024 / 1024:.2f} MB) - Exceeds safety limit." ) continue safe_trusted.append(f) if not safe_trusted: raise ValueError(f"No trusted wav files under size limit in {trusted_dir}") ref_embeddings = [] # Use first 5 or specific named files as anchors if available safe_trusted = sorted(safe_trusted, key=lambda p: p.stat().st_size) anchors = safe_trusted[: min(len(safe_trusted), 5)] for anchor in anchors: print(f" Encoding Anchor: {anchor.name}") try: emb = self.compute_embedding(anchor) ref_embeddings.append(emb) except Exception as e: print(f" Failed to encode anchor {anchor}: {e}") if not ref_embeddings: print("Critical: No reference embeddings created!") return # Average Reference for downstream untrusted filtering master_ref_emb = np.mean(np.stack(ref_embeddings), axis=0) try: np.save(self.output_dir / "ref_embedding.npy", master_ref_emb) with open(self.output_dir / "ref_anchors.json", "w", encoding="utf-8") as f: json.dump({"anchors": [str(p) for p in anchors]}, f) except Exception as e: print(f"Warning: failed to save reference embedding: {e}") if skip_trusted: print("--- Phase 1b: Transcribing Trusted - SKIPPED (User Request) ---") else: # Transcribing trusted is intentionally skipped to save time/avoid repetition print("--- Phase 1b: Transcribing Trusted - SKIPPED (Optimized) ---") # 2. Process Untrusted Data (Diarization -> Verify -> Transcribe) if untrusted_dir: print("--- Phase 2: Processing Untrusted Data (Diarization + Filtering) ---") untrusted_files = sorted(list(Path(untrusted_dir).rglob("*.wav"))) print(f"Scanning {len(untrusted_files)} files in {untrusted_dir}...") # DEFENSIVE: Filter out huge files to prevent OOM safe_files = [] MAX_SIZE_BYTES = 150 * 1024 * 1024 # 150MB Limit for f in untrusted_files: size = f.stat().st_size if size > MAX_SIZE_BYTES: print( f"WARNING: Skipping file {f.name} (Size: {size / 1024 / 1024:.2f} MB) - Exceeds safety limit." ) continue safe_files.append(f) print(f"Processing {len(safe_files)} safe files (filtered from {len(untrusted_files)})...") audit_fh = open(speaker_audit_path, "a", encoding="utf-8") try: for f in safe_files: print(f"Diarizing {f.name}...") try: # A. Run Diarization diarization_start = time.time() diarization = self.diarize_file(f) diarization_elapsed = time.time() - diarization_start print(f" Diarization complete in {diarization_elapsed:.1f}s") # B. Identify Target Speaker # Get embeddings for all speakers found emb_start = time.time() speaker_embs = self.get_speaker_embeddings(f, diarization) emb_elapsed = time.time() - emb_start print(f" Speaker embeddings complete in {emb_elapsed:.1f}s") target_speakers, scored, reason = self._select_target_speakers( speaker_embs, diarization, master_ref_emb, threshold, speaker_selection, speaker_margin, min_speaker_share, ) for s in scored: print(f" Speaker {s['speaker']}: Distance {s['dist']:.4f}, Dur {s['duration']:.1f}s") print(f" Selection: {reason} -> {target_speakers}") audit_fh.write( json.dumps( { "file": str(f), "selection": speaker_selection, "threshold": threshold, "min_margin": speaker_margin, "min_share": min_speaker_share, "reason": reason, "chosen": target_speakers, "scores": scored, }, ensure_ascii=False, ) + "\n" ) audit_fh.flush() if not target_speakers: print(f" Warning: No target speaker found in {f.name}!") continue # C. Extract Valid Segments & Transcribe print(" Extracting and Transcribing valid segments...") # Careful load for slicing full_audio, sr = torchaudio.load(str(f)) # Using path str for my patched load # Resampler for F5 resampler_f5 = None if sr != self.target_sr: resampler_f5 = torchaudio.transforms.Resample(sr, self.target_sr).to(full_audio.device) # Optional segment-level verification temp_verify_dir = None if segment_verify: temp_verify_dir = self.output_dir / "temp_verify" temp_verify_dir.mkdir(parents=True, exist_ok=True) # Iterate tracks valid_segments_count = 0 rejected_segments = 0 rejected_by_similarity = 0 for turn, _, speaker in diarization.itertracks(yield_label=True): if speaker not in target_speakers: continue if turn.duration < 1.5: continue # Skip short # Extract Audio start_s = int(turn.start * sr) end_s = int(turn.end * sr) # Boundary check if end_s > full_audio.shape[1]: end_s = full_audio.shape[1] seg_audio = full_audio[:, start_s:end_s] # Mix to mono if seg_audio.shape[0] > 1: seg_audio_mono = torch.mean(seg_audio, dim=0, keepdim=True) else: seg_audio_mono = seg_audio # Transcribe # Note: Transcribing short segments individually can be hallucination-prone. # Preferable to transcribe whole file and align, BUT here we want to ensure we ONLY get target audio. # So specific transcription is safer for data purity. # Better: Transcribe ONLY this segment try: audio_input = seg_audio_mono.squeeze(0).cpu().numpy() # Convert to 16k for Whisper to avoid sampling_rate incompatibility if sr != 16000: audio_input = librosa.resample(audio_input, orig_sr=sr, target_sr=16000) res = self.transcriber( audio_input, return_timestamps=False, generate_kwargs={"language": self.whisper_language, "task": "transcribe"}, ) text = res["text"].strip() if len(text) < 2: rejected_segments += 1 continue if not self._text_quality_ok(text): rejected_segments += 1 continue if segment_verify: temp_verify_path = ( temp_verify_dir / f"verify_{f.stem}_{speaker}_{int(turn.start * 1000)}.wav" ) sf.write(temp_verify_path, seg_audio_mono.squeeze().cpu().numpy(), sr) try: seg_emb = self.compute_embedding(temp_verify_path) seg_dist = float(cosine(seg_emb, master_ref_emb)) finally: if temp_verify_path.exists(): temp_verify_path.unlink() if seg_dist > segment_threshold: rejected_by_similarity += 1 continue # Use original SR audio for saving to avoid double resampling quality loss? # Actually we need target_sr for F5. # Resample if resampler_f5: seg_audio_f5 = resampler_f5( seg_audio ) # Re-use stereo/original channels or mono? F5 usually mono. else: seg_audio_f5 = seg_audio if seg_audio_f5.shape[0] > 1: seg_audio_f5 = torch.mean(seg_audio_f5, dim=0, keepdim=True) seg_name = f"{f.stem}_{speaker}_{turn.start:.2f}.wav" seg_path = self.wavs_dir / seg_name sf.write(seg_path, seg_audio_f5.squeeze().cpu().numpy(), self.target_sr) metadata_lines.append(f"{seg_path.absolute()}|{text}") valid_segments_count += 1 except Exception as e: print(f"Error transcribing segment: {e}") rejected_segments += 1 print( f" -> Extracted {valid_segments_count} segments " f"(rejected={rejected_segments}, similarity_reject={rejected_by_similarity})." ) # Explicit Cleanup del full_audio del diarization if resampler_f5: del resampler_f5 torch.cuda.empty_cache() except Exception as e: print(f"Failed to process chunk {f}: {e}") torch.cuda.empty_cache() finally: audit_fh.close() # 3. Save Output self.generate_arrow(metadata_lines) def generate_arrow(self, metadata_lines): # Same as before data_dicts = [] durations = [] print(f"Building dataset from {len(metadata_lines)} segments...") for line in tqdm(metadata_lines): parts = line.split("|") if len(parts) < 2: continue wav_path = parts[0] text = parts[1] try: info = sf.info(wav_path) data_dicts.append({"audio_path": wav_path, "text": text, "duration": info.duration}) durations.append(info.duration) except Exception: pass if not data_dicts: print("Error: No valid data found!") return ds = Dataset.from_list(data_dicts) ds.save_to_disk(str(self.output_dir / "raw")) with open(self.output_dir / "duration.json", "w") as f: json.dump({"duration": durations}, f) print(f"Saved dataset to {self.output_dir / 'raw'}") if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--trusted_dir", required=True) parser.add_argument("--untrusted_dir") parser.add_argument("--output_dir", required=True) parser.add_argument("--threshold", type=float, default=0.35) parser.add_argument("--device", help="Force specific device (e.g. 'cpu', 'cuda')") parser.add_argument("--skip_trusted", action="store_true", help="Skip Phase 1 (Trusted/Reference building)") parser.add_argument( "--speaker_selection", choices=["closest", "threshold_all"], default=os.environ.get("SPEAKER_SELECTION", "closest"), help="Speaker selection strategy (default: closest speaker only)", ) parser.add_argument( "--speaker_margin", type=float, default=float(os.environ.get("SPEAKER_MARGIN", "0.05")), help="Minimum distance margin vs 2nd closest speaker (closest mode)", ) parser.add_argument( "--min_speaker_share", type=float, default=float(os.environ.get("SPEAKER_MIN_SHARE", "0.4")), help="Minimum share of diarized speech for selected speaker (closest mode)", ) seg_thr_default = None seg_thr_env = os.environ.get("SEGMENT_THRESHOLD") if seg_thr_env: try: seg_thr_default = float(seg_thr_env) except ValueError: seg_thr_default = None parser.add_argument( "--segment_verify", action="store_true", default=env_flag("SEGMENT_VERIFY", False), help="Enable segment-level speaker verification", ) parser.add_argument( "--segment_threshold", type=float, default=seg_thr_default, help="Distance threshold for segment verification (default: use --threshold)", ) args = parser.parse_args() DataPreparer(args.output_dir, device=args.device).run( args.trusted_dir, args.untrusted_dir, args.threshold, args.skip_trusted, speaker_selection=args.speaker_selection, speaker_margin=args.speaker_margin, min_speaker_share=args.min_speaker_share, segment_verify=args.segment_verify, segment_threshold=args.segment_threshold, )