diora-web/podcasts/silence.py

114 lines
4.1 KiB
Python
Raw Permalink Normal View History

"""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()