diora-web/podcasts/silence.py
mrwnslz de1940b4e5
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 8m20s
Test / e2e (push) Successful in 1m53s
Stille überspringen: Regeln des Pausen-Kürzers, sanftere Übergänge
Der Server findet die Pausen jetzt nach den Regeln der App (PauseCutter):
10-ms-Fenster, Rauschboden als gleitendes Perzentil, ein Fünftel jeder Pause
bleibt (60–250 ms), halb davor, halb danach. Ältere Ergebnisse werden neu
berechnet (v: 2).

Der Player läuft durch diese Teile höchstens 4× schnell statt 8×: Chromium
schaltet darüber stumm, und weil currentTime in 10–20-ms-Schritten läuft,
landete der Wechsel zurück bei 8× bis zu 200 ms in der Sprache. Pro Pause
wird das Tempo gewählt, das am meisten spart und trotzdem rechtzeitig endet.
Das Tempo des Podcasts setzt die Rate während einer Pause nicht mehr zurück.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:18:16 +02:00

227 lines
7.8 KiB
Python

"""Skip silence in the browser: what to cut out of an episode's pauses, found once on the server.
Android shortens pauses while it plays (diora-android PauseCutter.kt). A browser
can't listen to the audio of most podcast servers (no CORS: Web Audio hears only
zeros), so the server reads the file once and keeps the result on the episode
(PodcastEpisode.silences); the browser then runs fast through those parts while
it plays (podcasts-web.js).
The rules are PauseCutter's, so a pause sounds the same everywhere: the audio
is judged in 10 ms windows against the recording itself — silence is well below
the recent speech level *and* close to the recording's noise floor, never above
-30 dB — and a pause of 90 ms or more keeps a fifth of its length (at least
60 ms, at most 250 ms), half before and half after the part that goes.
"""
import logging
import math
import os
import subprocess
import tempfile
import threading
import warnings
import requests
from django.db import close_old_connections
with warnings.catch_warnings():
warnings.simplefilter('ignore', DeprecationWarning)
try:
import audioop # the RMS of a window in C; gone from Python 3.13 (then: audioop-lts)
except ImportError: # pragma: no cover
audioop = None
log = logging.getLogger(__name__)
VERSION = 2 # what's stored on the episode; an older result is found anew
RATE = 8000 # Hz, mono: enough to judge loudness
WINDOW = 0.01 # s
TIMEOUT = 900 # seconds for decoding one episode
MAX_BYTES = 800 * 1024 * 1024 # a larger file isn't an episode to analyse
MAX_RUNNING = 2 # episodes analysed at the same time
# PauseCutter.Params
MIN_PAUSE_MS = 90
KEEP_MIN_MS = 60
KEEP_RATIO = 0.2
KEEP_MAX_MS = 250
FADE_MS = 12
BELOW_SPEECH_DB = 20.0
ABOVE_NOISE_DB = 12.0
CEILING_DB = -30.0
FLOOR_DB = -70.0
DECAY_DB_PER_WINDOW = 6.0 * WINDOW # 6 dB per second
NOISE_SHARE = 0.1
NOISE_WINDOWS = 1000 # 10 s
NOISE_MIN_DB = -90.0
NOISE_MAX_DB = -35.0
_running = set()
_lock = threading.Lock()
_slots = threading.BoundedSemaphore(MAX_RUNNING)
class _NoiseHistory:
"""The last windows' levels in 1 dB steps, for a running percentile."""
BINS = 121
def __init__(self):
self.ring = [0] * NOISE_WINDOWS
self.bins = [0] * self.BINS
self.count = 0
self.at = 0
def add(self, db):
b = min(max(int(db + 120.0), 0), self.BINS - 1)
if self.count == NOISE_WINDOWS:
self.bins[self.ring[self.at]] -= 1
else:
self.count += 1
self.ring[self.at] = b
self.bins[b] += 1
self.at = (self.at + 1) % NOISE_WINDOWS
def level(self):
if self.count < NOISE_WINDOWS // 10:
return -60.0 # not enough seen yet
target = max(int(self.count * NOISE_SHARE), 1)
seen = 0
for b, n in enumerate(self.bins):
seen += n
if seen >= target:
return b - 120.0
return 0.0
def _cut_of(start, end):
"""The part of the pause [start, end) (window indexes) that goes, in seconds, or None."""
p = round((end - start) * WINDOW * 1000) # ms
if p < MIN_PAUSE_MS:
return None
keep = min(max(round(p * KEEP_RATIO), KEEP_MIN_MS), KEEP_MAX_MS, p)
if p - keep < FADE_MS:
return None
head = keep // 2
tail = keep - head
return [round(start * WINDOW + head / 1000, 3), round(end * WINDOW - tail / 1000, 3)]
def cuts(levels):
"""[[from, to], …] in seconds: what to leave out, for 10 ms window levels in dB."""
level = -20.0 # recent speech level
noise = -60.0 # noise floor
history = _NoiseHistory()
out = []
start = None
for i, db in enumerate(levels):
threshold = max(min(level - BELOW_SPEECH_DB, noise + ABOVE_NOISE_DB, CEILING_DB), FLOOR_DB)
silent = db < threshold
level = db if db > level else max(FLOOR_DB, level - DECAY_DB_PER_WINDOW)
history.add(db)
noise = min(max(history.level(), NOISE_MIN_DB), NOISE_MAX_DB)
if silent:
if start is None:
start = i
elif start is not None:
cut = _cut_of(start, i)
if cut:
out.append(cut)
start = None
return out # a pause running to the end stays as it is
def _level_db(window):
if audioop is not None:
rms = audioop.rms(window, 2)
else: # pragma: no cover
samples = memoryview(window).cast('h')
rms = math.sqrt(sum(s * s for s in samples) / len(samples))
return -120.0 if rms < 1 else 20 * math.log10(rms / 32768)
def levels_of(path):
"""10 ms window levels (dB full scale) of the audio file at `path`."""
import imageio_ffmpeg
proc = subprocess.Popen(
[imageio_ffmpeg.get_ffmpeg_exe(), '-nostdin', '-hide_banner', '-loglevel', 'error',
'-vn', '-sn', '-dn', '-i', path, '-ac', '1', '-ar', str(RATE), '-f', 's16le', '-'],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
size = int(RATE * WINDOW) * 2
levels = []
rest = b''
timer = threading.Timer(TIMEOUT, proc.kill)
timer.start()
try:
while True:
chunk = proc.stdout.read(1 << 20)
if not chunk:
break
data = rest + chunk
whole = len(data) // size * size
for i in range(0, whole, size):
levels.append(_level_db(data[i:i + size]))
rest = data[whole:]
err = proc.stderr.read().decode('utf-8', 'replace')
if proc.wait() != 0:
raise RuntimeError((err.strip().splitlines()[-1:] or [f'ffmpeg failed ({proc.returncode})'])[0][:200])
finally:
timer.cancel()
return levels
def analyze(url):
"""What to cut out of the audio at `url`. Downloaded here first: the static ffmpeg
build can't look up host names (it crashes doing so), it reads a local file."""
fd, path = tempfile.mkstemp(suffix='.audio')
try:
with os.fdopen(fd, 'wb') as out, requests.get(
url, stream=True, timeout=(10, 60), headers={'User-Agent': 'diora podcast silence'}) as r:
if r.status_code >= 400:
raise RuntimeError(f'HTTP {r.status_code}')
size = 0
for chunk in r.iter_content(1 << 20):
size += len(chunk)
if size > MAX_BYTES:
raise RuntimeError('file too large')
out.write(chunk)
return cuts(levels_of(path))
finally:
try:
os.remove(path)
except OSError:
pass
def current(silences):
"""Whether a stored result is from these rules (else it's found anew)."""
return bool(silences) and silences.get('v') == VERSION
def ensure(episode_id):
"""Find the episode's cuts in the background, unless that runs already."""
with _lock:
if episode_id in _running:
return
_running.add(episode_id)
threading.Thread(target=_work, args=(episode_id,), daemon=True).start()
def _work(episode_id):
from .models import PodcastEpisode
try:
with _slots:
close_old_connections()
ep = PodcastEpisode.objects.filter(pk=episode_id).only('id', 'audio_url').first()
if ep is None:
return
try:
result = {'v': VERSION, 'spans': analyze(ep.audio_url)}
except Exception as e: # unreachable file, timeout, not audio: remembered, not retried
log.warning('silences of episode %s failed: %s', episode_id, e)
result = {'v': VERSION, 'spans': [], 'error': str(e)[:200] or type(e).__name__}
PodcastEpisode.objects.filter(pk=episode_id).update(silences=result)
finally:
with _lock:
_running.discard(episode_id)
close_old_connections()