Install fpcalc in the image; pull cut edges inward onto silence

The Dockerfile installed ffmpeg but not libchromaprint-tools, so in any
deploy the fingerprint and discover tiers were inert rather than broken:
fpcalc_available() returned False, the command exited tidily, and a
healthy-looking image silently never matched an ad.

Cutting a real episode (rather than trusting detection metrics) showed a
fingerprint match ends where the ad stops being recognisable, not where the
break ends. One edge sat 2.3s inside the resumed narration and the cut ate
the opening of "It was 405 on the morning of Thursday, June 19, 2014".

Snapping to the nearest silence was tried first and measured worse, 2.3s to
2.88s clipped, because the closest silence was a pause inside the narration.
Direction is the fix: starts only move later, ends only move earlier, so a
span shrinks and never grows and every error leaves a sliver of ad rather
than deleting a sentence.

This only helps where silence exists. On that episode it tightened three of
five edges and left the 2.31s clip untouched, since the ad-to-narration
transition has no detectable pause. That residual is a detection-edge
problem, likely BRIDGE_FRAMES extending a run, and is not solved here.
This commit is contained in:
flan
2026-07-23 21:19:12 +00:00
parent b80054b33e
commit 58b21b3c19
4 changed files with 145 additions and 1 deletions
+18
View File
@@ -96,6 +96,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **`fpcalc` is now installed in the image.** The Dockerfile installed `ffmpeg` but not
`libchromaprint-tools`, so in any deploy the `fingerprint`/`discover` tiers were not broken but
INERT: `fpcalc_available()` returned False, the command exited with a tidy message, and a
healthy-looking image silently never matched an ad.
- **Cut edges are pulled inward onto silence (`snap_spans_to_silence`).** Found by cutting a real
episode rather than by any metric: a fingerprint match ends where the ad stops being
*recognisable*, not where the break ends, which left one edge 2.3s inside the resumed narration
and ate the opening of "It was 405 on the morning of Thursday, June 19, 2014".
- Snapping to the NEAREST silence was tried first and measured **worse** (2.3s → 2.88s
clipped): the closest silence was a pause *within* the narration. Direction is the fix —
starts only move later, ends only move earlier, so a span can shrink and never grow, and
every error leaves a sliver of ad rather than deleting a sentence.
- **Known limit:** it only helps where silence exists. On that same episode it tightened 3 of 5
edges but left the 2.31s clip untouched, because the ad→narration transition has no
detectable pause. The residual is a detection-edge problem (likely `BRIDGE_FRAMES` extending
a run), not a cut problem, and is not solved here.
- **`cut` no longer removes audio on the strength of any span it can find.** It selected every
`ad_segments` row regardless of source, so the new discovery tiers would have silently started
deleting audio: `dai` spans whose END is only an upper bound (over-cutting into editorial
+5 -1
View File
@@ -37,7 +37,11 @@ ENV PATH="/app/.venv/bin:$PATH" \
# gosu drops from root to the unprivileged `adscrub` user after the entrypoint fixes
# ownership of /app/data. uid/gid 568 matches TrueNAS SCALE's standard "apps" account,
# same convention as hark/tiltmeter.
RUN apt-get update && apt-get install -y --no-install-recommends gosu ffmpeg \
# libchromaprint-tools provides fpcalc, which the `fingerprint`/`discover` tiers shell out to.
# Without it those tiers are not broken so much as INERT: fpcalc_available() returns False, the
# command exits with a tidy message, and an image that looks healthy silently never matches an
# ad. A missing system binary is the whole difference between the cheap tiers running and not.
RUN apt-get update && apt-get install -y --no-install-recommends gosu ffmpeg libchromaprint-tools \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --gid 568 adscrub \
&& useradd --system --uid 568 --gid 568 --no-create-home adscrub
+72
View File
@@ -43,6 +43,75 @@ from .db import utcnow
CUT_SOURCES = ("chapter", "llm", "repeat", "fpmatch")
# How far an ad edge may be moved to land on silence. Ad breaks are bounded by a beat of
# silence, so the true boundary is nearly always within a second or two of the detected one.
SNAP_WINDOW = 2.5
SILENCE_DB = -35 # ffmpeg silencedetect threshold
SILENCE_MIN = 0.30 # ignore pauses shorter than this; mid-sentence breaths are not boundaries
def detect_silences(
audio_path: Path, noise_db: int = SILENCE_DB, min_duration: float = SILENCE_MIN
) -> list[tuple[float, float]]:
"""Silent intervals in the file, via one ffmpeg silencedetect pass."""
proc = subprocess.run(
["ffmpeg", "-nostdin", "-i", str(audio_path),
"-af", f"silencedetect=noise={noise_db}dB:d={min_duration}", "-f", "null", "-"],
capture_output=True, text=True,
)
silences: list[tuple[float, float]] = []
start: float | None = None
for line in proc.stderr.splitlines():
if "silence_start:" in line:
try:
start = float(line.split("silence_start:")[1].split()[0])
except (IndexError, ValueError):
start = None
elif "silence_end:" in line and start is not None:
try:
silences.append((start, float(line.split("silence_end:")[1].split()[0])))
except (IndexError, ValueError):
pass
start = None
return silences
def snap_spans_to_silence(
spans: list[tuple[float, float]],
silences: list[tuple[float, float]],
window: float = SNAP_WINDOW,
) -> list[tuple[float, float]]:
"""Pull each ad edge INWARD onto silence, so a cut never runs into speech.
Why this exists, from a real cut: a fingerprint match ends where the ad RECORDING stops
being recognisable, not where the break ends. On Casefile episode 1 that left the edge 2.3s
inside the resumed narration, so the cut ate the opening of "It was 405 on the morning of
Thursday, June 19, 2014" — invisible to every detection metric, obvious to a listener.
Snapping to the NEAREST silence was tried first and measured worse (2.3s -> 2.88s clipped):
the closest silence to that edge was a pause *within* the narration, and "nearest" has no
idea which side of the edge is ad and which is content. Direction is the whole fix — starts
only move later, ends only move earlier, so a span can shrink and never grow. That biases
every error towards leaving a sliver of ad rather than deleting a sentence, which is the
right way round: the leftover ad is audible and harmless, the deleted words are gone.
An edge with no silence within `window` is left exactly where it was.
"""
if not silences:
return list(spans)
edges = sorted(s for pair in silences for s in pair)
out: list[tuple[float, float]] = []
for start, end in spans:
later = [x for x in edges if start <= x <= start + window]
earlier = [x for x in edges if end - window <= x <= end]
new_start = min(later) if later else start
new_end = max(earlier) if earlier else end
# shrinking must never invert or empty the span
out.append((new_start, new_end) if new_end > new_start else (start, end))
return out
def compute_keep_spans(
ad_spans: list[tuple[float, float]], duration: float
) -> list[tuple[float, float]]:
@@ -155,6 +224,9 @@ def cut_episode(
(episode["id"], *sources),
)
]
# A detected edge is where the ad stopped being RECOGNISABLE, which is not quite where the
# break ends; snapping to real silence keeps the cut off the first words of returning content.
ad_spans = snap_spans_to_silence(ad_spans, detect_silences(audio_path))
keep_spans = compute_keep_spans(ad_spans, duration)
ad_seconds = duration - sum(end - start for start, end in keep_spans)
+50
View File
@@ -243,3 +243,53 @@ def test_untrusted_spans_are_not_removed_from_audio(tmp_path):
def test_cut_sources_excludes_the_edge_unsafe_tiers():
assert "dai" not in cut.CUT_SOURCES and "recur" not in cut.CUT_SOURCES
assert {"chapter", "llm", "repeat", "fpmatch"} == set(cut.CUT_SOURCES)
# --- snapping cut edges to silence ---
SIL = [(100.0, 101.0), (200.0, 201.5), (400.0, 400.4)]
def test_snaps_an_edge_that_lands_inside_speech():
"""The real defect: a fingerprint edge 2.3s inside resumed narration clipped its first words."""
assert cut.snap_spans_to_silence([(99.5, 202.0)], SIL) == [(100.0, 201.5)]
def test_only_ever_shrinks_a_span():
"""Snapping to the NEAREST silence was measured worse on real audio (2.3s -> 2.88s clipped):
the closest silence was a pause inside the narration. Starts may only move later and ends
only earlier, so every error leaves a sliver of ad instead of deleting a sentence."""
start, end = cut.snap_spans_to_silence([(100.6, 199.0)], SIL)[0]
assert start >= 100.6 and end <= 199.0
# a silence just PAST the end must not be allowed to extend the cut into speech
assert cut.snap_spans_to_silence([(99.5, 199.0)], SIL)[0][1] <= 199.0
def test_leaves_edges_with_no_silence_nearby_alone():
"""Guessing further than the evidence reaches is how you start deleting content."""
assert cut.snap_spans_to_silence([(500.0, 600.0)], SIL) == [(500.0, 600.0)]
def test_respects_the_snap_window():
far = [(100.0, 600.0)]
assert cut.snap_spans_to_silence(far, SIL, window=0.1) == far
def test_never_inverts_or_empties_a_span():
"""Two edges snapping onto the same silence must not produce a zero/negative span."""
out = cut.snap_spans_to_silence([(400.1, 400.3)], SIL)
assert out[0][1] > out[0][0]
def test_no_silences_detected_is_a_no_op():
spans = [(10.0, 20.0)]
assert cut.snap_spans_to_silence(spans, []) == spans
def test_parses_ffmpeg_silencedetect_output(monkeypatch):
class P:
stderr = ("[silencedetect] silence_start: 12.5\n"
"[silencedetect] silence_end: 13.25 | silence_duration: 0.75\n"
"[silencedetect] silence_start: 40.0\n") # unterminated -> ignored
monkeypatch.setattr(cut.subprocess, "run", lambda *a, **k: P())
assert cut.detect_silences(cut.Path("x.mp3")) == [(12.5, 13.25)]