Measure Sample Peak and RMS in 16-Bit PCM WAV Files with Python

Published

Append one second of silence to a one-second tone. The sample peak stays the same, but the RMS level falls by about 3 dB. Sample peak tells you how far the signal reached. RMS tells you about its level across the whole interval.

This tutorial measures both values per channel using Python 3.13 or later and the standard library. You can run it on a synthetic stereo example or pass it a local 16-bit integer PCM WAV. The script reads or generates samples in memory without playing audio or making network requests.

What do sample peak and RMS measure?

Sample peak is the largest absolute sample value in a channel. Root mean square (RMS) is the square root of the mean of the squared samples. Squaring makes positive and negative samples contribute equally. The mean includes every frame, so pauses and silence affect the result. Audacity’s Measure RMS documentation

The script divides each signed sample by 32,768, so the most negative value, −32,768, becomes −1. It uses a normalized amplitude of 1 as the full-scale reference for both readings. For a positive peak or RMS value v, the level in decibels relative to full scale is 20 × log10(v). On this scale, the largest positive value, 32,767, becomes about 0.99997, so a positive full-scale sample reads about −0.0003 dBFS rather than exactly 0. A silent channel has zero peak and RMS, so the script displays -inf for both.

With this reference, a constant normalized signal at −1 reads 0 dBFS RMS. An ideal sine with peak amplitude 1 has RMS amplitude 1 / √2, or about −3.01 dBFS, when measured over whole cycles. Some meters use the RMS of a full-scale sine as their reference instead. When comparing readings, check the reference and the time interval. iZotope RX 11’s RMS reference options

Measure stereo channels separately. If the right sample is -x whenever the left sample is x, averaging them produces zero even though both channels carry a signal.

How do you run the measurement?

Save the script as wav_levels.py and run it with Python 3.13 or later. With no arguments, it generates a one-second stereo WAV at 48 kHz. The left channel contains a 1 kHz sine with a peak amplitude of 0.5. The right channel is silent.

import argparse
import io
import math
import struct
import wave


def dbfs(value):
    return -math.inf if value == 0 else 20 * math.log10(value)


def measure_pcm16(source):
    with wave.open(source, "rb") as wav:
        if wav.getsampwidth() != 2:
            raise ValueError("Use a 16-bit integer PCM WAV.")
        channels = wav.getnchannels()
        frames = wav.getnframes()
        rate = wav.getframerate()
        raw = wav.readframes(frames)

    if frames == 0:
        raise ValueError("The WAV contains no complete frames.")
    if len(raw) != frames * channels * 2:
        raise ValueError("The WAV returned fewer bytes than expected.")

    decoded = [sample / 32768 for (sample,) in struct.iter_unpack("=h", raw)]
    results = []
    for channel in range(channels):
        samples = decoded[channel::channels]
        peak = max(abs(sample) for sample in samples)
        rms = math.sqrt(math.fsum(sample * sample for sample in samples) / frames)
        results.append((dbfs(peak), dbfs(rms)))
    return rate, results


def synthetic_wav():
    rate = 48_000
    pcm = bytearray()
    for frame in range(rate):
        phase = 2 * math.pi * 1_000 * frame / rate
        left = round(16_384 * math.sin(phase))
        pcm.extend(struct.pack("=hh", left, 0))

    buffer = io.BytesIO()
    with wave.open(buffer, "wb") as wav:
        wav.setnchannels(2)
        wav.setsampwidth(2)
        wav.setframerate(rate)
        wav.writeframes(pcm)
    buffer.seek(0)
    return buffer


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Measure WAV sample peak and RMS.")
    parser.add_argument("path", nargs="?", help="Path to a 16-bit PCM WAV")
    args = parser.parse_args()
    source = synthetic_wav() if args.path is None else args.path

    try:
        rate, results = measure_pcm16(source)
    except (wave.Error, EOFError, OSError, ValueError) as exc:
        parser.exit(1, f"Could not measure WAV: {str(exc) or 'not a readable WAV file'}\n")

    if args.path is None:
        assert rate == 48_000
        peak, rms = results[0]
        assert math.isclose(peak, 20 * math.log10(0.5), abs_tol=0.001)
        assert math.isclose(rms, 20 * math.log10(0.5 / math.sqrt(2)), abs_tol=0.001)
        assert results[1] == (-math.inf, -math.inf)

    print(f"Sample rate: {rate} Hz")
    for channel, (peak, rms) in enumerate(results, 1):
        print(f"Channel {channel}: peak={peak:.2f} dBFS, RMS={rms:.2f} dBFS")

Run python wav_levels.py for the synthetic example. To measure a local file, run python wav_levels.py "my recording.wav". The assertions check only the built-in example, and Python skips them if you run it with python -O.

A frame contains one sample from each channel. The script reads the stored frames and takes every channels-th sample starting from the chosen channel. WAV files store samples in little-endian order, and wave converts them to your computer’s order, so the =h format (a signed two-byte integer in native byte order) works on any machine. Python wave documentation, Python struct format strings, CPython 3.13.5 wave source

Python 3.13’s wave reader supports uncompressed PCM WAV. The script checks for a two-byte sample width, a nonempty frame count, and the expected byte count for those frames. It loads the decoded samples into memory, so use short files for this example. Python wave documentation

What should the synthetic example report?

Sample rate: 48000 Hz
Channel 1: peak=-6.02 dBFS, RMS=-9.03 dBFS
Channel 2: peak=-inf dBFS, RMS=-inf dBFS

The left channel has 48,000 samples and exactly 1,000 cycles of the requested tone. The peak is 20 × log10(0.5), or about −6.02 dBFS. Over whole cycles, the mean of the squared sine is half the squared amplitude, giving an RMS of 0.5 / √2 before rounding the samples to integers. That works out to about −9.03 dBFS. The assertions allow a difference of 0.001 dB from the ideal levels to account for that rounding.

The silence example follows from the same calculation. Appending one second of silence doubles the number of samples and leaves the sum of squares unchanged. The RMS amplitude falls by a factor of √2, about 3.01 dB, so the left channel would read about −12.04 dBFS RMS while its peak stays at −6.02 dBFS.

How should you interpret a real recording?

For a voice recording, the RMS of the whole file includes both speech and the pauses between words. If you want the level of a particular phrase, measure that excerpt and record the interval you used. For a noise-only reading, select a section that contains only the background you want to measure. Audacity’s selection-based RMS measurement

For delivery checks that call for true peak, use a meter that estimates the reconstructed waveform’s peak, which can occur between samples. For integrated program loudness in LUFS, use a meter that follows the applicable loudness standard. ITU-R BS.1770 defines separate algorithms for program loudness and true peak. ITU-R BS.1770-5, Annexes 1 and 2

To skip the script, you can start a voice recording review from the WaveLocus app in ChatGPT. It measures the same sample peak and RMS in your browser and can also check a background-only passage. If a voice recording sounds quiet despite high peaks, the guide to quiet recordings with high peaks explains what to check next. WaveLocus is my website.

ShareX logoLinkedIn logoFacebook logo

About the author

Sahil Karawade is the developer and editor of WaveLocus and an Audio Machine Learning research member at Munich Music Labs, collaborating on industry research projects. His research includes audio watermarking and neural codec resynthesis. He has a degree in mathematics.