2026-10-15 11:18:16 +02:00
|
|
|
"""Skip silence in the browser: what to cut out of an episode's pauses, found once on the server.
|
2026-10-04 12:38:06 +02:00
|
|
|
|
2026-10-15 11:18:16 +02:00
|
|
|
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.
|
2026-10-04 12:38:06 +02:00
|
|
|
"""
|
|
|
|
|
import logging
|
2026-10-15 11:18:16 +02:00
|
|
|
import math
|
2026-10-04 12:45:19 +02:00
|
|
|
import os
|
2026-10-04 12:38:06 +02:00
|
|
|
import subprocess
|
2026-10-04 12:45:19 +02:00
|
|
|
import tempfile
|
2026-10-04 12:38:06 +02:00
|
|
|
import threading
|
2026-10-15 11:18:16 +02:00
|
|
|
import warnings
|
2026-10-04 12:38:06 +02:00
|
|
|
|
2026-10-04 12:45:19 +02:00
|
|
|
import requests
|
|
|
|
|
|
2026-10-04 12:38:06 +02:00
|
|
|
from django.db import close_old_connections
|
|
|
|
|
|
2026-10-15 11:18:16 +02:00
|
|
|
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
|
|
|
|
|
|
2026-10-04 12:38:06 +02:00
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
2026-10-15 11:18:16 +02:00
|
|
|
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
|
2026-10-04 12:45:19 +02:00
|
|
|
TIMEOUT = 900 # seconds for decoding one episode
|
|
|
|
|
MAX_BYTES = 800 * 1024 * 1024 # a larger file isn't an episode to analyse
|
2026-10-04 12:38:06 +02:00
|
|
|
MAX_RUNNING = 2 # episodes analysed at the same time
|
|
|
|
|
|
2026-10-15 11:18:16 +02:00
|
|
|
# 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
|
2026-10-04 12:38:06 +02:00
|
|
|
|
|
|
|
|
_running = set()
|
|
|
|
|
_lock = threading.Lock()
|
|
|
|
|
_slots = threading.BoundedSemaphore(MAX_RUNNING)
|
|
|
|
|
|
|
|
|
|
|
2026-10-15 11:18:16 +02:00
|
|
|
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 = []
|
2026-10-04 12:38:06 +02:00
|
|
|
start = None
|
2026-10-15 11:18:16 +02:00
|
|
|
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)
|
2026-10-04 12:38:06 +02:00
|
|
|
start = None
|
2026-10-15 11:18:16 +02:00
|
|
|
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
|
2026-10-04 12:38:06 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def analyze(url):
|
2026-10-15 11:18:16 +02:00
|
|
|
"""What to cut out of the audio at `url`. Downloaded here first: the static ffmpeg
|
2026-10-04 12:45:19 +02:00
|
|
|
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)
|
2026-10-15 11:18:16 +02:00
|
|
|
return cuts(levels_of(path))
|
2026-10-04 12:45:19 +02:00
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
os.remove(path)
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
2026-10-04 12:38:06 +02:00
|
|
|
|
|
|
|
|
|
2026-10-15 11:18:16 +02:00
|
|
|
def current(silences):
|
|
|
|
|
"""Whether a stored result is from these rules (else it's found anew)."""
|
|
|
|
|
return bool(silences) and silences.get('v') == VERSION
|
|
|
|
|
|
|
|
|
|
|
2026-10-04 12:38:06 +02:00
|
|
|
def ensure(episode_id):
|
2026-10-15 11:18:16 +02:00
|
|
|
"""Find the episode's cuts in the background, unless that runs already."""
|
2026-10-04 12:38:06 +02:00
|
|
|
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:
|
2026-10-15 11:18:16 +02:00
|
|
|
result = {'v': VERSION, 'spans': analyze(ep.audio_url)}
|
2026-10-04 12:38:06 +02:00
|
|
|
except Exception as e: # unreachable file, timeout, not audio: remembered, not retried
|
|
|
|
|
log.warning('silences of episode %s failed: %s', episode_id, e)
|
2026-10-15 11:18:16 +02:00
|
|
|
result = {'v': VERSION, 'spans': [], 'error': str(e)[:200] or type(e).__name__}
|
2026-10-04 12:38:06 +02:00
|
|
|
PodcastEpisode.objects.filter(pk=episode_id).update(silences=result)
|
|
|
|
|
finally:
|
|
|
|
|
with _lock:
|
|
|
|
|
_running.discard(episode_id)
|
|
|
|
|
close_old_connections()
|