
Scoring · 21 August 2026 · 7 min
What does a mix score mean? How to read a CoProducer score
A CoProducer score is 100 minus the points a file loses for specific technical findings: loudness far from −14 LUFS, true peak over −1 dBTP, clipped samples, squashed dynamics, weak mono compatibility, and a sample rate under 44.1 kHz. 90 or more rates as Release ready, which means it passes those checks. It doesn’t mean the song is good. The number is a headline. The findings underneath tell you what to fix, and two files with nearly the same score can need completely different fixes.
It’s natural to stare at the number. You bounce, you get a 77, you want an 85. But a 77 is a sum, and sums hide things. One big problem and three small ones can add up to the same score. So it helps to know how the number is built, then mostly ignore it.
Where does a CoProducer score come from?
The desktop engine’s scoring is in plain sight in the CoProducer source on GitHub. It measures the file, checks each result against a target, and charges points on a ramp. A tiny miss costs nearly nothing, and a big miss costs up to a fixed maximum for that check. The main ones:
- Loudness, measured to ITU-R BS.1770, against −14 LUFS. That’s where Spotify normalizes playback. Too loud costs more than too quiet, because a loud master gets turned down anyway and keeps its distortion.
- True peak against −1 dBTP, with a small tolerance. This is the ceiling Spotify recommends so lossy encoding doesn’t add distortion.
- Clipped samples. A handful costs a few points. Around 500 or more cost the maximum.
- Dynamics, as peak-to-RMS. Under about 6 dB starts to cost.
- Phase correlation and stereo width. Negative correlation costs the most, because parts cancel when the song plays in mono.
- Sample rate, lossy source and noise floor. Smaller, fixed costs.
Two rules sit on top. Automatic scores stop at 97, because 100 is kept for a human check. And a repair never lowers the score: if the engine’s plan wouldn’t help, you keep the original number.
What do the rating bands mean?
- 90 and up: Release ready. Nothing serious left on the technical checks.
- 75–89: Good with minor corrections. Usually one fix away, often a trim or a true-peak limiter.
- 60–74: Usable after technical corrections. Real problems, but fixable.
- 40–59: Major corrections required. Usually several problems at once, and often a trip back to the mix.
- Under 40: Not release ready.
Why is a 70 with 831 clips different from a 77?
The three before-and-after pairs on the CoProducer page are a good example of why you read the findings and not the integer.
- Crazy Stacy, 70 to 90. The before had 831 clipped samples, a true peak of +0.33 dBTP and −12.61 LUFS. Clipping is the emergency there, because flat-topped samples are already damaged. After the repair: 0 clips, −13.63 LUFS, true peak +0.04 dBTP.
- BeatGoHard, 77 to 95. No clips at all. The headline problem was a true peak of −0.3 dBTP, just over the ceiling, on a mix at −15.11 LUFS. That’s a small, clean fix, and the after landed at −1.0 dBTP.
- Drippin, 66 to 93. 231,310 clipped samples, −11.92 LUFS and +1.06 dBTP. That’s a file being driven far too hard. The after sits at −14.12 LUFS with 0 clips.
A 70 and a 77 are seven points apart, but one is “stop, the file is damaged” and the other is “trim it a little”. The integer can’t tell you that. The findings can.
Can I see how the points add up on my own files?
Yes. To show the shape without using anybody’s real song, we wrote a small open script. It’s a simplified re-creation of the engine’s penalty table, not the engine itself, so its numbers can differ from CoProducer’s. It needs Python 3, NumPy and FFmpeg, and it only reads your files.
#!/usr/bin/env python3
"""score_explain.py: show where a 0-100 'release readiness' score loses its points.
Usage: python3 score_explain.py FILE [FILE ...]
This is a SIMPLIFIED, OPEN RE-CREATION of the kind of graduated penalty table CoProducer's desktop
engine uses (target -14 LUFS, ceiling -1.0 dBTP, 0 clips, phase, 44.1 kHz minimum). It is not the
engine itself, so its numbers can differ from CoProducer's. The point is the shape: each finding
costs points on a ramp, the total is just 100 minus the costs, and the findings tell you what to fix.
Needs: Python 3, NumPy, FFmpeg/ffprobe on PATH."""
import json, re, subprocess, sys
import numpy as np
TARGET, CEILING, MIN_RATE, TP_TOL, MAX_AUTO = -14.0, -1.0, 44100, 0.15, 97
def ramp(x, start, full, most):
"""0 below start, `most` above full, a straight line in between."""
if x <= start: return 0.0
return most if x >= full else most * (x - start) / (full - start)
def measure(path):
err = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-i", path, "-af",
"ebur128=peak=true+sample", "-f", "null", "-"], capture_output=True, text=True).stderr
summ = err[err.rindex("Summary:"):]
I = float(re.search(r"I:\s+(-?[\d.]+) LUFS", summ).group(1))
peaks = re.findall(r"Peak:\s+(-?[\d.]+|-inf) dBFS", summ) # [sample peak, true peak]
sp, tp = float(peaks[0]), float(peaks[1])
rate = int(json.loads(subprocess.run(["ffprobe", "-v", "error", "-show_streams", "-of", "json", path],
capture_output=True, text=True).stdout)["streams"][0]["sample_rate"])
raw = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-f", "f32le", "-ac", "2", "-"], capture_output=True).stdout
x = np.frombuffer(raw, np.float32).reshape(-1, 2).astype(np.float64)
pk = np.max(np.abs(x)); rms = np.sqrt(np.mean(x ** 2))
clips = int(np.sum(np.abs(x) >= 10 ** (-0.01 / 20))) if pk >= 10 ** (-0.01 / 20) else 0
phase = float(np.sum(x[:, 0] * x[:, 1]) / np.sqrt(np.sum(x[:, 0] ** 2) * np.sum(x[:, 1] ** 2)))
return dict(lufs=I, tp=tp, sp=sp, rate=rate, clips=clips, phase=phase, dyn=20 * np.log10(pk / rms))
def score(m):
f = [] # (points lost, finding, what to do)
d = m["lufs"] - TARGET
if d > 1: f.append((ramp(d, 1, 6, 14), f"Loud for streaming: {m['lufs']:.1f} LUFS ({d:+.1f} LU)", "turn the master down; platforms will anyway"))
elif d < -2: f.append((ramp(-d, 2, 10, 8), f"Quiet: {m['lufs']:.1f} LUFS ({d:+.1f} LU)", "optional: raise level, or accept a quieter release"))
over = max(m["tp"], m["sp"]) - CEILING
if over > TP_TOL: f.append((ramp(over, TP_TOL, 2.5, 16), f"True peak {m['tp']:+.1f} dBTP over the {CEILING:.0f} dBTP ceiling", "true-peak limiter or a small gain trim"))
if m["clips"]:
p = ramp(m["clips"], 1, 500, 18); p = min(p, 4) if m["clips"] < 8 else p
f.append((p, f"{m['clips']} samples at full scale (clipping)", "go back to the unclipped mix; limiting can't rebuild flat tops"))
if m["dyn"] < 6: f.append((ramp(6 - m["dyn"], 0, 4, 10), f"Peak-to-RMS only {m['dyn']:.1f} dB", "ease off compression/limiting"))
if m["phase"] < 0: f.append((ramp(-m["phase"], 0, 0.5, 18), f"Phase correlation {m['phase']:.2f} (negative)", "fix polarity/widening; check in mono"))
elif m["phase"] < 0.25: f.append((ramp(0.25 - m["phase"], 0, 0.25, 9), f"Phase correlation {m['phase']:.2f} (weak mono)", "check in mono; narrow the widest part"))
if m["rate"] < MIN_RATE: f.append((8, f"Sample rate {m['rate']} Hz", "export at 44.1 kHz or higher"))
f = [(int(round(p)), a, b) for p, a, b in f]
s = max(0, 100 - sum(p for p, _, _ in f))
capped = s > MAX_AUTO; s = min(s, MAX_AUTO)
band = "Release ready" if s >= 90 else "Good with minor corrections" if s >= 75 else \
"Usable after technical corrections" if s >= 60 else "Major corrections required" if s >= 40 else "Not release ready"
return s, band, f, capped
if __name__ == "__main__":
for p in sys.argv[1:]:
m = measure(p); s, band, f, capped = score(m)
print(f"{p.split('/')[-1]}: {s} {band}")
print(f" measured: {m['lufs']:.1f} LUFS | TP {m['tp']:+.1f} dBTP | sample peak {m['sp']:+.1f} dBFS | clips {m['clips']} | phase {m['phase']:.2f} | {m['rate']} Hz")
for pts, what, todo in f: print(f" -{pts:<3} {what} -> {todo}")
if not f: print(" no findings" + (f" (automatic scores stop at {MAX_AUTO}; a listen is still yours to do)" if capped else ""))
print()
We ran it on five synthetic bounces of one generated 30-second beat (no real song): one pushed into a hard clip, one with its sample peaks limited to −0.2 dBFS, one left quiet at −21 LUFS, one limited and trimmed so its true peak lands at −1.3 dBTP, and the same clean one with a polarity-flipped noise layer added, the kind of thing a careless widener does.
$ python3 score_explain.py bounces/*.wav
a_clipped.wav: 51 Major corrections required
measured: -6.1 LUFS | TP +2.9 dBTP | sample peak +0.0 dBFS | clips 144802 | phase 0.96 | 48000 Hz
-14 Loud for streaming: -6.1 LUFS (+7.9 LU) -> turn the master down; platforms will anyway
-16 True peak +2.9 dBTP over the -1 dBTP ceiling -> true-peak limiter or a small gain trim
-18 144802 samples at full scale (clipping) -> go back to the unclipped mix; limiting can't rebuild flat tops
-1 Peak-to-RMS only 5.4 dB -> ease off compression/limiting
b_hot_peak.wav: 85 Good with minor corrections
measured: -13.4 LUFS | TP +1.3 dBTP | sample peak -0.2 dBFS | clips 0 | phase 0.96 | 48000 Hz
-15 True peak +1.3 dBTP over the -1 dBTP ceiling -> true-peak limiter or a small gain trim
c_quiet.wav: 95 Release ready
measured: -21.0 LUFS | TP -5.8 dBTP | sample peak -6.4 dBFS | clips 0 | phase 0.96 | 48000 Hz
-5 Quiet: -21.0 LUFS (-7.0 LU) -> optional: raise level, or accept a quieter release
d_clean.wav: 97 Release ready
measured: -14.6 LUFS | TP -1.3 dBTP | sample peak -2.8 dBFS | clips 0 | phase 0.96 | 48000 Hz
no findings (automatic scores stop at 97; a listen is still yours to do)
e_phasey.wav: 95 Release ready
measured: -13.3 LUFS | TP -1.3 dBTP | sample peak -1.8 dBFS | clips 0 | phase -0.14 | 48000 Hz
-5 Phase correlation -0.14 (negative) -> fix polarity/widening; check in mono
$ ffmpeg -i d_clean.wav -af "pan=mono|c0=0.5*c0+0.5*c1,ebur128" -f null - 2>&1 | grep "I:" | tail -1
I: -17.8 LUFS
$ ffmpeg -i e_phasey.wav -af "pan=mono|c0=0.5*c0+0.5*c1,ebur128" -f null - 2>&1 | grep "I:" | tail -1
I: -20.7 LUFS
Each one teaches something:
- The clipped bounce (51) loses points on four separate findings. A low score is usually several problems, not one huge one. The fix list starts with “go back to the unclipped mix”, because no limiter can rebuild flat tops.
- The hot-peak bounce (85) has a single finding. Its highest sample is −0.2 dBFS, which looks fine on a sample meter, but the true peak between samples is +1.3 dBTP. One true-peak limiter pass fixes it.
- The quiet bounce (95) is 7 LU under target and still rates as Release ready, because quiet isn’t damage. Spotify does turn quiet tracks up, but only as far as their headroom allows. Using Spotify’s rule, this one (true peak −5.8 dBTP) would come up to about −16.2 LUFS, so it would still play a couple of dB quieter than its neighbours. Whether that matters is your call.
- The clean bounce (97) has no findings, so it stops at the cap.
- The phasey bounce (95) is the one to remember. It rates as Release ready with a negative correlation of −0.14. Folded to mono with FFmpeg, it fell from −13.3 to −20.7 LUFS, a 7.4 LU drop, against 3.2 LU for the clean bounce, because the flipped layer cancels itself out. On a phone speaker or a club’s mono sub, that layer is gone. A 95 can hide a real problem, so read the findings.
Why can a repaired file score 90 with a red cell?
Because the score is graded, and the table isn’t. Crazy Stacy’s repaired file has a true peak of +0.04 dBTP. That’s still over −1, so the cell stays red even though the overall total cleared 90. That’s on purpose. Painting the cell green would be a nicer story and a worse report.
On the web analyzer there’s also an “If repaired” column. It’s a forecast of what the desktop repair plan would likely do, not a master you’ve downloaded. If the forecast still misses a target, what’s left is a mix decision, or a risk you choose to accept.
Why does the browser show a different number from the desktop app?
The analyzer on the CoProducer page sends your file to an engine on your own computer when one is running. When it isn’t, the page estimates in the browser instead. That estimate is honest but simpler: its loudness meter is approximate and it uses its own gates (−14 LUFS within 1.5 LU, true peak at −1 dBTP or lower, 0 clips, phase at 0.2 or more, 44.1 kHz or more). A file that misses any gate is held under 90 there, while the desktop engine grades each miss on a ramp. So the same bounce can read a few points apart. Treat the desktop report as the reference and the browser number as a quick read. Either way, the file stays on your machine.
What should I fix first?
- Clipping. Go back to a bounce that isn’t clipped. Every other fix sits on top of this one.
- True peak. A true-peak limiter at about −1 dBTP, or a small trim.
- Loudness. If you’re far over −14 LUFS, turn it down rather than limit harder. Platforms will turn it down anyway.
- Phase. Listen in mono. Find the widener or flipped layer that disappears.
- Stop. Once the findings are clear, the last few points aren’t worth another week. The song is.
- What does a CoProducer score of 90 mean?
- It means the file passed the technical checks well enough to rate as Release ready: loudness near −14 LUFS, true peak near or under −1 dBTP, no clipping, healthy mono compatibility and a sample rate of at least 44.1 kHz. It says nothing about whether the song is good. That part is still yours.
- Is a score of 100 possible?
- Not automatically. The desktop engine stops at 97, and keeps 100 for a human check. A file with no findings at all reads 97. Don’t chase the last few points. Clear the findings and trust your ears.
- Why can the true peak stay red after a repair?
- Because the table shows the measurement, not a nicer story. Crazy Stacy’s repaired file reached 90 with a true peak of +0.04 dBTP. That’s still over the −1 dBTP ceiling, so the cell stays red, and the remaining fix is a gentler limiter or a small trim.
- Why does the browser give a different score from the desktop app?
- When no local engine is running, the analyzer page estimates in your browser. That estimate uses a simpler meter and its own gate rules, so it can land a few points away. Use the desktop engine’s report as the reference and the browser number as a quick read.