CoProducer Measure a mix
Drives beside printed report pages

Reports · 21 August 2026 · 7 min

What should a loudness report include? Why HTML, JSON and TXT all matter

NoDAW Labs · Lacquer Vol. 02

A useful loudness report names the exact file it measured (its name plus a hash of the audio), says when it was measured, and lists the numbers delivery cares about: integrated loudness in LUFS, true peak in dBTP, loudness range, sample rate, channels and length. Keep it in three forms because it has three kinds of reader. HTML is for people, JSON is for scripts, and a one-line TXT is for chat and tickets. They hold the same facts, written once by the meter, so nobody has to retype a number.

Loudness report checklist

Before you trust a loudness report, check that it carries these. Each one is a measurement the standards define, so any decent meter can produce it.

  1. Integrated loudness (LUFS) for the whole file, gated as ITU-R BS.1770 describes.
  2. Loudness range (LRA, in LU), as defined in EBU Tech 3342.
  3. Maximum momentary and maximum short-term loudness, the loudest 0.4 s and 3 s windows (EBU Tech 3341). The integrated number can hide a section that is far louder than the rest.
  4. True peak per channel, in dBTP, not one number for the pair. A single figure hides which side is closer to the limit.
  5. Meter and settings: the tool and its version, the standard it follows, the channel layout, and whether it measured the whole file or a selection.
  6. The target and a pass or fail against it, with the target written down. EBU R 128 uses −23 LUFS integrated with −1 dBTP as the maximum true peak for broadcast; streaming platforms use their own numbers.

The file name, a hash of the audio and the measurement date, covered below, tie the numbers to one exact file. Sources: EBU R 128, ITU-R BS.1770, EBU Tech 3341 and EBU Tech 3342.

Here’s the situation it prevents. Someone in the group chat asks “is the single legal yet?” You’ve got single_master_v3.wav, single_master_v3_FINAL.wav and a screenshot of your meter from Tuesday. Which file did the screenshot measure? Was that before or after you pulled the limiter back? Nobody can say for sure, so somebody re-checks it, and somebody else asks again tomorrow. A report that travels with the file answers the question once.

What should a loudness report include?

Keep it short. Every field should answer a question someone will actually ask:

Notice what isn’t on the list: opinions. A report says what the file measures. Whether −9 LUFS is right for the record is a creative call, and it belongs in your notes, not in the numbers.

Why not just send a screenshot of the meter?

Screenshots are fine for a glance and bad for a record. Three problems:

Who reads the HTML, the JSON and the TXT?

FormatWho reads itWhat it’s good atWhere it falls short
HTMLArtists, A&R, clients, you on your phoneOpens in any browser, can show a clear pass/check line, easy to forwardAwkward for scripts to read back
JSONYour scripts, spreadsheets, a release checklistEvery number has a name and a type, so tools can compare and filter without guessing (RFC 8259 is the spec)Nobody wants to read it in a group chat
TXTChats, tickets, email, notes appsOne line you can paste anywhere, readable with no app at allNot built for tools to read back reliably

The point isn’t the formats, it’s that the meter writes all three at the same moment from the same measurement. Three files that were typed separately can disagree. Three files written by one run can’t.

What does a three-format report look like in practice?

Here’s a small script that measures a file with FFmpeg’s ebur128 meter and writes all three reports next to it. It needs Python 3 and FFmpeg. Nothing leaves your computer.

#!/usr/bin/env python3
"""report.py: measure one bounce and write the same facts three ways.
    python3 report.py my_mix_v2.wav
Writes my_mix_v2.report.json (for scripts), .txt (for chat and tickets) and .html (for people)
next to the audio. Needs ffmpeg/ffprobe on your PATH. Nothing is uploaded anywhere."""
import hashlib, html, json, re, subprocess, sys, datetime
from pathlib import Path

def measure(path):
    err = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-i", str(path), "-vn",
                          "-af", "ebur128=peak=sample+true", "-f", "null", "-"],
                         capture_output=True, text=True).stderr
    summary = err[err.rindex("Summary:"):]
    num = lambda pat: float(re.search(pat, summary, re.S).group(1))
    probe = json.loads(subprocess.run(["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries",
        "stream=sample_rate,channels,codec_name,bits_per_raw_sample:format=duration", "-of", "json", str(path)],
        capture_output=True, text=True).stdout)
    st, fmt = probe["streams"][0], probe["format"]
    return {
        "file": path.name,
        "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
        "measured_at": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
        "duration_s": round(float(fmt["duration"]), 2),
        "codec": st["codec_name"], "sample_rate_hz": int(st["sample_rate"]), "channels": st["channels"],
        "integrated_lufs": num(r"I:\s+(-?[\d.]+) LUFS"),
        "loudness_range_lu": num(r"LRA:\s+(-?[\d.]+) LU"),
        "true_peak_dbtp": num(r"True peak:\s+Peak:\s+(-?[\d.]+) dBFS"),
        "sample_peak_dbfs": num(r"Sample peak:\s+Peak:\s+(-?[\d.]+) dBFS"),
    }

