From 286ab53d26087c332519e6d61890e6a109501393 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 11:03:00 +0000 Subject: [PATCH] Fix Morse mode lifecycle stop hangs and rebuild CW decoder --- README.md | 34 +- routes/morse.py | 984 +++++++++++----- static/css/modes/morse.css | 319 ++++-- static/js/modes/morse.js | 1615 +++++++++++++++++++-------- templates/index.html | 35 +- templates/partials/modes/morse.html | 265 +++-- tests/test_morse.py | 815 ++++++-------- utils/morse.py | 1202 ++++++++++++++------ 8 files changed, 3453 insertions(+), 1816 deletions(-) diff --git a/README.md b/README.md index b4d27b6..a1a0738 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,34 @@ Support the developer of this open-source project - **Meshtastic** - LoRa mesh network integration - **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required) - **Spy Stations** - Number stations and diplomatic HF network database -- **Remote Agents** - Distributed SIGINT with remote sensor nodes -- **Offline Mode** - Bundled assets for air-gapped/field deployments - ---- - -## Installation / Debian / Ubuntu / MacOS +- **Remote Agents** - Distributed SIGINT with remote sensor nodes +- **Offline Mode** - Bundled assets for air-gapped/field deployments + +--- + +## CW / Morse Decoder Notes + +Recommended baseline settings: +- **Tone**: `700 Hz` +- **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals) +- **Threshold Mode**: `Auto` +- **WPM Mode**: `Auto` + +Auto Tone Track behavior: +- Continuously measures nearby tone energy around the configured CW pitch. +- Steers the detector toward the strongest valid CW tone when signal-to-noise is sufficient. +- Use **Hold Tone Lock** to freeze tracking once the desired signal is centered. + +Troubleshooting (no decode / noisy decode): +- Confirm demod path is **USB/CW-compatible** and frequency is tuned correctly. +- Match **tone** and **bandwidth** to the actual sidetone/pitch. +- Try **Threshold Auto** first; if needed, switch to manual threshold and recalibrate. +- Use **Reset/Calibrate** after major frequency or band condition changes. +- Raise **Minimum Signal Gate** to suppress random noise keying. + +--- + +## Installation / Debian / Ubuntu / MacOS **1. Clone and run:** ```bash diff --git a/routes/morse.py b/routes/morse.py index c15e713..0807247 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -1,288 +1,696 @@ -"""CW/Morse code decoder routes.""" - -from __future__ import annotations - -import contextlib -import queue -import subprocess -import threading -import time -from typing import Any - -from flask import Blueprint, Response, jsonify, request - -import app as app_module -from utils.event_pipeline import process_event -from utils.logging import sensor_logger as logger -from utils.morse import morse_decoder_thread -from utils.process import register_process, safe_terminate, unregister_process -from utils.sdr import SDRFactory, SDRType -from utils.sse import sse_stream_fanout -from utils.validation import ( - validate_device_index, - validate_frequency, - validate_gain, - validate_ppm, -) - -morse_bp = Blueprint('morse', __name__) - -# Track which device is being used -morse_active_device: int | None = None - - -def _validate_tone_freq(value: Any) -> float: - """Validate CW tone frequency (300-1200 Hz).""" - try: - freq = float(value) - if not 300 <= freq <= 1200: - raise ValueError("Tone frequency must be between 300 and 1200 Hz") - return freq - except (ValueError, TypeError) as e: - raise ValueError(f"Invalid tone frequency: {value}") from e - - -def _validate_wpm(value: Any) -> int: - """Validate words per minute (5-50).""" - try: - wpm = int(value) - if not 5 <= wpm <= 50: - raise ValueError("WPM must be between 5 and 50") - return wpm - except (ValueError, TypeError) as e: - raise ValueError(f"Invalid WPM: {value}") from e - - -@morse_bp.route('/morse/start', methods=['POST']) -def start_morse() -> Response: - global morse_active_device - - with app_module.morse_lock: - if app_module.morse_process: - return jsonify({'status': 'error', 'message': 'Morse decoder already running'}), 409 - - data = request.json or {} - - # Validate standard SDR inputs - try: - freq = validate_frequency(data.get('frequency', '14.060'), min_mhz=0.5, max_mhz=30.0) - gain = validate_gain(data.get('gain', '0')) - ppm = validate_ppm(data.get('ppm', '0')) - device = validate_device_index(data.get('device', '0')) - except ValueError as e: - return jsonify({'status': 'error', 'message': str(e)}), 400 - - # Validate Morse-specific inputs - try: - tone_freq = _validate_tone_freq(data.get('tone_freq', '700')) - except ValueError as e: - return jsonify({'status': 'error', 'message': str(e)}), 400 - - try: - wpm = _validate_wpm(data.get('wpm', '15')) - except ValueError as e: - return jsonify({'status': 'error', 'message': str(e)}), 400 - - # Claim SDR device - device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'morse') - if error: - return jsonify({ - 'status': 'error', - 'error_type': 'DEVICE_BUSY', - 'message': error, - }), 409 - morse_active_device = device_int - - # Clear queue - while not app_module.morse_queue.empty(): - try: - app_module.morse_queue.get_nowait() - except queue.Empty: - break - - # Build rtl_fm USB demodulation command - sdr_type_str = data.get('sdr_type', 'rtlsdr') - try: - sdr_type = SDRType(sdr_type_str) - except ValueError: - sdr_type = SDRType.RTL_SDR - - sdr_device = SDRFactory.create_default_device(sdr_type, index=device) - builder = SDRFactory.get_builder(sdr_device.sdr_type) - - sample_rate = 8000 - bias_t = data.get('bias_t', False) - - # RTL-SDR needs direct sampling mode for HF frequencies below 24 MHz - direct_sampling = 2 if freq < 24.0 else None - - rtl_cmd = builder.build_fm_demod_command( - device=sdr_device, - frequency_mhz=freq, - sample_rate=sample_rate, - gain=float(gain) if gain and gain != '0' else None, - ppm=int(ppm) if ppm and ppm != '0' else None, - modulation='usb', - bias_t=bias_t, - direct_sampling=direct_sampling, - ) - - full_cmd = ' '.join(rtl_cmd) - logger.info(f"Morse decoder running: {full_cmd}") - - try: - rtl_process = subprocess.Popen( - rtl_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=0, - ) - register_process(rtl_process) - - # Start threads IMMEDIATELY so stdout is read before pipe fills. - # Forward rtl_fm stderr to queue so frontend can display diagnostics - def monitor_stderr(): - for line in rtl_process.stderr: - err_text = line.decode('utf-8', errors='replace').strip() - if err_text: - logger.debug(f"[rtl_fm/morse] {err_text}") - with contextlib.suppress(queue.Full): - app_module.morse_queue.put_nowait({ - 'type': 'info', - 'text': f'[rtl_fm] {err_text}', - }) - - stderr_thread = threading.Thread(target=monitor_stderr) - stderr_thread.daemon = True - stderr_thread.start() - - # Start Morse decoder thread before sleep so it reads stdout immediately - stop_event = threading.Event() - decoder_thread = threading.Thread( - target=morse_decoder_thread, - args=( - rtl_process.stdout, - app_module.morse_queue, - stop_event, - sample_rate, - tone_freq, - wpm, - ), - ) - decoder_thread.daemon = True - decoder_thread.start() - - # Detect immediate startup failure (e.g. device busy, no device) - time.sleep(0.35) - if rtl_process.poll() is not None: - stop_event.set() - stderr_text = '' - try: - if rtl_process.stderr: - stderr_text = rtl_process.stderr.read().decode( - 'utf-8', errors='replace' - ).strip() - except Exception: - stderr_text = '' - msg = stderr_text or f'rtl_fm exited immediately (code {rtl_process.returncode})' - logger.error(f"Morse rtl_fm startup failed: {msg}") - unregister_process(rtl_process) - if morse_active_device is not None: - app_module.release_sdr_device(morse_active_device) - morse_active_device = None - return jsonify({'status': 'error', 'message': msg}), 500 - - app_module.morse_process = rtl_process - app_module.morse_process._stop_decoder = stop_event - app_module.morse_process._decoder_thread = decoder_thread - - app_module.morse_queue.put({'type': 'status', 'status': 'started'}) - with contextlib.suppress(queue.Full): - app_module.morse_queue.put_nowait({ - 'type': 'info', - 'text': f'[cmd] {full_cmd}', - }) - - return jsonify({ - 'status': 'started', - 'command': full_cmd, - 'tone_freq': tone_freq, - 'wpm': wpm, - }) - - except FileNotFoundError as e: - if morse_active_device is not None: - app_module.release_sdr_device(morse_active_device) - morse_active_device = None - return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}), 400 - - except Exception as e: - # Clean up rtl_fm if it was started - try: - rtl_process.terminate() - rtl_process.wait(timeout=2) - except Exception: - with contextlib.suppress(Exception): - rtl_process.kill() - unregister_process(rtl_process) - if morse_active_device is not None: - app_module.release_sdr_device(morse_active_device) - morse_active_device = None - return jsonify({'status': 'error', 'message': str(e)}), 500 - - -@morse_bp.route('/morse/stop', methods=['POST']) -def stop_morse() -> Response: - global morse_active_device - - with app_module.morse_lock: - if app_module.morse_process: - # Signal decoder thread to stop - stop_event = getattr(app_module.morse_process, '_stop_decoder', None) - if stop_event: - stop_event.set() - - safe_terminate(app_module.morse_process) - unregister_process(app_module.morse_process) - app_module.morse_process = None - - if morse_active_device is not None: - app_module.release_sdr_device(morse_active_device) - morse_active_device = None - - app_module.morse_queue.put({'type': 'status', 'status': 'stopped'}) - return jsonify({'status': 'stopped'}) - - return jsonify({'status': 'not_running'}) - - -@morse_bp.route('/morse/status') -def morse_status() -> Response: - with app_module.morse_lock: - running = ( - app_module.morse_process is not None - and app_module.morse_process.poll() is None - ) - return jsonify({'running': running}) - - -@morse_bp.route('/morse/stream') -def morse_stream() -> Response: - def _on_msg(msg: dict[str, Any]) -> None: - process_event('morse', msg, msg.get('type')) - - response = Response( - sse_stream_fanout( - source_queue=app_module.morse_queue, - channel_key='morse', - timeout=1.0, - keepalive_interval=30.0, - on_message=_on_msg, - ), - mimetype='text/event-stream', - ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' - return response +"""CW/Morse code decoder routes.""" + +from __future__ import annotations + +import contextlib +import queue +import subprocess +import tempfile +import threading +import time +from pathlib import Path +from typing import Any + +from flask import Blueprint, Response, jsonify, request + +import app as app_module +from utils.event_pipeline import process_event +from utils.logging import sensor_logger as logger +from utils.morse import decode_morse_wav_file, morse_decoder_thread +from utils.process import register_process, safe_terminate, unregister_process +from utils.sdr import SDRFactory, SDRType +from utils.sse import sse_stream_fanout +from utils.validation import ( + validate_device_index, + validate_frequency, + validate_gain, + validate_ppm, +) + +morse_bp = Blueprint('morse', __name__) + +# Track which device is being used +morse_active_device: int | None = None + +# Runtime lifecycle state. +MORSE_IDLE = 'idle' +MORSE_STARTING = 'starting' +MORSE_RUNNING = 'running' +MORSE_STOPPING = 'stopping' +MORSE_ERROR = 'error' + +morse_state = MORSE_IDLE +morse_state_message = 'Idle' +morse_state_since = time.monotonic() +morse_last_error = '' +morse_runtime_config: dict[str, Any] = {} +morse_session_id = 0 + +morse_decoder_worker: threading.Thread | None = None +morse_stderr_worker: threading.Thread | None = None +morse_stop_event: threading.Event | None = None +morse_control_queue: queue.Queue | None = None + + +def _set_state(state: str, message: str = '', *, enqueue: bool = True, extra: dict[str, Any] | None = None) -> None: + """Update lifecycle state and optionally emit a status queue event.""" + global morse_state, morse_state_message, morse_state_since + morse_state = state + morse_state_message = message or state + morse_state_since = time.monotonic() + + if not enqueue: + return + + payload: dict[str, Any] = { + 'type': 'status', + 'status': state, + 'state': state, + 'message': morse_state_message, + 'session_id': morse_session_id, + 'timestamp': time.strftime('%H:%M:%S'), + } + if extra: + payload.update(extra) + with contextlib.suppress(queue.Full): + app_module.morse_queue.put_nowait(payload) + + +def _drain_queue(q: queue.Queue) -> None: + while not q.empty(): + try: + q.get_nowait() + except queue.Empty: + break + + +def _join_thread(worker: threading.Thread | None, timeout_s: float) -> bool: + if worker is None: + return True + worker.join(timeout=timeout_s) + return not worker.is_alive() + + +def _close_pipe(pipe_obj: Any) -> None: + if pipe_obj is None: + return + with contextlib.suppress(Exception): + pipe_obj.close() + + +def _bool_value(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + text = str(value).strip().lower() + if text in {'1', 'true', 'yes', 'on'}: + return True + if text in {'0', 'false', 'no', 'off'}: + return False + return default + + +def _float_value(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return float(default) + + +def _validate_tone_freq(value: Any) -> float: + """Validate CW tone frequency (300-1200 Hz).""" + try: + freq = float(value) + if not 300 <= freq <= 1200: + raise ValueError('Tone frequency must be between 300 and 1200 Hz') + return freq + except (ValueError, TypeError) as e: + raise ValueError(f'Invalid tone frequency: {value}') from e + + +def _validate_wpm(value: Any) -> int: + """Validate words per minute (5-50).""" + try: + wpm = int(value) + if not 5 <= wpm <= 50: + raise ValueError('WPM must be between 5 and 50') + return wpm + except (ValueError, TypeError) as e: + raise ValueError(f'Invalid WPM: {value}') from e + + +def _validate_bandwidth(value: Any) -> int: + try: + bw = int(value) + if bw not in (50, 100, 200, 400): + raise ValueError('Bandwidth must be one of 50, 100, 200, 400 Hz') + return bw + except (TypeError, ValueError) as e: + raise ValueError(f'Invalid bandwidth: {value}') from e + + +def _validate_threshold_mode(value: Any) -> str: + mode = str(value or 'auto').strip().lower() + if mode not in {'auto', 'manual'}: + raise ValueError('threshold_mode must be auto or manual') + return mode + + +def _validate_wpm_mode(value: Any) -> str: + mode = str(value or 'auto').strip().lower() + if mode not in {'auto', 'manual'}: + raise ValueError('wpm_mode must be auto or manual') + return mode + + +def _validate_threshold_multiplier(value: Any) -> float: + try: + multiplier = float(value) + if not 1.1 <= multiplier <= 8.0: + raise ValueError('threshold_multiplier must be between 1.1 and 8.0') + return multiplier + except (TypeError, ValueError) as e: + raise ValueError(f'Invalid threshold multiplier: {value}') from e + + +def _validate_non_negative_float(value: Any, field_name: str) -> float: + try: + parsed = float(value) + if parsed < 0: + raise ValueError(f'{field_name} must be non-negative') + return parsed + except (TypeError, ValueError) as e: + raise ValueError(f'Invalid {field_name}: {value}') from e + + +def _validate_signal_gate(value: Any) -> float: + try: + gate = float(value) + if not 0.0 <= gate <= 1.0: + raise ValueError('signal_gate must be between 0.0 and 1.0') + return gate + except (TypeError, ValueError) as e: + raise ValueError(f'Invalid signal gate: {value}') from e + + +def _snapshot_live_resources() -> list[str]: + alive: list[str] = [] + if morse_decoder_worker and morse_decoder_worker.is_alive(): + alive.append('decoder_thread') + if morse_stderr_worker and morse_stderr_worker.is_alive(): + alive.append('stderr_thread') + if app_module.morse_process and app_module.morse_process.poll() is None: + alive.append('rtl_process') + return alive + + +@morse_bp.route('/morse/start', methods=['POST']) +def start_morse() -> Response: + global morse_active_device, morse_decoder_worker, morse_stderr_worker + global morse_stop_event, morse_control_queue, morse_runtime_config + global morse_last_error, morse_session_id + + data = request.json or {} + + # Validate standard SDR inputs + try: + freq = validate_frequency(data.get('frequency', '14.060'), min_mhz=0.5, max_mhz=30.0) + gain = validate_gain(data.get('gain', '0')) + ppm = validate_ppm(data.get('ppm', '0')) + device = validate_device_index(data.get('device', '0')) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + # Validate Morse-specific inputs + try: + tone_freq = _validate_tone_freq(data.get('tone_freq', '700')) + wpm = _validate_wpm(data.get('wpm', '15')) + bandwidth_hz = _validate_bandwidth(data.get('bandwidth_hz', '200')) + threshold_mode = _validate_threshold_mode(data.get('threshold_mode', 'auto')) + wpm_mode = _validate_wpm_mode(data.get('wpm_mode', 'auto')) + threshold_multiplier = _validate_threshold_multiplier(data.get('threshold_multiplier', '2.8')) + manual_threshold = _validate_non_negative_float(data.get('manual_threshold', '0'), 'manual threshold') + threshold_offset = _validate_non_negative_float(data.get('threshold_offset', '0'), 'threshold offset') + min_signal_gate = _validate_signal_gate(data.get('signal_gate', '0')) + auto_tone_track = _bool_value(data.get('auto_tone_track', True), True) + tone_lock = _bool_value(data.get('tone_lock', False), False) + wpm_lock = _bool_value(data.get('wpm_lock', False), False) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + with app_module.morse_lock: + if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}: + return jsonify({ + 'status': 'error', + 'message': f'Morse decoder is {morse_state}', + 'state': morse_state, + }), 409 + + # Claim SDR device + device_int = int(device) + error = app_module.claim_sdr_device(device_int, 'morse') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error, + }), 409 + + morse_active_device = device_int + morse_last_error = '' + morse_session_id += 1 + + _drain_queue(app_module.morse_queue) + + _set_state(MORSE_STARTING, 'Starting decoder...') + + sample_rate = 8000 + bias_t = _bool_value(data.get('bias_t', False), False) + + # RTL-SDR needs direct sampling mode for HF frequencies below 24 MHz + direct_sampling = 2 if freq < 24.0 else None + + sdr_type_str = data.get('sdr_type', 'rtlsdr') + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR + + sdr_device = SDRFactory.create_default_device(sdr_type, index=device) + builder = SDRFactory.get_builder(sdr_device.sdr_type) + + rtl_cmd = builder.build_fm_demod_command( + device=sdr_device, + frequency_mhz=freq, + sample_rate=sample_rate, + gain=float(gain) if gain and gain != '0' else None, + ppm=int(ppm) if ppm and ppm != '0' else None, + modulation='usb', + bias_t=bias_t, + direct_sampling=direct_sampling, + ) + + full_cmd = ' '.join(rtl_cmd) + logger.info(f'Morse decoder running: {full_cmd}') + + rtl_process: subprocess.Popen | None = None + stop_event: threading.Event | None = None + decoder_thread: threading.Thread | None = None + stderr_thread: threading.Thread | None = None + + runtime_config: dict[str, Any] = { + 'sample_rate': sample_rate, + 'tone_freq': tone_freq, + 'wpm': wpm, + 'bandwidth_hz': bandwidth_hz, + 'auto_tone_track': auto_tone_track, + 'tone_lock': tone_lock, + 'threshold_mode': threshold_mode, + 'manual_threshold': manual_threshold, + 'threshold_multiplier': threshold_multiplier, + 'threshold_offset': threshold_offset, + 'wpm_mode': wpm_mode, + 'wpm_lock': wpm_lock, + 'min_signal_gate': min_signal_gate, + } + + try: + rtl_process = subprocess.Popen( + rtl_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + register_process(rtl_process) + + stop_event = threading.Event() + control_queue: queue.Queue = queue.Queue(maxsize=16) + + def monitor_stderr() -> None: + if not rtl_process or rtl_process.stderr is None: + return + for line in rtl_process.stderr: + if stop_event.is_set(): + break + err_text = line.decode('utf-8', errors='replace').strip() + if err_text: + logger.debug(f'[rtl_fm/morse] {err_text}') + with contextlib.suppress(queue.Full): + app_module.morse_queue.put_nowait({ + 'type': 'info', + 'text': f'[rtl_fm] {err_text}', + }) + + stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr') + stderr_thread.start() + + decoder_thread = threading.Thread( + target=morse_decoder_thread, + args=( + rtl_process.stdout, + app_module.morse_queue, + stop_event, + sample_rate, + tone_freq, + wpm, + ), + kwargs={ + 'decoder_config': runtime_config, + 'control_queue': control_queue, + }, + daemon=True, + name='morse-decoder', + ) + decoder_thread.start() + + # Detect immediate startup failure (e.g. device busy, no device) + time.sleep(0.30) + if rtl_process.poll() is not None: + stop_event.set() + stderr_text = '' + try: + if rtl_process.stderr: + stderr_text = rtl_process.stderr.read().decode('utf-8', errors='replace').strip() + except Exception: + stderr_text = '' + msg = stderr_text or f'rtl_fm exited immediately (code {rtl_process.returncode})' + logger.error(f'Morse rtl_fm startup failed: {msg}') + safe_terminate(rtl_process, timeout=0.4) + unregister_process(rtl_process) + _join_thread(decoder_thread, timeout_s=0.25) + _join_thread(stderr_thread, timeout_s=0.25) + with app_module.morse_lock: + if morse_active_device is not None: + app_module.release_sdr_device(morse_active_device) + morse_active_device = None + morse_last_error = msg + _set_state(MORSE_ERROR, msg) + _set_state(MORSE_IDLE, 'Idle') + return jsonify({'status': 'error', 'message': msg}), 500 + + with app_module.morse_lock: + app_module.morse_process = rtl_process + app_module.morse_process._stop_decoder = stop_event + app_module.morse_process._decoder_thread = decoder_thread + app_module.morse_process._stderr_thread = stderr_thread + app_module.morse_process._control_queue = control_queue + + morse_stop_event = stop_event + morse_control_queue = control_queue + morse_decoder_worker = decoder_thread + morse_stderr_worker = stderr_thread + morse_runtime_config = dict(runtime_config) + _set_state(MORSE_RUNNING, 'Listening') + + with contextlib.suppress(queue.Full): + app_module.morse_queue.put_nowait({ + 'type': 'info', + 'text': f'[cmd] {full_cmd}', + }) + + return jsonify({ + 'status': 'started', + 'state': MORSE_RUNNING, + 'command': full_cmd, + 'tone_freq': tone_freq, + 'wpm': wpm, + 'config': runtime_config, + 'session_id': morse_session_id, + }) + + except FileNotFoundError as e: + if rtl_process is not None: + unregister_process(rtl_process) + with app_module.morse_lock: + if morse_active_device is not None: + app_module.release_sdr_device(morse_active_device) + morse_active_device = None + morse_last_error = f'Tool not found: {e.filename}' + _set_state(MORSE_ERROR, morse_last_error) + _set_state(MORSE_IDLE, 'Idle') + return jsonify({'status': 'error', 'message': morse_last_error}), 400 + + except Exception as e: + if rtl_process is not None: + safe_terminate(rtl_process, timeout=0.5) + unregister_process(rtl_process) + if stop_event is not None: + stop_event.set() + _join_thread(decoder_thread, timeout_s=0.25) + _join_thread(stderr_thread, timeout_s=0.25) + with app_module.morse_lock: + if morse_active_device is not None: + app_module.release_sdr_device(morse_active_device) + morse_active_device = None + morse_last_error = str(e) + _set_state(MORSE_ERROR, morse_last_error) + _set_state(MORSE_IDLE, 'Idle') + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@morse_bp.route('/morse/stop', methods=['POST']) +def stop_morse() -> Response: + global morse_active_device, morse_decoder_worker, morse_stderr_worker + global morse_stop_event, morse_control_queue + + stop_started = time.perf_counter() + + with app_module.morse_lock: + if morse_state == MORSE_STOPPING: + return jsonify({'status': 'stopping', 'state': MORSE_STOPPING}), 202 + + proc = app_module.morse_process + stop_event = morse_stop_event or getattr(proc, '_stop_decoder', None) + decoder_thread = morse_decoder_worker or getattr(proc, '_decoder_thread', None) + stderr_thread = morse_stderr_worker or getattr(proc, '_stderr_thread', None) + control_queue = morse_control_queue or getattr(proc, '_control_queue', None) + active_device = morse_active_device + + if not proc and not stop_event and not decoder_thread and not stderr_thread: + _set_state(MORSE_IDLE, 'Idle', enqueue=False) + return jsonify({'status': 'not_running', 'state': MORSE_IDLE}) + + # Prevent new starts while cleanup is in progress. + _set_state(MORSE_STOPPING, 'Stopping decoder...') + + # Detach global runtime pointers immediately to avoid double-stop races. + app_module.morse_process = None + morse_stop_event = None + morse_control_queue = None + morse_decoder_worker = None + morse_stderr_worker = None + + cleanup_steps: list[str] = [] + + def _mark(step: str) -> None: + cleanup_steps.append(step) + logger.debug(f'[morse.stop] {step}') + + _mark('enter stop') + + if stop_event is not None: + stop_event.set() + _mark('stop_event set') + + if control_queue is not None: + with contextlib.suppress(queue.Full): + control_queue.put_nowait({'cmd': 'shutdown'}) + _mark('control_queue shutdown signal sent') + + if proc is not None: + _close_pipe(getattr(proc, 'stdout', None)) + _close_pipe(getattr(proc, 'stderr', None)) + _mark('stdout/stderr pipes closed') + + safe_terminate(proc, timeout=0.6) + unregister_process(proc) + _mark('rtl_fm process terminated') + + decoder_joined = _join_thread(decoder_thread, timeout_s=0.45) + stderr_joined = _join_thread(stderr_thread, timeout_s=0.45) + _mark(f'decoder thread joined={decoder_joined}') + _mark(f'stderr thread joined={stderr_joined}') + + if active_device is not None: + app_module.release_sdr_device(active_device) + _mark(f'SDR device {active_device} released') + + stop_ms = round((time.perf_counter() - stop_started) * 1000.0, 1) + alive_after = [] + if not decoder_joined: + alive_after.append('decoder_thread') + if not stderr_joined: + alive_after.append('stderr_thread') + + with app_module.morse_lock: + morse_active_device = None + _set_state(MORSE_IDLE, 'Stopped', extra={ + 'stop_ms': stop_ms, + 'cleanup_steps': cleanup_steps, + 'alive': alive_after, + }) + + with contextlib.suppress(queue.Full): + app_module.morse_queue.put_nowait({ + 'type': 'status', + 'status': 'stopped', + 'state': MORSE_IDLE, + 'stop_ms': stop_ms, + 'cleanup_steps': cleanup_steps, + 'alive': alive_after, + 'timestamp': time.strftime('%H:%M:%S'), + }) + + if stop_ms > 500.0 or alive_after: + logger.warning( + '[morse.stop] slow/partial cleanup: stop_ms=%s alive=%s steps=%s', + stop_ms, + ','.join(alive_after) if alive_after else 'none', + '; '.join(cleanup_steps), + ) + else: + logger.info('[morse.stop] cleanup complete in %sms', stop_ms) + + return jsonify({ + 'status': 'stopped', + 'state': MORSE_IDLE, + 'stop_ms': stop_ms, + 'alive': alive_after, + 'cleanup_steps': cleanup_steps, + }) + + +@morse_bp.route('/morse/calibrate', methods=['POST']) +def calibrate_morse() -> Response: + """Reset decoder threshold/timing estimators without restarting the process.""" + with app_module.morse_lock: + if morse_state != MORSE_RUNNING or morse_control_queue is None: + return jsonify({ + 'status': 'not_running', + 'state': morse_state, + 'message': 'Morse decoder is not running', + }), 409 + + with contextlib.suppress(queue.Full): + morse_control_queue.put_nowait({'cmd': 'reset'}) + + with contextlib.suppress(queue.Full): + app_module.morse_queue.put_nowait({ + 'type': 'info', + 'text': '[morse] Calibration reset requested', + }) + + return jsonify({'status': 'ok', 'state': morse_state}) + + +@morse_bp.route('/morse/decode-file', methods=['POST']) +def decode_morse_file() -> Response: + """Decode Morse from an uploaded WAV file.""" + if 'audio' not in request.files: + return jsonify({'status': 'error', 'message': 'No audio file provided'}), 400 + + audio_file = request.files['audio'] + if not audio_file.filename: + return jsonify({'status': 'error', 'message': 'No file selected'}), 400 + + # Parse optional tuning/decoder parameters from form fields. + form = request.form or {} + try: + tone_freq = _validate_tone_freq(form.get('tone_freq', '700')) + wpm = _validate_wpm(form.get('wpm', '15')) + bandwidth_hz = _validate_bandwidth(form.get('bandwidth_hz', '200')) + threshold_mode = _validate_threshold_mode(form.get('threshold_mode', 'auto')) + wpm_mode = _validate_wpm_mode(form.get('wpm_mode', 'auto')) + threshold_multiplier = _validate_threshold_multiplier(form.get('threshold_multiplier', '2.8')) + manual_threshold = _validate_non_negative_float(form.get('manual_threshold', '0'), 'manual threshold') + threshold_offset = _validate_non_negative_float(form.get('threshold_offset', '0'), 'threshold offset') + signal_gate = _validate_signal_gate(form.get('signal_gate', '0')) + auto_tone_track = _bool_value(form.get('auto_tone_track', 'true'), True) + tone_lock = _bool_value(form.get('tone_lock', 'false'), False) + wpm_lock = _bool_value(form.get('wpm_lock', 'false'), False) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp: + audio_file.save(tmp.name) + tmp_path = Path(tmp.name) + + try: + result = decode_morse_wav_file( + tmp_path, + sample_rate=8000, + tone_freq=tone_freq, + wpm=wpm, + bandwidth_hz=bandwidth_hz, + auto_tone_track=auto_tone_track, + tone_lock=tone_lock, + threshold_mode=threshold_mode, + manual_threshold=manual_threshold, + threshold_multiplier=threshold_multiplier, + threshold_offset=threshold_offset, + wpm_mode=wpm_mode, + wpm_lock=wpm_lock, + min_signal_gate=signal_gate, + ) + + text = str(result.get('text', '')) + raw = str(result.get('raw', '')) + metrics = result.get('metrics', {}) + + return jsonify({ + 'status': 'ok', + 'text': text, + 'raw': raw, + 'char_count': len(text.replace(' ', '')), + 'word_count': len([w for w in text.split(' ') if w]), + 'metrics': metrics, + }) + except Exception as e: + logger.error(f'Morse decode-file error: {e}') + return jsonify({'status': 'error', 'message': str(e)}), 500 + finally: + with contextlib.suppress(Exception): + tmp_path.unlink(missing_ok=True) + + +@morse_bp.route('/morse/status') +def morse_status() -> Response: + with app_module.morse_lock: + running = ( + app_module.morse_process is not None + and app_module.morse_process.poll() is None + and morse_state in {MORSE_RUNNING, MORSE_STARTING, MORSE_STOPPING} + ) + since_ms = round((time.monotonic() - morse_state_since) * 1000.0, 1) + return jsonify({ + 'running': running, + 'state': morse_state, + 'message': morse_state_message, + 'since_ms': since_ms, + 'session_id': morse_session_id, + 'config': morse_runtime_config, + 'alive': _snapshot_live_resources(), + 'error': morse_last_error, + }) + + +@morse_bp.route('/morse/stream') +def morse_stream() -> Response: + def _on_msg(msg: dict[str, Any]) -> None: + process_event('morse', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.morse_queue, + channel_key='morse', + timeout=1.0, + keepalive_interval=30.0, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' + return response diff --git a/static/css/modes/morse.css b/static/css/modes/morse.css index 844bf49..2ea11fc 100644 --- a/static/css/modes/morse.css +++ b/static/css/modes/morse.css @@ -1,118 +1,201 @@ -/* Morse Code / CW Decoder Styles */ - -/* Scope canvas container */ -.morse-scope-container { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 8px; - margin-bottom: 12px; -} - -.morse-scope-container canvas { - width: 100%; - height: 80px; - display: block; - border-radius: 4px; -} - -/* Decoded text panel */ -.morse-decoded-panel { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 12px; - min-height: 120px; - max-height: 400px; - overflow-y: auto; - font-family: var(--font-mono); - font-size: 18px; - line-height: 1.6; - color: var(--text-primary); - word-wrap: break-word; -} - -.morse-decoded-panel:empty::before { - content: 'Decoded text will appear here...'; - color: var(--text-dim); - font-size: 14px; - font-style: italic; -} - -/* Individual decoded character with fade-in */ -.morse-char { - display: inline; - animation: morseFadeIn 0.3s ease-out; - position: relative; -} - -@keyframes morseFadeIn { - from { - opacity: 0; - color: var(--accent-cyan); - } - to { - opacity: 1; - color: var(--text-primary); - } -} - -/* Small Morse notation above character */ -.morse-char-morse { - font-size: 9px; - color: var(--text-dim); - letter-spacing: 1px; - display: block; - line-height: 1; - margin-bottom: -2px; -} - -/* Reference grid */ -.morse-ref-grid { - transition: max-height 0.3s ease, opacity 0.3s ease; - max-height: 500px; - opacity: 1; - overflow: hidden; -} - -.morse-ref-grid.collapsed { - max-height: 0; - opacity: 0; -} - -/* Toolbar: export/copy/clear */ -.morse-toolbar { - display: flex; - gap: 6px; - margin-bottom: 8px; - flex-wrap: wrap; -} - -.morse-toolbar .btn { - font-size: 11px; - padding: 4px 10px; -} - -/* Status bar at bottom */ -.morse-status-bar { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 11px; - color: var(--text-dim); - padding: 6px 0; - border-top: 1px solid var(--border-color); - margin-top: 8px; -} - -.morse-status-bar .status-item { - display: flex; - align-items: center; - gap: 4px; -} - -/* Word space styling */ -.morse-word-space { - display: inline; - width: 0.5em; -} +/* Morse Code / CW Decoder Styles */ + +.morse-mode-help, +.morse-help-text { + font-size: 11px; + color: var(--text-dim); +} + +.morse-help-text { + margin-top: 4px; + display: block; +} + +.morse-hf-note { + font-size: 11px; + color: #ffaa00; + line-height: 1.5; +} + +.morse-presets { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.morse-actions-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.morse-file-row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.morse-file-row input[type='file'] { + width: 100%; + max-width: 100%; +} + +.morse-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-dim); +} + +.morse-status #morseCharCount { + margin-left: auto; +} + +.morse-ref-grid { + transition: max-height 0.3s ease, opacity 0.3s ease; + max-height: 560px; + opacity: 1; + overflow: hidden; + font-family: var(--font-mono); + font-size: 10px; + line-height: 1.8; + columns: 2; + column-gap: 12px; + color: var(--text-dim); +} + +.morse-ref-grid.collapsed { + max-height: 0; + opacity: 0; +} + +.morse-ref-toggle { + font-size: 10px; + color: var(--text-dim); +} + +.morse-ref-divider { + margin-top: 4px; + border-top: 1px solid var(--border-color); + padding-top: 4px; +} + +.morse-decoded-panel { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 12px; + min-height: 120px; + max-height: 400px; + overflow-y: auto; + font-family: var(--font-mono); + font-size: 18px; + line-height: 1.6; + color: var(--text-primary); + word-wrap: break-word; +} + +.morse-decoded-panel:empty::before { + content: 'Decoded text will appear here...'; + color: var(--text-dim); + font-size: 14px; + font-style: italic; +} + +.morse-char { + display: inline; + animation: morseFadeIn 0.3s ease-out; + position: relative; +} + +@keyframes morseFadeIn { + from { + opacity: 0; + color: var(--accent-cyan); + } + to { + opacity: 1; + color: var(--text-primary); + } +} + +.morse-word-space { + display: inline; + width: 0.5em; +} + +.morse-raw-panel { + margin-top: 8px; + padding: 8px; + border: 1px solid #1a1a2e; + border-radius: 4px; + background: #080812; +} + +.morse-raw-label { + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #667; + margin-bottom: 4px; +} + +.morse-raw-text { + min-height: 30px; + max-height: 90px; + overflow-y: auto; + font-family: var(--font-mono); + font-size: 11px; + color: #8fd0ff; + white-space: pre-wrap; + word-break: break-word; +} + +.morse-metrics-panel { + margin-top: 8px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + font-size: 10px; + color: #7a8694; +} + +.morse-metrics-panel span { + padding: 4px 6px; + border-radius: 4px; + border: 1px solid #1a1a2e; + background: #080811; + font-family: var(--font-mono); +} + +.morse-status-bar { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + color: var(--text-dim); + padding: 6px 0; + border-top: 1px solid var(--border-color); + margin-top: 8px; + gap: 8px; + flex-wrap: wrap; +} + +.morse-status-bar .status-item { + display: flex; + align-items: center; + gap: 4px; +} + +@media (max-width: 768px) { + .morse-metrics-panel { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .morse-file-row { + flex-direction: column; + align-items: stretch; + } +} diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js index 12f6e52..f0d6f31 100644 --- a/static/js/modes/morse.js +++ b/static/js/modes/morse.js @@ -1,465 +1,1150 @@ -/** - * Morse Code (CW) decoder module. - * - * IIFE providing start/stop controls, SSE streaming, scope canvas, - * decoded text display, and export capabilities. - */ -var MorseMode = (function () { - 'use strict'; - - var state = { - running: false, - initialized: false, - eventSource: null, - charCount: 0, - decodedLog: [], // { timestamp, morse, char } - }; - - // Scope state - var scopeCtx = null; - var scopeAnim = null; - var scopeHistory = []; - var SCOPE_HISTORY_LEN = 300; - var scopeThreshold = 0; - var scopeToneOn = false; - var scopeWaiting = false; - var waitingStart = 0; // timestamp when waiting began - - // ---- Initialization ---- - - function init() { - if (state.initialized) { - checkStatus(); - return; - } - state.initialized = true; - checkStatus(); - } - - function destroy() { - disconnectSSE(); - stopScope(); - } - - // ---- Status ---- - - function checkStatus() { - fetch('/morse/status') - .then(function (r) { return r.json(); }) - .then(function (data) { - if (data.running) { - state.running = true; - updateUI(true); - connectSSE(); - startScope(); - } else { - state.running = false; - updateUI(false); - } - }) - .catch(function () {}); - } - - // ---- Start / Stop ---- - - function start() { - if (state.running) return; - - var payload = { - frequency: document.getElementById('morseFrequency').value || '14.060', - gain: document.getElementById('morseGain').value || '0', - ppm: document.getElementById('morsePPM').value || '0', - device: document.getElementById('deviceSelect')?.value || '0', - sdr_type: document.getElementById('sdrTypeSelect')?.value || 'rtlsdr', - tone_freq: document.getElementById('morseToneFreq').value || '700', - wpm: document.getElementById('morseWpm').value || '15', - bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false, - }; - - fetch('/morse/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) - .then(function (r) { return r.json(); }) - .then(function (data) { - if (data.status === 'started') { - state.running = true; - state.charCount = 0; - state.decodedLog = []; - updateUI(true); - connectSSE(); - startScope(); - clearDecodedText(); - } else { - alert('Error: ' + (data.message || 'Unknown error')); - } - }) - .catch(function (err) { - alert('Failed to start Morse decoder: ' + err); - }); - } - - function stop() { - fetch('/morse/stop', { method: 'POST' }) - .then(function (r) { return r.json(); }) - .then(function () { - state.running = false; - updateUI(false); - disconnectSSE(); - stopScope(); - }) - .catch(function (err) { - console.error('Morse stop request failed:', err); - // Reset UI regardless so the user isn't stuck - state.running = false; - updateUI(false); - disconnectSSE(); - stopScope(); - }); - } - - // ---- SSE ---- - - function connectSSE() { - disconnectSSE(); - var es = new EventSource('/morse/stream'); - - es.onmessage = function (e) { - try { - var msg = JSON.parse(e.data); - handleMessage(msg); - } catch (_) {} - }; - - es.onerror = function () { - // Reconnect handled by browser - }; - - state.eventSource = es; - } - - function disconnectSSE() { - if (state.eventSource) { - state.eventSource.close(); - state.eventSource = null; - } - } - - function handleMessage(msg) { - var type = msg.type; - - if (type === 'scope') { - // Update scope data - var amps = msg.amplitudes || []; - if (msg.waiting && amps.length === 0 && scopeHistory.length === 0) { - if (!scopeWaiting) { - scopeWaiting = true; - waitingStart = Date.now(); - } - } else if (amps.length > 0) { - scopeWaiting = false; - waitingStart = 0; - } - for (var i = 0; i < amps.length; i++) { - scopeHistory.push(amps[i]); - if (scopeHistory.length > SCOPE_HISTORY_LEN) { - scopeHistory.shift(); - } - } - scopeThreshold = msg.threshold || 0; - scopeToneOn = msg.tone_on || false; - - } else if (type === 'morse_char') { - appendChar(msg.char, msg.morse, msg.timestamp); - - } else if (type === 'morse_space') { - appendSpace(); - - } else if (type === 'status') { - if (msg.status === 'stopped') { - state.running = false; - updateUI(false); - disconnectSSE(); - stopScope(); - } - } else if (type === 'info') { - appendDiagLine(msg.text); - - } else if (type === 'error') { - console.error('Morse error:', msg.text); - } - } - - // ---- Decoded text ---- - - function appendChar(ch, morse, timestamp) { - state.charCount++; - state.decodedLog.push({ timestamp: timestamp, morse: morse, char: ch }); - - var panel = document.getElementById('morseDecodedText'); - if (!panel) return; - - var span = document.createElement('span'); - span.className = 'morse-char'; - span.textContent = ch; - span.title = morse + ' (' + timestamp + ')'; - panel.appendChild(span); - - // Auto-scroll - panel.scrollTop = panel.scrollHeight; - - // Update count - var countEl = document.getElementById('morseCharCount'); - if (countEl) countEl.textContent = state.charCount + ' chars'; - var barChars = document.getElementById('morseStatusBarChars'); - if (barChars) barChars.textContent = state.charCount + ' chars decoded'; - } - - function appendSpace() { - var panel = document.getElementById('morseDecodedText'); - if (!panel) return; - - var span = document.createElement('span'); - span.className = 'morse-word-space'; - span.textContent = ' '; - panel.appendChild(span); - } - - function clearDecodedText() { - var panel = document.getElementById('morseDecodedText'); - if (panel) panel.innerHTML = ''; - state.charCount = 0; - state.decodedLog = []; - var countEl = document.getElementById('morseCharCount'); - if (countEl) countEl.textContent = '0 chars'; - var barChars = document.getElementById('morseStatusBarChars'); - if (barChars) barChars.textContent = '0 chars decoded'; - } - - // ---- Scope canvas ---- - - function startScope() { - var canvas = document.getElementById('morseScopeCanvas'); - if (!canvas) return; - - var dpr = window.devicePixelRatio || 1; - var rect = canvas.getBoundingClientRect(); - canvas.width = rect.width * dpr; - canvas.height = 80 * dpr; - canvas.style.height = '80px'; - - scopeCtx = canvas.getContext('2d'); - scopeCtx.scale(dpr, dpr); - scopeHistory = []; - scopeWaiting = false; - - var toneLabel = document.getElementById('morseScopeToneLabel'); - var threshLabel = document.getElementById('morseScopeThreshLabel'); - - function draw() { - if (!scopeCtx) return; - var w = rect.width; - var h = 80; - - scopeCtx.fillStyle = '#050510'; - scopeCtx.fillRect(0, 0, w, h); - - // Update header labels - if (toneLabel) toneLabel.textContent = scopeToneOn ? 'ON' : '--'; - if (threshLabel) threshLabel.textContent = scopeThreshold > 0 ? Math.round(scopeThreshold) : '--'; - - if (scopeHistory.length === 0) { - if (scopeWaiting) { - var elapsed = waitingStart ? (Date.now() - waitingStart) / 1000 : 0; - var waitText = elapsed > 10 - ? 'No audio data \u2014 check SDR log below' - : 'Awaiting SDR data\u2026'; - scopeCtx.fillStyle = elapsed > 10 ? '#887744' : '#556677'; - scopeCtx.font = '12px monospace'; - scopeCtx.textAlign = 'center'; - scopeCtx.fillText(waitText, w / 2, h / 2); - scopeCtx.textAlign = 'start'; - } - scopeAnim = requestAnimationFrame(draw); - return; - } - - // Find max for normalization - var maxVal = 0; - for (var i = 0; i < scopeHistory.length; i++) { - if (scopeHistory[i] > maxVal) maxVal = scopeHistory[i]; - } - if (maxVal === 0) maxVal = 1; - - var barW = w / SCOPE_HISTORY_LEN; - var threshNorm = scopeThreshold / maxVal; - - // Draw amplitude bars - for (var j = 0; j < scopeHistory.length; j++) { - var norm = scopeHistory[j] / maxVal; - var barH = norm * (h - 10); - var x = j * barW; - var y = h - barH; - - // Green if above threshold, gray if below - if (scopeHistory[j] > scopeThreshold) { - scopeCtx.fillStyle = '#00ff88'; - } else { - scopeCtx.fillStyle = '#334455'; - } - scopeCtx.fillRect(x, y, Math.max(barW - 1, 1), barH); - } - - // Draw threshold line - if (scopeThreshold > 0) { - var threshY = h - (threshNorm * (h - 10)); - scopeCtx.strokeStyle = '#ff4444'; - scopeCtx.lineWidth = 1; - scopeCtx.setLineDash([4, 4]); - scopeCtx.beginPath(); - scopeCtx.moveTo(0, threshY); - scopeCtx.lineTo(w, threshY); - scopeCtx.stroke(); - scopeCtx.setLineDash([]); - } - - // Tone indicator - if (scopeToneOn) { - scopeCtx.fillStyle = '#00ff88'; - scopeCtx.beginPath(); - scopeCtx.arc(w - 12, 12, 5, 0, Math.PI * 2); - scopeCtx.fill(); - } - - scopeAnim = requestAnimationFrame(draw); - } - - draw(); - } - - function stopScope() { - if (scopeAnim) { - cancelAnimationFrame(scopeAnim); - scopeAnim = null; - } - scopeCtx = null; - } - - // ---- Export ---- - - function exportTxt() { - var text = state.decodedLog.map(function (e) { return e.char; }).join(''); - downloadFile('morse_decoded.txt', text, 'text/plain'); - } - - function exportCsv() { - var lines = ['timestamp,morse,character']; - state.decodedLog.forEach(function (e) { - lines.push(e.timestamp + ',"' + e.morse + '",' + e.char); - }); - downloadFile('morse_decoded.csv', lines.join('\n'), 'text/csv'); - } - - function copyToClipboard() { - var text = state.decodedLog.map(function (e) { return e.char; }).join(''); - navigator.clipboard.writeText(text).then(function () { - var btn = document.getElementById('morseCopyBtn'); - if (btn) { - var orig = btn.textContent; - btn.textContent = 'Copied!'; - setTimeout(function () { btn.textContent = orig; }, 1500); - } - }); - } - - function downloadFile(filename, content, type) { - var blob = new Blob([content], { type: type }); - var url = URL.createObjectURL(blob); - var a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - } - - // ---- Diagnostic log ---- - - function appendDiagLine(text) { - var log = document.getElementById('morseDiagLog'); - if (!log) return; - log.style.display = 'block'; - var line = document.createElement('div'); - line.textContent = text; - log.appendChild(line); - // Limit to 20 entries - while (log.children.length > 20) { - log.removeChild(log.firstChild); - } - log.scrollTop = log.scrollHeight; - } - - function clearDiagLog() { - var log = document.getElementById('morseDiagLog'); - if (log) { - log.innerHTML = ''; - log.style.display = 'none'; - } - } - - // ---- UI ---- - - function updateUI(running) { - var startBtn = document.getElementById('morseStartBtn'); - var stopBtn = document.getElementById('morseStopBtn'); - var indicator = document.getElementById('morseStatusIndicator'); - var statusText = document.getElementById('morseStatusText'); - - if (startBtn) startBtn.style.display = running ? 'none' : ''; - if (stopBtn) stopBtn.style.display = running ? '' : 'none'; - - if (indicator) { - indicator.style.background = running ? '#00ff88' : 'var(--text-dim)'; - } - if (statusText) { - statusText.textContent = running ? 'Listening' : 'Standby'; - } - - // Toggle scope and output panels (pager/sensor pattern) - var scopePanel = document.getElementById('morseScopePanel'); - var outputPanel = document.getElementById('morseOutputPanel'); - if (scopePanel) scopePanel.style.display = running ? 'block' : 'none'; - if (outputPanel) outputPanel.style.display = running ? 'block' : 'none'; - - var scopeStatus = document.getElementById('morseScopeStatusLabel'); - if (scopeStatus) scopeStatus.textContent = running ? 'ACTIVE' : 'IDLE'; - if (scopeStatus) scopeStatus.style.color = running ? '#0f0' : '#444'; - - // Diagnostic log: clear on start, hide on stop - if (running) { - clearDiagLog(); - } else { - var diagLog = document.getElementById('morseDiagLog'); - if (diagLog) diagLog.style.display = 'none'; - } - } - - function setFreq(mhz) { - var el = document.getElementById('morseFrequency'); - if (el) el.value = mhz; - } - - // ---- Public API ---- - - return { - init: init, - destroy: destroy, - start: start, - stop: stop, - setFreq: setFreq, - exportTxt: exportTxt, - exportCsv: exportCsv, - copyToClipboard: copyToClipboard, - clearText: clearDecodedText, - }; -})(); +/** + * Morse Code (CW) decoder mode. + * Lifecycle state machine: idle -> starting -> running -> stopping -> idle/error + */ +var MorseMode = (function () { + 'use strict'; + + var SETTINGS_KEY = 'intercept.morse.settings.v3'; + var STATUS_POLL_MS = 5000; + var LOCAL_STOP_TIMEOUT_MS = 2200; + var START_TIMEOUT_MS = 4000; + + var state = { + initialized: false, + controlsBound: false, + lifecycle: 'idle', + eventSource: null, + statusPollTimer: null, + stopPromise: null, + startSeq: 0, + charCount: 0, + decodedLog: [], // { timestamp, morse, char } + rawLog: [], + waiting: false, + waitingStart: 0, + lastMetrics: { + wpm: 15, + tone_freq: 700, + level: 0, + threshold: 0, + noise_floor: 0, + stop_ms: null, + }, + }; + + // Scope state + var scopeCtx = null; + var scopeAnim = null; + var scopeHistory = []; + var scopeThreshold = 0; + var scopeToneOn = false; + var scopeWaiting = false; + var waitingStart = 0; + var scopeRect = null; + var SCOPE_HISTORY_LEN = 300; + + function el(id) { + return document.getElementById(id); + } + + function notifyInfo(text) { + if (typeof showInfo === 'function') { + showInfo(text); + } else { + console.info(text); + } + } + + function notifyError(text) { + if (typeof showError === 'function') { + showError(text); + } else { + alert(text); + } + } + + function parseJsonSafe(response) { + return response.json().catch(function () { return {}; }); + } + + function postJson(url, payload, timeoutMs) { + var controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; + var timeoutId = controller ? setTimeout(function () { controller.abort(); }, timeoutMs) : null; + + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload || {}), + signal: controller ? controller.signal : undefined, + }).then(function (response) { + return parseJsonSafe(response).then(function (data) { + if (!response.ok) { + var msg = data.message || data.error || ('HTTP ' + response.status); + throw new Error(msg); + } + return data; + }); + }).finally(function () { + if (timeoutId) clearTimeout(timeoutId); + }); + } + + function collectConfig() { + return { + frequency: (el('morseFrequency') && el('morseFrequency').value) || '14.060', + gain: (el('morseGain') && el('morseGain').value) || '40', + ppm: (el('morsePPM') && el('morsePPM').value) || '0', + device: (el('deviceSelect') && el('deviceSelect').value) || '0', + sdr_type: (el('sdrTypeSelect') && el('sdrTypeSelect').value) || 'rtlsdr', + bias_t: (typeof getBiasTEnabled === 'function') ? getBiasTEnabled() : false, + tone_freq: (el('morseToneFreq') && el('morseToneFreq').value) || '700', + bandwidth_hz: (el('morseBandwidth') && el('morseBandwidth').value) || '200', + auto_tone_track: !!(el('morseAutoToneTrack') && el('morseAutoToneTrack').checked), + tone_lock: !!(el('morseToneLock') && el('morseToneLock').checked), + threshold_mode: (el('morseThresholdMode') && el('morseThresholdMode').value) || 'auto', + manual_threshold: (el('morseManualThreshold') && el('morseManualThreshold').value) || '0', + threshold_multiplier: (el('morseThresholdMultiplier') && el('morseThresholdMultiplier').value) || '2.8', + threshold_offset: (el('morseThresholdOffset') && el('morseThresholdOffset').value) || '0', + signal_gate: (el('morseSignalGate') && el('morseSignalGate').value) || '0.05', + wpm_mode: (el('morseWpmMode') && el('morseWpmMode').value) || 'auto', + wpm: (el('morseWpm') && el('morseWpm').value) || '15', + wpm_lock: !!(el('morseWpmLock') && el('morseWpmLock').checked), + }; + } + + function persistSettings() { + try { + var payload = { + frequency: (el('morseFrequency') && el('morseFrequency').value) || '14.060', + gain: (el('morseGain') && el('morseGain').value) || '40', + ppm: (el('morsePPM') && el('morsePPM').value) || '0', + tone_freq: (el('morseToneFreq') && el('morseToneFreq').value) || '700', + bandwidth_hz: (el('morseBandwidth') && el('morseBandwidth').value) || '200', + auto_tone_track: !!(el('morseAutoToneTrack') && el('morseAutoToneTrack').checked), + tone_lock: !!(el('morseToneLock') && el('morseToneLock').checked), + threshold_mode: (el('morseThresholdMode') && el('morseThresholdMode').value) || 'auto', + manual_threshold: (el('morseManualThreshold') && el('morseManualThreshold').value) || '0', + threshold_multiplier: (el('morseThresholdMultiplier') && el('morseThresholdMultiplier').value) || '2.8', + threshold_offset: (el('morseThresholdOffset') && el('morseThresholdOffset').value) || '0', + signal_gate: (el('morseSignalGate') && el('morseSignalGate').value) || '0.05', + wpm_mode: (el('morseWpmMode') && el('morseWpmMode').value) || 'auto', + wpm: (el('morseWpm') && el('morseWpm').value) || '15', + wpm_lock: !!(el('morseWpmLock') && el('morseWpmLock').checked), + show_raw: !!(el('morseShowRaw') && el('morseShowRaw').checked), + show_diag: !!(el('morseShowDiag') && el('morseShowDiag').checked), + }; + localStorage.setItem(SETTINGS_KEY, JSON.stringify(payload)); + } catch (_) { + // Ignore local storage errors. + } + } + + function applySettings(settings) { + if (!settings || typeof settings !== 'object') return; + + if (el('morseFrequency') && settings.frequency !== undefined) el('morseFrequency').value = settings.frequency; + if (el('morseGain') && settings.gain !== undefined) el('morseGain').value = settings.gain; + if (el('morsePPM') && settings.ppm !== undefined) el('morsePPM').value = settings.ppm; + if (el('morseToneFreq') && settings.tone_freq !== undefined) el('morseToneFreq').value = settings.tone_freq; + if (el('morseBandwidth') && settings.bandwidth_hz !== undefined) el('morseBandwidth').value = settings.bandwidth_hz; + if (el('morseThresholdMode') && settings.threshold_mode !== undefined) el('morseThresholdMode').value = settings.threshold_mode; + if (el('morseManualThreshold') && settings.manual_threshold !== undefined) el('morseManualThreshold').value = settings.manual_threshold; + if (el('morseThresholdMultiplier') && settings.threshold_multiplier !== undefined) el('morseThresholdMultiplier').value = settings.threshold_multiplier; + if (el('morseThresholdOffset') && settings.threshold_offset !== undefined) el('morseThresholdOffset').value = settings.threshold_offset; + if (el('morseSignalGate') && settings.signal_gate !== undefined) el('morseSignalGate').value = settings.signal_gate; + if (el('morseWpmMode') && settings.wpm_mode !== undefined) el('morseWpmMode').value = settings.wpm_mode; + if (el('morseWpm') && settings.wpm !== undefined) el('morseWpm').value = settings.wpm; + + if (el('morseAutoToneTrack') && settings.auto_tone_track !== undefined) el('morseAutoToneTrack').checked = !!settings.auto_tone_track; + if (el('morseToneLock') && settings.tone_lock !== undefined) el('morseToneLock').checked = !!settings.tone_lock; + if (el('morseWpmLock') && settings.wpm_lock !== undefined) el('morseWpmLock').checked = !!settings.wpm_lock; + if (el('morseShowRaw') && settings.show_raw !== undefined) el('morseShowRaw').checked = !!settings.show_raw; + if (el('morseShowDiag') && settings.show_diag !== undefined) el('morseShowDiag').checked = !!settings.show_diag; + + updateToneLabel((el('morseToneFreq') && el('morseToneFreq').value) || '700'); + updateWpmLabel((el('morseWpm') && el('morseWpm').value) || '15'); + onThresholdModeChange(); + onWpmModeChange(); + toggleRawPanel(); + toggleDiagPanel(); + } + + function loadSettings() { + try { + var raw = localStorage.getItem(SETTINGS_KEY); + if (!raw) return; + var parsed = JSON.parse(raw); + applySettings(parsed); + } catch (_) { + // Ignore malformed settings. + } + } + + function bindControls() { + if (state.controlsBound) return; + state.controlsBound = true; + + var ids = [ + 'morseFrequency', 'morseGain', 'morsePPM', 'morseToneFreq', 'morseBandwidth', + 'morseAutoToneTrack', 'morseToneLock', 'morseThresholdMode', 'morseManualThreshold', + 'morseThresholdMultiplier', 'morseThresholdOffset', 'morseSignalGate', + 'morseWpmMode', 'morseWpm', 'morseWpmLock', 'morseShowRaw', 'morseShowDiag' + ]; + + ids.forEach(function (id) { + var node = el(id); + if (!node) return; + node.addEventListener('change', persistSettings); + if (node.tagName === 'INPUT' && (node.type === 'range' || node.type === 'number' || node.type === 'text')) { + node.addEventListener('input', persistSettings); + } + }); + + if (el('morseShowRaw')) { + el('morseShowRaw').addEventListener('change', toggleRawPanel); + } + if (el('morseShowDiag')) { + el('morseShowDiag').addEventListener('change', toggleDiagPanel); + } + } + + function setLifecycle(next) { + state.lifecycle = next; + updateUI(); + } + + function isTransition() { + return state.lifecycle === 'starting' || state.lifecycle === 'stopping'; + } + + function isActive() { + return state.lifecycle === 'starting' || state.lifecycle === 'running' || state.lifecycle === 'stopping'; + } + + function init() { + bindControls(); + + if (state.initialized) { + checkStatus(); + return; + } + + state.initialized = true; + loadSettings(); + updateUI(); + checkStatus(); + + if (!state.statusPollTimer) { + state.statusPollTimer = setInterval(checkStatus, STATUS_POLL_MS); + } + } + + function destroy() { + if (state.statusPollTimer) { + clearInterval(state.statusPollTimer); + state.statusPollTimer = null; + } + + if (state.lifecycle === 'running' || state.lifecycle === 'starting') { + stop({ silent: true }).catch(function () { }); + } else { + disconnectSSE(); + stopScope(); + } + + state.initialized = false; + } + + function start() { + if (state.lifecycle === 'running' || state.lifecycle === 'starting') { + return Promise.resolve({ status: 'already_running' }); + } + + if (state.lifecycle === 'stopping' && state.stopPromise) { + return state.stopPromise.then(function () { + return start(); + }); + } + + if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('morse')) { + return Promise.resolve({ status: 'blocked' }); + } + + clearDiagLog(); + clearDecodedText(); + clearRawText(); + + var payload = collectConfig(); + persistSettings(); + + var seq = ++state.startSeq; + setLifecycle('starting'); + + return postJson('/morse/start', payload, START_TIMEOUT_MS) + .then(function (data) { + if (seq !== state.startSeq) { + return data; + } + + if (data.status !== 'started') { + throw new Error(data.message || 'Failed to start Morse decoder'); + } + + if (typeof reserveDevice === 'function') { + var parsedDevice = Number(payload.device); + if (Number.isFinite(parsedDevice)) { + reserveDevice(parsedDevice, 'morse'); + } + } + + setLifecycle('running'); + connectSSE(); + startScope(); + setStatusText('Listening'); + applyMetrics(data.config || {}, true); + notifyInfo('Morse decoder started'); + return data; + }) + .catch(function (err) { + if (seq !== state.startSeq) { + return { status: 'stale' }; + } + setLifecycle('error'); + setStatusText('Error'); + notifyError('Failed to start Morse decoder: ' + (err && err.message ? err.message : err)); + setTimeout(function () { + if (state.lifecycle === 'error') { + setLifecycle('idle'); + } + }, 800); + return { status: 'error', message: String(err && err.message ? err.message : err) }; + }); + } + + function stop(options) { + options = options || {}; + + if (state.stopPromise) { + return state.stopPromise; + } + + var currentlyActive = isActive(); + if (!currentlyActive && !options.force) { + disconnectSSE(); + stopScope(); + setLifecycle('idle'); + if (typeof releaseDevice === 'function') releaseDevice('morse'); + return Promise.resolve({ status: 'not_running' }); + } + + state.startSeq += 1; // invalidate in-flight start responses + setLifecycle('stopping'); + setStatusText('Stopping...'); + + disconnectSSE(); + stopScope(); + if (typeof releaseDevice === 'function') { + releaseDevice('morse'); + } + + var stopPromise; + if (options.skipRequest) { + stopPromise = Promise.resolve({ status: 'skipped' }); + } else { + stopPromise = postJson('/morse/stop', {}, LOCAL_STOP_TIMEOUT_MS) + .catch(function (err) { + appendDiagLine('[stop] ' + (err && err.message ? err.message : err)); + return { status: 'error', message: String(err && err.message ? err.message : err) }; + }); + } + + state.stopPromise = stopPromise.then(function (data) { + if (data && data.stop_ms !== undefined) { + state.lastMetrics.stop_ms = Number(data.stop_ms); + updateMetricLabel('morseMetricStopMs', 'STOP ' + Math.round(state.lastMetrics.stop_ms) + ' ms'); + } + + if (data && Array.isArray(data.cleanup_steps)) { + appendDiagLine('[stop] ' + data.cleanup_steps.join(' | ')); + } + if (data && Array.isArray(data.alive) && data.alive.length) { + appendDiagLine('[stop] still alive: ' + data.alive.join(', ')); + } + + setLifecycle('idle'); + setStatusText('Standby'); + return data; + }).finally(function () { + state.stopPromise = null; + }); + + return state.stopPromise; + } + + function checkStatus() { + if (!state.initialized) return; + + fetch('/morse/status') + .then(function (r) { return parseJsonSafe(r); }) + .then(function (data) { + if (!data || typeof data !== 'object') return; + + if (data.running) { + if (data.state === 'starting') { + setLifecycle('starting'); + } else if (data.state === 'stopping') { + setLifecycle('stopping'); + } else { + setLifecycle('running'); + } + + if (!state.eventSource) connectSSE(); + if (!scopeAnim && state.lifecycle === 'running') startScope(); + + var message = data.message || (state.lifecycle === 'running' ? 'Listening' : data.state); + setStatusText(message); + if (data.config) { + applyMetrics(data.config, true); + } + } else if (state.lifecycle === 'running' || state.lifecycle === 'starting' || state.lifecycle === 'stopping') { + disconnectSSE(); + stopScope(); + setLifecycle('idle'); + setStatusText('Standby'); + if (typeof releaseDevice === 'function') { + releaseDevice('morse'); + } + } + + if (data.error) { + appendDiagLine('[status] ' + data.error); + } + }) + .catch(function () { + // Ignore status polling errors. + }); + } + + function connectSSE() { + disconnectSSE(); + + var es = new EventSource('/morse/stream'); + es.onmessage = function (e) { + try { + var msg = JSON.parse(e.data); + handleMessage(msg); + } catch (_) { + // Ignore malformed events. + } + }; + + es.onerror = function () { + if (state.lifecycle === 'running') { + appendDiagLine('[stream] reconnecting...'); + } + }; + + state.eventSource = es; + } + + function disconnectSSE() { + if (state.eventSource) { + state.eventSource.close(); + state.eventSource = null; + } + } + + function handleMessage(msg) { + if (!msg || typeof msg !== 'object') return; + + var type = msg.type; + + if (type === 'scope') { + handleScope(msg); + applyMetrics(msg, false); + return; + } + + if (type === 'morse_char') { + appendChar(msg.char, msg.morse, msg.timestamp || '--:--:--'); + return; + } + + if (type === 'morse_space') { + appendSpace(); + appendRawToken(' // '); + return; + } + + if (type === 'morse_element') { + appendRawToken(msg.element || ''); + return; + } + + if (type === 'morse_gap') { + if (msg.gap === 'char') { + appendRawToken(' / '); + } else if (msg.gap === 'word') { + appendRawToken(' // '); + } + return; + } + + if (type === 'status') { + handleStatus(msg); + return; + } + + if (type === 'info') { + appendDiagLine(msg.text || '[info]'); + return; + } + + if (type === 'error') { + appendDiagLine('[error] ' + (msg.text || 'Decoder error')); + return; + } + } + + function handleStatus(msg) { + var stateValue = String(msg.state || msg.status || '').toLowerCase(); + if (stateValue === 'starting') { + setLifecycle('starting'); + setStatusText('Starting...'); + } else if (stateValue === 'running') { + setLifecycle('running'); + setStatusText('Listening'); + } else if (stateValue === 'stopping') { + setLifecycle('stopping'); + setStatusText('Stopping...'); + } + + if (msg.metrics) { + applyMetrics(msg.metrics, false); + } + + if (msg.stop_ms !== undefined) { + state.lastMetrics.stop_ms = Number(msg.stop_ms); + updateMetricLabel('morseMetricStopMs', 'STOP ' + Math.round(state.lastMetrics.stop_ms) + ' ms'); + } + + if (msg.cleanup_steps && Array.isArray(msg.cleanup_steps)) { + appendDiagLine('[cleanup] ' + msg.cleanup_steps.join(' | ')); + } + + if (msg.alive && Array.isArray(msg.alive) && msg.alive.length) { + appendDiagLine('[cleanup] alive: ' + msg.alive.join(', ')); + } + + if (msg.status === 'stopped' || stateValue === 'idle') { + disconnectSSE(); + stopScope(); + setLifecycle('idle'); + setStatusText('Standby'); + if (typeof releaseDevice === 'function') { + releaseDevice('morse'); + } + } + } + + function handleScope(msg) { + var amps = Array.isArray(msg.amplitudes) ? msg.amplitudes : []; + + if (msg.waiting && amps.length === 0) { + if (!scopeWaiting) { + scopeWaiting = true; + waitingStart = Date.now(); + } + } else if (amps.length > 0) { + scopeWaiting = false; + waitingStart = 0; + } + + for (var i = 0; i < amps.length; i++) { + scopeHistory.push(amps[i]); + if (scopeHistory.length > SCOPE_HISTORY_LEN) { + scopeHistory.shift(); + } + } + + scopeThreshold = Number(msg.threshold) || 0; + scopeToneOn = !!msg.tone_on; + + if (msg.tone_freq !== undefined) { + state.lastMetrics.tone_freq = Number(msg.tone_freq) || state.lastMetrics.tone_freq; + } + if (msg.wpm !== undefined) { + state.lastMetrics.wpm = Number(msg.wpm) || state.lastMetrics.wpm; + } + } + + function applyMetrics(metrics, fromConfig) { + if (!metrics || typeof metrics !== 'object') return; + + if (metrics.wpm !== undefined) { + state.lastMetrics.wpm = Number(metrics.wpm) || state.lastMetrics.wpm; + } + + if (metrics.tone_freq !== undefined) { + state.lastMetrics.tone_freq = Number(metrics.tone_freq) || state.lastMetrics.tone_freq; + } + + if (metrics.level !== undefined) { + state.lastMetrics.level = Number(metrics.level) || 0; + } + + if (metrics.threshold !== undefined) { + state.lastMetrics.threshold = Number(metrics.threshold) || 0; + } else if (fromConfig && metrics.manual_threshold !== undefined) { + state.lastMetrics.threshold = Number(metrics.manual_threshold) || state.lastMetrics.threshold; + } + + if (metrics.noise_floor !== undefined) { + state.lastMetrics.noise_floor = Number(metrics.noise_floor) || 0; + } + + updateMetricLabel('morseMetricTone', 'TONE ' + Math.round(state.lastMetrics.tone_freq || 700) + ' Hz'); + updateMetricLabel('morseMetricLevel', 'LEVEL ' + (state.lastMetrics.level || 0).toFixed(2)); + updateMetricLabel('morseMetricThreshold', 'THRESH ' + (state.lastMetrics.threshold || 0).toFixed(2)); + updateMetricLabel('morseMetricNoise', 'NOISE ' + (state.lastMetrics.noise_floor || 0).toFixed(2)); + + var toneScope = el('morseScopeToneLabel'); + if (toneScope) { + toneScope.textContent = scopeToneOn ? 'ON' : '--'; + } + + var thresholdScope = el('morseScopeThreshLabel'); + if (thresholdScope) { + thresholdScope.textContent = state.lastMetrics.threshold > 0 + ? Math.round(state.lastMetrics.threshold) + : '--'; + } + + var barWpm = el('morseStatusBarWpm'); + if (barWpm) barWpm.textContent = Math.round(state.lastMetrics.wpm || 0) + ' WPM'; + + var barTone = el('morseStatusBarTone'); + if (barTone) barTone.textContent = Math.round(state.lastMetrics.tone_freq || 700) + ' Hz'; + + var metricState = el('morseMetricState'); + if (metricState) metricState.textContent = 'STATE ' + state.lifecycle; + } + + function appendChar(ch, morse, timestamp) { + if (!ch) return; + + state.charCount += 1; + state.decodedLog.push({ + timestamp: timestamp || '--:--:--', + morse: morse || '', + char: ch, + }); + + var panel = el('morseDecodedText'); + if (panel) { + var span = document.createElement('span'); + span.className = 'morse-char'; + span.textContent = ch; + span.title = (morse || '') + ' (' + (timestamp || '--:--:--') + ')'; + panel.appendChild(span); + panel.scrollTop = panel.scrollHeight; + } + + updateCharCounts(); + } + + function appendSpace() { + var panel = el('morseDecodedText'); + if (!panel) return; + + var span = document.createElement('span'); + span.className = 'morse-word-space'; + span.textContent = ' '; + panel.appendChild(span); + panel.scrollTop = panel.scrollHeight; + } + + function appendRawToken(token) { + if (!token) return; + state.rawLog.push(token); + if (state.rawLog.length > 2000) { + state.rawLog.splice(0, state.rawLog.length - 2000); + } + + var rawText = el('morseRawText'); + if (rawText) { + rawText.textContent = state.rawLog.join(''); + rawText.scrollTop = rawText.scrollHeight; + } + } + + function clearRawText() { + state.rawLog = []; + var rawText = el('morseRawText'); + if (rawText) rawText.textContent = ''; + } + + function updateCharCounts() { + var countEl = el('morseCharCount'); + if (countEl) countEl.textContent = state.charCount + ' chars'; + + var barChars = el('morseStatusBarChars'); + if (barChars) barChars.textContent = state.charCount + ' chars decoded'; + } + + function clearDecodedText() { + state.charCount = 0; + state.decodedLog = []; + + var panel = el('morseDecodedText'); + if (panel) panel.innerHTML = ''; + + updateCharCounts(); + } + + function startScope() { + var canvas = el('morseScopeCanvas'); + if (!canvas) return; + + var rect = canvas.getBoundingClientRect(); + if (!rect.width) return; + + var dpr = window.devicePixelRatio || 1; + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(80 * dpr)); + canvas.style.height = '80px'; + + scopeCtx = canvas.getContext('2d'); + if (!scopeCtx) return; + scopeCtx.setTransform(1, 0, 0, 1, 0, 0); + scopeCtx.scale(dpr, dpr); + + scopeHistory = []; + scopeRect = rect; + + if (scopeAnim) { + cancelAnimationFrame(scopeAnim); + scopeAnim = null; + } + + function draw() { + if (!scopeCtx || !scopeRect) return; + + var w = scopeRect.width; + var h = 80; + + scopeCtx.fillStyle = '#050510'; + scopeCtx.fillRect(0, 0, w, h); + + if (scopeHistory.length === 0) { + if (scopeWaiting) { + var elapsed = waitingStart ? (Date.now() - waitingStart) / 1000 : 0; + var text = elapsed > 10 ? 'No audio data - check SDR log below' : 'Awaiting SDR data...'; + scopeCtx.fillStyle = elapsed > 10 ? '#887744' : '#556677'; + scopeCtx.font = '12px monospace'; + scopeCtx.textAlign = 'center'; + scopeCtx.fillText(text, w / 2, h / 2); + scopeCtx.textAlign = 'start'; + } + scopeAnim = requestAnimationFrame(draw); + return; + } + + var maxVal = 0; + for (var i = 0; i < scopeHistory.length; i++) { + if (scopeHistory[i] > maxVal) maxVal = scopeHistory[i]; + } + if (maxVal <= 0) maxVal = 1; + + var barWidth = w / SCOPE_HISTORY_LEN; + var thresholdNorm = scopeThreshold / maxVal; + + for (var j = 0; j < scopeHistory.length; j++) { + var norm = scopeHistory[j] / maxVal; + var barHeight = norm * (h - 10); + var x = j * barWidth; + var y = h - barHeight; + + scopeCtx.fillStyle = scopeHistory[j] > scopeThreshold ? '#00ff88' : '#334455'; + scopeCtx.fillRect(x, y, Math.max(barWidth - 1, 1), barHeight); + } + + if (scopeThreshold > 0) { + var yThresh = h - (thresholdNorm * (h - 10)); + scopeCtx.strokeStyle = '#ff4444'; + scopeCtx.lineWidth = 1; + scopeCtx.setLineDash([4, 4]); + scopeCtx.beginPath(); + scopeCtx.moveTo(0, yThresh); + scopeCtx.lineTo(w, yThresh); + scopeCtx.stroke(); + scopeCtx.setLineDash([]); + } + + if (scopeToneOn) { + scopeCtx.fillStyle = '#00ff88'; + scopeCtx.beginPath(); + scopeCtx.arc(w - 12, 12, 5, 0, Math.PI * 2); + scopeCtx.fill(); + } + + scopeAnim = requestAnimationFrame(draw); + } + + draw(); + } + + function stopScope() { + if (scopeAnim) { + cancelAnimationFrame(scopeAnim); + scopeAnim = null; + } + scopeCtx = null; + scopeRect = null; + scopeHistory = []; + scopeWaiting = false; + waitingStart = 0; + } + + function appendDiagLine(text) { + var log = el('morseDiagLog'); + if (!log) return; + + var showDiag = !!(el('morseShowDiag') && el('morseShowDiag').checked); + if (!showDiag) return; + + log.style.display = 'block'; + var line = document.createElement('div'); + line.textContent = text; + log.appendChild(line); + + while (log.children.length > 32) { + log.removeChild(log.firstChild); + } + log.scrollTop = log.scrollHeight; + } + + function clearDiagLog() { + var log = el('morseDiagLog'); + if (!log) return; + log.innerHTML = ''; + log.style.display = 'none'; + } + + function toggleDiagPanel() { + var log = el('morseDiagLog'); + if (!log) return; + + var showDiag = !!(el('morseShowDiag') && el('morseShowDiag').checked); + if (!showDiag) { + log.style.display = 'none'; + } else if (log.children.length > 0) { + log.style.display = 'block'; + } + } + + function toggleRawPanel() { + var panel = el('morseRawPanel'); + if (!panel) return; + + var showRaw = !!(el('morseShowRaw') && el('morseShowRaw').checked); + panel.style.display = showRaw ? 'block' : 'none'; + } + + function setStatusText(text) { + var statusText = el('morseStatusText'); + if (statusText) statusText.textContent = text; + } + + function updateMetricLabel(id, text) { + var node = el(id); + if (node) node.textContent = text; + } + + function updateUI() { + var startBtn = el('morseStartBtn'); + var stopBtn = el('morseStopBtn'); + var indicator = el('morseStatusIndicator'); + + var running = state.lifecycle === 'running'; + var starting = state.lifecycle === 'starting'; + var stopping = state.lifecycle === 'stopping'; + var busy = isTransition(); + + if (startBtn) { + startBtn.style.display = running || starting ? 'none' : 'block'; + startBtn.disabled = busy; + } + + if (stopBtn) { + stopBtn.style.display = (running || starting || stopping) ? 'block' : 'none'; + stopBtn.disabled = stopping; + stopBtn.textContent = stopping ? 'Stopping...' : 'Stop Decoder'; + } + + if (indicator) { + if (running) { + indicator.style.background = '#00ff88'; + } else if (starting || stopping) { + indicator.style.background = '#ffaa00'; + } else if (state.lifecycle === 'error') { + indicator.style.background = '#ff5555'; + } else { + indicator.style.background = 'var(--text-dim)'; + } + } + + if (state.lifecycle === 'idle') setStatusText('Standby'); + if (state.lifecycle === 'starting') setStatusText('Starting...'); + if (state.lifecycle === 'running') setStatusText('Listening'); + if (state.lifecycle === 'stopping') setStatusText('Stopping...'); + if (state.lifecycle === 'error') setStatusText('Error'); + + var scopePanel = el('morseScopePanel'); + if (scopePanel) scopePanel.style.display = (running || starting) ? 'block' : 'none'; + + var outputPanel = el('morseOutputPanel'); + if (outputPanel) outputPanel.style.display = (running || starting) ? 'block' : 'none'; + + var scopeStatus = el('morseScopeStatusLabel'); + if (scopeStatus) { + if (running) { + scopeStatus.textContent = 'ACTIVE'; + scopeStatus.style.color = '#0f0'; + } else if (starting) { + scopeStatus.textContent = 'STARTING'; + scopeStatus.style.color = '#ffaa00'; + } else if (stopping) { + scopeStatus.textContent = 'STOPPING'; + scopeStatus.style.color = '#ffaa00'; + } else { + scopeStatus.textContent = 'IDLE'; + scopeStatus.style.color = '#444'; + } + } + + var stateBar = el('morseStatusBarState'); + if (stateBar) { + stateBar.textContent = state.lifecycle.toUpperCase(); + } + + var metricState = el('morseMetricState'); + if (metricState) { + metricState.textContent = 'STATE ' + state.lifecycle; + } + + var controls = [ + 'morseFrequency', 'morseGain', 'morsePPM', 'morseToneFreq', 'morseBandwidth', + 'morseAutoToneTrack', 'morseToneLock', 'morseThresholdMode', 'morseManualThreshold', + 'morseThresholdMultiplier', 'morseThresholdOffset', 'morseSignalGate', 'morseWpmMode', + 'morseWpm', 'morseWpmLock', 'morseShowRaw', 'morseShowDiag', + 'morseCalibrateBtn', 'morseDecodeFileBtn', 'morseFileInput' + ]; + + controls.forEach(function (id) { + var node = el(id); + if (!node) return; + node.disabled = busy; + }); + + toggleRawPanel(); + toggleDiagPanel(); + } + + function updateToneLabel(value) { + var toneLabel = el('morseToneFreqLabel'); + if (toneLabel) toneLabel.textContent = String(value); + persistSettings(); + } + + function updateWpmLabel(value) { + var wpmLabel = el('morseWpmLabel'); + if (wpmLabel) wpmLabel.textContent = String(value); + persistSettings(); + } + + function onThresholdModeChange() { + var mode = (el('morseThresholdMode') && el('morseThresholdMode').value) || 'auto'; + var manualRow = el('morseManualThresholdRow'); + var autoRow = el('morseThresholdAutoRow'); + var offsetRow = el('morseThresholdOffsetRow'); + + if (manualRow) manualRow.style.display = mode === 'manual' ? 'block' : 'none'; + if (autoRow) autoRow.style.display = mode === 'manual' ? 'none' : 'block'; + if (offsetRow) offsetRow.style.display = mode === 'manual' ? 'none' : 'block'; + + persistSettings(); + } + + function onWpmModeChange() { + var mode = (el('morseWpmMode') && el('morseWpmMode').value) || 'auto'; + var manualRow = el('morseWpmManualRow'); + if (manualRow) { + manualRow.style.display = mode === 'manual' ? 'block' : 'none'; + } + persistSettings(); + } + + function setFreq(mhz) { + var freq = el('morseFrequency'); + if (freq) { + freq.value = String(mhz); + persistSettings(); + } + } + + function exportTxt() { + var text = state.decodedLog.map(function (entry) { return entry.char; }).join(''); + downloadFile('morse_decoded.txt', text, 'text/plain'); + } + + function exportCsv() { + var lines = ['timestamp,morse,character']; + state.decodedLog.forEach(function (entry) { + lines.push(entry.timestamp + ',"' + entry.morse + '",' + entry.char); + }); + downloadFile('morse_decoded.csv', lines.join('\n'), 'text/csv'); + } + + function copyToClipboard() { + var text = state.decodedLog.map(function (entry) { return entry.char; }).join(''); + if (!navigator.clipboard || !navigator.clipboard.writeText) return; + + navigator.clipboard.writeText(text).then(function () { + var btn = el('morseCopyBtn'); + if (!btn) return; + var original = btn.textContent; + btn.textContent = 'Copied!'; + setTimeout(function () { + btn.textContent = original; + }, 1200); + }).catch(function () { + // Ignore clipboard failures. + }); + } + + function downloadFile(filename, content, type) { + var blob = new Blob([content], { type: type }); + var url = URL.createObjectURL(blob); + var anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); + } + + function calibrate() { + if (state.lifecycle !== 'running') { + notifyInfo('Morse decoder is not running'); + return; + } + + postJson('/morse/calibrate', {}, 2000) + .then(function () { + appendDiagLine('[calibrate] estimator reset requested'); + notifyInfo('Morse estimator reset'); + }) + .catch(function (err) { + notifyError('Calibration failed: ' + (err && err.message ? err.message : err)); + }); + } + + function decodeFile() { + var input = el('morseFileInput'); + if (!input || !input.files || !input.files[0]) { + notifyError('Select a WAV file first.'); + return; + } + + var file = input.files[0]; + var config = collectConfig(); + var formData = new FormData(); + formData.append('audio', file); + + formData.append('tone_freq', config.tone_freq); + formData.append('wpm', config.wpm); + formData.append('bandwidth_hz', config.bandwidth_hz); + formData.append('auto_tone_track', String(config.auto_tone_track)); + formData.append('tone_lock', String(config.tone_lock)); + formData.append('threshold_mode', config.threshold_mode); + formData.append('manual_threshold', config.manual_threshold); + formData.append('threshold_multiplier', config.threshold_multiplier); + formData.append('threshold_offset', config.threshold_offset); + formData.append('wpm_mode', config.wpm_mode); + formData.append('wpm_lock', String(config.wpm_lock)); + formData.append('signal_gate', config.signal_gate); + + var decodeBtn = el('morseDecodeFileBtn'); + if (decodeBtn) { + decodeBtn.disabled = true; + decodeBtn.textContent = 'Decoding...'; + } + + fetch('/morse/decode-file', { + method: 'POST', + body: formData, + }).then(function (response) { + return parseJsonSafe(response).then(function (data) { + if (!response.ok || data.status !== 'ok') { + throw new Error(data.message || ('HTTP ' + response.status)); + } + + clearDecodedText(); + clearRawText(); + + var text = String(data.text || ''); + var raw = String(data.raw || ''); + + if (text.length > 0) { + for (var i = 0; i < text.length; i++) { + if (text[i] === ' ') { + appendSpace(); + } else { + appendChar(text[i], '', '--:--:--'); + } + } + } + + if (raw) { + state.rawLog = [raw]; + var rawText = el('morseRawText'); + if (rawText) rawText.textContent = raw; + } + + if (data.metrics) { + applyMetrics(data.metrics, false); + } + + toggleRawPanel(); + notifyInfo('File decode complete: ' + (data.char_count || 0) + ' chars'); + }); + }).catch(function (err) { + notifyError('WAV decode failed: ' + (err && err.message ? err.message : err)); + }).finally(function () { + if (decodeBtn) { + decodeBtn.disabled = false; + decodeBtn.textContent = 'Decode File'; + } + }); + } + + return { + init: init, + destroy: destroy, + start: start, + stop: stop, + setFreq: setFreq, + exportTxt: exportTxt, + exportCsv: exportCsv, + copyToClipboard: copyToClipboard, + clearText: clearDecodedText, + calibrate: calibrate, + decodeFile: decodeFile, + updateToneLabel: updateToneLabel, + updateWpmLabel: updateWpmLabel, + onThresholdModeChange: onThresholdModeChange, + onWpmModeChange: onWpmModeChange, + isActive: isActive, + }; +})(); diff --git a/templates/index.html b/templates/index.html index b86aa44..a48009b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3103,8 +3103,21 @@
+ +
+ STATE idle + TONE -- Hz + LEVEL -- + THRESH -- + NOISE -- + STOP -- ms +
- 15 WPM + IDLE + -- WPM 700 Hz 0 chars decoded
@@ -3863,6 +3876,11 @@ return { pager: Boolean(isRunning), sensor: Boolean(isSensorRunning), + morse: Boolean( + typeof MorseMode !== 'undefined' + && typeof MorseMode.isActive === 'function' + && MorseMode.isActive() + ), wifi: Boolean( ((typeof WiFiMode !== 'undefined' && typeof WiFiMode.isScanning === 'function' && WiFiMode.isScanning()) || isWifiRunning) ), @@ -3884,6 +3902,12 @@ if (isSensorRunning && typeof stopSensorDecoding === 'function') { Promise.resolve(stopSensorDecoding()).catch(() => { }); } + const morseActive = typeof MorseMode !== 'undefined' + && typeof MorseMode.isActive === 'function' + && MorseMode.isActive(); + if (morseActive && typeof MorseMode.stop === 'function') { + Promise.resolve(MorseMode.stop()).catch(() => { }); + } const wifiScanActive = ( typeof WiFiMode !== 'undefined' @@ -4009,6 +4033,12 @@ if (isSensorRunning) { stopTasks.push(awaitStopAction('sensor', () => stopSensorDecoding(), LOCAL_STOP_TIMEOUT_MS)); } + const morseActive = typeof MorseMode !== 'undefined' + && typeof MorseMode.isActive === 'function' + && MorseMode.isActive(); + if (morseActive && typeof MorseMode.stop === 'function') { + stopTasks.push(awaitStopAction('morse', () => MorseMode.stop(), LOCAL_STOP_TIMEOUT_MS)); + } const wifiScanActive = ( typeof WiFiMode !== 'undefined' && typeof WiFiMode.isScanning === 'function' @@ -4045,6 +4075,9 @@ if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') { SubGhz.destroy(); } + if (typeof MorseMode !== 'undefined' && currentMode === 'morse' && mode !== 'morse' && typeof MorseMode.destroy === 'function') { + MorseMode.destroy(); + } currentMode = mode; document.body.setAttribute('data-mode', mode); diff --git a/templates/partials/modes/morse.html b/templates/partials/modes/morse.html index 07e8f32..84f5042 100644 --- a/templates/partials/modes/morse.html +++ b/templates/partials/modes/morse.html @@ -1,99 +1,166 @@ - -
-
-

CW/Morse Decoder

-

- Decode CW (continuous wave) Morse code from amateur radio HF bands using USB demodulation - and Goertzel tone detection. -

-
- -
-

Frequency

-
- - - Enter frequency in MHz (e.g., 7.030 for 40m CW) -
-
- -
- - - - - - - - -
-
-
- -
-

Settings

-
- - -
-
- - -
-
- -
-

CW Settings

-
- - -
-
- - -
-
- - -
-

- Morse Reference (click to toggle) -

- -
- - -
-
- - Standby - 0 chars -
-
- - -
-

- CW operates on HF bands (1-30 MHz). Requires an HF-capable SDR with direct sampling - or an upconverter, plus an appropriate HF antenna (dipole, end-fed, or random wire). -

-
- - - -
+ +
+
+

CW/Morse Decoder

+

+ Decode CW (continuous wave) Morse with USB demod + Goertzel tone detection. + Start with 700 Hz tone and 200 Hz bandwidth. +

+
+ +
+

Frequency

+
+ + + Enter CW center frequency in MHz (e.g., 7.030 for 40m). +
+
+ +
+ + + + + + + + +
+
+
+ +
+

Device

+
+ + +
+
+ + +
+
+ +
+

CW Detector

+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Threshold + WPM

+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+
+ +
+

Output

+
+ + +
+
+ +
+
+ +
+

Decode WAV File

+
+ + +
+ Runs the same CW decoder pipeline against uploaded WAV audio. +
+ +
+

+ Morse Reference (click to toggle) +

+ +
+ +
+
+ + Standby + 0 chars +
+
+ +
+

+ CW on HF (1-30 MHz) requires an HF-capable SDR path (direct sampling or upconverter) + and an appropriate antenna. +

+
+ + + +
diff --git a/tests/test_morse.py b/tests/test_morse.py index 1eb43cf..1bd7596 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -1,467 +1,348 @@ -"""Tests for Morse code decoder (utils/morse.py) and routes.""" - -from __future__ import annotations - -import math -import queue -import struct -import threading - -import pytest - -from utils.morse import ( - CHAR_TO_MORSE, - MORSE_TABLE, - GoertzelFilter, - MorseDecoder, - morse_decoder_thread, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _login_session(client) -> None: - """Mark the Flask test session as authenticated.""" - with client.session_transaction() as sess: - sess['logged_in'] = True - sess['username'] = 'test' - sess['role'] = 'admin' - - -def generate_tone(freq: float, duration: float, sample_rate: int = 8000, amplitude: float = 0.8) -> bytes: - """Generate a pure sine wave as 16-bit LE PCM bytes.""" - n_samples = int(sample_rate * duration) - samples = [] - for i in range(n_samples): - t = i / sample_rate - val = int(amplitude * 32767 * math.sin(2 * math.pi * freq * t)) - samples.append(max(-32768, min(32767, val))) - return struct.pack(f'<{len(samples)}h', *samples) - - -def generate_silence(duration: float, sample_rate: int = 8000) -> bytes: - """Generate silence as 16-bit LE PCM bytes.""" - n_samples = int(sample_rate * duration) - return b'\x00\x00' * n_samples - - -def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sample_rate: int = 8000) -> bytes: - """Generate PCM audio for a Morse-encoded string.""" - dit_dur = 1.2 / wpm - dah_dur = 3 * dit_dur - element_gap = dit_dur - char_gap = 3 * dit_dur - word_gap = 7 * dit_dur - - audio = b'' - words = text.upper().split() - for wi, word in enumerate(words): - for ci, char in enumerate(word): - morse = CHAR_TO_MORSE.get(char) - if morse is None: - continue - for ei, element in enumerate(morse): - if element == '.': - audio += generate_tone(tone_freq, dit_dur, sample_rate) - elif element == '-': - audio += generate_tone(tone_freq, dah_dur, sample_rate) - if ei < len(morse) - 1: - audio += generate_silence(element_gap, sample_rate) - if ci < len(word) - 1: - audio += generate_silence(char_gap, sample_rate) - if wi < len(words) - 1: - audio += generate_silence(word_gap, sample_rate) - - # Add some leading/trailing silence for threshold settling - silence = generate_silence(0.3, sample_rate) - return silence + audio + silence - - -# --------------------------------------------------------------------------- -# MORSE_TABLE tests -# --------------------------------------------------------------------------- - -class TestMorseTable: - def test_all_26_letters_present(self): - chars = set(MORSE_TABLE.values()) - for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ': - assert letter in chars, f"Missing letter: {letter}" - - def test_all_10_digits_present(self): - chars = set(MORSE_TABLE.values()) - for digit in '0123456789': - assert digit in chars, f"Missing digit: {digit}" - - def test_reverse_lookup_consistent(self): - for morse, char in MORSE_TABLE.items(): - if char in CHAR_TO_MORSE: - assert CHAR_TO_MORSE[char] == morse - - def test_no_duplicate_morse_codes(self): - """Each morse pattern should map to exactly one character.""" - assert len(MORSE_TABLE) == len(set(MORSE_TABLE.keys())) - - -# --------------------------------------------------------------------------- -# GoertzelFilter tests -# --------------------------------------------------------------------------- - -class TestGoertzelFilter: - def test_detects_target_frequency(self): - gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160) - # Generate 700 Hz tone - samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(160)] - mag = gf.magnitude(samples) - assert mag > 10.0, f"Expected high magnitude for target freq, got {mag}" - - def test_rejects_off_frequency(self): - gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160) - # Generate 1500 Hz tone (well off target) - samples = [0.8 * math.sin(2 * math.pi * 1500 * i / 8000) for i in range(160)] - mag_off = gf.magnitude(samples) - - # Compare with on-target - samples_on = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(160)] - mag_on = gf.magnitude(samples_on) - - assert mag_on > mag_off * 3, "Target freq should be significantly stronger than off-freq" - - def test_silence_returns_near_zero(self): - gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160) - samples = [0.0] * 160 - mag = gf.magnitude(samples) - assert mag < 0.01, f"Expected near-zero for silence, got {mag}" - - def test_different_block_sizes(self): - for block_size in [80, 160, 320]: - gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=block_size) - samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(block_size)] - mag = gf.magnitude(samples) - assert mag > 5.0, f"Should detect tone with block_size={block_size}" - - -# --------------------------------------------------------------------------- -# MorseDecoder tests -# --------------------------------------------------------------------------- - -class TestMorseDecoder: - def _make_decoder(self, wpm=15): - """Create decoder with warm-up phase completed for testing. - - Feeds silence then tone then silence to get past the warm-up - blocks and establish a valid noise floor / signal peak. - """ - decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=wpm) - # Feed enough audio to get past warm-up (50 blocks = 1 sec) - # Mix silence and tone so warm-up sees both noise and signal - warmup_audio = generate_silence(0.6) + generate_tone(700.0, 0.4) + generate_silence(0.5) - decoder.process_block(warmup_audio) - # Reset state machine after warm-up so tests start clean - decoder._tone_on = False - decoder._current_symbol = '' - decoder._tone_blocks = 0 - decoder._silence_blocks = 0 - return decoder - - def test_dit_detection(self): - """A single dit should produce a '.' in the symbol buffer.""" - decoder = self._make_decoder() - dit_dur = 1.2 / 15 - - # Send a tone burst (dit) - tone = generate_tone(700.0, dit_dur) - decoder.process_block(tone) - - # Send silence to trigger end of tone - silence = generate_silence(dit_dur * 2) - decoder.process_block(silence) - - # Symbol buffer should have a dot - assert '.' in decoder._current_symbol, f"Expected '.' in symbol, got '{decoder._current_symbol}'" - - def test_dah_detection(self): - """A longer tone should produce a '-' in the symbol buffer.""" - decoder = self._make_decoder() - dah_dur = 3 * 1.2 / 15 - - tone = generate_tone(700.0, dah_dur) - decoder.process_block(tone) - - silence = generate_silence(dah_dur) - decoder.process_block(silence) - - assert '-' in decoder._current_symbol, f"Expected '-' in symbol, got '{decoder._current_symbol}'" - - def test_decode_letter_e(self): - """E is a single dit - the simplest character.""" - decoder = self._make_decoder() - audio = generate_morse_audio('E', wpm=15) - events = decoder.process_block(audio) - events.extend(decoder.flush()) - - chars = [e for e in events if e['type'] == 'morse_char'] - decoded = ''.join(e['char'] for e in chars) - assert 'E' in decoded, f"Expected 'E' in decoded text, got '{decoded}'" - - def test_decode_letter_t(self): - """T is a single dah.""" - decoder = self._make_decoder() - audio = generate_morse_audio('T', wpm=15) - events = decoder.process_block(audio) - events.extend(decoder.flush()) - - chars = [e for e in events if e['type'] == 'morse_char'] - decoded = ''.join(e['char'] for e in chars) - assert 'T' in decoded, f"Expected 'T' in decoded text, got '{decoded}'" - - def test_word_space_detection(self): - """A long silence between words should produce decoded chars with a space.""" - decoder = self._make_decoder() - dit_dur = 1.2 / 15 - # E = dit - audio = generate_tone(700.0, dit_dur) + generate_silence(7 * dit_dur * 1.5) - # T = dah - audio += generate_tone(700.0, 3 * dit_dur) + generate_silence(3 * dit_dur) - events = decoder.process_block(audio) - events.extend(decoder.flush()) - - spaces = [e for e in events if e['type'] == 'morse_space'] - assert len(spaces) >= 1, "Expected at least one word space" - - def test_scope_events_generated(self): - """Decoder should produce scope events for visualization.""" - audio = generate_morse_audio('SOS', wpm=15) - decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) - - events = decoder.process_block(audio) - - scope_events = [e for e in events if e['type'] == 'scope'] - assert len(scope_events) > 0, "Expected scope events" - # Check scope event structure - se = scope_events[0] - assert 'amplitudes' in se - assert 'threshold' in se - assert 'tone_on' in se - - def test_adaptive_threshold_adjusts(self): - """After processing enough audio to complete warm-up, threshold should be non-zero.""" - decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) - - # Feed enough audio to complete the 50-block warm-up (~1 second) - audio = generate_silence(0.6) + generate_tone(700.0, 0.4) + generate_silence(0.3) - decoder.process_block(audio) - - assert decoder._threshold > 0, "Threshold should adapt above zero after warm-up" - - def test_flush_emits_pending_char(self): - """flush() should emit any accumulated but not-yet-decoded symbol.""" - decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) - decoder._current_symbol = '.' # Manually set pending dit - events = decoder.flush() - assert len(events) == 1 - assert events[0]['type'] == 'morse_char' - assert events[0]['char'] == 'E' - - def test_flush_empty_returns_nothing(self): - decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) - events = decoder.flush() - assert events == [] - - def test_weak_signal_detection(self): - """CW tone at only 3x noise magnitude should still decode characters.""" - decoder = self._make_decoder(wpm=10) - # Generate weak CW audio (low amplitude simulating weak HF signal) - audio = generate_morse_audio('SOS', wpm=10, sample_rate=8000) - # Scale to low amplitude (simulating weak signal) - n_samples = len(audio) // 2 - samples = struct.unpack(f'<{n_samples}h', audio) - # Reduce to ~10% amplitude - weak_samples = [max(-32768, min(32767, int(s * 0.1))) for s in samples] - weak_audio = struct.pack(f'<{len(weak_samples)}h', *weak_samples) - - events = decoder.process_block(weak_audio) - events.extend(decoder.flush()) - - chars = [e for e in events if e['type'] == 'morse_char'] - decoded = ''.join(e['char'] for e in chars) - # Should decode at least some characters from the weak signal - assert len(chars) >= 1, f"Expected decoded chars from weak signal, got '{decoded}'" - - def test_agc_boosts_quiet_signal(self): - """Very quiet PCM (amplitude 0.01) should still produce usable Goertzel magnitudes.""" - decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) - # Generate very quiet tone - quiet_tone = generate_tone(700.0, 1.5, amplitude=0.01) # 1.5s of very quiet CW - events = decoder.process_block(quiet_tone) - - scope_events = [e for e in events if e['type'] == 'scope'] - assert len(scope_events) > 0, "Expected scope events from quiet signal" - # AGC should have boosted the signal — amplitudes should be visible - max_amp = max(max(se['amplitudes']) for se in scope_events) - assert max_amp > 1.0, f"AGC should boost quiet signal to usable magnitude, got {max_amp}" - - -# --------------------------------------------------------------------------- -# morse_decoder_thread tests -# --------------------------------------------------------------------------- - -class TestMorseDecoderThread: - def test_thread_stops_on_event(self): - """Thread should exit when stop_event is set.""" - import io - # Create a fake stdout that blocks until stop - stop = threading.Event() - q = queue.Queue(maxsize=100) - - # Feed some audio then close - audio = generate_morse_audio('E', wpm=15) - fake_stdout = io.BytesIO(audio) - - t = threading.Thread( - target=morse_decoder_thread, - args=(fake_stdout, q, stop), - ) - t.daemon = True - t.start() - t.join(timeout=5) - assert not t.is_alive(), "Thread should finish after reading all data" - - def test_thread_heartbeat_on_no_data(self): - """When rtl_fm produces no data, thread should emit waiting scope events.""" - import os as _os - stop = threading.Event() - q = queue.Queue(maxsize=100) - - # Create a pipe that never gets written to (simulates rtl_fm with no output) - read_fd, write_fd = _os.pipe() - read_file = _os.fdopen(read_fd, 'rb', 0) - - t = threading.Thread( - target=morse_decoder_thread, - args=(read_file, q, stop), - ) - t.daemon = True - t.start() - - # Wait up to 5 seconds for at least one heartbeat event - events = [] - import time as _time - deadline = _time.monotonic() + 5.0 - while _time.monotonic() < deadline: - try: - ev = q.get(timeout=0.5) - events.append(ev) - if ev.get('waiting'): - break - except queue.Empty: - continue - - stop.set() - _os.close(write_fd) - read_file.close() - t.join(timeout=3) - - waiting_events = [e for e in events if e.get('type') == 'scope' and e.get('waiting')] - assert len(waiting_events) >= 1, f"Expected waiting heartbeat events, got {events}" - ev = waiting_events[0] - assert ev['amplitudes'] == [] - assert ev['threshold'] == 0 - assert ev['tone_on'] is False - - def test_thread_produces_events(self): - """Thread should push character events to the queue.""" - import io - from unittest.mock import patch - stop = threading.Event() - q = queue.Queue(maxsize=1000) - - # Generate audio with pre-warmed decoder in mind - # The thread creates a fresh decoder, so generate lots of audio - audio = generate_silence(0.5) + generate_morse_audio('SOS', wpm=10) + generate_silence(1.0) - fake_stdout = io.BytesIO(audio) - - # Patch SCOPE_INTERVAL to 0 so scope events aren't throttled in fast reads - with patch('utils.morse.time') as mock_time: - # Make monotonic() always return increasing values - counter = [0.0] - def fake_monotonic(): - counter[0] += 0.15 # each call advances 150ms - return counter[0] - mock_time.monotonic = fake_monotonic - - t = threading.Thread( - target=morse_decoder_thread, - args=(fake_stdout, q, stop), - ) - t.daemon = True - t.start() - t.join(timeout=10) - - events = [] - while not q.empty(): - events.append(q.get_nowait()) - - # Should have at least some events (scope or char) - assert len(events) > 0, "Expected events from thread" - - -# --------------------------------------------------------------------------- -# Route tests -# --------------------------------------------------------------------------- - -class TestMorseRoutes: - def test_start_missing_required_fields(self, client): - """Start should succeed with defaults.""" - _login_session(client) - with pytest.MonkeyPatch.context() as m: - m.setattr('app.morse_process', None) - # Should fail because rtl_fm won't be found in test env - resp = client.post('/morse/start', json={'frequency': '14.060'}) - assert resp.status_code in (200, 400, 409, 500) - - def test_stop_when_not_running(self, client): - """Stop when nothing is running should return not_running.""" - _login_session(client) - with pytest.MonkeyPatch.context() as m: - m.setattr('app.morse_process', None) - resp = client.post('/morse/stop') - data = resp.get_json() - assert data['status'] == 'not_running' - - def test_status_when_not_running(self, client): - """Status should report not running.""" - _login_session(client) - with pytest.MonkeyPatch.context() as m: - m.setattr('app.morse_process', None) - resp = client.get('/morse/status') - data = resp.get_json() - assert data['running'] is False - - def test_invalid_tone_freq(self, client): - """Tone frequency outside range should be rejected.""" - _login_session(client) - with pytest.MonkeyPatch.context() as m: - m.setattr('app.morse_process', None) - resp = client.post('/morse/start', json={ - 'frequency': '14.060', - 'tone_freq': '50', # too low - }) - assert resp.status_code == 400 - - def test_invalid_wpm(self, client): - """WPM outside range should be rejected.""" - _login_session(client) - with pytest.MonkeyPatch.context() as m: - m.setattr('app.morse_process', None) - resp = client.post('/morse/start', json={ - 'frequency': '14.060', - 'wpm': '100', # too high - }) - assert resp.status_code == 400 - - def test_stream_endpoint_exists(self, client): - """Stream endpoint should return SSE content type.""" - _login_session(client) - resp = client.get('/morse/stream') - assert resp.content_type.startswith('text/event-stream') +"""Tests for Morse code decoder pipeline and lifecycle routes.""" + +from __future__ import annotations + +import io +import math +import os +import queue +import struct +import threading +import time +import wave +from collections import Counter + +import pytest + +import app as app_module +import routes.morse as morse_routes +from utils.morse import ( + CHAR_TO_MORSE, + MORSE_TABLE, + GoertzelFilter, + MorseDecoder, + decode_morse_wav_file, + morse_decoder_thread, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _login_session(client) -> None: + """Mark the Flask test session as authenticated.""" + with client.session_transaction() as sess: + sess['logged_in'] = True + sess['username'] = 'test' + sess['role'] = 'admin' + + +def generate_tone(freq: float, duration: float, sample_rate: int = 8000, amplitude: float = 0.8) -> bytes: + """Generate a pure sine wave as 16-bit LE PCM bytes.""" + n_samples = int(sample_rate * duration) + samples = [] + for i in range(n_samples): + t = i / sample_rate + val = int(amplitude * 32767 * math.sin(2 * math.pi * freq * t)) + samples.append(max(-32768, min(32767, val))) + return struct.pack(f'<{len(samples)}h', *samples) + + +def generate_silence(duration: float, sample_rate: int = 8000) -> bytes: + """Generate silence as 16-bit LE PCM bytes.""" + n_samples = int(sample_rate * duration) + return b'\x00\x00' * n_samples + + +def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sample_rate: int = 8000) -> bytes: + """Generate synthetic CW PCM for the given text.""" + dit_dur = 1.2 / wpm + dah_dur = 3 * dit_dur + element_gap = dit_dur + char_gap = 3 * dit_dur + word_gap = 7 * dit_dur + + audio = b'' + words = text.upper().split() + for wi, word in enumerate(words): + for ci, char in enumerate(word): + morse = CHAR_TO_MORSE.get(char) + if morse is None: + continue + + for ei, element in enumerate(morse): + if element == '.': + audio += generate_tone(tone_freq, dit_dur, sample_rate) + elif element == '-': + audio += generate_tone(tone_freq, dah_dur, sample_rate) + + if ei < len(morse) - 1: + audio += generate_silence(element_gap, sample_rate) + + if ci < len(word) - 1: + audio += generate_silence(char_gap, sample_rate) + + if wi < len(words) - 1: + audio += generate_silence(word_gap, sample_rate) + + # Leading/trailing silence for threshold settling. + return generate_silence(0.3, sample_rate) + audio + generate_silence(0.3, sample_rate) + + +def write_wav(path, pcm_bytes: bytes, sample_rate: int = 8000) -> None: + """Write mono 16-bit PCM bytes to a WAV file.""" + with wave.open(str(path), 'wb') as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(sample_rate) + wf.writeframes(pcm_bytes) + + +def decode_text_from_events(events) -> str: + out = [] + for ev in events: + if ev.get('type') == 'morse_char': + out.append(str(ev.get('char', ''))) + elif ev.get('type') == 'morse_space': + out.append(' ') + return ''.join(out) + + +# --------------------------------------------------------------------------- +# Unit tests +# --------------------------------------------------------------------------- + +class TestMorseTable: + def test_morse_table_contains_letters_and_digits(self): + chars = set(MORSE_TABLE.values()) + for ch in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789': + assert ch in chars + + def test_round_trip_morse_lookup(self): + for morse, char in MORSE_TABLE.items(): + if char in CHAR_TO_MORSE: + assert CHAR_TO_MORSE[char] == morse + + +class TestToneDetector: + def test_goertzel_prefers_target_frequency(self): + gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160) + on_tone = [0.8 * math.sin(2 * math.pi * 700.0 * i / 8000.0) for i in range(160)] + off_tone = [0.8 * math.sin(2 * math.pi * 1500.0 * i / 8000.0) for i in range(160)] + assert gf.magnitude(on_tone) > gf.magnitude(off_tone) * 3.0 + + +class TestTimingAndWpmEstimator: + def test_timing_classifier_distinguishes_dit_and_dah(self): + decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) + dit = 1.2 / 15.0 + dah = dit * 3.0 + + audio = ( + generate_silence(0.35) + + generate_tone(700.0, dit) + + generate_silence(dit * 1.5) + + generate_tone(700.0, dah) + + generate_silence(0.35) + ) + + events = decoder.process_block(audio) + events.extend(decoder.flush()) + elements = [e['element'] for e in events if e.get('type') == 'morse_element'] + + assert '.' in elements + assert '-' in elements + + def test_wpm_estimator_sanity(self): + target_wpm = 18 + audio = generate_morse_audio('PARIS PARIS PARIS', wpm=target_wpm) + decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=12, wpm_mode='auto') + + events = decoder.process_block(audio) + events.extend(decoder.flush()) + + metrics = decoder.get_metrics() + assert metrics['wpm'] >= 12.0 + assert metrics['wpm'] <= 24.0 + + +# --------------------------------------------------------------------------- +# Decoder thread tests +# --------------------------------------------------------------------------- + +class TestMorseDecoderThread: + def test_thread_emits_waiting_heartbeat_on_no_data(self): + stop_event = threading.Event() + output_queue = queue.Queue(maxsize=64) + + read_fd, write_fd = os.pipe() + read_file = os.fdopen(read_fd, 'rb', 0) + + worker = threading.Thread( + target=morse_decoder_thread, + args=(read_file, output_queue, stop_event), + daemon=True, + ) + worker.start() + + got_waiting = False + deadline = time.monotonic() + 3.5 + while time.monotonic() < deadline: + try: + msg = output_queue.get(timeout=0.3) + except queue.Empty: + continue + if msg.get('type') == 'scope' and msg.get('waiting'): + got_waiting = True + break + + stop_event.set() + os.close(write_fd) + read_file.close() + worker.join(timeout=2.0) + + assert got_waiting is True + assert not worker.is_alive() + + def test_thread_produces_character_events(self): + stop_event = threading.Event() + output_queue = queue.Queue(maxsize=512) + audio = generate_morse_audio('SOS', wpm=15) + + worker = threading.Thread( + target=morse_decoder_thread, + args=(io.BytesIO(audio), output_queue, stop_event), + daemon=True, + ) + worker.start() + worker.join(timeout=4.0) + + events = [] + while not output_queue.empty(): + events.append(output_queue.get_nowait()) + + chars = [e for e in events if e.get('type') == 'morse_char'] + assert len(chars) >= 1 + + +# --------------------------------------------------------------------------- +# Route lifecycle regression +# --------------------------------------------------------------------------- + +class TestMorseLifecycleRoutes: + def _reset_route_state(self): + with app_module.morse_lock: + app_module.morse_process = None + while not app_module.morse_queue.empty(): + try: + app_module.morse_queue.get_nowait() + except queue.Empty: + break + + morse_routes.morse_active_device = None + morse_routes.morse_decoder_worker = None + morse_routes.morse_stderr_worker = None + morse_routes.morse_stop_event = None + morse_routes.morse_control_queue = None + morse_routes.morse_runtime_config = {} + morse_routes.morse_last_error = '' + morse_routes.morse_state = morse_routes.MORSE_IDLE + morse_routes.morse_state_message = 'Idle' + + def test_start_stop_reaches_idle_and_releases_resources(self, client, monkeypatch): + _login_session(client) + self._reset_route_state() + + released_devices = [] + + monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None) + monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx)) + + class DummyDevice: + sdr_type = morse_routes.SDRType.RTL_SDR + + class DummyBuilder: + def build_fm_demod_command(self, **kwargs): + return ['rtl_fm', '-f', '14060000'] + + monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice())) + monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder())) + monkeypatch.setattr(morse_routes.time, 'sleep', lambda _secs: None) + + pcm = generate_morse_audio('E', wpm=15) + + class FakeProc: + def __init__(self): + self.stdout = io.BytesIO(pcm) + self.stderr = io.BytesIO(b'') + self.returncode = None + + def poll(self): + return self.returncode + + monkeypatch.setattr(morse_routes.subprocess, 'Popen', lambda *args, **kwargs: FakeProc()) + monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None) + monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None) + monkeypatch.setattr( + morse_routes, + 'safe_terminate', + lambda proc, timeout=0.0: setattr(proc, 'returncode', 0), + ) + + start_resp = client.post('/morse/start', json={ + 'frequency': '14.060', + 'gain': '20', + 'ppm': '0', + 'device': '0', + 'tone_freq': '700', + 'wpm': '15', + }) + assert start_resp.status_code == 200 + assert start_resp.get_json()['status'] == 'started' + + status_resp = client.get('/morse/status') + assert status_resp.status_code == 200 + assert status_resp.get_json()['state'] in {'running', 'starting', 'stopping', 'idle'} + + stop_resp = client.post('/morse/stop') + assert stop_resp.status_code == 200 + stop_data = stop_resp.get_json() + assert stop_data['status'] == 'stopped' + assert stop_data['state'] == 'idle' + assert stop_data['alive'] == [] + + final_status = client.get('/morse/status').get_json() + assert final_status['running'] is False + assert final_status['state'] == 'idle' + assert 0 in released_devices + + +# --------------------------------------------------------------------------- +# Integration: synthetic CW -> WAV decode +# --------------------------------------------------------------------------- + +class TestMorseIntegration: + def test_decode_morse_wav_contains_expected_phrase(self, tmp_path): + wav_path = tmp_path / 'cq_test_123.wav' + pcm = generate_morse_audio('CQ TEST 123', wpm=15, tone_freq=700.0) + write_wav(wav_path, pcm, sample_rate=8000) + + result = decode_morse_wav_file( + wav_path, + sample_rate=8000, + tone_freq=700.0, + wpm=15, + bandwidth_hz=200, + auto_tone_track=True, + threshold_mode='auto', + wpm_mode='auto', + min_signal_gate=0.0, + ) + + decoded = ' '.join(str(result.get('text', '')).split()) + assert 'CQ TEST 123' in decoded + + events = result.get('events', []) + event_counts = Counter(e.get('type') for e in events) + assert event_counts['morse_char'] >= len('CQTEST123') diff --git a/utils/morse.py b/utils/morse.py index 509a73a..97c59cf 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -1,372 +1,830 @@ -"""Morse code (CW) decoder using Goertzel tone detection. - -Signal chain: rtl_fm -M usb → raw PCM → Goertzel filter → timing state machine → characters. -""" - -from __future__ import annotations - -import contextlib -import math -import os -import queue -import struct -import threading -import time -from datetime import datetime -from typing import Any - -# International Morse Code table -MORSE_TABLE: dict[str, str] = { - '.-': 'A', '-...': 'B', '-.-.': 'C', '-..': 'D', '.': 'E', - '..-.': 'F', '--.': 'G', '....': 'H', '..': 'I', '.---': 'J', - '-.-': 'K', '.-..': 'L', '--': 'M', '-.': 'N', '---': 'O', - '.--.': 'P', '--.-': 'Q', '.-.': 'R', '...': 'S', '-': 'T', - '..-': 'U', '...-': 'V', '.--': 'W', '-..-': 'X', '-.--': 'Y', - '--..': 'Z', - '-----': '0', '.----': '1', '..---': '2', '...--': '3', - '....-': '4', '.....': '5', '-....': '6', '--...': '7', - '---..': '8', '----.': '9', - '.-.-.-': '.', '--..--': ',', '..--..': '?', '.----.': "'", - '-.-.--': '!', '-..-.': '/', '-.--.': '(', '-.--.-': ')', - '.-...': '&', '---...': ':', '-.-.-.': ';', '-...-': '=', - '.-.-.': '+', '-....-': '-', '..--.-': '_', '.-..-.': '"', - '...-..-': '$', '.--.-.': '@', - # Prosigns (unique codes only; -...- and -.--.- already mapped above) - '-.-.-': '', '.-.-': '', '...-.-': '', -} - -# Reverse lookup: character → morse notation -CHAR_TO_MORSE: dict[str, str] = {v: k for k, v in MORSE_TABLE.items()} - - -class GoertzelFilter: - """Single-frequency tone detector using the Goertzel algorithm. - - O(N) per block, much cheaper than FFT for detecting one frequency. - """ - - def __init__(self, target_freq: float, sample_rate: int, block_size: int): - self.target_freq = target_freq - self.sample_rate = sample_rate - self.block_size = block_size - # Precompute coefficient - k = round(target_freq * block_size / sample_rate) - omega = 2.0 * math.pi * k / block_size - self.coeff = 2.0 * math.cos(omega) - - def magnitude(self, samples: list[float] | tuple[float, ...]) -> float: - """Compute magnitude of the target frequency in the sample block.""" - s0 = 0.0 - s1 = 0.0 - s2 = 0.0 - coeff = self.coeff - for sample in samples: - s0 = sample + coeff * s1 - s2 - s2 = s1 - s1 = s0 - return math.sqrt(s1 * s1 + s2 * s2 - coeff * s1 * s2) - - -class MorseDecoder: - """Real-time Morse decoder with adaptive threshold. - - Processes blocks of PCM audio and emits decoded characters. - Timing based on PARIS standard: dit = 1.2/WPM seconds. - """ - - def __init__( - self, - sample_rate: int = 8000, - tone_freq: float = 700.0, - wpm: int = 15, - ): - self.sample_rate = sample_rate - self.tone_freq = tone_freq - self.wpm = wpm - - # Goertzel filter: ~50 blocks/sec at 8kHz - self._block_size = sample_rate // 50 - self._filter = GoertzelFilter(tone_freq, sample_rate, self._block_size) - self._block_duration = self._block_size / sample_rate # seconds per block - - # Timing thresholds (in blocks, converted from seconds) - dit_sec = 1.2 / wpm - self._dah_threshold = 2.0 * dit_sec / self._block_duration # blocks - self._dit_min = 0.3 * dit_sec / self._block_duration # min blocks for dit - self._char_gap = 3.0 * dit_sec / self._block_duration # blocks - self._word_gap = 7.0 * dit_sec / self._block_duration # blocks - - # AGC (automatic gain control) for direct sampling / weak signals - self._agc_target = 0.3 # target RMS amplitude (0-1 range) - self._agc_gain = 1.0 # current AGC multiplier - self._agc_alpha = 0.05 # EMA smoothing for gain changes - - # Warm-up phase constants - self._WARMUP_BLOCKS = 50 # ~1 second at 50 blocks/sec - self._SETTLE_BLOCKS = 200 # blocks for fast→slow EMA transition - self._mag_min = float('inf') - self._mag_max = 0.0 - - # Adaptive threshold via EMA - self._noise_floor = 0.0 - self._signal_peak = 0.0 - self._threshold = 0.0 - - # State machine (counts in blocks, not wall-clock time) - self._tone_on = False - self._tone_blocks = 0 # blocks since tone started - self._silence_blocks = 0 # blocks since silence started - self._current_symbol = '' # accumulates dits/dahs for current char - self._pending_buffer: list[float] = [] - self._blocks_processed = 0 # total blocks for warm-up tracking - - def process_block(self, pcm_bytes: bytes) -> list[dict[str, Any]]: - """Process a chunk of 16-bit LE PCM and return decoded events. - - Returns list of event dicts with keys: - type: 'scope' | 'morse_char' | 'morse_space' - + type-specific fields - """ - events: list[dict[str, Any]] = [] - - # Unpack PCM samples - n_samples = len(pcm_bytes) // 2 - if n_samples == 0: - return events - - samples = struct.unpack(f'<{n_samples}h', pcm_bytes[:n_samples * 2]) - - # Feed samples into pending buffer and process in blocks - self._pending_buffer.extend(samples) - - amplitudes: list[float] = [] - - while len(self._pending_buffer) >= self._block_size: - block = self._pending_buffer[:self._block_size] - self._pending_buffer = self._pending_buffer[self._block_size:] - - # Normalize to [-1, 1] - normalized = [s / 32768.0 for s in block] - - # AGC: boost quiet signals (e.g. direct sampling mode) - rms = math.sqrt(sum(s * s for s in normalized) / len(normalized)) - if rms > 1e-6: - desired_gain = self._agc_target / rms - self._agc_gain += self._agc_alpha * (desired_gain - self._agc_gain) - self._agc_gain = min(self._agc_gain, 500.0) # cap to prevent runaway - normalized = [s * self._agc_gain for s in normalized] - - mag = self._filter.magnitude(normalized) - amplitudes.append(mag) - - self._blocks_processed += 1 - - # Warm-up phase: collect statistics, suppress detection - if self._blocks_processed <= self._WARMUP_BLOCKS: - self._mag_min = min(self._mag_min, mag) - self._mag_max = max(self._mag_max, mag) - if self._blocks_processed == self._WARMUP_BLOCKS: - # Seed thresholds from observed range - self._noise_floor = self._mag_min - self._signal_peak = max(self._mag_max, self._mag_min * 2) - self._threshold = self._noise_floor + 0.3 * ( - self._signal_peak - self._noise_floor - ) - tone_detected = False - else: - # Adaptive EMA: fast initially, slow in steady state - alpha = 0.3 if self._blocks_processed < self._WARMUP_BLOCKS + self._SETTLE_BLOCKS else 0.05 - - if mag < self._threshold: - self._noise_floor += alpha * (mag - self._noise_floor) - else: - self._signal_peak += alpha * (mag - self._signal_peak) - - # Threshold at 30% between noise and signal (sensitive to weak CW) - self._threshold = self._noise_floor + 0.3 * ( - self._signal_peak - self._noise_floor - ) - - tone_detected = mag > self._threshold and self._threshold > 0 - - if tone_detected and not self._tone_on: - # Tone just started - check silence duration for gaps - self._tone_on = True - silence_count = self._silence_blocks - self._tone_blocks = 0 - - if self._current_symbol and silence_count >= self._char_gap: - # Character gap - decode accumulated symbol - char = MORSE_TABLE.get(self._current_symbol) - if char: - events.append({ - 'type': 'morse_char', - 'char': char, - 'morse': self._current_symbol, - 'timestamp': datetime.now().strftime('%H:%M:%S'), - }) - - if silence_count >= self._word_gap: - events.append({ - 'type': 'morse_space', - 'timestamp': datetime.now().strftime('%H:%M:%S'), - }) - - self._current_symbol = '' - - elif not tone_detected and self._tone_on: - # Tone just ended - classify as dit or dah - self._tone_on = False - tone_count = self._tone_blocks - self._silence_blocks = 0 - - if tone_count >= self._dah_threshold: - self._current_symbol += '-' - elif tone_count >= self._dit_min: - self._current_symbol += '.' - - elif tone_detected and self._tone_on: - self._tone_blocks += 1 - - elif not tone_detected and not self._tone_on: - self._silence_blocks += 1 - - # Emit scope data for visualization (~10 Hz is handled by caller) - if amplitudes: - events.append({ - 'type': 'scope', - 'amplitudes': amplitudes, - 'threshold': self._threshold, - 'tone_on': self._tone_on, - }) - - return events - - def flush(self) -> list[dict[str, Any]]: - """Flush any pending symbol at end of stream.""" - events: list[dict[str, Any]] = [] - if self._current_symbol: - char = MORSE_TABLE.get(self._current_symbol) - if char: - events.append({ - 'type': 'morse_char', - 'char': char, - 'morse': self._current_symbol, - 'timestamp': datetime.now().strftime('%H:%M:%S'), - }) - self._current_symbol = '' - return events - - -def _stdout_reader(stdout, data_queue: queue.Queue, stop_event: threading.Event) -> None: - """Blocking reader — pushes raw PCM chunks to queue, None on EOF. - - Uses os.read() on the raw fd when available to bypass BufferedReader, - which on Python 3.14 may block trying to fill its entire buffer before - returning. Falls back to .read() for objects without fileno() (tests). - """ - try: - fd = stdout.fileno() - except Exception: - fd = None - try: - while not stop_event.is_set(): - if fd is not None: - data = os.read(fd, 4096) - else: - data = stdout.read(4096) - if not data: - break - data_queue.put(data) - except Exception: - pass - finally: - data_queue.put(None) # sentinel: pipe closed / EOF - - -def morse_decoder_thread( - rtl_stdout, - output_queue: queue.Queue, - stop_event: threading.Event, - sample_rate: int = 8000, - tone_freq: float = 700.0, - wpm: int = 15, -) -> None: - """Thread function: reads PCM from rtl_fm, decodes Morse, pushes to queue. - - Reads raw 16-bit LE PCM from *rtl_stdout* and feeds it through the - MorseDecoder, pushing scope and character events onto *output_queue*. - """ - import logging - logger = logging.getLogger('intercept.morse') - - CHUNK = 4096 # bytes per read (2048 samples at 16-bit mono) - SCOPE_INTERVAL = 0.1 # scope updates at ~10 Hz - last_scope = time.monotonic() - waiting_since: float | None = None - - decoder = MorseDecoder( - sample_rate=sample_rate, - tone_freq=tone_freq, - wpm=wpm, - ) - - try: - pcm_queue: queue.Queue = queue.Queue(maxsize=50) - reader = threading.Thread( - target=_stdout_reader, - args=(rtl_stdout, pcm_queue, stop_event), - ) - reader.daemon = True - reader.start() - - while not stop_event.is_set(): - try: - data = pcm_queue.get(timeout=2.0) - except queue.Empty: - # No data from SDR — emit diagnostic heartbeat - now = time.monotonic() - if waiting_since is None: - waiting_since = now - if now - last_scope >= SCOPE_INTERVAL: - last_scope = now - with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'scope', - 'amplitudes': [], - 'threshold': 0, - 'tone_on': False, - 'waiting': True, - 'waiting_seconds': round(now - waiting_since, 1), - }) - continue - - if data is None: # EOF sentinel - break - waiting_since = None - - events = decoder.process_block(data) - - for event in events: - if event['type'] == 'scope': - # Throttle scope events to ~10 Hz - now = time.monotonic() - if now - last_scope >= SCOPE_INTERVAL: - last_scope = now - with contextlib.suppress(queue.Full): - output_queue.put_nowait(event) - else: - # Character and space events always go through - with contextlib.suppress(queue.Full): - output_queue.put_nowait(event) - - except Exception as e: - logger.debug(f"Morse decoder thread error: {e}") - finally: - # Flush any pending symbol - for event in decoder.flush(): - with contextlib.suppress(queue.Full): - output_queue.put_nowait(event) - # Notify frontend that the decoder has stopped (e.g. rtl_fm died) - with contextlib.suppress(queue.Full): - output_queue.put_nowait({'type': 'status', 'status': 'stopped'}) +"""Morse code (CW) decoding helpers. + +Signal chain: +- SDR audio from `rtl_fm -M usb` (16-bit LE PCM) +- Goertzel tone detection with optional auto-tone tracking +- Adaptive threshold + hysteresis + minimum signal gate +- Timing estimator (auto/manual WPM) and Morse symbol decoding +""" + +from __future__ import annotations + +import contextlib +import math +import os +import queue +import select +import struct +import threading +import time +import wave +from collections import deque +from datetime import datetime +from pathlib import Path +from typing import Any + +import numpy as np + +try: + # Reuse existing Goertzel helper when available. + from utils.sstv.dsp import goertzel_mag as _shared_goertzel_mag +except Exception: # pragma: no cover - fallback path + _shared_goertzel_mag = None + +# International Morse Code table +MORSE_TABLE: dict[str, str] = { + '.-': 'A', '-...': 'B', '-.-.': 'C', '-..': 'D', '.': 'E', + '..-.': 'F', '--.': 'G', '....': 'H', '..': 'I', '.---': 'J', + '-.-': 'K', '.-..': 'L', '--': 'M', '-.': 'N', '---': 'O', + '.--.': 'P', '--.-': 'Q', '.-.': 'R', '...': 'S', '-': 'T', + '..-': 'U', '...-': 'V', '.--': 'W', '-..-': 'X', '-.--': 'Y', + '--..': 'Z', + '-----': '0', '.----': '1', '..---': '2', '...--': '3', + '....-': '4', '.....': '5', '-....': '6', '--...': '7', + '---..': '8', '----.': '9', + '.-.-.-': '.', '--..--': ',', '..--..': '?', '.----.': "'", + '-.-.--': '!', '-..-.': '/', '-.--.': '(', '-.--.-': ')', + '.-...': '&', '---...': ':', '-.-.-.': ';', '-...-': '=', + '.-.-.': '+', '-....-': '-', '..--.-': '_', '.-..-.': '"', + '...-..-': '$', '.--.-.': '@', + # Prosigns (unique codes only; -...- and -.--.- already mapped above) + '-.-.-': '', '.-.-': '', '...-.-': '', +} + +# Reverse lookup: character -> morse notation +CHAR_TO_MORSE: dict[str, str] = {v: k for k, v in MORSE_TABLE.items()} + + +class GoertzelFilter: + """Single-frequency tone detector using the Goertzel algorithm.""" + + def __init__(self, target_freq: float, sample_rate: int, block_size: int): + self.target_freq = float(target_freq) + self.sample_rate = int(sample_rate) + self.block_size = int(block_size) + # Generalized coefficient (does not quantize to integer FFT bins) + omega = 2.0 * math.pi * self.target_freq / self.sample_rate + self.coeff = 2.0 * math.cos(omega) + + def magnitude(self, samples: list[float] | tuple[float, ...] | np.ndarray) -> float: + """Compute magnitude of the target frequency in the sample block.""" + s0 = 0.0 + s1 = 0.0 + s2 = 0.0 + coeff = self.coeff + for sample in samples: + s0 = float(sample) + coeff * s1 - s2 + s2 = s1 + s1 = s0 + power = s1 * s1 + s2 * s2 - coeff * s1 * s2 + return math.sqrt(max(power, 0.0)) + + +def _goertzel_mag(samples: np.ndarray, target_freq: float, sample_rate: int) -> float: + """Compute Goertzel magnitude, preferring shared DSP helper.""" + if _shared_goertzel_mag is not None: + try: + return float(_shared_goertzel_mag(samples, float(target_freq), int(sample_rate))) + except Exception: + pass + filt = GoertzelFilter(target_freq=target_freq, sample_rate=sample_rate, block_size=len(samples)) + return filt.magnitude(samples) + + +def _coerce_bool(value: Any, default: bool = False) -> bool: + """Convert arbitrary JSON-ish values to bool.""" + if isinstance(value, bool): + return value + if value is None: + return default + text = str(value).strip().lower() + if text in {'1', 'true', 'yes', 'on'}: + return True + if text in {'0', 'false', 'no', 'off'}: + return False + return default + + +def _normalize_threshold_mode(value: Any) -> str: + mode = str(value or 'auto').strip().lower() + return mode if mode in {'auto', 'manual'} else 'auto' + + +def _normalize_wpm_mode(value: Any) -> str: + mode = str(value or 'auto').strip().lower() + return mode if mode in {'auto', 'manual'} else 'auto' + + +def _clamp(value: float, lo: float, hi: float) -> float: + return min(hi, max(lo, value)) + + +class MorseDecoder: + """Real-time Morse decoder with adaptive threshold and timing estimation.""" + + def __init__( + self, + sample_rate: int = 8000, + tone_freq: float = 700.0, + wpm: int = 15, + bandwidth_hz: int = 200, + auto_tone_track: bool = True, + tone_lock: bool = False, + threshold_mode: str = 'auto', + manual_threshold: float = 0.0, + threshold_multiplier: float = 2.8, + threshold_offset: float = 0.0, + wpm_mode: str = 'auto', + wpm_lock: bool = False, + min_signal_gate: float = 0.0, + ): + self.sample_rate = int(sample_rate) + self.tone_freq = float(tone_freq) + self.wpm = int(wpm) + + self.bandwidth_hz = int(_clamp(float(bandwidth_hz), 50, 400)) + self.auto_tone_track = bool(auto_tone_track) + self.tone_lock = bool(tone_lock) + self.threshold_mode = _normalize_threshold_mode(threshold_mode) + self.manual_threshold = max(0.0, float(manual_threshold)) + self.threshold_multiplier = float(_clamp(float(threshold_multiplier), 1.1, 8.0)) + self.threshold_offset = max(0.0, float(threshold_offset)) + self.wpm_mode = _normalize_wpm_mode(wpm_mode) + self.wpm_lock = bool(wpm_lock) + self.min_signal_gate = float(_clamp(float(min_signal_gate), 0.0, 1.0)) + + # ~50 analysis windows/s at 8 kHz keeps CPU low and timing stable. + self._block_size = max(64, self.sample_rate // 50) + self._block_duration = self._block_size / float(self.sample_rate) + + self._active_tone_freq = float(_clamp(self.tone_freq, 300.0, 1200.0)) + self._tone_anchor_freq = self._active_tone_freq + self._tone_scan_range_hz = 180.0 + self._tone_scan_step_hz = 10.0 + self._tone_scan_interval_blocks = 8 + + self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size) + self._noise_detector_low = GoertzelFilter( + _clamp(self._active_tone_freq - max(60.0, self.bandwidth_hz * 0.5), 150.0, 2000.0), + self.sample_rate, + self._block_size, + ) + self._noise_detector_high = GoertzelFilter( + _clamp(self._active_tone_freq + max(60.0, self.bandwidth_hz * 0.5), 150.0, 2000.0), + self.sample_rate, + self._block_size, + ) + + # AGC for weak HF/direct-sampling signals. + self._agc_target = 0.22 + self._agc_gain = 1.0 + self._agc_alpha = 0.06 + + # Envelope smoothing. + self._attack_alpha = 0.55 + self._release_alpha = 0.45 + self._envelope = 0.0 + + # Adaptive threshold model. + self._noise_floor = 0.0 + self._signal_peak = 0.0 + self._threshold = 0.0 + self._hysteresis = 0.12 + + # Warm-up bootstrap. + self._WARMUP_BLOCKS = 16 + self._SETTLE_BLOCKS = 140 + self._mag_min = float('inf') + self._mag_max = 0.0 + self._blocks_processed = 0 + + # Timing model (in block units, kept for backward compatibility with tests). + dit_sec = 1.2 / max(self.wpm, 1) + dit_blocks = max(1.0, dit_sec / self._block_duration) + self._dah_threshold = 2.2 * dit_blocks + self._dit_min = 0.38 * dit_blocks + self._char_gap = 2.6 * dit_blocks + self._word_gap = 6.0 * dit_blocks + self._dit_observations: deque[float] = deque(maxlen=32) + self._estimated_wpm = float(self.wpm) + + # State machine. + self._tone_on = False + self._tone_blocks = 0.0 + self._silence_blocks = 0.0 + self._current_symbol = '' + self._pending_buffer: list[int] = [] + + # Output / diagnostics. + self._last_level = 0.0 + self._last_noise_ref = 0.0 + + def reset_calibration(self) -> None: + """Reset adaptive threshold and timing estimator state.""" + self._noise_floor = 0.0 + self._signal_peak = 0.0 + self._threshold = 0.0 + self._mag_min = float('inf') + self._mag_max = 0.0 + self._blocks_processed = 0 + self._dit_observations.clear() + self._estimated_wpm = float(self.wpm) + self._tone_on = False + self._tone_blocks = 0.0 + self._silence_blocks = 0.0 + self._current_symbol = '' + + def get_metrics(self) -> dict[str, float | bool]: + """Return latest decoder metrics for UI/status messages.""" + return { + 'wpm': float(self._estimated_wpm), + 'tone_freq': float(self._active_tone_freq), + 'level': float(self._last_level), + 'noise_floor': float(self._noise_floor), + 'threshold': float(self._threshold), + 'tone_on': bool(self._tone_on), + 'dit_ms': float((self._effective_dit_blocks() * self._block_duration) * 1000.0), + } + + def _rebuild_detectors(self) -> None: + """Rebuild target/noise Goertzel filters after tone updates.""" + self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size) + ref_offset = max(60.0, self.bandwidth_hz * 0.5) + self._noise_detector_low = GoertzelFilter( + _clamp(self._active_tone_freq - ref_offset, 150.0, 2000.0), + self.sample_rate, + self._block_size, + ) + self._noise_detector_high = GoertzelFilter( + _clamp(self._active_tone_freq + ref_offset, 150.0, 2000.0), + self.sample_rate, + self._block_size, + ) + + def _estimate_tone_frequency( + self, + normalized: np.ndarray, + signal_mag: float, + noise_ref: float, + ) -> bool: + """Track dominant CW tone in a local window when a valid tone is present. + + Returns True when the detector frequency changed. + """ + if not self.auto_tone_track or self.tone_lock: + return False + + # Skip retunes when the detector is mostly seeing noise. + if signal_mag <= max(noise_ref * 1.8, 0.02): + return False + + lo = _clamp(self._active_tone_freq - self._tone_scan_range_hz, 300.0, 1200.0) + hi = _clamp(self._active_tone_freq + self._tone_scan_range_hz, 300.0, 1200.0) + if hi <= lo: + return False + + best_freq = self._active_tone_freq + best_mag = float(signal_mag) + + freq = lo + while freq <= hi + 1e-6: + mag = _goertzel_mag(normalized, freq, self.sample_rate) + if mag > best_mag: + best_mag = mag + best_freq = freq + freq += self._tone_scan_step_hz + + # Require a meaningful improvement before moving off the current tone. + if best_mag <= (signal_mag * 1.12): + return False + + # Smooth and cap per-step movement to avoid jumps on noisy windows. + delta = _clamp(best_freq - self._active_tone_freq, -30.0, 30.0) + smoothed = self._active_tone_freq + (0.35 * delta) + # Do not drift too far from the configured tone unless the user retunes. + smoothed = _clamp( + smoothed, + max(300.0, self._tone_anchor_freq - 240.0), + min(1200.0, self._tone_anchor_freq + 240.0), + ) + + if abs(smoothed - self._active_tone_freq) >= 2.5: + self._active_tone_freq = smoothed + self._rebuild_detectors() + return True + return False + + def _effective_dit_blocks(self) -> float: + """Return current dit estimate in block units.""" + if self.wpm_mode == 'manual' or self.wpm_lock: + wpm = max(5.0, min(50.0, float(self.wpm))) + dit_blocks = max(1.0, (1.2 / wpm) / self._block_duration) + self._estimated_wpm = wpm + return dit_blocks + + if self._dit_observations: + ordered = sorted(self._dit_observations) + mid = ordered[len(ordered) // 2] + dit_blocks = max(1.0, float(mid)) + est_wpm = 1.2 / (dit_blocks * self._block_duration) + self._estimated_wpm = _clamp(est_wpm, 5.0, 60.0) + return dit_blocks + + self._estimated_wpm = float(self.wpm) + return max(1.0, (1.2 / max(self.wpm, 1)) / self._block_duration) + + def _record_dit_candidate(self, blocks: float) -> None: + """Feed a possible dit duration into the estimator.""" + if blocks <= 0: + return + if self.wpm_mode == 'manual' or self.wpm_lock: + return + if blocks > 20: + return + self._dit_observations.append(float(blocks)) + + def _decode_symbol(self, symbol: str, timestamp: str) -> dict[str, Any] | None: + char = MORSE_TABLE.get(symbol) + if char is None: + return None + return { + 'type': 'morse_char', + 'char': char, + 'morse': symbol, + 'timestamp': timestamp, + } + + def process_block(self, pcm_bytes: bytes) -> list[dict[str, Any]]: + """Process PCM bytes and return decode/scope events.""" + events: list[dict[str, Any]] = [] + + n_samples = len(pcm_bytes) // 2 + if n_samples <= 0: + return events + + samples = struct.unpack(f'<{n_samples}h', pcm_bytes[:n_samples * 2]) + self._pending_buffer.extend(samples) + + amplitudes: list[float] = [] + + while len(self._pending_buffer) >= self._block_size: + block = np.array(self._pending_buffer[:self._block_size], dtype=np.float64) + del self._pending_buffer[:self._block_size] + + normalized = block / 32768.0 + + # AGC + rms = float(np.sqrt(np.mean(np.square(normalized)))) + if rms > 1e-7: + desired_gain = self._agc_target / rms + self._agc_gain += self._agc_alpha * (desired_gain - self._agc_gain) + self._agc_gain = _clamp(self._agc_gain, 0.2, 450.0) + normalized *= self._agc_gain + + self._blocks_processed += 1 + + mag = self._detector.magnitude(normalized) + noise_low = self._noise_detector_low.magnitude(normalized) + noise_high = self._noise_detector_high.magnitude(normalized) + noise_ref = max(1e-9, (noise_low + noise_high) * 0.5) + + if ( + self.auto_tone_track + and not self.tone_lock + and self._blocks_processed > self._WARMUP_BLOCKS + and (self._blocks_processed % self._tone_scan_interval_blocks == 0) + and self._estimate_tone_frequency(normalized, mag, noise_ref) + ): + # Detector changed; refresh magnitudes for this window. + mag = self._detector.magnitude(normalized) + noise_low = self._noise_detector_low.magnitude(normalized) + noise_high = self._noise_detector_high.magnitude(normalized) + noise_ref = max(1e-9, (noise_low + noise_high) * 0.5) + + level = float(mag) + alpha = self._attack_alpha if level >= self._envelope else self._release_alpha + self._envelope += alpha * (level - self._envelope) + self._last_level = self._envelope + self._last_noise_ref = noise_ref + amplitudes.append(level) + + if self._blocks_processed <= self._WARMUP_BLOCKS: + self._mag_min = min(self._mag_min, level) + self._mag_max = max(self._mag_max, level) + if self._blocks_processed == self._WARMUP_BLOCKS: + self._noise_floor = self._mag_min if math.isfinite(self._mag_min) else 0.0 + if self._mag_max <= (self._noise_floor * 1.2): + self._signal_peak = max(self._noise_floor + 0.5, self._noise_floor * 2.5) + else: + self._signal_peak = max(self._mag_max, self._noise_floor * 1.8) + self._threshold = self._noise_floor + 0.22 * ( + self._signal_peak - self._noise_floor + ) + tone_detected = False + else: + settle_alpha = 0.30 if self._blocks_processed < (self._WARMUP_BLOCKS + self._SETTLE_BLOCKS) else 0.06 + + detector_level = level + + if detector_level <= self._threshold: + self._noise_floor += settle_alpha * (detector_level - self._noise_floor) + else: + self._signal_peak += settle_alpha * (detector_level - self._signal_peak) + + self._signal_peak = max(self._signal_peak, self._noise_floor * 1.05) + + if self.threshold_mode == 'manual': + self._threshold = max(0.0, self.manual_threshold) + else: + self._threshold = ( + max(0.0, self._noise_floor * self.threshold_multiplier) + + self.threshold_offset + ) + self._threshold = max(self._threshold, self._noise_floor + 0.35) + + dynamic_span = max(0.0, self._signal_peak - self._noise_floor) + gate_level = self._noise_floor + (self.min_signal_gate * dynamic_span) + gate_ok = self.min_signal_gate <= 0.0 or detector_level >= gate_level + + on_threshold = self._threshold * (1.0 + self._hysteresis) + off_threshold = self._threshold * (1.0 - self._hysteresis) + + if self._tone_on: + tone_detected = gate_ok and detector_level >= off_threshold + else: + tone_detected = gate_ok and detector_level >= on_threshold + + dit_blocks = self._effective_dit_blocks() + self._dah_threshold = 2.2 * dit_blocks + self._dit_min = max(1.0, 0.38 * dit_blocks) + self._char_gap = 2.6 * dit_blocks + self._word_gap = 6.0 * dit_blocks + + if tone_detected and not self._tone_on: + # Tone edge up. + self._tone_on = True + silence_count = self._silence_blocks + self._silence_blocks = 0.0 + self._tone_blocks = 0.0 + + if self._current_symbol and silence_count >= self._char_gap: + timestamp = datetime.now().strftime('%H:%M:%S') + decoded = self._decode_symbol(self._current_symbol, timestamp) + if decoded is not None: + events.append(decoded) + + if silence_count >= self._word_gap: + events.append({ + 'type': 'morse_space', + 'timestamp': timestamp, + }) + events.append({ + 'type': 'morse_gap', + 'gap': 'word', + 'duration_ms': round(silence_count * self._block_duration * 1000.0, 1), + }) + else: + events.append({ + 'type': 'morse_gap', + 'gap': 'char', + 'duration_ms': round(silence_count * self._block_duration * 1000.0, 1), + }) + + self._current_symbol = '' + elif silence_count >= 1.0: + # Intra-symbol gap candidate improves dit estimate for Farnsworth-style spacing. + if silence_count <= (self._char_gap * 0.95): + self._record_dit_candidate(silence_count) + + elif (not tone_detected) and self._tone_on: + # Tone edge down. + self._tone_on = False + tone_count = max(1.0, self._tone_blocks) + self._tone_blocks = 0.0 + self._silence_blocks = 0.0 + + element = '' + if tone_count >= self._dah_threshold: + element = '-' + elif tone_count >= self._dit_min: + element = '.' + + if element: + self._current_symbol += element + events.append({ + 'type': 'morse_element', + 'element': element, + 'duration_ms': round(tone_count * self._block_duration * 1000.0, 1), + }) + if element == '.': + self._record_dit_candidate(tone_count) + elif tone_count <= (self._dah_threshold * 1.6): + # Some operators send short-ish dahs; still useful for tracking. + self._record_dit_candidate(tone_count / 3.0) + + elif tone_detected and self._tone_on: + self._tone_blocks += 1.0 + + elif (not tone_detected) and (not self._tone_on): + self._silence_blocks += 1.0 + + if amplitudes: + events.append({ + 'type': 'scope', + 'amplitudes': amplitudes, + 'threshold': self._threshold, + 'tone_on': self._tone_on, + 'tone_freq': round(self._active_tone_freq, 1), + 'level': self._last_level, + 'noise_floor': self._noise_floor, + 'wpm': round(self._estimated_wpm, 1), + 'dit_ms': round(self._effective_dit_blocks() * self._block_duration * 1000.0, 1), + }) + + return events + + def flush(self) -> list[dict[str, Any]]: + """Flush pending symbols at end-of-stream.""" + events: list[dict[str, Any]] = [] + + if self._tone_on and self._tone_blocks >= self._dit_min: + tone_count = self._tone_blocks + element = '-' if tone_count >= self._dah_threshold else '.' + self._current_symbol += element + events.append({ + 'type': 'morse_element', + 'element': element, + 'duration_ms': round(tone_count * self._block_duration * 1000.0, 1), + }) + + if self._current_symbol: + decoded = self._decode_symbol(self._current_symbol, datetime.now().strftime('%H:%M:%S')) + if decoded is not None: + events.append(decoded) + self._current_symbol = '' + + self._tone_on = False + self._tone_blocks = 0.0 + self._silence_blocks = 0.0 + return events + + +def _wav_to_mono_float(path: Path) -> tuple[np.ndarray, int]: + """Load WAV file and return mono float32 samples in [-1, 1].""" + with wave.open(str(path), 'rb') as wf: + n_channels = wf.getnchannels() + sampwidth = wf.getsampwidth() + sample_rate = wf.getframerate() + n_frames = wf.getnframes() + raw = wf.readframes(n_frames) + + if sampwidth == 1: + pcm = np.frombuffer(raw, dtype=np.uint8).astype(np.float64) + pcm = (pcm - 128.0) / 128.0 + elif sampwidth == 2: + pcm = np.frombuffer(raw, dtype=np.int16).astype(np.float64) / 32768.0 + elif sampwidth == 4: + pcm = np.frombuffer(raw, dtype=np.int32).astype(np.float64) / 2147483648.0 + else: + raise ValueError(f'Unsupported WAV sample width: {sampwidth * 8} bits') + + if n_channels > 1: + pcm = pcm.reshape(-1, n_channels).mean(axis=1) + + return pcm.astype(np.float64), int(sample_rate) + + +def _resample_linear(samples: np.ndarray, from_rate: int, to_rate: int) -> np.ndarray: + """Linear resampler with no extra dependencies.""" + if from_rate == to_rate or len(samples) == 0: + return samples + + ratio = float(to_rate) / float(from_rate) + new_len = max(1, int(round(len(samples) * ratio))) + x_old = np.linspace(0.0, 1.0, len(samples), endpoint=False) + x_new = np.linspace(0.0, 1.0, new_len, endpoint=False) + return np.interp(x_new, x_old, samples).astype(np.float64) + + +def decode_morse_wav_file( + wav_path: str | Path, + *, + sample_rate: int = 8000, + tone_freq: float = 700.0, + wpm: int = 15, + bandwidth_hz: int = 200, + auto_tone_track: bool = True, + tone_lock: bool = False, + threshold_mode: str = 'auto', + manual_threshold: float = 0.0, + threshold_multiplier: float = 2.8, + threshold_offset: float = 0.0, + wpm_mode: str = 'auto', + wpm_lock: bool = False, + min_signal_gate: float = 0.0, +) -> dict[str, Any]: + """Decode Morse from a WAV file and return text/events/metrics.""" + path = Path(wav_path) + if not path.is_file(): + raise FileNotFoundError(f'WAV file not found: {path}') + + audio, file_rate = _wav_to_mono_float(path) + if file_rate != sample_rate: + audio = _resample_linear(audio, file_rate, sample_rate) + + pcm = np.clip(audio, -1.0, 1.0) + pcm16 = (pcm * 32767.0).astype(np.int16) + + decoder = MorseDecoder( + sample_rate=sample_rate, + tone_freq=tone_freq, + wpm=wpm, + bandwidth_hz=bandwidth_hz, + auto_tone_track=auto_tone_track, + tone_lock=tone_lock, + threshold_mode=threshold_mode, + manual_threshold=manual_threshold, + threshold_multiplier=threshold_multiplier, + threshold_offset=threshold_offset, + wpm_mode=wpm_mode, + wpm_lock=wpm_lock, + min_signal_gate=min_signal_gate, + ) + + events: list[dict[str, Any]] = [] + chunk_samples = 2048 + idx = 0 + while idx < len(pcm16): + chunk = pcm16[idx:idx + chunk_samples] + if len(chunk) == 0: + break + events.extend(decoder.process_block(chunk.tobytes())) + idx += chunk_samples + + events.extend(decoder.flush()) + + text_parts: list[str] = [] + raw_parts: list[str] = [] + for event in events: + et = event.get('type') + if et == 'morse_char': + text_parts.append(str(event.get('char', ''))) + elif et == 'morse_space': + text_parts.append(' ') + elif et == 'morse_element': + raw_parts.append(str(event.get('element', ''))) + elif et == 'morse_gap': + gap = str(event.get('gap', '')) + if gap == 'char': + raw_parts.append(' / ') + elif gap == 'word': + raw_parts.append(' // ') + + text = ''.join(text_parts) + raw = ''.join(raw_parts).strip() + + return { + 'text': text, + 'raw': raw, + 'events': events, + 'metrics': decoder.get_metrics(), + } + + +def _drain_control_queue(control_queue: queue.Queue | None, decoder: MorseDecoder) -> bool: + """Process pending control commands; return False to request shutdown.""" + if control_queue is None: + return True + + keep_running = True + while True: + try: + cmd = control_queue.get_nowait() + except queue.Empty: + break + + if not isinstance(cmd, dict): + continue + action = str(cmd.get('cmd', '')).strip().lower() + if action == 'reset': + decoder.reset_calibration() + elif action in {'shutdown', 'stop'}: + keep_running = False + + return keep_running + + +def _emit_waiting_scope(output_queue: queue.Queue, waiting_since: float) -> None: + """Emit waiting heartbeat while no PCM arrives.""" + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'scope', + 'amplitudes': [], + 'threshold': 0, + 'tone_on': False, + 'waiting': True, + 'waiting_seconds': round(max(0.0, time.monotonic() - waiting_since), 1), + }) + + +def morse_decoder_thread( + rtl_stdout, + output_queue: queue.Queue, + stop_event: threading.Event, + sample_rate: int = 8000, + tone_freq: float = 700.0, + wpm: int = 15, + decoder_config: dict[str, Any] | None = None, + control_queue: queue.Queue | None = None, +) -> None: + """Decode Morse from live PCM stream and push events to *output_queue*.""" + import logging + logger = logging.getLogger('intercept.morse') + + CHUNK = 4096 + SCOPE_INTERVAL = 0.10 + WAITING_INTERVAL = 0.25 + + cfg = dict(decoder_config or {}) + decoder = MorseDecoder( + sample_rate=int(cfg.get('sample_rate', sample_rate)), + tone_freq=float(cfg.get('tone_freq', tone_freq)), + wpm=int(cfg.get('wpm', wpm)), + bandwidth_hz=int(cfg.get('bandwidth_hz', 200)), + auto_tone_track=_coerce_bool(cfg.get('auto_tone_track', True), True), + tone_lock=_coerce_bool(cfg.get('tone_lock', False), False), + threshold_mode=_normalize_threshold_mode(cfg.get('threshold_mode', 'auto')), + manual_threshold=float(cfg.get('manual_threshold', 0.0) or 0.0), + threshold_multiplier=float(cfg.get('threshold_multiplier', 2.8) or 2.8), + threshold_offset=float(cfg.get('threshold_offset', 0.0) or 0.0), + wpm_mode=_normalize_wpm_mode(cfg.get('wpm_mode', 'auto')), + wpm_lock=_coerce_bool(cfg.get('wpm_lock', False), False), + min_signal_gate=float(cfg.get('min_signal_gate', 0.0) or 0.0), + ) + + last_scope = time.monotonic() + last_waiting_emit = 0.0 + waiting_since: float | None = None + + try: + fd: int | None + try: + fd = rtl_stdout.fileno() + except Exception: + fd = None + + while not stop_event.is_set(): + if not _drain_control_queue(control_queue, decoder): + break + + data = b'' + if fd is not None: + try: + ready, _, _ = select.select([fd], [], [], 0.20) + except Exception: + break + + if ready: + data = os.read(fd, CHUNK) + else: + if waiting_since is None: + waiting_since = time.monotonic() + now = time.monotonic() + if now - last_waiting_emit >= WAITING_INTERVAL: + last_waiting_emit = now + _emit_waiting_scope(output_queue, waiting_since) + continue + else: + # Fallback for test streams without fileno(). + data = rtl_stdout.read(CHUNK) + + if not data: + break + + waiting_since = None + + events = decoder.process_block(data) + for event in events: + if event.get('type') == 'scope': + now = time.monotonic() + if now - last_scope >= SCOPE_INTERVAL: + last_scope = now + with contextlib.suppress(queue.Full): + output_queue.put_nowait(event) + else: + with contextlib.suppress(queue.Full): + output_queue.put_nowait(event) + + except Exception as e: # pragma: no cover - defensive runtime guard + logger.debug(f'Morse decoder thread error: {e}') + finally: + for event in decoder.flush(): + with contextlib.suppress(queue.Full): + output_queue.put_nowait(event) + + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'status', + 'status': 'stopped', + 'metrics': decoder.get_metrics(), + })