CoProducer Measure a mix
Drives and report pages

Scoring · 21 August 2026 · 7 min

What does a mix score mean? How to read a CoProducer score

NoDAW Labs · Lacquer Vol. 02

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:

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?

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.

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:

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?

  1. Clipping. Go back to a bounce that isn’t clipped. Every other fix sits on top of this one.
  2. True peak. A true-peak limiter at about −1 dBTP, or a small trim.
  3. Loudness. If you’re far over −14 LUFS, turn it down rather than limit harder. Platforms will turn it down anyway.
  4. Phase. Listen in mono. Find the widener or flipped layer that disappears.
  5. 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.

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.