Prevent stale monitor start requests from retuning audio

This commit is contained in:
Smittix
2026-02-23 23:56:21 +00:00
parent 2af238aed5
commit 975a95e1b0
2 changed files with 213 additions and 186 deletions
+205 -185
View File
@@ -38,13 +38,15 @@ receiver_bp = Blueprint('receiver', __name__, url_prefix='/receiver')
# ============================================ # ============================================
# Audio demodulation state # Audio demodulation state
audio_process = None audio_process = None
audio_rtl_process = None audio_rtl_process = None
audio_lock = threading.Lock() audio_lock = threading.Lock()
audio_running = False audio_start_lock = threading.Lock()
audio_frequency = 0.0 audio_running = False
audio_modulation = 'fm' audio_frequency = 0.0
audio_source = 'process' audio_modulation = 'fm'
audio_source = 'process'
audio_start_token = 0
# Scanner state # Scanner state
scanner_thread: Optional[threading.Thread] = None scanner_thread: Optional[threading.Thread] = None
@@ -1269,184 +1271,202 @@ def get_presets() -> Response:
# MANUAL AUDIO ENDPOINTS (for direct listening) # MANUAL AUDIO ENDPOINTS (for direct listening)
# ============================================ # ============================================
@receiver_bp.route('/audio/start', methods=['POST']) @receiver_bp.route('/audio/start', methods=['POST'])
def start_audio() -> Response: def start_audio() -> Response:
"""Start audio at specific frequency (manual mode).""" """Start audio at specific frequency (manual mode)."""
global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread
global audio_running, audio_frequency, audio_modulation, audio_source global audio_running, audio_frequency, audio_modulation, audio_source, audio_start_token
# Stop scanner if running data = request.json or {}
if scanner_running:
scanner_running = False try:
if scanner_active_device is not None: frequency = float(data.get('frequency', 0))
app_module.release_sdr_device(scanner_active_device) modulation = normalize_modulation(data.get('modulation', 'wfm'))
scanner_active_device = None squelch = int(data.get('squelch', 0))
if scanner_thread and scanner_thread.is_alive(): gain = int(data.get('gain', 40))
try: device = int(data.get('device', 0))
scanner_thread.join(timeout=2.0) sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
except Exception: request_token_raw = data.get('request_token')
pass request_token = int(request_token_raw) if request_token_raw is not None else None
if scanner_power_process and scanner_power_process.poll() is None: bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False))
try: if isinstance(bias_t_raw, str):
scanner_power_process.terminate() bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'}
scanner_power_process.wait(timeout=1) else:
except Exception: bias_t = bool(bias_t_raw)
try: except (ValueError, TypeError) as e:
scanner_power_process.kill() return jsonify({
except Exception: 'status': 'error',
pass 'message': f'Invalid parameter: {e}'
scanner_power_process = None }), 400
try:
subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5) if frequency <= 0:
except Exception: return jsonify({
pass 'status': 'error',
time.sleep(0.5) 'message': 'frequency is required'
}), 400
data = request.json or {}
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
try: if sdr_type not in valid_sdr_types:
frequency = float(data.get('frequency', 0)) return jsonify({
modulation = normalize_modulation(data.get('modulation', 'wfm')) 'status': 'error',
squelch = int(data.get('squelch', 0)) 'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
gain = int(data.get('gain', 40)) }), 400
device = int(data.get('device', 0))
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower() with audio_start_lock:
bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False)) if request_token is not None:
if isinstance(bias_t_raw, str): if request_token < audio_start_token:
bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'} return jsonify({
else: 'status': 'stale',
bias_t = bool(bias_t_raw) 'message': 'Superseded audio start request',
except (ValueError, TypeError) as e: 'source': audio_source,
return jsonify({ 'superseded': True,
'status': 'error', }), 409
'message': f'Invalid parameter: {e}' audio_start_token = request_token
}), 400 else:
audio_start_token += 1
if frequency <= 0: request_token = audio_start_token
return jsonify({
'status': 'error', # Stop scanner if running
'message': 'frequency is required' if scanner_running:
}), 400 scanner_running = False
if scanner_active_device is not None:
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay'] app_module.release_sdr_device(scanner_active_device)
if sdr_type not in valid_sdr_types: scanner_active_device = None
return jsonify({ if scanner_thread and scanner_thread.is_alive():
'status': 'error', try:
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}' scanner_thread.join(timeout=2.0)
}), 400 except Exception:
pass
# Update config for audio if scanner_power_process and scanner_power_process.poll() is None:
scanner_config['squelch'] = squelch try:
scanner_config['gain'] = gain scanner_power_process.terminate()
scanner_config['device'] = device scanner_power_process.wait(timeout=1)
scanner_config['sdr_type'] = sdr_type except Exception:
scanner_config['bias_t'] = bias_t try:
scanner_power_process.kill()
# Preferred path: when waterfall WebSocket is active on the same SDR, except Exception:
# derive monitor audio from that IQ stream instead of spawning rtl_fm. pass
try: scanner_power_process = None
from routes.waterfall_websocket import ( try:
get_shared_capture_status, subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
start_shared_monitor_from_capture, except Exception:
) pass
time.sleep(0.5)
shared = get_shared_capture_status()
if shared.get('running') and shared.get('device') == device: # Update config for audio
_stop_audio_stream() scanner_config['squelch'] = squelch
ok, msg = start_shared_monitor_from_capture( scanner_config['gain'] = gain
device=device, scanner_config['device'] = device
frequency_mhz=frequency, scanner_config['sdr_type'] = sdr_type
modulation=modulation, scanner_config['bias_t'] = bias_t
squelch=squelch,
) # Preferred path: when waterfall WebSocket is active on the same SDR,
if ok: # derive monitor audio from that IQ stream instead of spawning rtl_fm.
audio_running = True try:
audio_frequency = frequency from routes.waterfall_websocket import (
audio_modulation = modulation get_shared_capture_status,
audio_source = 'waterfall' start_shared_monitor_from_capture,
# Shared monitor uses the waterfall's existing SDR claim. )
if receiver_active_device is not None:
app_module.release_sdr_device(receiver_active_device) shared = get_shared_capture_status()
receiver_active_device = None if shared.get('running') and shared.get('device') == device:
return jsonify({ _stop_audio_stream()
'status': 'started', ok, msg = start_shared_monitor_from_capture(
'frequency': frequency, device=device,
'modulation': modulation, frequency_mhz=frequency,
'source': 'waterfall', modulation=modulation,
}) squelch=squelch,
logger.warning(f"Shared waterfall monitor unavailable: {msg}") )
except Exception as e: if ok:
logger.debug(f"Shared waterfall monitor probe failed: {e}") audio_running = True
audio_frequency = frequency
# Stop waterfall if it's using the same SDR (SSE path) audio_modulation = modulation
if waterfall_running and waterfall_active_device == device: audio_source = 'waterfall'
_stop_waterfall_internal() # Shared monitor uses the waterfall's existing SDR claim.
time.sleep(0.2) if receiver_active_device is not None:
app_module.release_sdr_device(receiver_active_device)
# Claim device for listening audio. The WebSocket waterfall handler receiver_active_device = None
# may still be tearing down its IQ capture process (thread join + return jsonify({
# safe_terminate can take several seconds), so we retry with back-off 'status': 'started',
# to give the USB device time to be fully released. 'frequency': frequency,
if receiver_active_device is None or receiver_active_device != device: 'modulation': modulation,
if receiver_active_device is not None: 'source': 'waterfall',
app_module.release_sdr_device(receiver_active_device) 'request_token': request_token,
receiver_active_device = None })
logger.warning(f"Shared waterfall monitor unavailable: {msg}")
error = None except Exception as e:
max_claim_attempts = 6 logger.debug(f"Shared waterfall monitor probe failed: {e}")
for attempt in range(max_claim_attempts):
error = app_module.claim_sdr_device(device, 'receiver') # Stop waterfall if it's using the same SDR (SSE path)
if not error: if waterfall_running and waterfall_active_device == device:
break _stop_waterfall_internal()
if attempt < max_claim_attempts - 1: time.sleep(0.2)
logger.debug(
f"Device claim attempt {attempt + 1}/{max_claim_attempts} " # Claim device for listening audio. The WebSocket waterfall handler
f"failed, retrying in 0.5s: {error}" # may still be tearing down its IQ capture process (thread join +
) # safe_terminate can take several seconds), so we retry with back-off
time.sleep(0.5) # to give the USB device time to be fully released.
if receiver_active_device is None or receiver_active_device != device:
if error: if receiver_active_device is not None:
return jsonify({ app_module.release_sdr_device(receiver_active_device)
'status': 'error', receiver_active_device = None
'error_type': 'DEVICE_BUSY',
'message': error error = None
}), 409 max_claim_attempts = 6
receiver_active_device = device for attempt in range(max_claim_attempts):
error = app_module.claim_sdr_device(device, 'receiver')
_start_audio_stream(frequency, modulation) if not error:
break
if audio_running: if attempt < max_claim_attempts - 1:
audio_source = 'process' logger.debug(
return jsonify({ f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
'status': 'started', f"failed, retrying in 0.5s: {error}"
'frequency': frequency, )
'modulation': modulation, time.sleep(0.5)
'source': 'process',
}) if error:
else: return jsonify({
# Avoid leaving a stale device claim after startup failure. 'status': 'error',
if receiver_active_device is not None: 'error_type': 'DEVICE_BUSY',
app_module.release_sdr_device(receiver_active_device) 'message': error
receiver_active_device = None }), 409
receiver_active_device = device
start_error = ''
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'): _start_audio_stream(frequency, modulation)
try:
with open(log_path, 'r') as handle: if audio_running:
content = handle.read().strip() audio_source = 'process'
if content: return jsonify({
start_error = content.splitlines()[-1] 'status': 'started',
break 'frequency': audio_frequency,
except Exception: 'modulation': audio_modulation,
continue 'source': 'process',
'request_token': request_token,
message = 'Failed to start audio. Check SDR device.' })
if start_error:
message = f'Failed to start audio: {start_error}' # Avoid leaving a stale device claim after startup failure.
return jsonify({ if receiver_active_device is not None:
'status': 'error', app_module.release_sdr_device(receiver_active_device)
'message': message receiver_active_device = None
}), 500
start_error = ''
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
try:
with open(log_path, 'r') as handle:
content = handle.read().strip()
if content:
start_error = content.splitlines()[-1]
break
except Exception:
continue
message = 'Failed to start audio. Check SDR device.'
if start_error:
message = f'Failed to start audio: {start_error}'
return jsonify({
'status': 'error',
'message': message
}), 500
@receiver_bp.route('/audio/stop', methods=['POST']) @receiver_bp.route('/audio/stop', methods=['POST'])
+8 -1
View File
@@ -2704,6 +2704,7 @@ const Waterfall = (function () {
gain, gain,
device, device,
biasT, biasT,
requestToken,
}) { }) {
const response = await fetch('/receiver/audio/start', { const response = await fetch('/receiver/audio/start', {
method: 'POST', method: 'POST',
@@ -2716,6 +2717,7 @@ const Waterfall = (function () {
device: device.deviceIndex, device: device.deviceIndex,
sdr_type: device.sdrType, sdr_type: device.sdrType,
bias_t: biasT, bias_t: biasT,
request_token: requestToken,
}), }),
}); });
@@ -2812,10 +2814,13 @@ const Waterfall = (function () {
gain, gain,
device: monitorDevice, device: monitorDevice,
biasT, biasT,
requestToken: nonce,
}); });
if (nonce !== _audioConnectNonce) return; if (nonce !== _audioConnectNonce) return;
const busy = payload?.error_type === 'DEVICE_BUSY' || response.status === 409; const staleStart = payload?.superseded === true || payload?.status === 'stale';
if (staleStart) return;
const busy = payload?.error_type === 'DEVICE_BUSY' || (response.status === 409 && !staleStart);
if ( if (
busy busy
&& _running && _running
@@ -2834,8 +2839,10 @@ const Waterfall = (function () {
gain, gain,
device: monitorDevice, device: monitorDevice,
biasT, biasT,
requestToken: nonce,
})); }));
if (nonce !== _audioConnectNonce) return; if (nonce !== _audioConnectNonce) return;
if (payload?.superseded === true || payload?.status === 'stale') return;
} }
if (!response.ok || payload.status !== 'started') { if (!response.ok || payload.status !== 'started') {