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.
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
reviewflag 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.
ffmpegon the host.- Python 3.10+ with the SDK:
pip install audd https://audd.tech/example.mp3is 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 alimitthat covers a full episode at your sampling rate and keep it in the config — an automated step is exactly where a missinglimitquietly 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_secondsis the position in your episode;timecodeis position inside the matched song) are covered in full in Generate music credits for a podcast episode. - Keep the automated cost bounded.
limit,every, andskiptrade 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.
