91 lines
3.2 KiB
Python
91 lines
3.2 KiB
Python
|
|
"""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 re
|
||
|
|
import subprocess
|
||
|
|
import threading
|
||
|
|
|
||
|
|
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 one episode (download and decode)
|
||
|
|
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` (ffmpeg reads it straight from there)."""
|
||
|
|
import imageio_ffmpeg
|
||
|
|
proc = subprocess.run(
|
||
|
|
[imageio_ffmpeg.get_ffmpeg_exe(), '-nostdin', '-hide_banner', '-nostats',
|
||
|
|
'-user_agent', 'diora podcast silence', '-vn', '-sn', '-dn', '-i', url,
|
||
|
|
'-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 ['ffmpeg failed']
|
||
|
|
raise RuntimeError(tail[0][:200])
|
||
|
|
return parse(proc.stderr)
|
||
|
|
|
||
|
|
|
||
|
|
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()
|