---
title: "Flag and remove music in a live-event recording"
description: "Find where copyrighted music plays in a long broadcast or livestream recording, flag each segment in a report, and produce a cleaned copy with those segments muted or cut."
slug: "/resources/recipes/flag-music-in-live-recording"
section: "recipes"
keywords: [audd, broadcast music detection, enterprise endpoint, start_seconds, ffmpeg mute, livestream vod]
---

# Flag and remove music in a live-event recording

A recording of a live event — a sports broadcast, a radio show, a livestream
VOD, a conference session — is mostly speech, crowd, and commentary, with
stretches of copyrighted music threaded through it: walk-on music, stingers,
background beds, a DJ segment. This recipe finds exactly *where* the music
plays and *what* it is, flags each segment in a report, and then builds a
cleaned copy of the recording with those segments muted or cut.

## What you'll build

Two outputs from one recognition pass:

1. A **flag report** — one row per music segment, each with a file-absolute
   start/end time and the track that plays there (artist, title, label, ISRC,
   links). This is what you hand to a reviewer, a rights team, or a compliance
   log.
2. A **cleaned recording** — the same file with every flagged segment either
   muted (audio zeroed, video untouched) or cut out entirely, built with
   `ffmpeg` from the timestamps in the report.

The enterprise endpoint is the right tool: it handles arbitrary-length
recordings and finds *every* track rather than one. Both the report rows and
the `ffmpeg` edits are driven by file positions — where in the recording each
music hit sits. Each match carries those as `start_seconds` and `end_seconds`,
the song's position in your file, and `recognize_enterprise` requests accurate
offsets by default, so they're precise.

> **AudD identifies the music; the policy is yours.** This recipe locates and
> names recordings and edits your file. Whether a given segment actually needs
> muting or cutting — licensing, fair use, your platform's rules — is a
> decision you make on top of the report, not something the API decides.

## Prerequisites

