podcast_helper.streaming module
podcast_helper.streaming
Universal audio stream consumer — URL-in → PCM-out.
Accepts:
Local file paths (
"/tmp/episode.mp3","file:///tmp/episode.mp3").Direct HTTP audio URLs (RSS enclosure MP3 / M4A / Opus / WAV / HLS m3u8).
RSS / Atom feed URLs (auto-picks the latest episode’s enclosure).
yt-dlp-supported URLs (YouTube, Vimeo, SoundCloud, Twitch VOD / live, …).
Refuses with a clear NotImplementedError for known-DRM sources
(Spotify-exclusive audio) and known-catalog-not-audio URLs (Apple
Podcasts links — point to podcasts.apple.com rather than the
underlying RSS) with hints on the correct workaround.
Signal-processing correctness
When target_sample_rate differs from the source rate, the conversion
is performed by ffmpeg’s libswresample (default) or libsoxr
(resample_quality="high"), both of which apply an anti-aliasing
low-pass filter at the new Nyquist frequency (target_sample_rate / 2)
before decimation. This satisfies the Shannon-Nyquist sampling theorem.
Naïve subsampling (keeping every Nth sample) would fold supra-Nyquist
energy into the audible band and is never used.
Channel handling — two modes only
to_mono=True→ ffmpeg’s standard downmix matrix (stereo: L+R with -3 dB each; 5.1: ITU-standard mix). PCM frame shape(n_samples,).to_mono=False→ the source’s native channel count is preserved. No synthetic upmix, ever. PCM frame shape(n_samples, n_channels), interleaved per sample (LRLR… for stereo).
There is no arbitrary channels=N knob: downmix-to-mono is canonical
and deterministic; upmix / multi-channel synthesis is a creative choice
that doesn’t belong in a library primitive.
- class podcast_helper.streaming.PcmFrame[source]
Bases:
TypedDictOne PCM frame in absolute stream time.
Keys
- t_abs_sfloat
Seconds since the source started. Monotonic — does not reset on reconnect (future v0.3 will pad with silence if reconnect lands).
- pcmNDArray[np.float32]
Audio samples in
[-1.0, 1.0],float32.Shape
(n_samples,)whento_mono=True(default).Shape
(n_samples, n_channels)whento_mono=False, with samples interleaved by channel ([L0, R0, L1, R1, ...]for stereo, reshaped to(n_samples, 2)).
- voicedbool | None
Always
Nonehere — VAD downstream fills it in if used.
- async podcast_helper.streaming.extract_audio_stream(url, *, target_sample_rate=16000, to_mono=True, resample_quality='default', realtime=True, frame_ms=20, headers=None, cookies_from_browser=None, speed=1.0, record_to=None)[source]
Yield PCM frames from any audio-bearing URL.
- Parameters:
url (str) – File path,
file://URL, direct audio URL (RSS enclosure MP3 / M4A / Opus / WAV / HLS m3u8), RSS feed URL (auto-picks latest episode), or anyyt-dlp-supported URL (YouTube, Vimeo, SoundCloud, Twitch VOD / live, etc.). Spotify-protected and Apple Podcasts catalog URLs raiseNotImplementedErrorearly with hints to the right workaround.target_sample_rate (int, default 16000) – Exact output sample rate in Hz. Conversion uses libswresample (default) or libsoxr (
resample_quality="high"); both apply a Shannon-correct anti-aliasing low-pass before decimation.to_mono (bool, default True) –
True: ffmpeg standard downmix to one channel;
pcm.shape == (n_samples,).False: source’s native channel count preserved;
pcm.shape == (n_samples, n_channels).
resample_quality (
"default"|"high", default"default") –"high"triggersaresample=...:resampler=soxr:precision=28(~10× slower, inaudible artefacts). Default libswresample is more than enough for ASR / VAD / ML.realtime (bool, default True) – Pace decoding at wall-clock (ffmpeg’s
-re). Forced OFF for live streams (they pace themselves;-rewould only add latency).frame_ms (int, default 20) – Frame duration in milliseconds.
20matches Silero VAD’s native frame size, avoiding a downstream re-buffer.headers (dict[str, str], optional) – HTTP headers ffmpeg should send. Merged on top of yt-dlp’s per-source headers (user keys win).
cookies_from_browser (str, optional) –
"firefox"/"chrome"/"safari"/ etc. — passed to yt-dlp’s--cookies-from-browserfor age-gated or members-only sources.speed (float, default 1.0) – Playback rate for VOD only. Implemented via ffmpeg’s
atempo=filter so the pitch is preserved (no chipmunk effect).1.0= unchanged;> 1.0= faster (e.g.2.0for 2× ASR throughput on long episodes);0 < speed < 1.0= slower (e.g.0.5for half-speed transcription proofing). Outside[0.5, 100]the chain wraps multipleatempofilters whose product equalsspeed. Raises ``ValueError`` on live streams — you can’t fast-forward past the live edge, and slowing down lets the consumer fall behind unboundedly. Implicitly disables the-rerealtime pacing whenspeed != 1.0.record_to (str, optional) –
If set, ffmpeg writes a parallel compressed archive of the same audio to this path while the live PCM stream is consumed. Implemented via ffmpeg’s native multi-output: one decode, two encoder paths, no extra subprocess. Codec is picked from the extension:
.mp3→libmp3lame(128 kbps).m4a/.aac→aac(128 kbps).opus→libopus(96 kbps).ogg→libvorbis(quality 5).flac→flac(lossless).wav→pcm_s16le
Unknown extensions raise
ValueError. The archive istarget_sample_rate/to_mono/speed-coherent with the live PCM (same filter chain). For live sources, the archive grows for the duration of the stream.
- Yields:
PcmFrame – Successive frames in absolute stream time.
- Raises:
NotImplementedError – For Spotify-protected URLs (DRM) and Apple Podcasts catalog URLs (point to the show’s RSS feed instead).
RuntimeError – If yt-dlp can’t resolve the URL (private / removed / geo-blocked).
FileNotFoundError – If ffmpeg is not on PATH.
ValueError – If
speed != 1.0and the source is live, ifspeed <= 0, or ifrecord_to’s extension is not in the supported set.
- Return type:
Examples
>>> import asyncio, podcast_helper as ph >>> async def main(): ... async for frame in ph.extract_audio_stream( ... "https://www.youtube.com/watch?v=YE7VzlLtp-4", ... target_sample_rate=16000, to_mono=True, ... ): ... # frame["pcm"]: np.float32, shape (320,) for 20ms @ 16kHz ... pass >>> asyncio.run(main())