Integration

Add AudD to a podcast post-production pipeline

Wire AudD's enterprise endpoint into a repeatable podcast workflow — on every episode export, scan for music with ffmpeg, write credits to chapters and show notes, and flag unlicensed commercial tracks for review.

view .md auddpodcastpost-productionautomation

This page wires AudD into a repeatable post-production workflow. It makes music recognition an automatic step on every export: an ffmpeg stage preps the audio, the enterprise endpoint scans it, and the results land in your chapter markers, your show-notes template, and a licensing log, with any unlicensed commercial track flagged for review.

If you just need a credits list for a single episode, the Generate music credits for a podcast episode recipe does exactly that and explains the per-fragment response in detail. This page assumes you’ve seen that and want the pipeline: how to make the scan a hands-off step in a Makefile, a CI job, or a watch-folder, and how to route its output to the right places.

What you’ll build

A scan-episode step that takes a finished episode file and produces three artifacts:

  • Chapter markers — a chapters file the detected music can be merged into.
  • A show-notes credits block — the rendered credits, ready to paste or template into the episode page.
  • A licensing log — an append-only record of every track detected in every episode, with the identifiers you need to clear rights, and a review flag on commercial releases that aren’t in your cleared list.

The step is a single script you can invoke three ways: from a Makefile target, from a CI job on a new export, or from a watch-folder daemon that fires when a render appears. The recognition is AudD’s enterprise endpoint; ffmpeg normalizes the input so the scan is cheap and consistent.

Prerequisites

  • An API token from dashboard.audd.io with the enterprise endpoint enabled. ISRC and UPC in responses — useful for a licensing log — require a Startup plan or higher.
  • ffmpeg on the host.
  • Python 3.10+ with the SDK: pip install audd
  • https://audd.tech/example.mp3 is a public, reproducible file to wire the pipeline against before pointing it at your own exports.

The pipeline step

Prep the audio with ffmpeg

A raw episode export is often a large stereo WAV or a video file. You don’t need to send that whole thing to the API at full fidelity — a mono, downsampled MP3 fingerprints just as well and uploads faster. Make ffmpeg the front of the step:

# normalize any export into a compact mono MP3 the scan can chew on
ffmpeg -y -i "$EPISODE" -ac 1 -ar 44100 -b:a 128k "$WORKDIR/scan.mp3"

-ac 1 collapses to mono, -ar 44100 resamples, -b:a 128k keeps the bitrate modest. Recognition fingerprints survive this fine; you’ve just made the upload smaller.

Scan with the enterprise endpoint

An episode is arbitrary length and you want every song in it, so this is the enterprise endpoint, not standard. recognize_enterprise scans the whole file and returns a flat list[EnterpriseMatch] — one entry per recognized fragment, in time order. Each match carries its position in your episode directly as start_seconds / end_seconds (file-absolute float seconds); the call requests accurate offsets by default, so those positions are precise and there’s no offset math to do.

from audd import AudD

audd = AudD("your-api-token")  # token from dashboard.audd.io


def scan(path: str, limit: int = 60):
    """Send a prepped episode file to the enterprise endpoint.

    Returns a list[EnterpriseMatch] — one entry per recognized fragment.
    """
    return audd.recognize_enterprise(path, limit=limit)  # ALWAYS cap metered fragments

Always set limit, in the pipeline too. The enterprise endpoint bills per 12 seconds of audio processed. A 90-minute episode is hundreds of fragments; an unbounded call meters every one of them, on every export, automatically. Pick a limit that covers a full episode at your sampling rate and keep it in the config — an automated step is exactly where a missing limit quietly runs up a bill. See Enterprise cost optimization.

Collapse, classify, and route

A long track comes back as a run of consecutive matches naming the same (artist, title). Collapse runs of the same song into one credit (the credits recipe walks this dedupe in full), then classify each credit against your cleared-music list and route the output. The match’s start_seconds / end_seconds are already file-absolute seconds, so the credit’s span comes straight off them — no offset conversion.

CLEARED = {
    # ISRCs / titles you've licensed or own — your production music, etc.
    "GBUM71403885",
}


def build_credits(matches) -> list[dict]:
    """Collapse consecutive same-song matches into one credit per run."""
    credits = []
    for m in matches:
        if m.start_seconds is None:
            continue  # no usable position for this fragment — skip it
        end = m.end_seconds if m.end_seconds is not None else m.start_seconds + 12
        last = credits[-1] if credits else None
        if last and last["artist"] == m.artist and last["title"] == m.title:
            last["end"] = max(last["end"], end)  # same track still playing
        else:
            credits.append({
                "artist": m.artist,
                "title": m.title,
                "label": m.label,
                "isrc": m.isrc,
                "song_link": m.song_link,
                "start": m.start_seconds,
                "end": end,
            })
    return credits


def classify(credit: dict) -> str:
    """Flag commercial releases that aren't in the cleared list."""
    if credit["isrc"] in CLEARED or credit["title"] in CLEARED:
        return "cleared"
    if credit.get("label"):
        # a labelled (commercial) release we haven't cleared → review
        return "review"
    return "unknown"

A non-null label is the commercial-release signal: a track with a label that isn’t in your CLEARED set is the thing a human should look at before publishing. EnterpriseMatch fields parse leniently — any can be None on a given match — so the guard on start_seconds and the end_seconds fallback keep the scan from crashing on a fragment that came back without a position.

Write the three artifacts

