Stille überspringen im Browser/Desktop (Server findet die Pausen); Ambient-Rekorder im Audio-Thread; sw v86
- 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/<id>/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 <noreply@anthropic.com>
This commit is contained in:
parent
6cc62fd4d1
commit
c1bbaccccf
10 changed files with 271 additions and 22 deletions
18
podcasts/migrations/0018_episode_silences.py
Normal file
18
podcasts/migrations/0018_episode_silences.py
Normal file
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -158,6 +158,9 @@ class PodcastEpisode(models.Model):
|
|||
chapters_fetched_at = models.DateTimeField(null=True, blank=True)
|
||||
# [{url, type, language}] from <podcast:transcript>.
|
||||
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')
|
||||
|
|
|
|||
90
podcasts/silence.py
Normal file
90
podcasts/silence.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ urlpatterns = [
|
|||
path('episodes/discard/', views.discard_episodes, name='podcast_discard_episodes'),
|
||||
path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'),
|
||||
path('episodes/<int:pk>/transcript/', views.episode_transcript, name='podcast_episode_transcript'),
|
||||
path('episodes/<int:pk>/silences/', views.episode_silences, name='podcast_episode_silences'),
|
||||
path('episodes/<int:pk>/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'),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue