Source code for pitch_garden.ranges.base

"""A module defining the base class of F0 range estimators."""

import logging
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass
from typing import Any

import numpy as np

logger = logging.getLogger("pitch_garden")


[docs] @dataclass class F0Range: """Dataclass to hold an estimated F0 search range.""" fmin: float # The lower bound in Hz. fmax: float # The upper bound in Hz. num_voiced_frames: int # The number of voiced frames used for the estimation. fallback: bool = False # Whether the first-pass range is used as it is.
[docs] def to_dict(self) -> dict[str, Any]: """Convert to a dictionary for serialization. Returns ------- out : dict[str, Any] The dictionary of plain Python objects. """ out = asdict(self) out["fmin"] = float(self.fmin) out["fmax"] = float(self.fmax) return out
[docs] class BaseRangeEstimator(ABC): """An abstract base class for F0 range estimators.""" def __init__(self, min_octaves: float = 0.5) -> None: """Initialize the estimator. Parameters ---------- min_octaves : float, optional The minimum width of the range in octaves. A narrower range, e.g., of a steady tone, is widened around its geometric center. Raises ------ ValueError If min_octaves is negative. """ if min_octaves < 0: raise ValueError("min_octaves must be non-negative.") self.min_octaves = min_octaves def __call__(self, values: np.ndarray, default: tuple[float, float]) -> F0Range: """Estimate the F0 search range from voiced F0 values. Parameters ---------- values : np.ndarray The voiced F0 values in Hz. default : tuple[float, float] The first-pass range, which is used as a fallback and bounds the result. Returns ------- out : F0Range The estimated F0 search range. """ values = np.asarray(values, dtype=np.float64) values = values[0 < values] if len(values) == 0: logger.warning( f"No voiced frames are found. The range {default} is used as it is." ) return F0Range(default[0], default[1], len(values), fallback=True) fmin, fmax = self.estimate(values) if 0 < fmin <= fmax: fmin, fmax = self._widen(fmin, fmax) fmin = float(np.clip(fmin, default[0], default[1])) fmax = float(np.clip(fmax, default[0], default[1])) if fmax <= fmin: return F0Range(default[0], default[1], len(values), fallback=True) return F0Range(fmin, fmax, len(values)) def _widen(self, fmin: float, fmax: float) -> tuple[float, float]: """Widen the range to the minimum width around its geometric center.""" octaves = np.log2(fmax / fmin) if self.min_octaves <= octaves: return fmin, fmax center = np.sqrt(fmin * fmax) half = self.min_octaves / 2 return float(center * 2**-half), float(center * 2**half)
[docs] @abstractmethod def estimate(self, values: np.ndarray) -> tuple[float, float]: """Estimate the F0 search range. Parameters ---------- values : np.ndarray The voiced F0 values in Hz. Returns ------- out : tuple[float, float] The lower and upper bounds in Hz. """