Skip to content

Speech activity and pitch

kudio.core.vad

Voice activity detection: where in this recording is somebody talking.

kudio.split_on_silence cuts wherever the level drops, which is the right answer for a clean studio take and the wrong one for anything with a noise floor — a fan, a road outside, a hiss. Above a certain noise level a fixed top_db either keeps the whole file or throws away the quiet consonants, and there is no value in between that works.

This decides per frame instead, on three cheap signals:

  • level against the recording's own noise floor, rather than against a fixed threshold, so it adapts to how noisy the file happens to be;
  • spectral flatness — speech is harmonic and therefore peaky, broadband noise is flat. This is what separates "a loud hiss" from "a quiet voice". Flatness is taken over the whole band, so noise confined to part of it (a hum, a rumble, a fan with a strong tone) is peaky too and can pass as voice on this test alone;
  • zero-crossing rate, which keeps the unvoiced fricatives (s, f, sh) that carry almost no energy and would otherwise be cut off the ends of words — but only where they sit next to something voiced, because on its own a fricative and a hiss are the same measurement.

The consequence of that last rule is worth knowing: a whisper, which has no voiced frames at all, is not detected. Admitting it would mean admitting every noise burst in every file, and a detector that fires on the air conditioning is not a detector.

Then the frame decisions are smoothed: short gaps inside speech are bridged, segments too short to be a word are dropped, and what survives is padded so the cut does not land on the first consonant. Pure numpy — no webrtcvad, no torch.

Functions:

Name Description
vad

Find the spans of y that contain speech, in seconds.

vad_split

The speech segments themselves, as waveforms.

vad_trim

Trim to the first and last speech, keeping whatever is between them.

vad_frames

vad as one decision per STFT frame, True for speech.

speech_ratio

Fraction of the clip that vad calls speech, in [0, 1].

FRAME module-attribute

FRAME = 0.03

Analysis frame / hop in seconds. 30 ms sits inside a single phoneme.

HOP module-attribute

HOP = 0.01

Seconds between frames.

THRESHOLD_DB module-attribute

THRESHOLD_DB = 8.0

How far above the estimated noise floor a frame has to be, in dB.

FLATNESS_MAX module-attribute

FLATNESS_MAX = 0.4

Spectral flatness above this is noise-like, not voice. A perfectly even spectrum is 1.0; white noise on these 30 ms frames measures about 0.56, because one frame's spectrum is not flat, and noise confined to a band measures lower still.

FLOOR_REL_DB module-attribute

FLOOR_REL_DB = 45.0

A frame this far under the loudest one is silence whatever else says.

ZCR_MIN module-attribute

ZCR_MIN = 0.25

Zero-crossing rate above this rescues unvoiced fricatives.

FRICATIVE_REACH module-attribute

FRICATIVE_REACH = 0.2

...but only within this many seconds of something actually voiced. A fricative on its own is indistinguishable from broadband noise; a fricative at the edge of a vowel is a consonant.

vad

vad(
    y: ndarray,
    sr: int,
    *,
    threshold_db: float = THRESHOLD_DB,
    flatness_max: float = FLATNESS_MAX,
    min_speech: float = 0.1,
    min_silence: float = 0.15,
    pad: float = 0.05,
    frame: float = FRAME,
    hop: float = HOP,
) -> List[Tuple[float, float]]

Find the spans of y that contain speech, in seconds.

>>> spans = kudio.vad(y, sr)
>>> spans[:2]
[(0.31, 1.84), (2.42, 3.97)]

Parameters:

Name Type Description Default
threshold_db float

how far above the estimated noise floor a frame must sit. Lower it for a very quiet talker, raise it for a noisy room.

THRESHOLD_DB
flatness_max float

spectral flatness above which a loud frame is judged to be noise rather than voice. Raise it towards 1.0 to disable that test — useful for non-speech audio, where "harmonic" means nothing.

FLATNESS_MAX
min_speech float

segments shorter than this are dropped (a click, a door).

