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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user