From c1bbaccccf694594bb7f7114ef5773f8672f6234 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Sun, 4 Oct 2026 12:38:06 +0200 Subject: [PATCH] =?UTF-8?q?Stille=20=C3=BCberspringen=20im=20Browser/Deskt?= =?UTF-8?q?op=20(Server=20findet=20die=20Pausen);=20Ambient-Rekorder=20im?= =?UTF-8?q?=20Audio-Thread;=20sw=20v86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - podcasts/silence.py: ffmpeg (imageio-ffmpeg) findet die Pausen einer Folge einmal (silencedetect -35 dB, ab 0,4 s), im Hintergrund, höchstens 2 zugleich; gespeichert in PodcastEpisode.silences (Migration 0018); GET episodes//silences/ - podcasts-web.js: skip_silence als Einstellung (Gerät, pro Podcast vom Server), springt beim Abspielen über die Pausen (0,15 s bleiben) - Drift: Ringpuffer für die Körner als AudioWorklet statt ScriptProcessor im Hauptthread (gehäufte Aussetzer am Desktop, wenn die Seite kurz stockte) Co-Authored-By: Claude Opus 5.5 --- podcasts/migrations/0018_episode_silences.py | 18 ++++ podcasts/models.py | 3 + podcasts/silence.py | 90 ++++++++++++++++++++ podcasts/tests.py | 54 ++++++++++++ podcasts/urls.py | 1 + podcasts/views.py | 19 +++++ requirements.txt | 2 + static/ambient/index.html | 58 +++++++++---- static/js/podcasts-web.js | 46 +++++++++- static/js/sw.js | 2 +- 10 files changed, 271 insertions(+), 22 deletions(-) create mode 100644 podcasts/migrations/0018_episode_silences.py create mode 100644 podcasts/silence.py diff --git a/podcasts/migrations/0018_episode_silences.py b/podcasts/migrations/0018_episode_silences.py new file mode 100644 index 0000000..b646b4d --- /dev/null +++ b/podcasts/migrations/0018_episode_silences.py @@ -0,0 +1,18 @@ +# Generated by Django 6.1 on 2026-10-04 10:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('podcasts', '0017_feed_body_hash'), + ] + + operations = [ + migrations.AddField( + model_name='podcastepisode', + name='silences', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/podcasts/models.py b/podcasts/models.py index 87085bf..a3c95af 100644 --- a/podcasts/models.py +++ b/podcasts/models.py @@ -158,6 +158,9 @@ class PodcastEpisode(models.Model): chapters_fetched_at = models.DateTimeField(null=True, blank=True) # [{url, type, language}] from . transcripts = models.JSONField(default=list, blank=True) + # The pauses for skipping silence in the browser (silence.py), found on + # first request: {"spans": [[start, end], …]} (+ "error"); null = not yet. + silences = models.JSONField(null=True, blank=True) class Meta: unique_together = ('feed', 'guid') diff --git a/podcasts/silence.py b/podcasts/silence.py new file mode 100644 index 0000000..40774a7 --- /dev/null +++ b/podcasts/silence.py @@ -0,0 +1,90 @@ +"""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() diff --git a/podcasts/tests.py b/podcasts/tests.py index 37d9207..e33a44a 100644 --- a/podcasts/tests.py +++ b/podcasts/tests.py @@ -2668,3 +2668,57 @@ class DiscardTests(TestCase): self.assertEqual(self.client.get('/podcasts/episodes/discard/').status_code, 405) self.client.logout() self.assertEqual(self.post({'episode_ids': [self.eps[0].id]}).status_code, 401) + + +# --------------------------------------------------------------------------- +# Skip silence in the browser: pauses found on the server (silence.py) +# --------------------------------------------------------------------------- + +class SilenceTests(TestCase): + STDERR = """Input #0, mp3, from 'x': +[silencedetect @ 0x1] silence_start: 1.5 +[silencedetect @ 0x1] silence_end: 2.4 | silence_duration: 0.9 +[silencedetect @ 0x1] silence_start: 10 +[silencedetect @ 0x1] silence_end: 10.2 | silence_duration: 0.2 +[silencedetect @ 0x1] silence_start: -0.01 +[silencedetect @ 0x1] silence_end: 0.75 | silence_duration: 0.76 +[silencedetect @ 0x1] silence_start: 99.123456 +""" + + def setUp(self): + self.user = User.objects.create_user(username='alice', password='pw12345678') + self.client.force_login(self.user) + feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/f', title='F') + self.ep = PodcastEpisode.objects.create(feed=feed, guid='e', title='E', audio_url='https://example.org/e.mp3') + + def test_parse_keeps_pauses_long_enough(self): + from .silence import parse + # 0.2 s is too short; a start below 0 is 0; an open start at the end is dropped. + self.assertEqual(parse(self.STDERR), [[1.5, 2.4], [0.0, 0.75]]) + + def test_first_request_starts_the_search_then_answers_with_the_pauses(self): + with patch('podcasts.silence.ensure') as ensure: + r = self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/') + self.assertEqual(r.json(), {'ready': False}) + ensure.assert_called_once_with(self.ep.id) + with patch('podcasts.silence.analyze', return_value=[[1.5, 2.4]]): + from .silence import _work + _work(self.ep.id) + r = self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/') + self.assertEqual(r.json(), {'ready': True, 'spans': [[1.5, 2.4]]}) + + def test_a_failure_is_remembered_not_retried(self): + from .silence import _work + with patch('podcasts.silence.analyze', side_effect=RuntimeError('HTTP error 404 Not Found')): + _work(self.ep.id) + with patch('podcasts.silence.ensure') as ensure: + r = self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/') + ensure.assert_not_called() + self.assertEqual(r.json(), {'ready': True, 'spans': [], 'error': 'HTTP error 404 Not Found'}) + + def test_only_own_episodes(self): + bob = User.objects.create_user(username='bob', password='pw12345678') + self.client.force_login(bob) + self.assertEqual(self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/').status_code, 404) + self.client.logout() + self.assertEqual(self.client.get(f'/podcasts/episodes/{self.ep.id}/silences/').status_code, 401) diff --git a/podcasts/urls.py b/podcasts/urls.py index 348a522..2ec6463 100644 --- a/podcasts/urls.py +++ b/podcasts/urls.py @@ -19,6 +19,7 @@ urlpatterns = [ path('episodes/discard/', views.discard_episodes, name='podcast_discard_episodes'), path('episodes//', views.episode_detail, name='podcast_episode_detail'), path('episodes//transcript/', views.episode_transcript, name='podcast_episode_transcript'), + path('episodes//silences/', views.episode_silences, name='podcast_episode_silences'), path('episodes//favorite/', views.set_favorite, name='podcast_set_favorite'), path('stats/', views.podcast_stats, name='podcast_stats'), path('queue/', views.queue_get, name='podcast_queue_get'), diff --git a/podcasts/views.py b/podcasts/views.py index 158aa06..d2f1c9d 100644 --- a/podcasts/views.py +++ b/podcasts/views.py @@ -25,6 +25,7 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from . import chapters as chapters_mod +from . import silence as silence_mod from . import feedext from . import transcripts as transcripts_mod from .models import ( @@ -1169,6 +1170,24 @@ def _has_transcript(ep): return not (cached and cached[0] == sources and cached[1]) +@csrf_exempt +@require_http_methods(['GET']) +def episode_silences(request, pk): + """The episode's pauses for skipping silence in the browser: + {"ready": true, "spans": [[start, end], …]} once found; the first request + starts the search on the server ({"ready": false}, ask again later).""" + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + ep = PodcastEpisode.objects.filter(pk=pk, feed__user=request.user).only('id', 'silences').first() + if ep is None: + return JsonResponse({'error': 'not found'}, status=404) + if ep.silences is None: + silence_mod.ensure(ep.pk) + return JsonResponse({'ready': False}) + return JsonResponse({'ready': True, 'spans': ep.silences.get('spans', []), + **({'error': ep.silences['error']} if ep.silences.get('error') else {})}) + + @csrf_exempt @require_http_methods(['GET']) def episode_transcript(request, pk): diff --git a/requirements.txt b/requirements.txt index 40bc49d..424fcbe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,5 @@ gevent==26.8.0 gunicorn==26.2.0 packaging==26.3 piper-tts==1.7.0 +# ffmpeg (a static binary in the wheel) finds the pauses for skipping silence in the browser. +imageio-ffmpeg==0.6.0 diff --git a/static/ambient/index.html b/static/ambient/index.html index 3118c64..8817e3b 100644 --- a/static/ambient/index.html +++ b/static/ambient/index.html @@ -300,19 +300,20 @@ function buildGraph() { const nSend = g.nSend = ctx.createGain(); nSend.gain.value = .3; g.noiseBus.connect(nSend).connect(g.conv); S.musicLinked = true; S.revLinked = true; // siehe applyRev (Telefone) - // Granular: Ringpuffer der Stimmen, daraus werden Körner geschnitten. Der ScriptProcessor läuft im - // Hauptthread und hält bei Last den ganzen Graphen auf – auf Telefonen gibt es daher keine Körner. - S.wp = 0; S.written = 0; - if (!LITE) { - S.ring = new Float32Array(ctx.sampleRate * 12); - const proc = ctx.createScriptProcessor(4096, 1, 1); - proc.onaudioprocess = e => { - const inp = e.inputBuffer.getChannelData(0), n = S.ring.length; - for (let i = 0; i < inp.length; i++) { S.ring[S.wp] = inp[i]; S.wp = (S.wp + 1) % n; } - S.written += inp.length; - }; - const zero = ctx.createGain(); zero.gain.value = 0; - g.voiceBus.connect(proc); proc.connect(zero).connect(ctx.destination); + // Granular: Ringpuffer der Stimmen, daraus werden Körner geschnitten. Er läuft als AudioWorklet + // im Audio-Thread – ein ScriptProcessor im Hauptthread hielt bei jedem Stocken der Seite den + // ganzen Ton auf (gehäufte Aussetzer). Telefone: keine Körner. + if (!LITE && ctx.audioWorklet) { + const url = URL.createObjectURL(new Blob([RING_WORKLET], { type: "application/javascript" })); + ctx.audioWorklet.addModule(url).then(() => { + const node = new AudioWorkletNode(ctx, "drift-ring", { numberOfInputs: 1, numberOfOutputs: 0, processorOptions: { n: ctx.sampleRate * 12 } }); + node.port.onmessage = e => { + const d = e.data; + if (!d || d.length < ctx.sampleRate * 6) return; + const b = ctx.createBuffer(1, d.length, ctx.sampleRate); b.copyToChannel(d, 0); S.grainBuf = b; + }; + g.voiceBus.connect(node); S.ringNode = node; + }).catch(() => {}).finally(() => URL.revokeObjectURL(url)); } g.grainBus = ctx.createGain(); g.grainBus.connect(g.filter); @@ -486,12 +487,33 @@ function playSwell(buf, t) { } // Granulare Wolke aus dem eigenen Output +// Der Ringpuffer im Audio-Thread: auf Anfrage eine Kopie der letzten Sekunden (siehe snapGrains). +const RING_WORKLET = ` +class DriftRing extends AudioWorkletProcessor { + constructor(o) { + super(); + this.n = o.processorOptions.n; this.buf = new Float32Array(this.n); this.wp = 0; this.written = 0; + this.port.onmessage = () => { + const len = Math.min(this.written, this.n), out = new Float32Array(len), start = (this.wp - len + this.n) % this.n; + if (start + len <= this.n) out.set(this.buf.subarray(start, start + len)); + else { out.set(this.buf.subarray(start)); out.set(this.buf.subarray(0, len - (this.n - start)), this.n - start); } + this.port.postMessage(out, [out.buffer]); + }; + } + process(inputs) { + const inp = inputs[0] && inputs[0][0]; + if (inp) { + const n = this.n, k = inp.length, room = n - this.wp; + if (k <= room) this.buf.set(inp, this.wp); else { this.buf.set(inp.subarray(0, room), this.wp); this.buf.set(inp.subarray(room), 0); } + this.wp = (this.wp + k) % n; this.written += k; + } + return true; + } +} +registerProcessor("drift-ring", DriftRing); +`; function snapGrains() { - const sr = S.ctx.sampleRate; - if (!S.ring || S.written < sr * 6) return; - const len = Math.min(S.written, S.ring.length), b = S.ctx.createBuffer(1, len, sr), d = b.getChannelData(0); - for (let i = 0; i < len; i++) d[i] = S.ring[(S.wp - len + i + S.ring.length) % S.ring.length]; - S.grainBuf = b; + if (S.ringNode) S.ringNode.port.postMessage("snap"); // die Antwort setzt S.grainBuf } function grainCloud() { const amt = S.cur.grain * eff(); diff --git a/static/js/podcasts-web.js b/static/js/podcasts-web.js index d06988e..ad914e4 100644 --- a/static/js/podcasts-web.js +++ b/static/js/podcasts-web.js @@ -433,7 +433,7 @@ smart_played: [0, 15, 30, 45, 60, 120, 300, 600], }; const prefs = Object.assign({speed: 1, skip_back: 15, skip_forward: 30, remove_played: true, smart_played: 30, - sleep_last: 15, sleep_auto: false, sleep_from: 22, sleep_to: 6}, load('prefs', {})); + sleep_last: 15, sleep_auto: false, sleep_from: 22, sleep_to: 6, skip_silence: false}, load('prefs', {})); // Skip lengths and "counts as played": the account's where it has them, else this browser's. function eff(name) { @@ -453,19 +453,22 @@ if (name in (state.settings || {})) { setSettings({[name]: Math.round(value)}); flush(); } } else if (name === 'remove_played') { prefs.remove_played = !!value; + } else if (name === 'skip_silence') { + prefs.skip_silence = !!value; // the server finds the pauses, we jump over them (below) } else if (name === 'sleep_from' || name === 'sleep_to') { if (!(Number.isInteger(value) && value >= 0 && value <= 23)) return; prefs[name] = value; } else { - return; // skip_silence, headset_next: nothing a browser can do + return; // headset_next: nothing a browser can do } store('prefs', prefs); } - // What the page shows as settings. No skip_silence / headset_next: the page leaves those out. + // What the page shows as settings. No headset_next: the page leaves it out. function prefsJson() { return {speed: prefs.speed, skip_back: eff('skip_back'), skip_forward: eff('skip_forward'), remove_played: prefs.remove_played, smart_played: eff('smart_played'), feeds: {}, + skip_silence: !!prefs.skip_silence, sleep_from: sleepHour('sleep_from', 22), sleep_to: sleepHour('sleep_to', 6)}; } @@ -945,6 +948,43 @@ if (playing || sleep.leftMs > 0 || otherActive(sync.state)) report(); }, 1000); + // ---- skip silence ---------------------------------------------------------------------- + // A browser can't listen to most podcast servers' audio (no CORS), so the server finds an + // episode's pauses once (podcasts/silence.py, it takes a little while the first time) and + // the player jumps over them, a little of each kept. The podcast's own choice, else this + // browser's (as in the app, PlaybackRules.skipSilence). + function skipSilenceFor(feedId) { + const f = feed(feedId); + return f && f.skip_silence != null ? !!f.skip_silence : !!prefs.skip_silence; + } + + const silence = {id: null, spans: null, asked: 0}; + + function silenceSpans() { + const id = curId(); + if (id == null || !skipSilenceFor(curFeed())) return null; + if (silence.id !== id) { silence.id = id; silence.spans = null; silence.asked = 0; } + if (!silence.spans && Date.now() - silence.asked > 20000) { + silence.asked = Date.now(); + fetch(`/podcasts/episodes/${id}/silences/`, {credentials: 'same-origin', cache: 'no-store'}) + .then(r => (r.ok ? r.json() : null)) + .then(d => { if (d && d.ready && silence.id === id) silence.spans = d.spans || []; }) + .catch(() => {}); + } + return silence.spans; + } + + audio.addEventListener('timeupdate', () => { + if (!loaded || audio.paused || audio.seeking) return; + const spans = silenceSpans(); + if (!spans || !spans.length) return; + const t = audio.currentTime; + for (const [start, end] of spans) { // in order + if (start > t) break; + if (t < end - 0.3) { try { audio.currentTime = end - 0.15; } catch (e) {} return; } + } + }); + // ---- radio and podcasts take turns --------------------------------------------------- function stopRadio() { diff --git a/static/js/sw.js b/static/js/sw.js index 6ffb77c..054601c 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v85'; +const CACHE = 'diora-v86'; const SHELL = [ '/static/css/app.css', '/static/js/app.js',