Clubs and festivals pay licence fees for the music played in them, and PRS, the UK's
performing-rights society, shares that money out according to what was actually played. In 46
UK venues it knows exactly, because it has installed music-recognition devices that fingerprint
every DJ set (PRS).
Everywhere else it relies on setlists, and dance music is where setlists go missing. PRS has put
reporting at roughly 27% of sets at dance events, against about 95% at guitar-led festivals,
and at Creamfields in 2018 only 10 setlists came back from around 230 DJs
(DIY).
An unreported set gets allocated from proxy data, which is unlikely to include the unreleased
edit a producer played at 2 a.m. (India has IPRS, and the problem travels.)
This week I built SetLedger, which is the same fingerprinting idea run by the DJ, on their own
recording, for every gig outside those 46 rooms. Give it a set recording and your crate of
tracks, and it hands back a setlist report with evidence attached to every line. My bet is that
a DJ-producer would pay a few pounds a set for that, because it recovers money they aren't
claiming now, and PRS gives members three years to claim live royalties, so last year's
recordings are still worth scanning.
SetLedger never claims a play it can't prove. Every claim traces back to fingerprint evidence,
and anything it can't prove gets flagged for a human instead. Most of this post is about what
went wrong along the way, including the afternoon my own benchmark lied to me.
What I Built
SetLedger is a scanner for DJ-producers. You give it two things already sitting on your laptop,
the recording of your set and the folder of tracks you own, and you get back three things.
A claims timeline shows which of your tracks played, when, for how long, with a confidence
score and the method that proved it. Review spans cover every stretch it couldn't prove,
labeled "review, not claimed". And a setlist report (CSV plus PDF) carries what PRS's
live-performance report asks for, which is the date, the venue, and each work with its duration,
plus whether each work is registered, so you can see which lines you can report today and which
tracks you need to register first.
Behind that sits a ledger. Every confirmed play from every gig goes into Postgres on Tiger Data,
and before each of PRS's four payout rounds SetLedger reads off the statement you'd actually
use: per track, how many plays, how many minutes, and the weakest piece of evidence behind them.
Demo
Live demo: setledger.risp.tech, seeded, no sign-in. Scans run
for real on a small Render server, with nomination going through pgvector on Tiger. If that
server doesn't answer within six seconds, the page falls back to a real scan I recorded from
the same pipeline, replayed on the timeline it actually ran, and the header says which one
you're watching. The first screen tells you how to judge it in 90 seconds: press Scan, watch claims land with confidence scores, click a blue ≈
chip (that play is tempo-shifted, caught by embeddings and confirmed by fingerprints), notice
the amber span that stays unclaimed, then download the report.
Code
github.com/itssaharsh/setledger. The demo corpus is 25 procedurally generated tracks I own outright.
scripts/fetch_corpus.py rebuilds the audio byte for byte, scripts/demo_run.py reproduces the
demo-set benchmark, and scripts/ledger_demo.py generates and scans the five extra gigs and
prints the quarterly statements (it falls back to an embedded Postgres if you don't have a Tiger
account).
The evaluation outputs are in the repo if you'd rather read than run: the
five-gig benchmark and quarterly statements,
the demo-set benchmark, the
Gemma before/after table, and both failure
write-ups in docs/memory/failures/. The README has a
Deploy to Render button that builds the same live scanner from render.yaml.
How I Built It
The bug that taught me what a match is
My first exact matcher picked the alignment with the lowest bit-error rate. Textbook, and
wrong. Techno is loop-based, so a track matches itself at every bar multiple. The scanner kept
locking onto a short, self-similar alignment eight bars off the real one, and my very first
benchmark run rejected a play that was sitting right there in the set.
The fix was to stop asking which offset has the lowest error and start asking which offset gives
the longest run I'd actually be willing to claim. Every offset within a tolerance band gets its
error profile trimmed to the longest stretch of low error, and the longest stretch wins. I wrote
the failure up in the repo because I suspect every beginner at audio matching hits some version
of it.
Embeddings nominate, fingerprints confirm
Chromaprint is unforgiving about tempo. A play sped up 6% (DJs do this constantly) matched the
right track for about 30% of its length, and the rest fell into a review gap. The obvious move
is an audio-embedding layer. The obvious risk is that cosine similarity starts making claims no
fingerprint ever checked, and on a crate of 25 synthetic techno tracks that risk is real: CLAP
scored a track against itself at 0.992 and against a different track at 0.964. Thin.
So the embedding layer is not allowed to claim anything. For each unmatched segment, CLAP
vectors stored in pgvector nominate two candidate tracks. The segment then gets tempo-corrected
across a 0.90 to 1.10 sweep with ffmpeg, and the same exact matcher that guards the rest of the
set has to pass before a claim exists. The claim gets labeled "embedding" with the fingerprint
run's confidence.
In one leftover gap the nominator proposed two tracks and both were wrong. The confirmation step
refused both, the gap stayed amber, and the benchmark logged zero false claims.
My benchmark lied to me
With all that working, the demo set scored perfectly, tempo-shifted plays included. I almost
wrote that number into this post. Instead I generated five more gigs from different seeds and
scanned those too, and the tempo-shifted score fell to 4 out of 10.
It took one diagnostic script to see why, and the answer was worse than a recall problem. Recall
was fine: every shifted play was covered 74% to 97%. But a shifted play gets proven in pieces
(an exact run in the middle, recall-confirmed runs either side, a couple of seconds apart), and
the scanner was reporting each piece as its own play. My scorer only credited the best single
piece, which hid the recall. The ledger counted every piece, so one track showed five plays in a
quarter where it had really been played three times, and in one gig a single play had become
three claims. On a royalty report, that's one play filed three times.
The fix is a dozen lines: join same-track claims that sit within five seconds of each other into
one play, label it "embedding" if any piece needed recall, and give it the confidence of its
weakest piece. Across all six sets the numbers are now:
| tier | plays | found | rate |
|---|---|---|---|
| clean | 30 | 30 | 100% |
| tempo-shifted (+6% / −5%) | 12 | 12 | 100% |
| foreign track, not in the crate | 6 | 0 claimed | no false claims |
Each gig now records exactly 7 plays in the ledger, one per real play. The lesson I'm keeping:
a benchmark on your demo data is a smoke test, not a result.
Gemma may reformat. It may not invent, and it may not lose
Crate tags are a mess: MIDNIGHT_HARBOUR_FINAL_v2, artists filed as "unknown", feat. credits
jammed into filenames. PRS's report form matches what you type against registered works, so the
tags need a canonical artist and title. Gemma 3 1B runs locally through ollama on CPU to clean
them up, behind a guard with two rules. Every word in Gemma's suggestion must already exist in
the raw tag, and every meaningful word in the raw tag must survive into the suggestion. On top
of that, placeholder artists like "unknown" are blanked before the model sees the tag, so
echoing one back counts as inventing it.
The guard earned its keep on the first live run, when Gemma 1B turned "Low Orbit Club" into
"Low Orbit" and echoed "Unknown" as an artist. The second lesson came from checking every row
against the true names instead of eyeballing the table: two suggestions kept "Final v2" in the
title. No invented words, so the guard passed them, but PRS's search wouldn't match "Paper Motor
Final v2" to "Paper Motor". So the model now does the language and plain code finishes the
facts, stripping a trailing junk run like "Final v2" and fixing ALL-CAPS names. On the last run,
checked row by row: 14 of 25 tags fixed correctly, 4 already-clean tags left alone, 0 wrong, and
7 sent to passthrough by the guard. All 7 were feat.-credit tags, which is exactly where the 1B
model struggles.
My favorite failure wasn't the model's fault. My synthetic mess generator wrote "Title - Artist"
filenames and Gemma kept "swapping" them back, because real filenames are "Artist - Title" and
its prior knew the convention better than my test data did.
A cold read fixed my UI
Before shipping the interface I gave three screenshots to a fresh agent with no context and
asked it to judge like someone in a hurry. It failed my first screen on the five-second test,
and it was right. Nothing on the page said "DJ" or "royalty", the green/blue/amber evidence
colors had no legend, and the product was, in its words, renting its clarity from the how-to
card. Now the tagline names the user and the job, a legend explains each color, and
tempo-shifted claims carry track names. If you build with an agent, have a second agent grade
the result cold. Mine over-praises its own work about as much as I do.
What each partner does
Tiger Data: the ledger and the vectors. One Postgres holds both jobs. CLAP window embeddings
live in a pgvector table with an HNSW index, and every nomination in the five-gig benchmark is a
cosine top-k query against it (the store also has a tested hybrid query that puts relational
filters such as "registered works only" under the cosine ordering, for crates big enough to
need it). The ledger is a TimescaleDB
hypertable, plays, partitioned on played_at, with a continuous aggregate, plays_monthly,
that rolls plays up by track and month. The quarterly statement reads those pre-rolled buckets
instead of rescanning every play ever recorded, and each row keeps the weakest confidence behind
it and whether recall was involved. All of it runs on a free Tiger Cloud service (Postgres 18,
TimescaleDB 2.30): the hypertable and continuous-aggregate tests pass there, and the five-gig
benchmark above nominated every candidate through pgvector on it and wrote its plays into the
hypertable (the resulting statement is also one
click from the demo's crate panel).
Gemma: tag cleanup with a guard. Covered above. It runs locally on the DJ's own machine, so
tags never leave it, and the before/after table is one click from the demo's crate panel.
.tech: the address. The demo lives at setledger.risp.tech, a subdomain of the .tech domain
I'm keeping for what comes after this.
Render: the live scanner, squeezed into 512 MB. The FastAPI app runs on Render from the repo's
render.yaml and Dockerfile: a Starter instance in Virginia, next to the Tiger database in
us-east-1, using the lightweight spectral recall driver. Fitting it took one fix I'd never have
found on my laptop. Drawing the waveform meant decoding the whole 7-minute set at startup, and
when I measured it, that alone took the process from 51 MB to 407 MB, most of the box before
the app had done anything else. Now the Docker build computes the waveform once and caches it,
and the same container setup peaked around 190 MB when I ran it locally. The .tech page sends
its scans to Render over CORS, and a full scan there takes about 25 seconds. While Render was
still bringing up the first deploy it answered with 502s, which is exactly the case the page's
six-second fallback to the recorded replay is for.
DigitalOcean: built for it, blocked from it. A droplet deploy script (scripts/deploy_do.sh)
and a client for DigitalOcean's Serverless Inference are in the repo, checked against doctl
2.0's own help and a fake server speaking the same wire format. Neither has run on DigitalOcean:
support fixed a team-slot glitch on my account within a day, and then my bank declined the card
with card_declined: transaction_not_allowed. Judge this part as code, not as a deployment.
Why Does Open Innovation Matter?
Everything except the hosted ledger runs on a laptop with no API key, and pointing it at a local
Postgres takes the ledger local too. Chromaprint is open source, CLAP's weights are open,
Gemma's weights are open, and Postgres and pgvector are open source. A royalty side hustle only
works if each scan costs close to nothing, and a per-call API fee eats exactly the small,
specific money this is meant to recover. Open weights also stay put. The model that cleaned this
quarter's tags is the same file next quarter, so a suggestion can be reproduced when someone
asks where it came from, while a hosted model can change underneath you.
My Agent Session (optional)
I built this with Claude Code under an evidence-ledger workflow: nothing counts as done without a
recorded passing run, and every failure becomes a searchable record (the plan is in the repo
under .prod-build/). The ledger once refused to close a task because I'd edited code after its
last passing run. Sending fresh sub-agents after my own work paid off twice: a cold-read judge
caught that my first screen never said "DJ" or "royalty", and a writing reviewer caught that I'd
called my export "PRS-format" without checking what PRS asks for. It's date, venue, works and
durations, and the post now says only that.
My First User (optional)
I showed the demo to a DJ friend who posts his sets on Instagram (he'd rather stay anonymous).
His reply, word for word:
Dang its good Might use sometimes if it also helps me save time dang bro
"Might use sometimes" is the honest part, and it's the most useful thing anyone told me this week.
He didn't mention the money. He mentioned time. Right now SetLedger asks him to find the
recording, point it at a crate folder and press Scan, and for someone who plays every weekend
that's one more chore after the gig. To earn a place in his routine it has to cost zero minutes:
watch the folder his recordings land in, scan each new set on its own, and only ping him when
something lands in review. That's the next milestone. I haven't scanned one of his real
recordings yet either; that's the test I want to run next, and I'll publish the numbers either way.
Limits, so you don't have to find them yourself
- Every number here comes from synthetic audio I generated, with tempo shifts I chose. It shows the architecture works, not that club recordings behave. Real sets bring crowd noise, EQ, keylock and mashups, and more of a real set will land in review, which is the designed failure mode.
- The tempo sweep covers ±10%, but I only tested +6% and −5%. Key shifts, loops and mashups land in review by design.
- Filing stays manual. SetLedger produces the setlist and the evidence; it doesn't submit anything to PRS on your behalf.
- One venue, one server, scan state in memory for the demo. The ledger is the persistent part.
Prize categories
Best Use of Tiger Data · Best Use of Render · Best Use of Gemma · Best Use of .tech ·
Best Use of DigitalOcean (code only, see above)

Top comments (0)