0.1
min_silence float

gaps shorter than this do not split a segment; a stop consonant is a real silence in the middle of a word.

0.15
pad float

seconds added to each end, so the cut misses the consonant.

0.05

Returns:

Type Description
List[Tuple[float, float]]

non-overlapping (start, end) pairs, in order. Empty when nothing in the clip looks like speech.

Raises:

Type Description
FeatureError

on non-mono audio, samples that are not finite, or a non-positive sample rate. A clip with NaN or inf in it is refused, as kudio.f0 and kudio.spectral_enhance refuse it, rather than judged with those frames left out.

Source code in kudio/core/vad.py
def vad(y: np.ndarray, sr: int, *,
        threshold_db: float = THRESHOLD_DB,
        flatness_max: float = FLATNESS_MAX,
        min_speech: float = 0.10,
        min_silence: float = 0.15,
        pad: float = 0.05,
        frame: float = FRAME,
        hop: float = HOP) -> List[Tuple[float, float]]:
    """Find the spans of *y* that contain speech, in **seconds**.

    ```
    >>> spans = kudio.vad(y, sr)
    >>> spans[:2]
    [(0.31, 1.84), (2.42, 3.97)]
    ```

    :param threshold_db: how far above the estimated noise floor a frame must
        sit. Lower it for a very quiet talker, raise it for a noisy room.
    :param flatness_max: spectral flatness above which a loud frame is judged
        to be noise rather than voice. Raise it towards 1.0 to disable that
        test — useful for non-speech audio, where "harmonic" means nothing.
    :param min_speech: segments shorter than this are dropped (a click, a door).
    :param min_silence: gaps shorter than this do not split a segment; a stop
        consonant is a real silence in the middle of a word.
    :param pad: seconds added to each end, so the cut misses the consonant.
    :returns: non-overlapping ``(start, end)`` pairs, in order. Empty when
        nothing in the clip looks like speech.

    :raises FeatureError: on non-mono audio, samples that are not finite, or
        a non-positive sample rate. A clip with NaN or inf in it is refused,
        as :func:`kudio.f0` and :func:`kudio.spectral_enhance` refuse it,
        rather than judged with those frames left out.
    """
    if sr <= 0:
        raise FeatureError(f"vad() needs a positive sample rate, got {sr}")
    y = np.ascontiguousarray(np.asarray(as_float(y), dtype=np.float64).squeeze())
    if y.ndim != 1:
        raise FeatureError(f"vad() takes mono audio, got shape {y.shape}")
    if y.size == 0:
        return []
    if not np.all(np.isfinite(y)):
        raise FeatureError(f"vad() got {int(np.count_nonzero(~np.isfinite(y)))} "
                           f"sample(s) that are not finite (NaN or inf); repair "
                           f"the clip first")

    size = max(16, int(round(frame * sr)))
    step = max(1, int(round(hop * sr)))
    frames = frame_view(y, size, step)
    n_frames = frames.shape[0]
    duration = len(y) / sr

    rms = np.sqrt(np.mean(frames ** 2, axis=-1))
    with np.errstate(divide='ignore'):
        db = 20.0 * np.log10(np.maximum(rms, 0.0))
    finite = db[np.isfinite(db)]
    if finite.size == 0:                       # digital silence
        return []

    noise_floor = float(np.percentile(finite, _NOISE_PERCENTILE))
    loudest = float(finite.max())
    # two gates: above the room, and not absurdly far under the loudest moment.
    # The second stops a dead-quiet file from having its own hiss promoted to
    # speech simply because the hiss is 8 dB above the quietest hiss.
    active = (db > noise_floor + threshold_db) & (db > loudest - FLOOR_REL_DB)

    # what is loud *and* harmonic is the only thing trusted on its own
    core = active & (_flatness(frames) < flatness_max)
    # a hissy frame joins only if it is next to one of those. Zero-crossing
    # rate cannot tell an /s/ from a burst of white noise -- position can.
    reach = max(1, int(round(FRICATIVE_REACH / hop)))
    near_core = np.convolve(core.astype(np.float64),
                            np.ones(2 * reach + 1), mode='same') > 0
    speech = core | (active & (_zcr(frames) > ZCR_MIN) & near_core)

    if not speech.any():
        return []

    # frame index -> the seconds it covers
    edges = np.diff(np.concatenate(([0], speech.view(np.int8), [0])))
    starts = np.nonzero(edges == 1)[0]
    stops = np.nonzero(edges == -1)[0]
    spans = [(float(a * step) / sr,
              float(min((b - 1) * step + size, len(y))) / sr)
             for a, b in zip(starts, stops)]
    if n_frames == 1:                          # clip shorter than one frame
        spans = [(0.0, duration)] if speech[0] else []

    spans = _merge(spans, min_silence)
    spans = [s for s in spans if s[1] - s[0] >= min_speech]
    if pad > 0:
        spans = [(max(0.0, a - pad), min(duration, b + pad)) for a, b in spans]
        spans = _merge(spans, 0.0)
    return spans

