Concept

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.

view .md auddcopyright moderationcontent policyallow list

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

InputWhere it comes fromAvailabilityUse it forCaveat
Matched or notStandard: result is null. Enterprise: empty list.All plansWhether there’s anything to decideNo match is not proof of no music.
scoreEach matchStartup plan or higherConfidence gates: act, review, ignoreNot a probability. Calibrate it.
Custom vs public matchaudio_id present on custom-catalog matchesCustom catalog (special access)Allow lists and partner block lists you controlartist/title may be null; map audio_id to your records.
isrc, upcEach matchStartup plan or higherMatching against license records and allow listsA version or another release of the same recording can carry a different code.
labelEach matchAll plansDisplay, routing to a reviewerThe label on a release, not necessarily the rights owner.
Matched seconds per trackEnterprise start_seconds/end_secondsEnterprise endpointTelling a 3-second background hit from a full-song uploadComputed by you; see below.
Matched share of the uploadMatched seconds ÷ upload durationEnterprise endpoint plus your duration”Is this upload mostly someone else’s recording?”Needs a full scan, not a thinned one.
timecodeEach matchAll plansWhich 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 skip and every to 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 a limit that 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)ActionReason
1No matchesPublish; log as “unknown”Nothing recognized. Not cleared: rights-holder reports still apply.
2Every matched track is on the allow list (licensed ISRCs or allowed audio_ids)Allow; record matchesLicensed use. Keep the record for reporting.
3A custom-catalog match whose owner asked for blockingBlock; notify the uploaderRule set by your agreement with that rights holder.
4Unlicensed tracks at or above the act score cover at least 50% of the uploadBlock, or hold until the uploader shows a licenseThe upload is mostly someone else’s recording.
5Unlicensed tracks at or above the act score, at most 30 seconds in totalMute those segments; publish the restShort use; the rest of the upload is the creator’s.
6Any other unlicensed track at or above the review scoreSend to reviewToo long to mute automatically, or not confident enough to act.
7Only matches below the review scorePublish; record matchesWeak 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 the audio_id and 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

  1. Scan a few hundred real uploads and have people label the right action for each.
  2. Run your policy over the logged matches and compare.
  3. Adjust ACT_SCORE, REVIEW_SCORE, BLOCK_SHARE, and MUTE_MAX_SECONDS until automated blocks are almost never overturned, then bump the policy version.
  4. 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.