mirror of
https://github.com/smittix/intercept.git
synced 2026-07-07 09:08:12 -07:00
96172ca593
First-time run of ruff-format via pre-commit hook normalises quote style, trailing commas, and whitespace across 188 Python files. No logic changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
273 lines
8.2 KiB
Python
273 lines
8.2 KiB
Python
"""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")
|