81 lines
2.9 KiB
Python
81 lines
2.9 KiB
Python
# decode_stream.py
|
|
import time
|
|
import threading
|
|
import av
|
|
from av.audio.resampler import AudioResampler
|
|
import asyncio
|
|
|
|
|
|
class HLSReader:
|
|
def __init__(
|
|
self,
|
|
url: str,
|
|
*,
|
|
sample_rate: int = 48_000,
|
|
channels: int = 1,
|
|
chunk_ms: int = 20,
|
|
):
|
|
self.url = url
|
|
self.sample_rate = sample_rate
|
|
self.channels = channels
|
|
self.chunk_ms = chunk_ms
|
|
|
|
self._stop_evt = threading.Event()
|
|
self._thread: threading.Thread | None = None
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
self._queue: "asyncio.Queue[bytes] | None" = None
|
|
|
|
# derived
|
|
self._bytes_per_sample = 2 # s16
|
|
self._samples_per_chunk = int(self.sample_rate * (self.chunk_ms / 1000.0))
|
|
self._chunk_bytes = self._samples_per_chunk * self.channels * self._bytes_per_sample
|
|
|
|
def start(self, loop: asyncio.AbstractEventLoop, out_queue: "asyncio.Queue[bytes]") -> None:
|
|
"""Begin reading in a daemon thread, pushing chunks onto out_queue (on 'loop')."""
|
|
self._loop = loop
|
|
self._queue = out_queue
|
|
self._thread = threading.Thread(target=self._run, name=f"hls-{id(self):x}", daemon=True)
|
|
self._thread.start()
|
|
|
|
def stop(self) -> None:
|
|
self._stop_evt.set()
|
|
|
|
# ---------- internal ----------
|
|
def _run(self) -> None:
|
|
assert self._loop and self._queue
|
|
while not self._stop_evt.is_set():
|
|
try:
|
|
container = av.open(self.url, mode="r")
|
|
audio_stream = next(s for s in container.streams if s.type == "audio")
|
|
|
|
resampler = AudioResampler(
|
|
format="s16",
|
|
layout="mono" if self.channels == 1 else "stereo",
|
|
rate=self.sample_rate,
|
|
)
|
|
|
|
buf = bytearray()
|
|
for packet in container.demux(audio_stream):
|
|
if self._stop_evt.is_set():
|
|
break
|
|
for frame in packet.decode():
|
|
for out in resampler.resample(frame):
|
|
buf.extend(out.planes[0].to_bytes())
|
|
while len(buf) >= self._chunk_bytes:
|
|
chunk = bytes(buf[: self._chunk_bytes])
|
|
del buf[: self._chunk_bytes]
|
|
# push into asyncio queue on the event loop thread
|
|
self._loop.call_soon_threadsafe(self._queue.put_nowait, chunk)
|
|
|
|
# Some HLS variants EOF → reopen
|
|
try:
|
|
container.close()
|
|
except Exception:
|
|
pass
|
|
if not self._stop_evt.is_set():
|
|
time.sleep(0.5)
|
|
|
|
except Exception:
|
|
# transient network/playlist issues → backoff & retry
|
|
time.sleep(1.0)
|