
Reports · 21 August 2026 · 7 min
What should a loudness report include? Why HTML, JSON and TXT all matter
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.
- Integrated loudness (LUFS) for the whole file, gated as ITU-R BS.1770 describes.
- Loudness range (LRA, in LU), as defined in EBU Tech 3342.
- 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.
- True peak per channel, in dBTP, not one number for the pair. A single figure hides which side is closer to the limit.
- 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.
- 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:
- File name and a hash. The name tells people what it is. The hash (a SHA-256 fingerprint of the file’s bytes) proves which file it is. Rename it and the hash stays the same. Re-bounce it and the hash changes, even if the numbers barely move. That’s the only way to know a report belongs to the file in your hand.
- When it was measured, with the time zone. A date written like
2026-10-22T10:04:56-04:00(the RFC 3339 format) sorts correctly and reads the same everywhere. - Integrated loudness (LUFS). The average loudness of the whole track, measured the way ITU-R BS.1770 defines it. It’s the number streaming services use when they turn tracks up or down.
- True peak (dBTP). The highest point of the waveform once it’s rebuilt between samples, which is where codecs and converters clip. Also in BS.1770.
- Sample peak (dBFS). Worth keeping beside true peak because the gap between them tells a story. A master at −0.1 dBFS sample peak and +1.2 dBTP true peak was limited by sample peak only.
- Loudness range (LU). How much the loudness moves across the track, defined in EBU Tech 3342. When a revision suddenly drops in range, something squashed it.
- Format facts. Sample rate, channels, codec and length. They catch the boring mistakes, like a 44.1 kHz file where the label asked for 48, or a bounce that stopped eight seconds early.
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:
- No identity. A screenshot doesn’t say which file it measured. Two weeks later, neither do you.
- Retyping. Someone will copy “−9.8” into a spreadsheet as “−8.9”. Every copy by hand is a chance to flip digits or units, and LUFS, LU, dBFS and dBTP are easy to mix up.
- No history. You can’t search a folder of screenshots for “every master with true peak above −1”. You can do that with text in about one line.
Who reads the HTML, the JSON and the TXT?
| Format | Who reads it | What it’s good at | Where it falls short |
|---|---|---|---|
| HTML | Artists, A&R, clients, you on your phone | Opens in any browser, can show a clear pass/check line, easy to forward | Awkward for scripts to read back |
| JSON | Your scripts, spreadsheets, a release checklist | Every 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 |
| TXT | Chats, tickets, email, notes apps | One line you can paste anywhere, readable with no app at all | Not 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:
- v1 looked clean and wasn’t. Its sample peak was −0.1 dBFS, so an ordinary peak meter showed no problem. True peak was +1.2 dBTP, over full scale, and the TXT line flags it. Spotify’s loudness guidance asks for true peak below −1 dBTP, and below −2 dBTP for masters louder than −14 LUFS like this one. That’s why the PASS rule in the script is only a starting point.
- The hashes are different (65c5ad9b… and 4cca0dba…), so nobody can confuse the reports even if the files get renamed.
- The loudness range is tiny (0.3 LU) because the test beat is a short loop that never changes. A real song with a verse and a chorus usually reads several LU. Don’t copy that number as a target.
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?
- Beside the bounce, with the same name.
single_master_v4.wavsits next tosingle_master_v4.report.json,.txtand.html. Move the folder and everything moves together. - Never edit a report by hand. If a number looks wrong, measure again. A report you edited is a note, not a measurement.
- Re-measure after every render that changes the audio. That includes sample-rate conversion, a new limiter setting, a slowed or sped-up version and an encoded preview. Each is a new file with a new hash and its own peaks.
- Archive them with the release. When a distributor, a sync house or a collaborator asks a year from now what you delivered, the JSON and the hash answer it.
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.