---
title: "Add AudD to a podcast post-production pipeline"
description: "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."
slug: "/resources/integrations/podcast-post-production"
section: "integrations"
keywords: [audd, podcast, post-production, automation, enterprise endpoint, ffmpeg, licensing log]
---

# Add AudD to a podcast post-production pipeline

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](/resources/recipes/podcast-music-credits)
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](https://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:

```bash
# 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.

```python
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](/resources/concepts/enterprise-cost-control).

### 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](/resources/recipes/podcast-music-credits) 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.

```python
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.

```python
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:

```bash
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

```makefile
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.

```bash
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.

```python
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](/resources/recipes/podcast-music-credits).
- **Keep the automated cost bounded.** `limit`, `every`, and `skip` trade
  coverage against metered fragments —
  [Enterprise cost optimization](/resources/concepts/enterprise-cost-control).
- **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**

- [Generate music credits for a podcast episode](/resources/recipes/podcast-music-credits)
- [Enterprise cost optimization](/resources/concepts/enterprise-cost-control)
- [Python SDK docs](https://docs.audd.io/sdks/python)
- [API reference](https://docs.audd.io)