vad_split

vad_split(y: ndarray, sr: int, **kwargs) -> List[ndarray]

The speech segments themselves, as waveforms.

The noise-adaptive counterpart of kudio.split_on_silence; takes the same keyword arguments as vad.

Source code in kudio/core/vad.py
def vad_split(y: np.ndarray, sr: int, **kwargs) -> List[np.ndarray]:
    """The speech segments themselves, as waveforms.

    The noise-adaptive counterpart of :func:`kudio.split_on_silence`; takes the
    same keyword arguments as :func:`vad`.
    """
    return [y[int(round(a * sr)):int(round(b * sr))]
            for a, b in vad(y, sr, **kwargs)]

vad_trim

vad_trim(
    y: ndarray, sr: int, **kwargs
) -> Tuple[ndarray, Tuple[int, int]]

Trim to the first and last speech, keeping whatever is between them.

Returns (trimmed, (start_sample, end_sample)), matching kudio.trim_silence. A clip with no detected speech comes back untouched with its full span, rather than as an empty array — deleting the audio is never the safer reading of "nothing found".

Source code in kudio/core/vad.py
def vad_trim(y: np.ndarray, sr: int, **kwargs
             ) -> Tuple[np.ndarray, Tuple[int, int]]:
    """Trim to the first and last speech, keeping whatever is between them.

    Returns ``(trimmed, (start_sample, end_sample))``, matching
    :func:`kudio.trim_silence`. A clip with no detected speech comes back
    untouched with its full span, rather than as an empty array — deleting the
    audio is never the safer reading of "nothing found".
    """
    spans = vad(y, sr, **kwargs)
    if not spans:
        log.info("vad_trim: no speech detected, returning the clip unchanged")
        return np.asarray(y), (0, int(np.asarray(y).shape[0]))
    start = int(round(spans[0][0] * sr))
    end = min(int(round(spans[-1][1] * sr)), np.asarray(y).shape[0])
    return np.asarray(y)[start:end], (start, end)

vad_frames

vad_frames(
    y: ndarray,
    sr: int,
    *,
    hop_length: int = 256,
    n_fft: int = 512,
    center: bool = True,
    pad: float = 0.0,
    **kwargs,
) -> ndarray

vad as one decision per STFT frame, True for speech.

A frame is speech when its centre falls inside a span vad found. With the default center=True the frames are the ones kudio.waveform_to_spectrogram and kudio.STFT make with the same hop_length and n_fft, both of which centre their frames, so the mask indexes their rows directly:

```pycon
>>> spec = kudio.waveform_to_spectrogram(y)       # (frames, bins)
>>> speech = kudio.vad_frames(y, sr)              # (frames,) bool
>>> noise_only = spec[~speech]
```

Parameters:

Name Type Description Default
pad float

seconds added to each end of a span, as in vad -- but 0 by default here rather than 0.05. Padding keeps a cut from clipping a consonant; on a frame label it only marks the frames next to the speech as speech too.

