"""WebSocket-based audio streaming for SDR.""" import json import shutil import socket import subprocess import threading import time from flask import Flask # Try to import flask-sock try: from flask_sock import Sock WEBSOCKET_AVAILABLE = True except ImportError: WEBSOCKET_AVAILABLE = False Sock = None import contextlib from utils.logging import get_logger logger = get_logger("intercept.audio_ws") # Global state audio_process = None rtl_process = None process_lock = threading.Lock() current_config = {"frequency": 118.0, "modulation": "am", "squelch": 0, "gain": 40, "device": 0} def find_rtl_fm(): return shutil.which("rtl_fm") def find_ffmpeg(): return shutil.which("ffmpeg") def _rtl_fm_demod_mode(modulation): """Map UI modulation names to rtl_fm demod tokens.""" mod = str(modulation or "").lower().strip() return "wbfm" if mod == "wfm" else mod def kill_audio_processes(): """Kill any running audio processes.""" global audio_process, rtl_process if audio_process: try: audio_process.terminate() audio_process.wait(timeout=0.5) except: with contextlib.suppress(BaseException): audio_process.kill() audio_process = None if rtl_process: try: rtl_process.terminate() rtl_process.wait(timeout=0.5) except: with contextlib.suppress(BaseException): rtl_process.kill() rtl_process = None time.sleep(0.3) def start_audio_stream(config): """Start rtl_fm + ffmpeg pipeline, return the ffmpeg process.""" global audio_process, rtl_process, current_config kill_audio_processes() rtl_fm = find_rtl_fm() ffmpeg = find_ffmpeg() if not rtl_fm or not ffmpeg: logger.error("rtl_fm or ffmpeg not found") return None current_config.update(config) freq = config.get("frequency", 118.0) mod = config.get("modulation", "am") squelch = config.get("squelch", 0) gain = config.get("gain", 40) device = config.get("device", 0) # Sample rates based on modulation if mod == "wfm": sample_rate = 170000 resample_rate = 32000 elif mod in ["usb", "lsb"]: sample_rate = 12000 resample_rate = 12000 else: sample_rate = 24000 resample_rate = 24000 freq_hz = int(freq * 1e6) rtl_cmd = [ rtl_fm, "-M", _rtl_fm_demod_mode(mod), "-f", str(freq_hz), "-s", str(sample_rate), "-r", str(resample_rate), "-g", str(gain), "-d", str(device), "-l", str(squelch), ] # Encode to MP3 for browser compatibility ffmpeg_cmd = [ ffmpeg, "-hide_banner", "-loglevel", "error", "-f", "s16le", "-ar", str(resample_rate), "-ac", "1", "-i", "pipe:0", "-acodec", "libmp3lame", "-b:a", "128k", "-f", "mp3", "-flush_packets", "1", "pipe:1", ] try: logger.info(f"Starting rtl_fm: {freq} MHz, {mod}") rtl_process = subprocess.Popen(rtl_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) audio_process = subprocess.Popen( ffmpeg_cmd, stdin=rtl_process.stdout, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=0 ) rtl_process.stdout.close() # Check processes started time.sleep(0.2) if rtl_process.poll() is not None or audio_process.poll() is not None: logger.error("Audio process failed to start") kill_audio_processes() return None return audio_process except Exception as e: logger.error(f"Failed to start audio: {e}") kill_audio_processes() return None def init_audio_websocket(app: Flask): """Initialize WebSocket audio streaming.""" if not WEBSOCKET_AVAILABLE: logger.warning("flask-sock not installed, WebSocket audio disabled") return sock = Sock(app) @sock.route("/ws/audio") def audio_stream(ws): """WebSocket endpoint for audio streaming.""" logger.info("WebSocket audio client connected") proc = None streaming = False try: while True: # Check for messages from client (non-blocking with timeout) try: msg = ws.receive(timeout=0.01) if msg: data = json.loads(msg) cmd = data.get("cmd") if cmd == "start": config = data.get("config", {}) logger.info(f"Starting audio: {config}") with process_lock: proc = start_audio_stream(config) if proc: streaming = True ws.send(json.dumps({"status": "started"})) else: ws.send(json.dumps({"status": "error", "message": "Failed to start"})) elif cmd == "stop": logger.info("Stopping audio") streaming = False with process_lock: kill_audio_processes() proc = None ws.send(json.dumps({"status": "stopped"})) elif cmd == "tune": # Change frequency/modulation - restart stream config = data.get("config", {}) logger.info(f"Retuning: {config}") with process_lock: proc = start_audio_stream(config) if proc: streaming = True ws.send(json.dumps({"status": "tuned"})) else: streaming = False ws.send(json.dumps({"status": "error", "message": "Failed to tune"})) except TimeoutError: pass except Exception as e: msg = str(e).lower() if "connection closed" in msg: logger.info("WebSocket closed by client") break if "timed out" not in msg: logger.error(f"WebSocket receive error: {e}") # Stream audio data if active if streaming and proc and proc.poll() is None: try: chunk = proc.stdout.read(4096) if chunk: ws.send(chunk) except Exception as e: logger.error(f"Audio read error: {e}") streaming = False elif streaming: # Process died streaming = False ws.send(json.dumps({"status": "error", "message": "Audio process died"})) else: time.sleep(0.01) except Exception as e: logger.info(f"WebSocket closed: {e}") finally: with process_lock: kill_audio_processes() # Complete WebSocket close handshake, then shut down the # raw socket so Werkzeug cannot write its HTTP 200 response # on top of the WebSocket stream. with contextlib.suppress(Exception): ws.close() with contextlib.suppress(Exception): ws.sock.shutdown(socket.SHUT_RDWR) with contextlib.suppress(Exception): ws.sock.close() logger.info("WebSocket audio client disconnected")