| """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 |
| |
| |
| 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: |
| |
| f0, voiced_flag, _ = librosa.pyin( |
| signal_16k.astype(np.float32), |
| fmin=librosa.note_to_hz("C2"), |
| fmax=librosa.note_to_hz("C6"), |
| sr=sr_work, |
| frame_length=2048, |
| ) |
| |
| |
| 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 |
| |
| |
| median_f0 = float(np.nanmedian(voiced_f0)) |
| |
| |
| 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 |
|
|