- An API token from [dashboard.audd.io](https://dashboard.audd.io). Get your
  own token there; the enterprise endpoint must be enabled on it. `label`,
  `isrc`, and `upc` come back on enterprise responses (ISRC/UPC require a
  Startup plan or higher).
- Python 3.10+ with the SDK: `pip install audd`
- `ffmpeg` (and `ffprobe`) on your `PATH` for the cleaning step.
- The recording as a URL or a local file. `https://audd.tech/example.mp3` is a
  public, reproducible file you can run against end-to-end.

## Walkthrough

### Step 1: Recognize the recording

Send the recording to `recognize_enterprise` as a URL, file bytes, or a path.
A multi-hour broadcast comes back as a flat `list[EnterpriseMatch]` — one
entry per recognized fragment, in time order — and each entry has a precise
`start_seconds` / `end_seconds`, since accurate offsets are requested by
default.

```python
from audd import AudD

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

# limit caps metered fragments while you get the report and the edit right
matches = audd.recognize_enterprise(
    "https://audd.tech/example.mp3",
    limit=25,
    return_metadata=["apple_music", "spotify"],  # optional streaming links per match
)

for m in matches:
    print(f"{m.start_seconds:.1f}s  {m.artist} — {m.title}  (score {m.score})")
```

Each printed line is one recognized fragment, in time order. A music bed that
runs for a minute appears as several consecutive matches naming the same track
(Step 3 merges them into one segment). Stretches that are pure speech or crowd
noise produce no match at all — that's a gap, not an error, and exactly what
you want: those stretches stay untouched.

> **Always set `limit` during development.** The enterprise endpoint bills per
> 12 seconds of audio processed. A two-hour broadcast is hundreds of fragments;
> an unbounded call meters all of them. Start with `limit=25`, get the report
> and the edit right, then raise the cap for the full recording.

### Step 2: File positions, not song positions

The mute filter and the cut math both consume file positions, and
`start_seconds` / `end_seconds` are exactly that: where this song plays in
your file, in seconds (e.g. `64.2` to `71.6`). Feed them straight to a player
or `ffmpeg`. They're `None` only when a fragment arrived without a usable
position.

Don't reach for `start_offset` / `end_offset` — those are the raw offsets the
seconds are derived from, milliseconds *within* AudD's internal 12-second scan
fragment (0–~12000), not file seconds. And don't use `timecode` as a file
position: it's the position inside the matched recording, telling you which
part of the song was playing, not where in your file it plays.

### Step 3: Build music segments from the matches

The report and the edit both want *one* segment per continuous play — "this
music runs from 64.2s to 96.0s" — not a row per fragment. Collapse consecutive
matches naming the same `(artist, title)` into one segment spanning from the
first match's `start_seconds` to the last match's `end_seconds`.

```python
def build_segments(matches):
    """Collapse consecutive same-track matches into one segment with a time range."""
    segments = []
    for m in matches:
        if m.start_seconds is None:
            continue  # no usable position for this match — skip it
        end = m.end_seconds if m.end_seconds is not None else m.start_seconds + 12
        extending = (
            segments
            and segments[-1]["artist"] == m.artist
            and segments[-1]["title"] == m.title
        )
        if extending:
            segments[-1]["end"] = max(segments[-1]["end"], end)  # still playing
            continue
        segments.append({
            "artist": m.artist,
            "title": m.title,
            "album": m.album,
            "label": m.label,
            "isrc": m.isrc,
            "upc": m.upc,
            "song_link": m.song_link,
            "score": m.score,
            "start": m.start_seconds,
            "end": end,
        })
    return segments
```

A 30-second stinger becomes one segment with a real start and end. Because the
SDK parses responses leniently, any field can be absent or `None`; the
`start_seconds` guard and the `end_seconds` fallback cover the fragment that
arrives without a position.

### Step 4: Format positions into HH:MM:SS

A report wants clock times, not raw seconds. Format the file-absolute
positions:

```python
def fmt_hms(total_seconds: float) -> str:
    s = int(total_seconds)
    h, rem = divmod(s, 3600)
    m, sec = divmod(rem, 60)
    return f"{h:02d}:{m:02d}:{sec:02d}"


print(fmt_hms(64.2))  # -> "00:01:04"
```

This always gives a zero-padded `HH:MM:SS`, which reads cleanly for a recording
that runs past an hour.

### Step 5: Render the flag report

Turn the segments into a report a reviewer can act on — both as structured rows
(for a compliance log or a downstream tool) and as a readable summary.

```python
def flag_report(matches, source: str) -> list[dict]:
    rows = []
    for seg in build_segments(matches):
        rows.append({
            "start_seconds": round(seg["start"], 2),
            "end_seconds": round(seg["end"], 2),
            "start": fmt_hms(seg["start"]),
            "end": fmt_hms(seg["end"]),
            "artist": seg["artist"],
            "title": seg["title"],
            "label": seg["label"],
            "isrc": seg["isrc"],
            "song_link": seg["song_link"],
            "score": seg["score"],
            "source": source,
        })
    return rows


def render_report(rows: list[dict]) -> str:
    lines = [f"Music segments flagged: {len(rows)}", ""]
    for i, r in enumerate(rows, 1):
        lines += [
            f"[{i}] {r['start']}–{r['end']}  {r['artist']} — {r['title']}",
            f"     label: {r['label']}   isrc: {r['isrc']}",
            f"     link:  {r['song_link']}   score: {r['score']}",
            "",
        ]
    return "\n".join(lines)


rows = flag_report(matches, "https://audd.tech/example.mp3")
print(render_report(rows))
```

Each row names the recording and locates it by a file-absolute span you can
seek straight to. An empty report means the scan recognized no music in the
covered range — a clean result, not an error.

### Step 6: Mute the flagged segments

If you want to keep the recording's full length and timeline (so existing
chapter marks, subtitles, and clip references still line up), mute the audio
over each flagged span and leave everything else intact. `ffmpeg`'s `volume`
filter with an `enable` expression zeroes the audio only between the start and
end of each segment:

