Zero-Crossing Rate

Short answer. Zero-crossing rate is the number of times the waveform changes sign per frame, reported either as a count or normalized by frame length. It costs almost nothing to compute and it separates fricatives from vowels in clean audio: /s/ and /f/ run 10 to 20 times higher than /a/ or /i/, because a voiced vowel crosses zero about twice per pitch period while a fricative is noise-like. What it cannot tell you is whether a signal is voiced — a silent frame with a high-frequency noise floor has a higher ZCR than any vowel.

How to do it

Fix the convention before comparing any two numbers

A frame of N samples has at most N-1 sign changes. Reporting the raw count makes the value depend on the frame length, so most implementations normalize to crossings per sample (between 0 and 0.5) or crossings per second. A "ZCR of 0.15" is 0.15 per sample, which at 16 kHz is 2,400 per second.

The zero sample itself needs a convention. numpy.sign returns 0 for exact zeros, so a signal that sits exactly at zero — a muted channel or a clipped waveform with a flat top — produces a crossing count that depends on how you define the comparison. Use a strict sign comparison (s[:-1] * s[1:] < 0) or state that zeros count as positive, and keep it the same everywhere.

Why it separates fricatives from vowels, and by how much

A voiced vowel is dominated by its fundamental, so the waveform crosses zero about twice per pitch period. At f0 = 120 Hz that is 240 crossings per second, or 0.015 per sample at 16 kHz. Higher harmonics add extra crossings, which is why the measured value comes out above the theoretical 2 x f0.

A fricative is noise-like, with most of its energy above 4 kHz, and the crossing rate of a noise-like signal tracks its spectral centroid: thousands of crossings per second. In clean audio the fricative-to-vowel ratio is 10 to 20x, which is why one cheap feature was enough for the fricative detectors in pre-neural systems.

What moves the number before the phoneme does

Four things move the number more than the phoneme does, and every one of them varies with the recording rather than with the speech.

  • Frame length. A 10 ms frame at 16 kHz has 160 samples, so the count is quantized in steps of 1/160 and the variance is large. A 25 ms frame is stable enough to compare across frames; a 5 ms frame is not.
  • The noise floor. Broadband noise produces crossings at close to the maximum rate regardless of the signal, so a quiet passage with a high-frequency floor can outscore a fricative.
  • DC offset. An offset shifts the waveform away from zero and suppresses crossings on the side it moves toward, so an uncalibrated input with a large offset reports a vowel ZCR that is too low.
  • Low-pass filtering or a lossy codec. Both remove high frequencies, which reduces the crossing rate of noise-like segments far more than voiced ones, so ZCR on a phone-band or low-bitrate recording is not comparable to the same feature on the original.

What it cannot tell you

ZCR measures the dominant frequency content of a frame. It does not measure voicing, formants, pitch, or speaker identity, and it cannot distinguish a fricative from a noise floor or a click. That last point is the practical limit: a frame of silence with a noisy microphone has a ZCR near the theoretical maximum, higher than the vowel it is contrasted with.

The consequence is that ZCR is never a decision on its own. Any threshold will be wrong for some recording condition, because the same phoneme recorded on two devices produces two different values, and the device difference is often larger than the phoneme difference.

Where it still earns its place

Paired with short-term energy it makes a serviceable voice activity detector: speech has both high energy and a moderate ZCR, while a noise floor has low energy and a high ZCR, so the two together separate cases that neither separates alone. It is also a useful first pass for voiced/unvoiced pre-classification, and a ZCR-based fricative detector will catch a dropped /s/ before you spend a model on it.

In classic feature sets it is one of four or five standard descriptors, alongside short-term energy, spectral centroid, spectral rolloff, and spectral flux. Modern systems learn these from the spectrogram instead, but they remain useful for diagnostics: when a model fails on a segment, ZCR and centroid over that segment usually tell you what kind of segment it was.

Code

ZCR over synthetic vowels and fricative-like noise, plus the two cases that break it: a pure noise floor and a short frame.

import numpy as np

SR, FRAME, HOP = 16000, 400, 160          # 25 ms frames, 10 ms hop

def vowel(f0=130.0, dur=1.0):
    t = np.arange(int(SR * dur)) / SR
    x = sum(np.sin(2 * np.pi * k * f0 * t) / k for k in range(1, 20))
    return (x / np.max(np.abs(x))).astype(np.float32)

def fricative(dur=1.0, seed=0):
    """Noise with an upward spectral tilt, which is what a fricative looks like."""
    r = np.random.default_rng(seed)
    x = r.normal(size=int(SR * dur))
    x = np.convolve(x, np.ones(24) / 24.0, mode='same')    # remove the lowest band
    x = np.diff(x, prepend=x[0])                           # then tilt it upward
    return (x / np.max(np.abs(x))).astype(np.float32)

