Update audio_streamer.py
This commit is contained in:
@@ -1,11 +1,4 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
|
||||||
Audio Streamer Module.
|
|
||||||
|
|
||||||
This module provides an HTTP streaming server that distributes audio to
|
|
||||||
connected clients (like Shoutcast/Icecast). It pipes audio from an FFMPEG
|
|
||||||
process into an asyncio buffer and serves it via aiohttp.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -21,16 +14,13 @@ import logging
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class StreamSource(Enum):
|
class StreamSource(Enum):
|
||||||
FILE = "file"
|
FILE = "file"
|
||||||
URL = "url"
|
URL = "url"
|
||||||
SILENCE = "silence"
|
SILENCE = "silence"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class StreamState:
|
class StreamState:
|
||||||
"""Holds metadata about the current stream."""
|
|
||||||
source_type: StreamSource = StreamSource.SILENCE
|
source_type: StreamSource = StreamSource.SILENCE
|
||||||
source_path: str = ""
|
source_path: str = ""
|
||||||
title: str = "Silence"
|
title: str = "Silence"
|
||||||
@@ -39,16 +29,7 @@ class StreamState:
|
|||||||
duration: float = 0.0
|
duration: float = 0.0
|
||||||
listeners: int = 0
|
listeners: int = 0
|
||||||
|
|
||||||
|
|
||||||
class AudioStreamer:
|
class AudioStreamer:
|
||||||
"""
|
|
||||||
Manages the FFMPEG subprocess and HTTP server for audio streaming.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
_audio_buffer: Queue holding audio chunks from FFMPEG.
|
|
||||||
_clients: Set of active HTTP response objects to write to.
|
|
||||||
"""
|
|
||||||
|
|
||||||
CHUNK_SIZE = 2048
|
CHUNK_SIZE = 2048
|
||||||
MAX_BUFFER_SIZE = 30
|
MAX_BUFFER_SIZE = 30
|
||||||
MAX_CLIENTS = 50
|
MAX_CLIENTS = 50
|
||||||
@@ -97,7 +78,6 @@ class AudioStreamer:
|
|||||||
return self.state.source_path
|
return self.state.source_path
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""Initializes the web app and starts the broadcast loop."""
|
|
||||||
self._shutdown = False
|
self._shutdown = False
|
||||||
self._app = web.Application()
|
self._app = web.Application()
|
||||||
self._app.router.add_get(self.mount_point, self._handle_stream)
|
self._app.router.add_get(self.mount_point, self._handle_stream)
|
||||||
@@ -112,10 +92,9 @@ class AudioStreamer:
|
|||||||
await self._site.start()
|
await self._site.start()
|
||||||
|
|
||||||
self._broadcast_task = asyncio.create_task(self._broadcast_loop())
|
self._broadcast_task = asyncio.create_task(self._broadcast_loop())
|
||||||
logger.info(f"HTTP streaming server started at {self.stream_url}")
|
logger.info(f"🔊 HTTP streaming server started at {self.stream_url}")
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
"""Stops FFMPEG, disconnects clients, and shuts down the server."""
|
|
||||||
self._shutdown = True
|
self._shutdown = True
|
||||||
await self._stop_ffmpeg()
|
await self._stop_ffmpeg()
|
||||||
|
|
||||||
@@ -127,7 +106,6 @@ class AudioStreamer:
|
|||||||
pass
|
pass
|
||||||
self._broadcast_task = None
|
self._broadcast_task = None
|
||||||
|
|
||||||
# Cleanup pending callback tasks
|
|
||||||
for task in self._pending_tasks:
|
for task in self._pending_tasks:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
try:
|
try:
|
||||||
@@ -151,10 +129,9 @@ class AudioStreamer:
|
|||||||
self._clear_buffer()
|
self._clear_buffer()
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
logger.info("HTTP streaming server stopped")
|
logger.info("🔇 HTTP streaming server stopped")
|
||||||
|
|
||||||
def _clear_buffer(self):
|
def _clear_buffer(self):
|
||||||
"""Drains the audio buffer to prevent memory bloat."""
|
|
||||||
count = 0
|
count = 0
|
||||||
while not self._audio_buffer.empty():
|
while not self._audio_buffer.empty():
|
||||||
try:
|
try:
|
||||||
@@ -167,7 +144,6 @@ class AudioStreamer:
|
|||||||
logger.debug(f"Cleared {count} chunks from buffer")
|
logger.debug(f"Cleared {count} chunks from buffer")
|
||||||
|
|
||||||
async def play_file(self, filepath: str, title: str = "", artist: str = "") -> bool:
|
async def play_file(self, filepath: str, title: str = "", artist: str = "") -> bool:
|
||||||
"""Starts streaming a local file."""
|
|
||||||
if not os.path.exists(filepath):
|
if not os.path.exists(filepath):
|
||||||
logger.error(f"File not found: {filepath}")
|
logger.error(f"File not found: {filepath}")
|
||||||
return False
|
return False
|
||||||
@@ -183,12 +159,11 @@ class AudioStreamer:
|
|||||||
success = await self._start_ffmpeg(filepath)
|
success = await self._start_ffmpeg(filepath)
|
||||||
if success:
|
if success:
|
||||||
self.state.is_playing = True
|
self.state.is_playing = True
|
||||||
logger.info(f"Playing file: {filepath}")
|
logger.info(f"▶️ Playing file: {filepath}")
|
||||||
|
|
||||||
return success
|
return success
|
||||||
|
|
||||||
async def play_url(self, url: str, title: str = "") -> bool:
|
async def play_url(self, url: str, title: str = "") -> bool:
|
||||||
"""Starts streaming from a remote URL."""
|
|
||||||
await self._stop_ffmpeg()
|
await self._stop_ffmpeg()
|
||||||
|
|
||||||
self.state.source_type = StreamSource.URL
|
self.state.source_type = StreamSource.URL
|
||||||
@@ -200,7 +175,7 @@ class AudioStreamer:
|
|||||||
success = await self._start_ffmpeg(url)
|
success = await self._start_ffmpeg(url)
|
||||||
if success:
|
if success:
|
||||||
self.state.is_playing = True
|
self.state.is_playing = True
|
||||||
logger.info(f"Playing URL: {url}")
|
logger.info(f"📻 Playing URL: {url}")
|
||||||
|
|
||||||
return success
|
return success
|
||||||
|
|
||||||
@@ -213,7 +188,6 @@ class AudioStreamer:
|
|||||||
self.state.artist = ""
|
self.state.artist = ""
|
||||||
|
|
||||||
async def _start_ffmpeg(self, source: str) -> bool:
|
async def _start_ffmpeg(self, source: str) -> bool:
|
||||||
"""Constructs and executes the FFMPEG command to transcode audio."""
|
|
||||||
codec_args = {
|
codec_args = {
|
||||||
'mp3': ['-c:a', 'libmp3lame', '-b:a', f'{self.bitrate}k'],
|
'mp3': ['-c:a', 'libmp3lame', '-b:a', f'{self.bitrate}k'],
|
||||||
'ogg': ['-c:a', 'libvorbis', '-b:a', f'{self.bitrate}k'],
|
'ogg': ['-c:a', 'libvorbis', '-b:a', f'{self.bitrate}k'],
|
||||||
@@ -223,7 +197,6 @@ class AudioStreamer:
|
|||||||
|
|
||||||
format_args = codec_args.get(self.stream_format, codec_args['mp3'])
|
format_args = codec_args.get(self.stream_format, codec_args['mp3'])
|
||||||
|
|
||||||
# Optimize inputs for network streams
|
|
||||||
if source.startswith(('http://', 'https://', 'rtmp://')):
|
if source.startswith(('http://', 'https://', 'rtmp://')):
|
||||||
cmd = [
|
cmd = [
|
||||||
'ffmpeg', '-reconnect', '1', '-reconnect_streamed', '1',
|
'ffmpeg', '-reconnect', '1', '-reconnect_streamed', '1',
|
||||||
@@ -255,7 +228,6 @@ class AudioStreamer:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def _stop_ffmpeg(self):
|
async def _stop_ffmpeg(self):
|
||||||
"""Terminates the FFMPEG process."""
|
|
||||||
if self._stream_task:
|
if self._stream_task:
|
||||||
self._stream_task.cancel()
|
self._stream_task.cancel()
|
||||||
try:
|
try:
|
||||||
@@ -286,7 +258,6 @@ class AudioStreamer:
|
|||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
async def _read_stream(self):
|
async def _read_stream(self):
|
||||||
"""Reads stdout from FFMPEG and pushes to the broadcast buffer."""
|
|
||||||
try:
|
try:
|
||||||
while not self._shutdown:
|
while not self._shutdown:
|
||||||
if not self._ffmpeg_process or self._ffmpeg_process.returncode is not None:
|
if not self._ffmpeg_process or self._ffmpeg_process.returncode is not None:
|
||||||
@@ -304,7 +275,6 @@ class AudioStreamer:
|
|||||||
logger.debug('Stream EOF')
|
logger.debug('Stream EOF')
|
||||||
break
|
break
|
||||||
|
|
||||||
# Drop frames if buffer is full to keep stream "live"
|
|
||||||
dropped = 0
|
dropped = 0
|
||||||
while self._audio_buffer.full():
|
while self._audio_buffer.full():
|
||||||
try:
|
try:
|
||||||
@@ -324,7 +294,6 @@ class AudioStreamer:
|
|||||||
|
|
||||||
self.state.is_playing = False
|
self.state.is_playing = False
|
||||||
|
|
||||||
# Fire track end callback safely
|
|
||||||
if self.on_track_end and not self._shutdown:
|
if self.on_track_end and not self._shutdown:
|
||||||
task = asyncio.create_task(self._safe_callback(self.on_track_end))
|
task = asyncio.create_task(self._safe_callback(self.on_track_end))
|
||||||
self._pending_tasks.add(task)
|
self._pending_tasks.add(task)
|
||||||
@@ -348,7 +317,6 @@ class AudioStreamer:
|
|||||||
logger.error(f"Callback error: {e}")
|
logger.error(f"Callback error: {e}")
|
||||||
|
|
||||||
async def _broadcast_loop(self):
|
async def _broadcast_loop(self):
|
||||||
"""Reads from the buffer and writes to all connected HTTP clients."""
|
|
||||||
try:
|
try:
|
||||||
while not self._shutdown:
|
while not self._shutdown:
|
||||||
try:
|
try:
|
||||||
@@ -378,7 +346,6 @@ class AudioStreamer:
|
|||||||
logger.error(f"Broadcast loop error: {e}")
|
logger.error(f"Broadcast loop error: {e}")
|
||||||
|
|
||||||
async def _get_duration(self, filepath: str) -> float:
|
async def _get_duration(self, filepath: str) -> float:
|
||||||
"""Uses ffprobe to determine file duration."""
|
|
||||||
try:
|
try:
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
'ffprobe', '-v', 'quiet', '-show_entries', 'format=duration',
|
'ffprobe', '-v', 'quiet', '-show_entries', 'format=duration',
|
||||||
@@ -392,7 +359,6 @@ class AudioStreamer:
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
async def _handle_stream(self, request: web.Request) -> web.StreamResponse:
|
async def _handle_stream(self, request: web.Request) -> web.StreamResponse:
|
||||||
"""Handles HTTP GET requests for the audio stream."""
|
|
||||||
if len(self._clients) >= self.MAX_CLIENTS:
|
if len(self._clients) >= self.MAX_CLIENTS:
|
||||||
return web.Response(status=503, text="Server at capacity")
|
return web.Response(status=503, text="Server at capacity")
|
||||||
|
|
||||||
@@ -442,5 +408,12 @@ class AudioStreamer:
|
|||||||
return web.Response(text=json.dumps(status), content_type='application/json')
|
return web.Response(text=json.dumps(status), content_type='application/json')
|
||||||
|
|
||||||
async def _handle_info(self, request: web.Request) -> web.Response:
|
async def _handle_info(self, request: web.Request) -> web.Response:
|
||||||
html = f"<!DOCTYPE html><html><head><title>XMPP Radio</title></head><body><h1>XMPP Radio Bot</h1><p><b>Now Playing:</b> {self.state.title}</p><p><b>Listeners:</b> {self.state.listeners}</p><audio controls autoplay><source src=\"{self.mount_point}\" type=\"audio/mpeg\"></audio></body></html>"
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html><head><title>XMPP Radio</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>🎵 XMPP Radio Bot</h1>
|
||||||
|
<p><b>Now Playing:</b> {self.state.title}</p>
|
||||||
|
<p><b>Listeners:</b> {self.state.listeners}</p>
|
||||||
|
<audio controls autoplay><source src="{self.mount_point}" type="audio/mpeg"></audio>
|
||||||
|
</body></html>"""
|
||||||
return web.Response(text=html, content_type='text/html')
|
return web.Response(text=html, content_type='text/html')
|
||||||
Reference in New Issue
Block a user