"""Gender classification based on fundamental frequency (F0).""" import logging import numpy as np import librosa from typing import Tuple log = logging.getLogger(__name__) def estimate_gender(signal: np.ndarray, sr: int) -> Tuple[str, float]: """ Estimate speaker gender from fundamental frequency (F0). Typical F0 ranges: - Male: 70-180 Hz (median ~120 Hz) - Female: 160-255 Hz (median ~210 Hz) - Ambiguous: 140-185 Hz Args: signal: Audio signal sr: Sample rate in Hz Returns: Tuple of (gender_string, median_f0_hz) - gender_string: "male", "female", "ambiguous", or "unknown" - median_f0_hz: Median F0 value or None if too short """ min_samples = 1024 if len(signal) < min_samples: log.warning(f"Signal too short ({len(signal)} samples) for F0 estimation") return "unknown", None # Resample to 16kHz if needed (standard for pitch estimation) if sr != 16000: signal_16k = librosa.resample(signal, orig_sr=sr, target_sr=16000) sr_work = 16000 else: signal_16k = signal sr_work = sr try: # Use probabilistic YIN (pYIN) for robust F0 estimation f0, voiced_flag, _ = librosa.pyin( signal_16k.astype(np.float32), fmin=librosa.note_to_hz("C2"), # ~65 Hz fmax=librosa.note_to_hz("C6"), # ~1047 Hz sr=sr_work, frame_length=2048, # CRITICAL: must be set for proper operation ) # Extract only voiced frames voiced_f0 = f0[voiced_flag] if len(voiced_f0) < 10: log.warning(f"Insufficient voiced frames ({len(voiced_f0)}) for gender estimation") return "unknown", None # Use median F0 (more robust than mean to outliers) median_f0 = float(np.nanmedian(voiced_f0)) # Classification thresholds based on gender-specific F0 ranges if median_f0 < 140: gender = "male" elif median_f0 > 185: gender = "female" else: gender = "ambiguous" log.debug(f"Gender: {gender}, Median F0: {median_f0:.1f} Hz") return gender, median_f0 except Exception as e: log.warning(f"Pitch estimation failed: {e}") return "unknown", None