"""A module for the pitch extraction algorithms of SPTK."""
from dataclasses import dataclass
from typing import Any, ClassVar, cast
import numpy as np
from ..interfaces.types import Audio
from .base import AlgorithmConfig, BaseAlgorithm, NativeContour, TwoPassPolicy
# The scale of 16-bit integers, which is expected by the pitch extraction of SPTK.
_INT16_SCALE = 32768.0
# The smallest lower bound greater than 10 Hz, which SPTK excludes.
_MIN_F0 = float(np.nextafter(10.0, np.inf))
# The margin in Hz between the upper bound of the search range and the Nyquist
# frequency, within which the upper bound is clipped.
_NYQUIST_MARGIN = 1.0
def _import_sptk() -> Any:
"""Import the binding of SPTK lazily.
Returns
-------
out : module
The pitch_garden_sptk module.
Raises
------
ImportError
If the binding is not installed.
"""
try:
import pitch_garden_sptk
except ImportError as e:
raise ImportError(
"The algorithms of SPTK require the pitch-garden-sptk package. "
'Install it via `pip install "pitch-garden[lite]"`.'
) from e
return pitch_garden_sptk
@dataclass(frozen=True)
class SptkConfig(AlgorithmConfig):
"""The base configuration of the algorithms of SPTK.
The default search range covers both speech and singing voice.
"""
fmin: float | None = 40.0
fmax: float | None = 800.0
voicing_threshold: float = 0.0
[docs]
@dataclass(frozen=True)
class RaptConfig(SptkConfig):
"""Configuration of RAPT, where a higher threshold gives more voiced frames.
The recommended range of the threshold is [-0.6, 0.7] as in SPTK, but values
outside are also accepted.
"""
voicing_threshold: float = 0.0
[docs]
@dataclass(frozen=True)
class SwipeConfig(SptkConfig):
"""Configuration of SWIPE', where a lower threshold gives more voiced frames.
The recommended range of the threshold is [0.2, 0.5] as in SPTK, but values
outside are also accepted.
"""
voicing_threshold: float = 0.3
[docs]
@dataclass(frozen=True)
class ReaperConfig(SptkConfig):
"""Configuration of REAPER, where a higher threshold gives more voiced frames.
The recommended range of the threshold is [-0.5, 1.6] as in SPTK, but values
outside are also accepted.
"""
voicing_threshold: float = 0.9
[docs]
@dataclass(frozen=True)
class DioConfig(SptkConfig):
"""Configuration of DIO, where a higher threshold gives more voiced frames.
The recommended range of the threshold is [0.02, 0.2] as in SPTK, but values
outside are also accepted.
"""
voicing_threshold: float = 0.1
[docs]
@dataclass(frozen=True)
class HarvestConfig(SptkConfig):
"""Configuration of Harvest, where a higher threshold gives more voiced frames.
The recommended range of the threshold is [0.0, 0.2] as in SPTK, but values
outside are also accepted.
"""
voicing_threshold: float = 0.01
class SptkAlgorithm(BaseAlgorithm):
"""The base class of the pitch extraction algorithms of SPTK.
The results are identical to the pitch command of SPTK for 16-bit audio unless
the frame period is out of [`min_frame_period`, `max_frame_period`] or exceeds a
quarter of the duration. Since the frame shift of SPTK is an integer number of
samples, a frame period that is not, e.g., 5 ms at 44.1 kHz, is rounded to the
nearest integer number of samples, and the result is interpolated onto the frame
grid. The audio whose sample rate is out of those supported by the binding of
SPTK is resampled to 16 kHz, where the results are not identical to SPTK. The
voicing decision is made inside the algorithms, and thus the voicing score is
not provided.
"""
config_class = SptkConfig
# The name of the algorithm in the binding.
sptk_name: ClassVar[str] = ""
def __init__(self, config: AlgorithmConfig) -> None:
"""Initialize the algorithm.
Parameters
----------
config : AlgorithmConfig
The configuration of the algorithm.
"""
super().__init__(config)
self.sptk: Any = None
@classmethod
def get_supported_sample_rates(cls) -> tuple[int, int] | None:
"""Get the sample rates supported by the binding of SPTK.
Returns
-------
out : tuple[int, int] | None
The lower and upper limits in Hz.
"""
sptk = _import_sptk()
return sptk.MIN_SAMPLE_RATE, sptk.MAX_SAMPLE_RATE
@classmethod
def get_f0_limits(cls) -> tuple[float, float]:
"""Get the F0 range that SPTK accepts.
SPTK requires the lower bound to be greater than 10 Hz, which is a sanity
check of the arguments rather than a limitation of the algorithms. The upper
bound is clipped at the estimation to be lower than the Nyquist frequency.
Returns
-------
out : tuple[float, float]
The lower and upper limits in Hz.
"""
return _MIN_F0, 49000.0
def load(self) -> None:
"""Import the binding of SPTK.
Raises
------
ImportError
If the binding is not installed.
"""
if self.sptk is None:
self.sptk = _import_sptk()
def estimate(
self, audio: Audio, f0_range: tuple[float, float], frame_period: float
) -> NativeContour:
"""Estimate the F0 contour.
Parameters
----------
audio : Audio
The input audio.
f0_range : tuple[float, float]
The F0 search range in Hz.
frame_period : float
The frame period in milliseconds, which is used as the frame shift
rounded to an integer number of samples.
Returns
-------
out : NativeContour
The estimated contour.
Raises
------
ValueError
If the whole search range is not lower than the Nyquist frequency.
RuntimeError
If SPTK fails to extract the F0, e.g., for too short audio.
"""
self.load()
config = cast(SptkConfig, self.config)
waveform, sample_rate = audio.data, audio.sample_rate
# The upper bound is clipped to be lower than the Nyquist frequency with a
# margin, because RAPT fails for a bound too close to it.
fmin = f0_range[0]
fmax = min(f0_range[1], sample_rate / 2 - _NYQUIST_MARGIN)
if fmax <= fmin:
raise ValueError(
f"The search range {f0_range} Hz must be below the Nyquist frequency "
f"({sample_rate / 2} Hz)."
)
frame_shift = max(1, round(sample_rate * frame_period / 1000))
try:
f0 = self.sptk.extract_f0(
np.ascontiguousarray(waveform * _INT16_SCALE),
float(sample_rate),
frame_shift,
fmin,
fmax,
config.voicing_threshold,
self.sptk_name,
)
except RuntimeError as e:
raise RuntimeError(
f"{e} The arguments of {self.sptk_name} were: {len(waveform)} samples "
f"at {sample_rate} Hz, frame shift of {frame_shift} samples, search "
f"range of {fmin:g}-{fmax:g} Hz, and voicing threshold of "
f"{config.voicing_threshold:g}."
) from e
times = np.arange(len(f0)) * frame_shift / sample_rate
return NativeContour(times=times, f0=np.asarray(f0, dtype=np.float64))
[docs]
class RaptAlgorithm(SptkAlgorithm):
"""RAPT: a robust algorithm for pitch tracking (Talkin, 1995)."""
config_class = RaptConfig
sptk_name = "rapt"
# The setting chosen on the speech of pitch-benchmark (examples/pitch_benchmark).
# Narrowing the search range increases the unvoiced frames, which is avoided by
# the voicing of the first pass.
two_pass_policy = TwoPassPolicy(enabled=True, voicing_from="1st")
[docs]
class SwipeAlgorithm(SptkAlgorithm):
"""SWIPE': a sawtooth waveform inspired pitch estimator (Camacho, 2007)."""
config_class = SwipeConfig
sptk_name = "swipe"
# The setting chosen on the speech of pitch-benchmark (examples/pitch_benchmark).
two_pass_policy = TwoPassPolicy(enabled=False)
[docs]
class ReaperAlgorithm(SptkAlgorithm):
"""REAPER: robust epoch and pitch estimator (Talkin, 2015)."""
config_class = ReaperConfig
sptk_name = "reaper"
# The setting chosen on the speech of pitch-benchmark (examples/pitch_benchmark).
two_pass_policy = TwoPassPolicy(enabled=True, voicing_from="1st")
[docs]
class DioAlgorithm(SptkAlgorithm):
"""DIO: a fast F0 estimator of WORLD (Morise et al., 2009)."""
config_class = DioConfig
sptk_name = "dio"
# The setting chosen on the speech of pitch-benchmark (examples/pitch_benchmark).
two_pass_policy = TwoPassPolicy(enabled=True, voicing_from="2nd")
[docs]
class HarvestAlgorithm(SptkAlgorithm):
"""Harvest: a high-performance F0 estimator of WORLD (Morise, 2017)."""
config_class = HarvestConfig
sptk_name = "harvest"
# The setting chosen on the speech of pitch-benchmark (examples/pitch_benchmark).
two_pass_policy = TwoPassPolicy(enabled=True, voicing_from="2nd")