```python
import subprocess


def build_mute_filter(rows: list[dict]) -> str:
    """One volume filter that drops audio to 0 across every flagged span."""
    clauses = [
        f"volume=enable='between(t,{r['start_seconds']},{r['end_seconds']})':volume=0"
        for r in rows
    ]
    return ",".join(clauses)


def mute_segments(input_path: str, output_path: str, rows: list[dict]) -> None:
    if not rows:
        return  # nothing flagged — leave the file as-is
    af = build_mute_filter(rows)
    subprocess.run(
        [
            "ffmpeg", "-y", "-i", input_path,
            "-af", af,
            "-c:v", "copy",   # video untouched; only the audio is re-encoded
            output_path,
        ],
        check=True,
    )


mute_segments("event.mp4", "event-muted.mp4", rows)
```

Chaining one `volume=...:volume=0` clause per segment produces a single audio
filter that silences each flagged span and passes the rest through. `-c:v copy`
leaves the video stream byte-for-byte; only the audio is re-encoded. The output
has the same duration and timeline as the source — the music is just gone.

### Step 7: Or cut the flagged segments out

If instead you want the music *removed* — a shorter recording with the music
segments excised — keep the spans that are **not** flagged and concatenate
them. Invert the flagged spans against the file duration, then cut and join the
keep-spans:

```python
def keep_spans(rows: list[dict], duration: float) -> list[tuple[float, float]]:
    """Invert flagged spans into the spans to keep, across [0, duration]."""
    flagged = sorted((r["start_seconds"], r["end_seconds"]) for r in rows)
    spans, cursor = [], 0.0
    for start, end in flagged:
        if start > cursor:
            spans.append((cursor, start))   # speech/crowd before this music
        cursor = max(cursor, end)
    if cursor < duration:
        spans.append((cursor, duration))    # tail after the last music
    return spans


def media_duration(input_path: str) -> float:
    out = subprocess.run(
        ["ffprobe", "-v", "error", "-show_entries", "format=duration",
         "-of", "default=nokey=1:noprint_wrappers=1", input_path],
        check=True, capture_output=True, text=True,
    )
    return float(out.stdout.strip())


def cut_segments(input_path: str, output_path: str, rows: list[dict]) -> None:
    if not rows:
        return
    spans = keep_spans(rows, media_duration(input_path))
    # Build one trimmed video+audio pair per keep-span, then concat them.
    parts, filters = [], []
    for i, (start, end) in enumerate(spans):
        filters.append(
            f"[0:v]trim=start={start}:end={end},setpts=PTS-STARTPTS[v{i}];"
            f"[0:a]atrim=start={start}:end={end},asetpts=PTS-STARTPTS[a{i}]"
        )
        parts.append(f"[v{i}][a{i}]")
    concat = f"{''.join(parts)}concat=n={len(spans)}:v=1:a=1[v][a]"
    filtergraph = ";".join(filters + [concat])
    subprocess.run(
        [
            "ffmpeg", "-y", "-i", input_path,
            "-filter_complex", filtergraph,
            "-map", "[v]", "-map", "[a]",
            output_path,
        ],
        check=True,
    )


cut_segments("event.mp4", "event-cut.mp4", rows)
```

This trims each keep-span out of the source and concatenates them, dropping the
music entirely. The result is shorter than the source by the total duration of
the flagged segments. (For an audio-only recording, drop the `[0:v]`/`v=1`
halves and keep just the `atrim`/audio concat.)

> **Muting preserves the timeline; cutting changes it.** Mute when other assets
> (captions, chapter marks, highlight clips) reference timestamps in the
> original — their positions still line up. Cut when you want the music gone and
> a shorter file, and you're willing to re-derive any timestamped assets against
> the new timeline.

### Step 8: Run it end to end

```python
def process(source: str, input_path: str, mode: str = "mute") -> list[dict]:
    matches = audd.recognize_enterprise(source, limit=50,
                                        return_metadata=["apple_music", "spotify"])
    rows = flag_report(matches, source)
    print(render_report(rows))
    if mode == "mute":
        mute_segments(input_path, "event-cleaned.mp4", rows)
    elif mode == "cut":
        cut_segments(input_path, "event-cleaned.mp4", rows)
    return rows


rows = process("https://audd.tech/example.mp3", "event.mp4", mode="mute")
```