def verdict(r, tp_max=-1.0):
    return "PASS" if r["true_peak_dbtp"] <= tp_max else f"CHECK: true peak above {tp_max} dBTP"

def write_all(r, stem):
    stem.with_suffix(".report.json").write_text(json.dumps(r, indent=2) + "\n")
    txt = (f"{r['file']}  {r['integrated_lufs']:.1f} LUFS | TP {r['true_peak_dbtp']:+.1f} dBTP | "
           f"LRA {r['loudness_range_lu']:.1f} LU | {r['sample_rate_hz']/1000:g} kHz | {verdict(r)} | "
           f"sha256 {r['sha256'][:12]}")
    stem.with_suffix(".report.txt").write_text(txt + "\n")
    rows = "".join(f"<tr><th>{html.escape(k)}</th><td>{html.escape(str(v))}</td></tr>" for k, v in r.items())
    stem.with_suffix(".report.html").write_text(
        f"<!doctype html><meta charset=utf-8><title>{html.escape(r['file'])}</title>"
        f"<h1>{html.escape(r['file'])}</h1><p><b>{html.escape(verdict(r))}</b></p><table>{rows}</table>\n")
    return txt

if __name__ == "__main__":
    for arg in sys.argv[1:]:
        p = Path(arg); print(write_all(measure(p), p.with_suffix("")))

To show it working, we made two synthetic test files (a 30-second generated beat, no real song). mix_v1.wav was pushed 6 dB into a sample-peak limiter set to −0.1 dBFS, the way plenty of loud masters get made. mix_v2.wav is the same audio turned down so its true peak lands at −1.0 dBTP. Here are the two TXT lines the script printed:

$ python3 report.py mix_v1.wav mix_v2.wav
mix_v1.wav  -7.6 LUFS | TP +1.2 dBTP | LRA 0.3 LU | 48 kHz | CHECK: true peak above -1.0 dBTP | sha256 65c5ad9b4035
mix_v2.wav  -9.8 LUFS | TP -1.0 dBTP | LRA 0.3 LU | 48 kHz | PASS | sha256 4cca0dbad305

And here’s the JSON for the fixed version, exactly as written:

{
  "file": "mix_v2.wav",
  "sha256": "4cca0dbad305bdaed6978829aa13fcff9fd6e80253834131490bace296e46c07",
  "measured_at": "2026-10-22T10:04:56-04:00",
  "duration_s": 31.0,
  "codec": "pcm_f32le",
  "sample_rate_hz": 48000,
  "channels": 2,
  "integrated_lufs": -9.8,
  "loudness_range_lu": 0.3,
  "true_peak_dbtp": -1.0,
  "sample_peak_dbfs": -2.3
}

A few things worth reading out of that:

What a fuller report looks like

This is a fuller version of the report above, for a synthetic 30-second stereo test file we made and measured with FFmpeg. It is not a real song, and the target is an example, not a recommendation. The short script in this essay writes the core fields; the per-channel peaks and maximum momentary and short-term values come from the same meter and are added here.

{
  "file": "demo_master.wav",
  "sha256": "cb73442ce914098e9cebe5fc627ac44f11f6f6cc3b62177e448887c1db39630b",
  "measured_at": "2026-11-04T19:48:00-05:00",
  "duration_s": 30.0,
  "sample_rate_hz": 48000,
  "channels": 2,
  "meter": {
    "tool": "ffmpeg 7.1 ebur128",
    "standard": "ITU-R BS.1770 / EBU R 128",
    "gating": "-70 LUFS absolute, -10 LU relative",
    "true_peak": "peak=true"
  },
  "integrated_lufs": -10.9,
  "loudness_range_lu": 1.9,
  "max_momentary_lufs": -9.2,
  "max_short_term_lufs": -10.0,
  "true_peak_dbtp": {
    "L": -0.7,
    "R": -2.2
  },
  "target": {
    "integrated_lufs": -14.0,
    "tolerance_lu": 1.0,
    "true_peak_max_dbtp": -1.0
  },
  "verdict": {
    "integrated": "fail (3.1 LU above target)",
    "true_peak": "fail (L channel 0.3 dB over)",
    "overall": "fail"
  }
}

