From match to decision: designing allow, block, and review policies
AudD reports which recordings matched; your platform decides what happens next. The policy inputs an AudD result gives you, how to compute matched seconds and the matched share of an upload from enterprise results, a worked decision table with code, and how to handle appeals, logging, repeat uploaders, and regional rules.
AudD tells you which recordings it found in an upload, where they play, and how confident the match is. It doesn’t decide whether the upload may go live. That decision depends on things only your platform knows: which music you’ve licensed, who uploaded the file, where it will be shown, and how much a wrong call costs you. This page lists the inputs an AudD result gives you, shows how to turn enterprise results into numbers a policy can use, and works through a decision table with code.
This page describes engineering patterns, not legal requirements. It is not legal advice. Notice-and-takedown, Article 17, and DSA obligations differ by service and country; check them with counsel. As of October 12, 2026.
TL;DR
- Separate the two steps: recognition produces facts (matches, scores, offsets); a versioned policy turns facts plus your own context into an action.
- From one enterprise scan you can compute, per matched track, how many seconds play, and for the whole upload, what share of it is matched music.
- Check your allow list first: licensed tracks, by ISRC or by custom-catalog
audio_id. - Typical actions: allow, record only, mute a segment, send to review, block.
- Every number on this page is illustrative. Calibrate on your own labeled uploads before you automate anything.
- No match means unknown, not cleared. See What music recognition matches.
Policy inputs from an AudD result
| Input | Where it comes from | Availability | Use it for | Caveat |
|---|---|---|---|---|
| Matched or not | Standard: result is null. Enterprise: empty list. | All plans | Whether there’s anything to decide | No match is not proof of no music. |
score | Each match | Startup plan or higher | Confidence gates: act, review, ignore | Not a probability. Calibrate it. |
| Custom vs public match | audio_id present on custom-catalog matches | Custom catalog (special access) | Allow lists and partner block lists you control | artist/title may be null; map audio_id to your records. |
isrc, upc | Each match | Startup plan or higher | Matching against license records and allow lists | A version or another release of the same recording can carry a different code. |
label | Each match | All plans | Display, routing to a reviewer | The label on a release, not necessarily the rights owner. |
| Matched seconds per track | Enterprise start_seconds/end_seconds | Enterprise endpoint | Telling a 3-second background hit from a full-song upload | Computed by you; see below. |
| Matched share of the upload | Matched seconds ÷ upload duration | Enterprise endpoint plus your duration | ”Is this upload mostly someone else’s recording?” | Needs a full scan, not a thinned one. |
timecode | Each match | All plans | Which part of the song played (reviewer context) | A position in the matched song, not in your file. |
Your own data completes the picture: the uploader’s account history, whether the upload is monetized, the regions it will be shown in, and any license the uploader has on file with you.
Computing matched seconds and matched share
The enterprise endpoint splits the file into 12-second chunks and returns a
match for each chunk where it recognized something. With accurate_offsets
(the Python SDK requests it by default), each match carries start_seconds and
end_seconds: where the matched audio starts and ends in your file. A
track that plays for two minutes comes back as a run of consecutive matches;
merge their intervals to get the seconds it plays.
from collections import defaultdict
from audd import AudD
audd = AudD("your-api-token") # Get your own token at dashboard.audd.io
def track_key(m):
"""One key per recording: custom-catalog id, else ISRC, else artist/title."""
audio_id = getattr(m, "audio_id", None)
if audio_id is not None:
return f"custom:{audio_id}"
if m.isrc:
return f"isrc:{m.isrc}"
return f"name:{(m.artist or '').casefold()}|{(m.title or '').casefold()}"
def merge(intervals):
merged = []
for start, end in sorted(intervals):
if merged and start <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], end)
else:
merged.append([start, end])
return merged
def summarize(matches, duration_seconds):
tracks = {}
intervals = defaultdict(list)
for m in matches:
key = track_key(m)
t = tracks.setdefault(key, {
"key": key, "artist": m.artist, "title": m.title, "label": m.label,
"isrc": m.isrc, "audio_id": getattr(m, "audio_id", None), "score": None,
})
if m.score is not None:
t["score"] = max(t["score"] or 0, m.score)
if m.start_seconds is not None and m.end_seconds is not None:
intervals[key].append((m.start_seconds, m.end_seconds))
for key, t in tracks.items():
t["spans"] = merge(intervals[key])
t["seconds"] = sum(end - start for start, end in t["spans"])
everything = merge([iv for ivs in intervals.values() for iv in ivs])
matched = sum(end - start for start, end in everything)
return {
"tracks": list(tracks.values()),
"duration_seconds": duration_seconds,
"matched_seconds": matched,
"matched_share": matched / duration_seconds if duration_seconds else None,
}
matches = audd.recognize_enterprise("upload.mp4", limit=50) # cap chunks while testing
summary = summarize(matches, duration_seconds=183.4)
Notes on the numbers:
- Duration comes from you. Enterprise results don’t include the file’s
length. Read it at ingest, for example with
ffprobe -v error -show_entries format=duration -of csv=p=0 upload.mp4. - Matched share needs every chunk. If you thin the scan with
skipandeveryto save requests (see enterprise cost control), the unscanned chunks count as unmatched and the share comes out low. Thin scans are fine for a yes/no screen; use a full scan, with alimitthat covers the file, when the share drives the decision. - Seconds are approximate. Each match covers the part of a 12-second chunk that was recognized, so a continuous play can show small gaps at chunk boundaries. Treat matched seconds as a measurement with a margin, not an exact figure.
- The overall share uses the union of intervals, so two tracks overlapping in a transition aren’t counted twice.
Without the SDK
If you call the HTTP API yourself, request offsets and compute the file
position from the chunk’s offset plus the song’s start_offset and
end_offset, which are milliseconds within that 12-second chunk:
curl https://enterprise.audd.io/ \
-F api_token=your-api-token \
-F url=https://example.com/upload.mp4 \
-F accurate_offsets=true \
-F limit=50
def parse_offset(text):
seconds = 0
for part in text.split(":"): # "MM:SS" or "HH:MM:SS"
seconds = seconds * 60 + int(part)
return seconds
def file_intervals(response):
for chunk in response.get("result") or []:
base = parse_offset(chunk["offset"]) if chunk.get("offset") else None
for song in chunk.get("songs") or []:
if base is None or song.get("start_offset") is None or song.get("end_offset") is None:
continue
yield song, base + song["start_offset"] / 1000, base + song["end_offset"] / 1000
The enterprise match fields reference documents the full response shape.
A worked decision table
This example is for a video platform that has licensed a library of tracks for creators to use, takes reference files from some rights holders into its custom catalog, and doesn’t want to block uploads with a few seconds of background music. The numbers are placeholders.
| # | Condition (checked in order) | Action | Reason |
|---|---|---|---|
| 1 | No matches | Publish; log as “unknown” | Nothing recognized. Not cleared: rights-holder reports still apply. |
| 2 | Every matched track is on the allow list (licensed ISRCs or allowed audio_ids) | Allow; record matches | Licensed use. Keep the record for reporting. |
| 3 | A custom-catalog match whose owner asked for blocking | Block; notify the uploader | Rule set by your agreement with that rights holder. |
| 4 | Unlicensed tracks at or above the act score cover at least 50% of the upload | Block, or hold until the uploader shows a license | The upload is mostly someone else’s recording. |
| 5 | Unlicensed tracks at or above the act score, at most 30 seconds in total | Mute those segments; publish the rest | Short use; the rest of the upload is the creator’s. |
| 6 | Any other unlicensed track at or above the review score | Send to review | Too long to mute automatically, or not confident enough to act. |
| 7 | Only matches below the review score | Publish; record matches | Weak evidence. Kept for later re-evaluation. |
Two adjustments that platforms commonly add: send to review instead of blocking when the uploader is new or has a clean history, and block instead of muting when the uploader has repeated violations (see Repeat uploaders).
The policy as code
from dataclasses import dataclass, field
POLICY_VERSION = "2026-10-12.1"
# Illustrative values. Replace with numbers calibrated on your own uploads.
ACT_SCORE = 85
REVIEW_SCORE = 55
BLOCK_SHARE = 0.5
MUTE_MAX_SECONDS = 30
MUTE_PADDING = 1.0 # seconds added on each side of a muted span
LICENSED_ISRCS = {"GBUM71403885"} # from your license records
CUSTOM_POLICY = {742918: "allow", 5501: "block"} # audio_id -> rule
@dataclass
class Decision:
action: str
reasons: list = field(default_factory=list)
mute: list = field(default_factory=list)
policy_version: str = POLICY_VERSION
def allowed(t):
if t["audio_id"] is not None:
return CUSTOM_POLICY.get(t["audio_id"]) == "allow"
return t["isrc"] in LICENSED_ISRCS
def decide(summary):
tracks = summary["tracks"]
if not tracks:
return Decision("publish", ["no match: unknown, not cleared"])
unlicensed = [t for t in tracks if not allowed(t)]
if not unlicensed:
return Decision("allow", ["all matches licensed"])
blocked = [t for t in unlicensed if CUSTOM_POLICY.get(t["audio_id"]) == "block"]
if blocked:
return Decision("block", [f"rights-holder block: {t['key']}" for t in blocked])
# With no score (below Startup plan), every match counts as confident;
# consider sending those to review instead.
strong = [t for t in unlicensed if t["score"] is None or t["score"] >= ACT_SCORE]
medium = [t for t in unlicensed if t not in strong and t["score"] >= REVIEW_SCORE]
strong_spans = merge([iv for t in strong for iv in t["spans"]])
strong_seconds = sum(e - s for s, e in strong_spans)
duration = summary["duration_seconds"]
share = strong_seconds / duration if duration else None
if strong and share is not None and share >= BLOCK_SHARE:
return Decision("block", [f"{share:.0%} of upload is unlicensed music"])
located = all(t["spans"] for t in strong) # every strong track has positions
if strong and not medium and located and strong_seconds <= MUTE_MAX_SECONDS:
spans = [[max(0, s - MUTE_PADDING), e + MUTE_PADDING] for s, e in strong_spans]
return Decision("mute", [f"{strong_seconds:.0f}s of unlicensed music"], mute=spans)
if strong or medium:
return Decision("review", [f"unlicensed: {t['key']}" for t in strong + medium])
return Decision("publish", ["only low-score matches; recorded"])
decision = decide(summary)
print(decision.action, decision.reasons, decision.mute)
The mute spans feed straight into ffmpeg’s volume filter; the
live-recording recipe
shows the command. The padding covers the approximate edges of each match.
Building the allow list
You have two ways to say “this music is fine here”:
- By identifier. Keep the ISRCs (Startup plan or higher) of the tracks you have licensed. List every version the license covers, since a remaster or edit can come back with a different ISRC. A match on artist and title can serve as a second check that sends near-misses to review.
- By custom catalog. Upload the licensed files themselves (or music your
creators own) to your custom catalog and allow those
audio_ids. This works for music that isn’t in the public database, such as a production library. If the same recording is also in the public database, a match may come back from either, so allow both theaudio_idand the ISRC.
Review queues
Give reviewers what they need to decide quickly: the track name and
song_link, the spans in the upload (as clock times they can jump to), the
timecode in the matched track, the score, and the uploader’s history. Record
the reviewer’s decision and reason next to the automated one. Review outcomes
are your best calibration data: a high rate of “this was fine” at some score
range means the act score is too low.
Appeals and disputes
Uploaders will dispute decisions, and some will be right: they licensed the track, they are the artist, or the match was another version of a recording they own. Plan for it:
- Show the uploader which track was found and where, so they can respond to something specific.
- Let them attach a license or explanation, and route it to a person.
- When an appeal succeeds because of a license, add an allow-list entry scoped to that uploader (and that track), not a global one.
- Re-run the policy, not the scan, when only the policy or the allow list changed. That’s why the raw matches are logged.
- Rights holders file disputes too, about uploads that didn’t match. Those go to review, and if they hold the recording, a custom-catalog upload of it can catch future re-uploads.
Logging decisions
Store enough to explain any decision later and to re-run it under a new policy:
{
"upload_id": "u_81723",
"scanned_at": "2026-10-12T09:14:03Z",
"scan": {"endpoint": "enterprise", "limit": 50, "accurate_offsets": true},
"duration_seconds": 183.4,
"matches": "<raw enterprise matches, as returned>",
"summary": {"matched_seconds": 21.6, "matched_share": 0.118},
"decision": {"action": "mute", "reasons": ["22s of unlicensed music"], "policy_version": "2026-10-12.1"},
"review": null,
"appeal": null
}
Keep the policy version on every decision. When you change a threshold, you can then tell which decisions the old rule made and re-evaluate them. If your service has to give users a statement of reasons or run an internal complaint process (the EU Digital Services Act requires both from many platforms), these records are where that information comes from.
Repeat uploaders
AudD has no notion of your users; repeat-infringer handling lives in your database. Count confirmed violations (automated blocks that weren’t overturned, plus reviewer confirmations) per account within a time window, and let the count change the outcome: more review for a first offense, blocking instead of muting after repeated ones, account-level action after that. Remove a strike when an appeal succeeds.
Regional policy
Licenses are often territorial, and the laws on platform liability differ between countries. AudD returns the same result wherever the request comes from, and doesn’t know where the content will be shown or which territories a license covers. Model region in your policy: evaluate the decision per region using the licenses valid there, and restrict visibility by region where your platform supports it.
Calibrate before you automate
- Scan a few hundred real uploads and have people label the right action for each.
- Run your policy over the logged matches and compare.
- Adjust
ACT_SCORE,REVIEW_SCORE,BLOCK_SHARE, andMUTE_MAX_SECONDSuntil automated blocks are almost never overturned, then bump the policy version. - Start with “review” where you plan to “block”, watch the overturn rate, and switch to automatic action when the numbers support it.
Interpreting the recognition score covers the score part of this in more detail.
Related
Reading this as an AI agent? The raw Markdown is at concepts/match-to-decision.md, and the full index is /resources/llms.txt.