Now route the classified credits.

def fmt(seconds: float) -> str:
    h, rem = divmod(int(seconds), 3600)
    m, s = divmod(rem, 60)
    return f"{h:02d}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"


def write_chapters(credits, path):
    """An ffmetadata chapters file ffmpeg can mux into the episode."""
    lines = [";FFMETADATA1"]
    for c in credits:
        lines += [
            "[CHAPTER]",
            "TIMEBASE=1/1000",
            f"START={int(c['start'] * 1000)}",
            f"END={int(c['end'] * 1000)}",
            f"title={c['artist']} - {c['title']}",
        ]
    with open(path, "w") as f:
        f.write("\n".join(lines) + "\n")


def write_show_notes(credits, path):
    lines = ["## Music in this episode", ""]
    for c in credits:
        span = f"{fmt(c['start'])}-{fmt(c['end'])}"
        label = f" ({c['label']})" if c["label"] else ""
        link = f" - {c['song_link']}" if c["song_link"] else ""
        lines.append(f"- {span} {c['artist']} - {c['title']}{label}{link}")
    with open(path, "w") as f:
        f.write("\n".join(lines) + "\n")


def append_licensing_log(episode_id, credits, path):
    """Append-only CSV: one row per detected track, per episode, with a flag."""
    import csv, os
    new = not os.path.exists(path)
    with open(path, "a", newline="") as f:
        w = csv.writer(f)
        if new:
            w.writerow(["episode", "start", "artist", "title", "label", "isrc", "status"])
        for c in credits:
            w.writerow([
                episode_id, fmt(c["start"]), c["artist"], c["title"],
                c["label"] or "", c["isrc"] or "", classify(c),
            ])

The chapters file is plain ffmetadata, so the same ffmpeg you preprocessed with can mux it back into the released file:

ffmpeg -y -i "$EPISODE" -i "$WORKDIR/chapters.txt" \
  -map_metadata 1 -codec copy "$OUT/episode-with-chapters.m4a"

Wiring it in

The step above is one function call away from being automatic. Three common homes for it:

A Makefile target

WORKDIR := build
OUT     := dist

scan-%: export/%.wav
	@mkdir -p $(WORKDIR) $(OUT)
	ffmpeg -y -i $< -ac 1 -ar 44100 -b:a 128k $(WORKDIR)/scan.mp3
	python3 pipeline.py --episode $* --audio $(WORKDIR)/scan.mp3 \
	  --chapters $(WORKDIR)/chapters.txt \
	  --notes $(OUT)/$*-notes.md \
	  --log licensing-log.csv

make scan-ep042 preps, scans, and writes all three artifacts for one episode.

A CI step

On a CI runner that builds your episode pages, run the same script when a new export lands. Store API_TOKEN as a CI secret, fail the job (or post a review comment) when the licensing log gains a review row, and commit the show-notes artifact. That turns “did we accidentally ship an uncleared commercial track?” into a build gate.

python3 pipeline.py --episode "$EPISODE_ID" --audio scan.mp3 \
  --chapters chapters.txt --notes notes.md --log licensing-log.csv

# fail the build if anything needs a human
grep -q ',review$' licensing-log.csv && {
  echo "Uncleared commercial track detected — see licensing-log.csv"; exit 1; }

A watch-folder daemon

If episodes are exported to a folder, a small watcher that fires the same script on each new file gives you a hands-off pipeline without CI. Whatever triggers it — inotify, a cron sweep, your editor’s export hook — the body is the same ffmpeg prep + scan + route.

Scan once, store the result. Re-running the scan re-meters every fragment. Key the stored result by episode ID and skip episodes already scanned, so a re-run of the pipeline (a CI retry, a re-export of unrelated assets) doesn’t re-bill. Only re-scan when the audio actually changed.

Handling errors in an unattended run

Because this runs without a human watching, fail loudly and route the failure, don’t swallow it. The SDK raises typed exceptions, all subclasses of AudDError:

  • Authentication errors — bad or missing token. Fail the whole run; a silent auth failure means every episode ships uncredited.
  • Quota / subscription errors — you’ve hit a limit, or enterprise isn’t enabled on the account. Surface to whoever owns the AudD account; don’t retry in a loop.
  • Invalid-audio errors — the export wasn’t decodable (a truncated render, a wrong file). Report which episode failed and stop that episode’s step, rather than writing empty credits as if it were clean.
  • Connection errors — transient. Retry with backoff before failing the episode.
from audd.errors import AudDError

try:
    matches = scan(audio_path, limit=60)
except AudDError as e:
    # any AudD API/transport failure — fail the episode, retry connection errors
    raise SystemExit(f"recognition error on {episode_id}: {e}")

An empty match list (or a run that produced no credits) is a genuine “no music detected” verdict, not an error — a talk-only episode produces an empty credits block and no licensing rows, which is correct.

Going further

  • Per-episode rendering detail. The dedupe and the time-field distinction (start_seconds is the position in your episode; timecode is position inside the matched song) are covered in full in Generate music credits for a podcast episode.
  • Keep the automated cost bounded. limit, every, and skip trade coverage against metered fragments — Enterprise cost optimization.
  • Scanning a back catalog? The same step, looped over old episodes, fills the licensing log retroactively — just store results per episode so a retry doesn’t re-bill.

Related

Reading this as an AI agent? The raw Markdown is at integrations/podcast-post-production.md, and the full index is /resources/llms.txt.