0.0
center bool

True (librosa's default and kudio's) centres frame t on sample t * hop_length; False starts it there.

Other keyword arguments go to vad.

True
Source code in kudio/core/vad.py
def vad_frames(y: np.ndarray, sr: int, *, hop_length: int = 256,
               n_fft: int = 512, center: bool = True, pad: float = 0.0,
               **kwargs) -> np.ndarray:
    """:func:`vad` as one decision per STFT frame, ``True`` for speech.

    A frame is speech when its centre falls inside a span :func:`vad` found.
    With the default ``center=True`` the frames are the ones
    :func:`kudio.waveform_to_spectrogram` and :class:`kudio.STFT` make with
    the same *hop_length* and *n_fft*, both of which centre their frames, so
    the mask indexes their rows directly::

        >>> spec = kudio.waveform_to_spectrogram(y)       # (frames, bins)
        >>> speech = kudio.vad_frames(y, sr)              # (frames,) bool
        >>> noise_only = spec[~speech]

    :param pad: seconds added to each end of a span, as in :func:`vad` --
        but 0 by default here rather than 0.05. Padding keeps a cut from
        clipping a consonant; on a frame label it only marks the frames next
        to the speech as speech too.
    :param center: ``True`` (librosa's default and kudio's) centres frame *t*
        on sample ``t * hop_length``; ``False`` starts it there.

    Other keyword arguments go to :func:`vad`.
    """
    if hop_length < 1 or n_fft < 1:
        raise FeatureError(f"hop_length and n_fft must be >= 1, got "
                           f"{hop_length} and {n_fft}")
    spans = vad(y, sr, pad=pad, **kwargs)
    n = np.asarray(y).squeeze().shape[0] if np.asarray(y).size else 0
    if center:
        # the count librosa gives after padding n_fft // 2 each side, which is
        # one frame short of 1 + n // hop for an odd n_fft
        count = 1 + (n + 2 * (n_fft // 2) - n_fft) // hop_length
        centres = np.arange(count) * hop_length
    else:
        count = 1 + (n - n_fft) // hop_length if n >= n_fft else 0
        centres = np.arange(count) * hop_length + n_fft / 2
    seconds = centres / float(sr)
    speech = np.zeros(len(centres), dtype=bool)
    for start, end in spans:
        speech |= (seconds >= start) & (seconds < end)
    return speech

speech_ratio

speech_ratio(y: ndarray, sr: int, **kwargs) -> float

Fraction of the clip that vad calls speech, in [0, 1].

Useful as a dataset filter: a "speech" file at 0.02 is usually a mislabelled recording of a room. The floor vad measures against comes from the quietest 15% of the clip, though, so a clip cut tight around speech, with no room tone left in it, reads near 0 as well.

Source code in kudio/core/vad.py
def speech_ratio(y: np.ndarray, sr: int, **kwargs) -> float:
    """Fraction of the clip that :func:`vad` calls speech, in ``[0, 1]``.

    Useful as a dataset filter: a "speech" file at 0.02 is usually a
    mislabelled recording of a room. The floor :func:`vad` measures against
    comes from the quietest 15% of the clip, though, so a clip cut tight
    around speech, with no room tone left in it, reads near 0 as well.
    """
    y = np.asarray(y)
    if y.size == 0:
        return 0.0
    total = sum(b - a for a, b in vad(y, sr, **kwargs))
    return float(min(1.0, total / (y.shape[0] / sr)))

kudio.core.pitch

Fundamental frequency, and the voiced/unvoiced decision that goes with it.

A spectrogram shows where the energy is; it does not say what note the speaker is on, and reading harmonic spacing off an image is guesswork the moment the fundamental is weak -- which it is over a phone, through a high-pass, or on any male voice whose f0 sits below the first bin anyone bothers to draw.

>>> track = kudio.f0(y, sr)
>>> track.median_hz, track.voiced_ratio
(118.3, 0.61)
>>> track.f0[track.voiced].min()
94.2

Half of this is the voicing decision. An estimator asked "what is the pitch of this frame" always answers, including for silence, for a door slamming and for the s in this -- and a contour drawn through those answers is a picture of noise with a line through it. PitchTrack.f0 is NaN wherever the frame is unvoiced, so plotting it leaves gaps where there genuinely is no pitch, and voiced_ratio says how much of the clip had one at all.

The estimator is pYIN (Mauch & Dixon 2014), librosa's implementation: YIN with a probabilistic voicing decision resolved by a hidden Markov model over the whole clip, rather than a per-frame threshold. That last part is why it costs more than plain YIN -- about a quarter of a second per ten seconds of 16 kHz audio on a laptop, once librosa has warmed up -- and why it does not flicker between voiced and unvoiced twice per syllable the way a thresholded estimator does.

Defaults are speech. fmin=65 to fmax=400 Hz covers a low male voice through a high female one. Music, children and singing all want a wider range said out loud -- an octave outside the range is not detected, it is reported as the nearest wrong answer inside it.

Classes:

Name Description
PitchTrack

A pitch contour plus the voicing decision that makes it readable.

Functions:

Name Description
f0

Track the fundamental frequency of y with pYIN.

SPEECH_FMIN module-attribute

SPEECH_FMIN = 65.0

Default search range, in Hz: a low male voice to a high female one.

SPEECH_FMAX module-attribute

SPEECH_FMAX = 400.0

Top of the default search range, in Hz: a high female voice.

PitchTrack dataclass

PitchTrack(
    f0: ndarray,
    voiced: ndarray,
    voiced_prob: ndarray,
    times: ndarray,
    sr: int,
    fmin: float,
    fmax: float,
    hop_length: int,
)

A pitch contour plus the voicing decision that makes it readable.

Parameters:

Name Type Description Default
f0 ndarray

one value per frame, in Hz, NaN where unvoiced.

required
voiced ndarray

the voicing decision, aligned with f0.

required
voiced_prob ndarray

pYIN's confidence, aligned with f0.

required
times ndarray

centre time of each frame, in seconds.

required

Methods:

Name Description
range_hz

Percentile range of the voiced f0, in Hz.

summary

Everything above as plain numbers, for a report or a CSV row.

voiced_segments

Contiguous voiced runs as (start, end) in seconds.

to_labels

The voiced runs as kudio.Label objects.

Attributes:

Name Type Description
voiced_ratio float

Fraction of frames with a pitch. Not the same as speech: an f

median_hz float

Median f0 over voiced frames; nan when nothing was voiced.

semitone_range float

The same span in semitones -- the unit pitch is actually heard in.

voiced_ratio property

voiced_ratio: float

Fraction of frames with a pitch. Not the same as speech: an f or an s is speech and has no pitch.

median_hz property

median_hz: float

Median f0 over voiced frames; nan when nothing was voiced.

The median and not the mean: an octave error is a factor of two, and one of those moves a mean far enough to matter.

semitone_range property

semitone_range: float

The same span in semitones -- the unit pitch is actually heard in.

60 Hz of range means something different at the bottom of a male voice than at the top of a female one; twelve semitones is an octave either way.

range_hz

range_hz(
    low: float = 5.0, high: float = 95.0
) -> Tuple[float, float]

Percentile range of the voiced f0, in Hz.

Percentiles rather than min/max, because a single misjudged frame at the octave sets both extremes.

Source code in kudio/core/pitch.py
def range_hz(self, low: float = 5.0, high: float = 95.0) -> Tuple[float, float]:
    """Percentile range of the voiced f0, in Hz.

    Percentiles rather than min/max, because a single misjudged frame at
    the octave sets both extremes.
    """
    values = self.f0[self.voiced]
    if not values.size:
        return (float('nan'), float('nan'))
    lo, hi = np.percentile(values, (low, high))
    return (float(lo), float(hi))

summary

summary() -> dict

Everything above as plain numbers, for a report or a CSV row.

Source code in kudio/core/pitch.py
def summary(self) -> dict:
    """Everything above as plain numbers, for a report or a CSV row."""
    lo, hi = self.range_hz()
    return {
        'frames': len(self),
        'voiced_ratio': self.voiced_ratio,
        'median_hz': self.median_hz,
        'low_hz': lo,
        'high_hz': hi,
        'semitone_range': self.semitone_range,
    }

voiced_segments

voiced_segments(
    min_seconds: float = 0.05,
) -> List[Tuple[float, float]]

Contiguous voiced runs as (start, end) in seconds.

Runs shorter than min_seconds are dropped: at a 10 ms hop a single voiced frame in the middle of a fricative is an estimator hiccup, not a syllable.

Source code in kudio/core/pitch.py
def voiced_segments(self, min_seconds: float = 0.05
                    ) -> List[Tuple[float, float]]:
    """Contiguous voiced runs as ``(start, end)`` in seconds.

    Runs shorter than *min_seconds* are dropped: at a 10 ms hop a single
    voiced frame in the middle of a fricative is an estimator hiccup, not
    a syllable.
    """
    if not len(self):
        return []
    hop = self.hop_length / self.sr
    segments: List[Tuple[float, float]] = []
    start: Optional[int] = None
    for i, on in enumerate(np.append(self.voiced, False)):
        if on and start is None:
            start = i
        elif not on and start is not None:
            a, b = self.times[start], self.times[i - 1] + hop
            if b - a >= min_seconds:
                segments.append((float(a), float(b)))
            start = None
    return segments

to_labels

to_labels(min_seconds: float = 0.05) -> List

The voiced runs as kudio.Label objects.

Saveable with kudio.save_labels, so a pitch track can be opened as a label track in an editor rather than only read as numbers.

Source code in kudio/core/pitch.py
def to_labels(self, min_seconds: float = 0.05) -> List:
    """The voiced runs as :class:`kudio.Label` objects.

    Saveable with :func:`kudio.save_labels`, so a pitch track can be
    opened as a label track in an editor rather than only read as numbers.
    """
    from kudio.core.dataset import Label
    return [Label(start=a, end=b, text=f"voiced {i + 1}")
            for i, (a, b) in enumerate(self.voiced_segments(min_seconds))]

f0

f0(
    y: ndarray,
    sr: int,
    *,
    fmin: float = SPEECH_FMIN,
    fmax: float = SPEECH_FMAX,
    frame_length: Optional[int] = None,
    hop_length: Optional[int] = None,
    center: bool = True,
) -> PitchTrack

Track the fundamental frequency of y with pYIN.

Parameters:

Name Type Description Default
fmin float

bottom of the search range, in Hz. Nothing below it is found; a voice below it is reported as something wrong above it.

SPEECH_FMIN
fmax float

top of the search range.

SPEECH_FMAX
frame_length Optional[int]

analysis window. Must hold more than two periods of fmin, so a low fmin needs a long window -- checked, not assumed. Omitted, it is 2048 samples, or the next power of two that is long enough when 2048 is not: 4096 at 88.2 and 96 kHz.

None
hop_length Optional[int]

samples between frames; 10 ms when omitted, which is the usual resolution for a pitch contour.

None
center bool

frames centred on their time stamp, so times lines up with the waveform. Turn it off to match an uncentred transform.

True

Returns:

Type Description
PitchTrack

a PitchTrack.

>>> track = kudio.f0(y, 16000, fmin=80, fmax=300)
>>> track.median_hz
124.7
Source code in kudio/core/pitch.py
def f0(y: np.ndarray, sr: int, *, fmin: float = SPEECH_FMIN,
       fmax: float = SPEECH_FMAX, frame_length: Optional[int] = None,
       hop_length: Optional[int] = None,
       center: bool = True) -> PitchTrack:
    """Track the fundamental frequency of *y* with pYIN.

    :param fmin: bottom of the search range, in Hz. Nothing below it is
        found; a voice below it is reported as something wrong above it.
    :param fmax: top of the search range.
    :param frame_length: analysis window. Must hold more than two periods of
        *fmin*, so a low *fmin* needs a long window -- checked, not assumed.
        Omitted, it is 2048 samples, or the next power of two that is long
        enough when 2048 is not: 4096 at 88.2 and 96 kHz.
    :param hop_length: samples between frames; 10 ms when omitted, which is
        the usual resolution for a pitch contour.
    :param center: frames centred on their time stamp, so :attr:`times` lines
        up with the waveform. Turn it off to match an uncentred transform.
    :returns: a :class:`PitchTrack`.

    >>> track = kudio.f0(y, 16000, fmin=80, fmax=300)      # doctest: +SKIP
    >>> track.median_hz                                    # doctest: +SKIP
    124.7
    """
    y = np.asarray(as_float(y), dtype=np.float32)
    if y.ndim > 1 and y.shape[1] == 1:
        y = y[:, 0]
    if y.ndim != 1:
        # reshape(-1) used to read a stereo clip as one interleaved signal at
        # half its pitch: fan.wav came back at 65 Hz instead of 126
        raise FeatureError(f"f0 takes mono audio, got shape {y.shape}; pass "
                           f"one channel or mix down first")
    if not np.all(np.isfinite(y)):
        raise FeatureError(f"f0 got {int(np.count_nonzero(~np.isfinite(y)))} "
                           f"sample(s) that are not finite (NaN or inf); repair "
                           f"the clip first")
    sr = int(sr)
    if sr <= 0:
        raise FeatureError(f"f0 needs a positive rate, got {sr}")
    if not (0 < fmin < fmax):
        raise FeatureError(
            f"need 0 < fmin < fmax, got fmin={fmin}, fmax={fmax}")
    if fmax > sr / 2:
        raise FeatureError(
            f"fmax={fmax} Hz is above Nyquist for sr={sr}; nothing up there "
            f"survived sampling.")
    hop = int(hop_length) if hop_length else max(1, sr // 100)
    # pYIN correlates a frame against itself one period later, so the frame
    # has to hold more than two periods of the lowest pitch it is asked for
    # (librosa wants sr / fmin < frame_length // 2). Short of that, the
    # pitches whose periods do not fit are lost and the rest look fine, which
    # reads as "nothing down there" rather than as a settings mistake.
    needed = 2 * int(np.floor(sr / fmin)) + 2
    if frame_length is None:
        frame_length = max(2048, 1 << int(np.ceil(np.log2(needed))))
    if frame_length < needed:
        raise FeatureError(
            f"frame_length={frame_length} is too short for fmin={fmin:g} Hz at "
            f"sr={sr}: two periods need {needed} samples. Raise frame_length "
            f"or raise fmin.")

    if y.size == 0:
        empty_f = np.zeros(0, dtype=np.float32)
        return PitchTrack(empty_f, np.zeros(0, dtype=bool), empty_f, empty_f,
                          sr, float(fmin), float(fmax), hop)

    import librosa
    values, voiced, prob = librosa.pyin(
        y, fmin=float(fmin), fmax=float(fmax), sr=sr,
        frame_length=int(frame_length), hop_length=hop, center=center)
    values = np.asarray(values, dtype=np.float64)
    voiced = np.asarray(voiced, dtype=bool)
    # pYIN can hand back a number on a frame it also calls unvoiced; keeping
    # both would let a caller plot the contour and get a line through silence.
    values = np.where(voiced, values, np.nan)
    # n_fft is the *uncentred* correction: it shifts a frame index to the
    # middle of the window it covers. A centred frame is already there, so
    # passing it in that case would move every time stamp half a frame late.
    times = librosa.times_like(values, sr=sr, hop_length=hop,
                               n_fft=None if center else int(frame_length))
    return PitchTrack(values, voiced, np.asarray(prob, dtype=np.float64),
                      np.asarray(times, dtype=np.float64), sr,
                      float(fmin), float(fmax), hop)