Source code for pitch_garden.algorithms.yin

# This module follows the YIN of librosa (https://github.com/librosa/librosa), which
# is distributed under the following license.
#
# ISC License
#
# Copyright (c) 2013--2026, librosa development team.
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

"""A module for the YIN algorithm.

The F0 is estimated as in `librosa.yin`, and the voicing is decided by the
aperiodicity, i.e., the cumulative mean normalized difference at the chosen period,
as suggested by de Cheveigné and Kawahara (2002).

"""

from dataclasses import dataclass
from typing import cast

import numpy as np

from ..interfaces.types import Audio
from ._utils import frame_audio
from .base import AlgorithmConfig, BaseAlgorithm, NativeContour, TwoPassPolicy

SAMPLE_RATE = 16000  # The sample rate of the analysis in Hz.
FRAME_LENGTH = 1024  # The length of an analysis frame in samples, i.e., 64 ms.

# The number of frames analyzed at once to limit the memory usage.
_BATCH_SIZE = 1024


def cumulative_mean_normalized_difference(
    frames: np.ndarray, min_period: int, max_period: int
) -> np.ndarray:
    """Calculate the cumulative mean normalized difference function.

    Parameters
    ----------
    frames : np.ndarray
        The frames with shape (T, frame_length).

    min_period : int
        The shortest period in samples.

    max_period : int
        The longest period in samples.

    Returns
    -------
    out : np.ndarray
        The function of the periods in [min_period, max_period] with shape (T, P).

    """
    n_fft = 2 ** int(np.ceil(np.log2(2 * frames.shape[1] - 1)))
    spectrum = np.fft.rfft(frames, n=n_fft, axis=1)
    acf = np.fft.irfft(np.abs(spectrum) ** 2, n=n_fft, axis=1)[:, : max_period + 1]
    energy = np.cumsum(frames**2, axis=1)
    difference = np.zeros_like(acf)
    difference[:, 1:] = 2 * (acf[:, :1] - acf[:, 1:]) - energy[:, :max_period]
    lags = np.arange(1, max_period + 1)
    cumulative_mean = np.cumsum(difference[:, 1:], axis=1) / lags
    numerator = difference[:, min_period : max_period + 1]
    denominator = cumulative_mean[:, min_period - 1 : max_period]
    # Unlike librosa, silent frames, whose function is 0/0, are regarded as aperiodic.
    positive = 0 < denominator
    return np.where(positive, numerator / np.where(positive, denominator, 1), 1.0)


def parabolic_shifts(x: np.ndarray) -> np.ndarray:
    """Get the positions of the parabola optima relative to the bins.

    The shift is 0 if the optimum is outside the neighbors or at the edges.

    """
    shifts = np.zeros_like(x)
    a = x[:, 2:] + x[:, :-2] - 2 * x[:, 1:-1]
    b = (x[:, 2:] - x[:, :-2]) / 2
    inside = np.abs(b) < np.abs(a)
    safe_a = np.where(inside, a, 1)
    shifts[:, 1:-1] = np.where(inside, -b / safe_a, 0)
    return shifts


def find_troughs(x: np.ndarray) -> np.ndarray:
    """Find the troughs of the cumulative mean normalized difference as in librosa.

    Parameters
    ----------
    x : np.ndarray
        The function with shape (T, P).

    Returns
    -------
    out : np.ndarray
        The boolean array that is True at the local minima, where the first period
        is a trough if it is lower than the second, and the last one is not.

    """
    is_trough = np.zeros_like(x, dtype=bool)
    is_trough[:, 1:-1] = (x[:, 1:-1] < x[:, :-2]) & (x[:, 1:-1] <= x[:, 2:])
    is_trough[:, 0] = x[:, 0] < x[:, 1]
    return is_trough