`source` is what AudD recognizes (URL or uploaded bytes); `input_path` is the
local file you edit. They're often the same recording — recognize a hosted copy
or upload the bytes, then apply the resulting spans to your local master.

## What you get back

One `EnterpriseMatch` per recognized fragment, in time order. The fields this
recipe reads:

| Field | Type | Role here |
|---|---|---|
| `start_seconds`, `end_seconds` | float \| None | Where this song plays **in your file**, in seconds. The values you flag, mute, and cut on. `None` only when a fragment had no usable position. |
| `artist`, `title` | str \| None | The recording, named. |
| `album`, `release_date` | str \| None | The release it appears on, and when. |
| `label` | str \| None | The releasing label. |
| `isrc`, `upc` | str \| None | Recording/release identifiers (Startup plan or higher). |
| `song_link` | str \| None | `lis.tn` universal link to the track. |
| `score` | int \| None | Match confidence for the fragment. |
| `start_offset`, `end_offset` | int \| None | Raw milliseconds **within** the 12-second scan fragment that `start_seconds`/`end_seconds` are derived from. Rarely needed directly. |
| `timecode` | str \| None | Position **inside the matched recording**, not your file. Never use it to locate a segment. |

For the example file, a match might come back with `start_seconds = 64.2` and
`end_seconds = 71.6` — the span that feeds both the
`volume=enable='between(t,64.2,71.6)':volume=0` mute filter and the cut/keep
math.

## Handling errors

The SDK raises typed exceptions; catch the ones you can act on.

- **Authentication errors** (`AudDAuthenticationError`) — bad or missing token.
  Fail at startup.
- **Quota / subscription errors** (`AudDSubscriptionError`) — request limit hit,
  or the enterprise endpoint isn't enabled on your token. The enterprise
  endpoint and ISRC/UPC both depend on plan tier; surface these to the account
  owner rather than retrying.
- **Invalid-audio errors** — the URL or file wasn't decodable audio/video.
  Treat it as a hard failure for that source, not a silent empty report.
- **Connection errors** (`AudDConnectionError`) — transient. Retry with backoff.
- **`ffmpeg`/`ffprobe` failures** — a non-zero exit raises
  `subprocess.CalledProcessError`. A bad input path or codec issue surfaces
  here, separate from recognition; don't conflate it with an empty report.

```python
from audd.errors import AudDError

try:
    rows = process(source, input_path, mode="mute")
except AudDError as e:
    # any AudD API/transport failure — log and decide; retry connection errors
    print(f"Recognition failed: {e}")
    rows = []
```

An empty report (no flagged segments) is a valid, distinct outcome from an
error — the covered range recognized no music, so there's nothing to mute or
cut, and the cleaning steps no-op.

## Going further

- Re-running recognition re-meters every fragment. Run the scan once per
  recording and persist the report; rebuild the muted or cut file from the
  stored spans rather than re-scanning.
- For a multi-hour broadcast where you only need a rough map of where music
  sits, sample with `every`/`skip` to trade coverage for metered fragments —
  see [Enterprise cost optimization](/resources/concepts/enterprise-cost-control).
- If your goal is a takedown filing rather than an in-place edit, the same
  `start_seconds`/`end_seconds` feed an evidence pack — see
  [Generate DMCA takedown evidence](/resources/recipes/dmca-takedown-evidence).
- To scan files as they're uploaded rather than after the fact, see
  [Build a copyright scanner for user-uploaded content](/resources/recipes/ugc-copyright-scanner).

---

**Related**

- [Generate DMCA takedown evidence](/resources/recipes/dmca-takedown-evidence)
- [Build a copyright scanner for user-uploaded content](/resources/recipes/ugc-copyright-scanner)
- [Enterprise cost optimization](/resources/concepts/enterprise-cost-control)
- [Python SDK docs](https://docs.audd.io/sdks/python)
- [API reference](https://docs.audd.io)