def zcr(x, frame=FRAME, hop=HOP):
    n = 1 + (len(x) - frame) // hop
    out = np.empty(n)
    for i in range(n):
        s = x[i * hop:i * hop + frame]
        out[i] = np.count_nonzero(np.diff(np.sign(s))) / float(frame - 1)
    return out

v, f = vowel(), fricative()
vz, fz = zcr(v), zcr(f)
print('vowel, f0 = 130 Hz       mean ZCR %.4f  = %5.0f crossings/second'
      % (vz.mean(), vz.mean() * SR))
print('fricative-like noise     mean ZCR %.4f  = %5.0f crossings/second'
      % (fz.mean(), fz.mean() * SR))
print('ratio fricative / vowel: %.1fx' % (fz.mean() / vz.mean()))

print()
print('frame length changes the variance, not the mean:')
for frame in (160, 400, 1600):
    z = zcr(v, frame=frame, hop=frame)
    print('  %4d samples (%4.0f ms): mean %.4f  std over frames %.4f'
          % (frame, 1000.0 * frame / SR, z.mean(), z.std()))

floor = (0.002 * np.random.default_rng(2).normal(size=SR)).astype(np.float32)
print()
print('low-level noise floor    mean ZCR %.4f  <-- no speech present at all'
      % zcr(floor).mean())
print('the noise floor outscores the vowel, which is why ZCR alone is not voicing')
  • pip install numpy. Nothing else, and no audio files: both signals are generated.
  • Expected output: the vowel lands around 0.02 crossings per sample and the fricative around 0.3, for a ratio in the 10 to 20x range. The exact ratio depends on the noise realization, not on the code.
  • The frame-length block prints an almost constant mean with a standard deviation that falls as the frame grows. That is the reason ZCR is compared frame-to-frame only at a fixed frame length.
  • The last block is the limitation from the steps, in numbers: white noise at an amplitude of 0.002 scores near 0.5, higher than any speech sound. ZCR describes spectral content, not speech.

Where this goes wrong

Comparing ZCR values computed at different frame lengths

A raw crossing count at a 10 ms frame and at a 25 ms frame are different numbers for the same audio, and the normalized version still differs in variance. Pick one frame length, write it into the feature definition, and never mix. Feature stores that accept ZCR from two sources without recording the frame length are a common source of unexplained model behavior.

Using ZCR as a voice activity detector

The naive rule — high ZCR means speech — fails on exactly the recordings that matter, because the noise floor in a quiet room, a fan, or a bad preamp is broadband and scores higher than a vowel. VAD needs energy as well: speech is high energy and moderate ZCR, a noise floor is low energy and high ZCR, and the two together separate the cases. ZCR alone will mark silence as speech and mark soft fricatives as silence.

Assuming ZCR is sample-rate invariant

Normalized ZCR is a fraction of the sample rate, so the same signal sampled at 8 kHz and at 16 kHz gives values that differ by a factor of two. Worse, resampling with an anti-aliasing filter removes the high band, which changes the value for noise-like segments far more than for voiced ones. If the corpus mixes sample rates, resample everything first, or the feature means something different per file.

Treating an exact zero as a sign change

A signal that sits exactly at zero for several samples — a muted channel, a digital fade, or a clipped region — produces a crossing count that depends entirely on the zero convention in np.sign. Depending on the implementation you can get zero crossings, one crossing, or a crossing per sample. Check the convention and test it on a signal with a deliberate run of zeros.

Computing ZCR on lossy-compressed audio and comparing to uncompressed

A low-bitrate codec removes the high band, which is most of what ZCR is measuring for fricatives. The same utterance encoded at 32 kbps will show a noticeably lower fricative ZCR than the original WAV, and a classifier thresholded on the WAV values will misfire on the compressed version. Either keep the corpus in one format or recompute the thresholds per format.

When to buy the data instead of building the pipeline

ZCR is a two-line computation, so nobody buys data for it. The reason to buy data in this area is that the feature only works when the recording conditions are consistent: a corpus where every file has the same sample rate, the same frame convention documented, and a noise floor low enough that the fricative-to-floor ratio is meaningful. Buy when you need fricative-heavy material with phone-level labels to validate a detector against, since /s/ and /f/ are exactly the phones that are hardest to annotate and most often missing from a corpus. If you are building a VAD or a quick voicing pre-classifier on your own recordings, compute it yourself; the feature is free and the data is what costs.

How buying training data works →

More technical reference

All technical reference →

Need data for Zero-Crossing Rate?

Tell us the language, the hours, and what the data needs to look like. You will get a real number and a real timeline — not a range. If we cannot source it well, we will tell you that instead.

  • Pilot batch before the full run, so problems surface early.
  • Consent documentation delivered with the data.
  • No medical or clinical data. No recorded telephone calls.

We reply within two business days. Your details are used only to answer this request. See our privacy policy.

Contact

Talk to a human

Send a specification and we will come back with a real number and timeline.

Submit a sourcing request

Or email hello@linguacorpus.com