The right channel is quieter than the left, so a single combined true-peak figure would show only the louder side and hide the difference. In this example the file fails on both integrated loudness and left-channel true peak, and the report says why.

How do I compare two versions of a master?

This is where JSON pays for itself. A second tiny script reads two reports and prints only what changed:

#!/usr/bin/env python3
"""compare.py: what changed between two versions? Reads two .report.json files.
    python3 compare.py mix_v1.report.json mix_v2.report.json"""
import json, sys
a, b = (json.load(open(p)) for p in sys.argv[1:3])
print(f"{a['file']} -> {b['file']}")
if a["sha256"] == b["sha256"]:
    print("  same audio bytes: nothing changed")
for k in ("integrated_lufs", "true_peak_dbtp", "sample_peak_dbfs", "loudness_range_lu", "duration_s", "sample_rate_hz"):
    if a[k] != b[k]:
        print(f"  {k:18} {a[k]:>8} -> {b[k]:>8}  ({b[k] - a[k]:+.1f})")

On the synthetic pair it printed:

$ python3 compare.py mix_v1.report.json mix_v2.report.json
mix_v1.wav -> mix_v2.wav
  integrated_lufs        -7.6 ->     -9.8  (-2.2)
  true_peak_dbtp          1.2 ->     -1.0  (-2.2)
  sample_peak_dbfs       -0.1 ->     -2.3  (-2.2)

Integrated loudness, true peak and sample peak all moved by exactly −2.2 dB, and loudness range didn’t move at all. That pattern means the only change was a gain trim. If the range had dropped too, you’d know something compressed or limited the new version harder, and that’s worth a listen before anyone signs off. If the two hashes had matched, the script would say “same audio bytes”, which settles the classic “did you actually send me the new one?” question in a second.

When you want a spreadsheet, jq turns a folder of JSON reports into CSV rows in one line:

$ jq -r '[.file, .integrated_lufs, .true_peak_dbtp, .sha256[0:12]] | @csv' *.report.json
"mix_v1.wav",-7.6,1.2,"65c5ad9b4035"
"mix_v2.wav",-9.8,-1.0,"4cca0dbad305"

Where should the reports live?

Use the same meter for every version you compare. Meters that follow BS.1770 should agree closely on integrated loudness, but true-peak readings can differ a little with the oversampling each meter uses. Comparing v1 from one tool with v2 from another adds noise you don’t need. EBU Tech 3341 describes the test signals meters are checked against, if you ever want to test one yourself.

What should a report never claim?

That a track is “good”. A report can tell you a file is −9.8 LUFS with −1.0 dBTP true peak at 48 kHz. It can’t tell you whether the vocal sits right or whether the chorus lifts. It also can’t promise how a platform will play it, since each service applies its own normalization and encoding. Keep the numbers honest and narrow, and keep the taste in your ears.

If you’d rather not keep scripts around, CoProducer’s analyzer works the same way. It writes HTML, JSON and TXT beside each bounce, so the facts travel with the file.

What should a loudness report include?
The file name and a hash of the audio, the date it was measured, integrated loudness in LUFS, true peak in dBTP, loudness range in LU, sample rate, channels and length. The hash ties the numbers to one exact file, so a renamed or re-bounced version can’t borrow an old report.
Why keep a report in three formats?
Because three kinds of reader need the same facts. HTML is for people who open it in a browser, JSON is for scripts that compare versions or check a target, and a one-line TXT is for pasting into a chat or ticket. When the meter writes all three at once, nobody retypes a number.
Can I just send a screenshot of my meter?
It works for a quick look, but a screenshot doesn’t say which file it measured, can’t be searched or compared, and invites someone to retype the numbers wrong. A text report with a file hash is easier to trust and keeps working months later.
How do I compare two versions of a master?
Measure both with the same meter and compare the JSON reports field by field. If the hashes match, the audio is identical. If loudness, true peak and sample peak all moved by the same amount, the only change was gain. If loudness range changed too, something compressed or expanded the track.

A free one-pager

The checklist we run before every release.

Loudness, true peak, mono, tails, export settings and the metadata basics, on one page. Leave your email and it opens straight away, or read it here first. No email needed for that.