"""Skip silence in the browser: the pauses of an episode, found once on the server. Android's player skips silence itself. 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 with ffmpeg's silencedetect and keeps the pauses on the episode (PodcastEpisode.silences); the browser then jumps over them while it plays (podcasts-web.js). """ import logging import os import re import subprocess import tempfile import threading import requests from django.db import close_old_connections log = logging.getLogger(__name__) NOISE = '-35dB' # quieter than this counts as silence MIN_SILENCE = 0.4 # seconds; shorter pauses stay (they belong to speech) 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 _START = re.compile(r'silence_start: (-?[\d.]+)') _END = re.compile(r'silence_end: ([\d.]+)') _running = set() _lock = threading.Lock() _slots = threading.BoundedSemaphore(MAX_RUNNING) def parse(stderr): """ffmpeg's silencedetect output -> [[start, end], …] in seconds (2 decimals).""" spans = [] start = None for line in stderr.splitlines(): m = _START.search(line) if m: start = max(0.0, float(m.group(1))) continue m = _END.search(line) if m and start is not None: end = float(m.group(1)) if end - start >= MIN_SILENCE: spans.append([round(start, 2), round(end, 2)]) start = None return spans def analyze(url): """The pauses 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.""" import imageio_ffmpeg 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) proc = subprocess.run( [imageio_ffmpeg.get_ffmpeg_exe(), '-nostdin', '-hide_banner', '-nostats', '-vn', '-sn', '-dn', '-i', path, '-af', f'silencedetect=noise={NOISE}:d={MIN_SILENCE}', '-f', 'null', '-'], capture_output=True, text=True, timeout=TIMEOUT, ) if proc.returncode != 0: tail = (proc.stderr or '').strip().splitlines()[-1:] or [f'ffmpeg failed ({proc.returncode})'] raise RuntimeError(tail[0][:200]) return parse(proc.stderr) finally: try: os.remove(path) except OSError: pass def ensure(episode_id): """Find the episode's pauses 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 = {'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 = {'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()