def yin(
    frames: np.ndarray,
    sample_rate: int,
    f0_range: tuple[float, float],
    trough_threshold: float = 0.1,
) -> tuple[np.ndarray, np.ndarray]:
    """Estimate F0 by YIN as in librosa.

    Parameters
    ----------
    frames : np.ndarray
        The frames with shape (T, frame_length).

    sample_rate : int
        The sample rate in Hz.

    f0_range : tuple[float, float]
        The F0 search range in Hz.

    trough_threshold : float, optional
        The first trough below this threshold is chosen as the period, or the
        global minimum if there is none.

    Returns
    -------
    f0 : np.ndarray
        The F0 candidates in Hz with shape (T,).

    aperiodicity : np.ndarray
        The cumulative mean normalized difference at the chosen period with shape
        (T,), which is close to 0 for periodic frames.

    """
    fmin, fmax = f0_range
    min_period = int(np.floor(sample_rate / fmax))
    max_period = min(int(np.ceil(sample_rate / fmin)), frames.shape[1] - 1)
    cmndf = cumulative_mean_normalized_difference(frames, min_period, max_period)
    shifts = parabolic_shifts(cmndf)

    below = find_troughs(cmndf) & (cmndf < trough_threshold)
    index = np.where(below.any(axis=1), below.argmax(axis=1), cmndf.argmin(axis=1))

    rows = np.arange(len(cmndf))
    period = min_period + index + shifts[rows, index]
    return sample_rate / period, cmndf[rows, index]


[docs] @dataclass(frozen=True) class YinConfig(AlgorithmConfig): """Configuration of YIN. The default search range covers both speech and singing voice. """ fmin: float | None = 40.0 fmax: float | None = 800.0 # The first trough of the cumulative mean normalized difference below this # threshold is chosen as the period. The default value is that of librosa. trough_threshold: float = 0.1 # The frames whose score, i.e., 1 - aperiodicity, is greater than or equal to # the threshold are regarded as voiced. The default value is chosen on the speech # and the singing voice of pitch-benchmark (examples/pitch_benchmark). voicing_threshold: float = 0.4
[docs] class YinAlgorithm(BaseAlgorithm): """YIN: a fundamental frequency estimator (de Cheveigné and Kawahara, 2002).""" config_class = YinConfig # The setting chosen on the speech of pitch-benchmark (examples/pitch_benchmark). two_pass_policy = TwoPassPolicy(enabled=True, voicing_from="2nd") @classmethod def get_supported_sample_rates(cls) -> tuple[int, int] | None: """Get the sample rate of the analysis. Returns ------- out : tuple[int, int] | None The sample rate in Hz as the lower and upper limits. """ return SAMPLE_RATE, SAMPLE_RATE @classmethod def get_f0_limits(cls) -> tuple[float, float]: """Get the F0 range where at least one period fits into the frame. Returns ------- out : tuple[float, float] The lower and upper limits in Hz. """ return SAMPLE_RATE / (FRAME_LENGTH - 2), SAMPLE_RATE / 2 def load(self) -> None: """Load nothing.""" def estimate( self, audio: Audio, f0_range: tuple[float, float], frame_period: float ) -> NativeContour: """Estimate the F0 contour. Parameters ---------- audio : Audio The input audio at 16 kHz. f0_range : tuple[float, float] The F0 search range in Hz. frame_period : float The frame period in milliseconds, which is rounded to an integer number of samples. Returns ------- out : NativeContour The estimated contour, where the score is 1 - aperiodicity. """ config = cast(YinConfig, self.config) frames, hop = frame_audio(audio.data, SAMPLE_RATE, frame_period, FRAME_LENGTH) num_frames = len(frames) candidate, aperiodicity = ( np.concatenate(results) for results in zip( *( yin( frames[start : start + _BATCH_SIZE], SAMPLE_RATE, f0_range, config.trough_threshold, ) for start in range(0, num_frames, _BATCH_SIZE) ) ) ) score = np.clip(1 - aperiodicity, 0, 1) f0 = np.where(config.voicing_threshold <= score, candidate, 0.0) return NativeContour( times=np.arange(num_frames) * hop / SAMPLE_RATE, f0=f0, score=score, candidate=candidate, )