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 @@
+ +- Decode CW (continuous wave) Morse code from amateur radio HF bands using USB demodulation - and Goertzel tone detection. -
-- 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). -
-+ Decode CW (continuous wave) Morse with USB demod + Goertzel tone detection. + Start with 700 Hz tone and 200 Hz bandwidth. +
++ CW on HF (1-30 MHz) requires an HF-capable SDR path (direct sampling or upconverter) + and an appropriate antenna. +
+