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.

Author

Warith HARCHAOUI — https://linkedin.com/in/warith-harchaoui

class podcast_helper.streaming.PcmFrame[source]

Bases: TypedDict

One 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,) when to_mono=True (default).

  • Shape (n_samples, n_channels) when to_mono=False, with samples interleaved by channel ([L0, R0, L1, R1, ...] for stereo, reshaped to (n_samples, 2)).

voicedbool | None

Always None here — VAD downstream fills it in if used.

pcm: ndarray[tuple[Any, ...], dtype[float32]]
t_abs_s: float
voiced: bool | None
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 any yt-dlp-supported URL (YouTube, Vimeo, SoundCloud, Twitch VOD / live, etc.). Spotify-protected and Apple Podcasts catalog URLs raise NotImplementedError early 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" triggers aresample=...: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; -re would only add latency).

  • frame_ms (int, default 20) – Frame duration in milliseconds. 20 matches 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-browser for 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.0 for 2× ASR throughput on long episodes); 0 < speed < 1.0 = slower (e.g. 0.5 for half-speed transcription proofing). Outside [0.5, 100] the chain wraps multiple atempo filters whose product equals speed. 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 -re realtime pacing when speed != 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:

    • .mp3libmp3lame (128 kbps)

    • .m4a / .aacaac (128 kbps)

    • .opuslibopus (96 kbps)

    • .ogglibvorbis (quality 5)

    • .flacflac (lossless)

    • .wavpcm_s16le

    Unknown extensions raise ValueError. The archive is target_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.0 and the source is live, if speed <= 0, or if record_to’s extension is not in the supported set.

Return type:

AsyncIterator[PcmFrame]

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())