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)
|
chapters_fetched_at = models.DateTimeField(null=True, blank=True)
|
||||||
# [{url, type, language}] from <podcast:transcript>.
|
# [{url, type, language}] from <podcast:transcript>.
|
||||||
transcripts = models.JSONField(default=list, blank=True)
|
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:
|
class Meta:
|
||||||
unique_together = ('feed', 'guid')
|
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.assertEqual(self.client.get('/podcasts/episodes/discard/').status_code, 405)
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
self.assertEqual(self.post({'episode_ids': [self.eps[0].id]}).status_code, 401)
|
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/discard/', views.discard_episodes, name='podcast_discard_episodes'),
|
||||||
path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'),
|
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>/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('episodes/<int:pk>/favorite/', views.set_favorite, name='podcast_set_favorite'),
|
||||||
path('stats/', views.podcast_stats, name='podcast_stats'),
|
path('stats/', views.podcast_stats, name='podcast_stats'),
|
||||||
path('queue/', views.queue_get, name='podcast_queue_get'),
|
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 django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from . import chapters as chapters_mod
|
from . import chapters as chapters_mod
|
||||||
|
from . import silence as silence_mod
|
||||||
from . import feedext
|
from . import feedext
|
||||||
from . import transcripts as transcripts_mod
|
from . import transcripts as transcripts_mod
|
||||||
from .models import (
|
from .models import (
|
||||||
|
|
@ -1169,6 +1170,24 @@ def _has_transcript(ep):
|
||||||
return not (cached and cached[0] == sources and cached[1])
|
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
|
@csrf_exempt
|
||||||
@require_http_methods(['GET'])
|
@require_http_methods(['GET'])
|
||||||
def episode_transcript(request, pk):
|
def episode_transcript(request, pk):
|
||||||
|
|
|
||||||
|
|
@ -17,3 +17,5 @@ gevent==26.8.0
|
||||||
gunicorn==26.2.0
|
gunicorn==26.2.0
|
||||||
packaging==26.3
|
packaging==26.3
|
||||||
piper-tts==1.7.0
|
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);
|
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)
|
S.musicLinked = true; S.revLinked = true; // siehe applyRev (Telefone)
|
||||||
|
|
||||||
// Granular: Ringpuffer der Stimmen, daraus werden Körner geschnitten. Der ScriptProcessor läuft im
|
// Granular: Ringpuffer der Stimmen, daraus werden Körner geschnitten. Er läuft als AudioWorklet
|
||||||
// Hauptthread und hält bei Last den ganzen Graphen auf – auf Telefonen gibt es daher keine Körner.
|
// im Audio-Thread – ein ScriptProcessor im Hauptthread hielt bei jedem Stocken der Seite den
|
||||||
S.wp = 0; S.written = 0;
|
// ganzen Ton auf (gehäufte Aussetzer). Telefone: keine Körner.
|
||||||
if (!LITE) {
|
if (!LITE && ctx.audioWorklet) {
|
||||||
S.ring = new Float32Array(ctx.sampleRate * 12);
|
const url = URL.createObjectURL(new Blob([RING_WORKLET], { type: "application/javascript" }));
|
||||||
const proc = ctx.createScriptProcessor(4096, 1, 1);
|
ctx.audioWorklet.addModule(url).then(() => {
|
||||||
proc.onaudioprocess = e => {
|
const node = new AudioWorkletNode(ctx, "drift-ring", { numberOfInputs: 1, numberOfOutputs: 0, processorOptions: { n: ctx.sampleRate * 12 } });
|
||||||
const inp = e.inputBuffer.getChannelData(0), n = S.ring.length;
|
node.port.onmessage = e => {
|
||||||
for (let i = 0; i < inp.length; i++) { S.ring[S.wp] = inp[i]; S.wp = (S.wp + 1) % n; }
|
const d = e.data;
|
||||||
S.written += inp.length;
|
if (!d || d.length < ctx.sampleRate * 6) return;
|
||||||
|
const b = ctx.createBuffer(1, d.length, ctx.sampleRate); b.copyToChannel(d, 0); S.grainBuf = b;
|
||||||
};
|
};
|
||||||
const zero = ctx.createGain(); zero.gain.value = 0;
|
g.voiceBus.connect(node); S.ringNode = node;
|
||||||
g.voiceBus.connect(proc); proc.connect(zero).connect(ctx.destination);
|
}).catch(() => {}).finally(() => URL.revokeObjectURL(url));
|
||||||
}
|
}
|
||||||
g.grainBus = ctx.createGain(); g.grainBus.connect(g.filter);
|
g.grainBus = ctx.createGain(); g.grainBus.connect(g.filter);
|
||||||
|
|
||||||
|
|
@ -486,12 +487,33 @@ function playSwell(buf, t) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Granulare Wolke aus dem eigenen Output
|
// 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() {
|
function snapGrains() {
|
||||||
const sr = S.ctx.sampleRate;
|
if (S.ringNode) S.ringNode.port.postMessage("snap"); // die Antwort setzt S.grainBuf
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
function grainCloud() {
|
function grainCloud() {
|
||||||
const amt = S.cur.grain * eff();
|
const amt = S.cur.grain * eff();
|
||||||
|
|
|
||||||
|
|
@ -433,7 +433,7 @@
|
||||||
smart_played: [0, 15, 30, 45, 60, 120, 300, 600],
|
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,
|
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.
|
// Skip lengths and "counts as played": the account's where it has them, else this browser's.
|
||||||
function eff(name) {
|
function eff(name) {
|
||||||
|
|
@ -453,19 +453,22 @@
|
||||||
if (name in (state.settings || {})) { setSettings({[name]: Math.round(value)}); flush(); }
|
if (name in (state.settings || {})) { setSettings({[name]: Math.round(value)}); flush(); }
|
||||||
} else if (name === 'remove_played') {
|
} else if (name === 'remove_played') {
|
||||||
prefs.remove_played = !!value;
|
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') {
|
} else if (name === 'sleep_from' || name === 'sleep_to') {
|
||||||
if (!(Number.isInteger(value) && value >= 0 && value <= 23)) return;
|
if (!(Number.isInteger(value) && value >= 0 && value <= 23)) return;
|
||||||
prefs[name] = value;
|
prefs[name] = value;
|
||||||
} else {
|
} else {
|
||||||
return; // skip_silence, headset_next: nothing a browser can do
|
return; // headset_next: nothing a browser can do
|
||||||
}
|
}
|
||||||
store('prefs', prefs);
|
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() {
|
function prefsJson() {
|
||||||
return {speed: prefs.speed, skip_back: eff('skip_back'), skip_forward: eff('skip_forward'),
|
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: {},
|
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)};
|
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();
|
if (playing || sleep.leftMs > 0 || otherActive(sync.state)) report();
|
||||||
}, 1000);
|
}, 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 ---------------------------------------------------
|
// ---- radio and podcasts take turns ---------------------------------------------------
|
||||||
|
|
||||||
function stopRadio() {
|
function stopRadio() {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v85';
|
const CACHE = 'diora-v86';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
'/static/js/app.js',
|
'/static/js/app.js',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue