Fix Morse mode lifecycle stop hangs and rebuild CW decoder

This commit is contained in:
Smittix
2026-02-26 11:03:00 +00:00
parent 5d90c308a9
commit 286ab53d26
8 changed files with 3453 additions and 1816 deletions

View File

@@ -50,12 +50,34 @@ Support the developer of this open-source project
- **Meshtastic** - LoRa mesh network integration - **Meshtastic** - LoRa mesh network integration
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required) - **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 - **Spy Stations** - Number stations and diplomatic HF network database
- **Remote Agents** - Distributed SIGINT with remote sensor nodes - **Remote Agents** - Distributed SIGINT with remote sensor nodes
- **Offline Mode** - Bundled assets for air-gapped/field deployments - **Offline Mode** - Bundled assets for air-gapped/field deployments
--- ---
## Installation / Debian / Ubuntu / MacOS ## 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:** **1. Clone and run:**
```bash ```bash

View File

@@ -1,288 +1,696 @@
"""CW/Morse code decoder routes.""" """CW/Morse code decoder routes."""
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
import queue import queue
import subprocess import subprocess
import threading import tempfile
import time import threading
from typing import Any import time
from pathlib import Path
from flask import Blueprint, Response, jsonify, request from typing import Any
import app as app_module from flask import Blueprint, Response, jsonify, request
from utils.event_pipeline import process_event
from utils.logging import sensor_logger as logger import app as app_module
from utils.morse import morse_decoder_thread from utils.event_pipeline import process_event
from utils.process import register_process, safe_terminate, unregister_process from utils.logging import sensor_logger as logger
from utils.sdr import SDRFactory, SDRType from utils.morse import decode_morse_wav_file, morse_decoder_thread
from utils.sse import sse_stream_fanout from utils.process import register_process, safe_terminate, unregister_process
from utils.validation import ( from utils.sdr import SDRFactory, SDRType
validate_device_index, from utils.sse import sse_stream_fanout
validate_frequency, from utils.validation import (
validate_gain, validate_device_index,
validate_ppm, validate_frequency,
) validate_gain,
validate_ppm,
morse_bp = Blueprint('morse', __name__) )
# Track which device is being used morse_bp = Blueprint('morse', __name__)
morse_active_device: int | None = None
# 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).""" # Runtime lifecycle state.
try: MORSE_IDLE = 'idle'
freq = float(value) MORSE_STARTING = 'starting'
if not 300 <= freq <= 1200: MORSE_RUNNING = 'running'
raise ValueError("Tone frequency must be between 300 and 1200 Hz") MORSE_STOPPING = 'stopping'
return freq MORSE_ERROR = 'error'
except (ValueError, TypeError) as e:
raise ValueError(f"Invalid tone frequency: {value}") from e morse_state = MORSE_IDLE
morse_state_message = 'Idle'
morse_state_since = time.monotonic()
def _validate_wpm(value: Any) -> int: morse_last_error = ''
"""Validate words per minute (5-50).""" morse_runtime_config: dict[str, Any] = {}
try: morse_session_id = 0
wpm = int(value)
if not 5 <= wpm <= 50: morse_decoder_worker: threading.Thread | None = None
raise ValueError("WPM must be between 5 and 50") morse_stderr_worker: threading.Thread | None = None
return wpm morse_stop_event: threading.Event | None = None
except (ValueError, TypeError) as e: morse_control_queue: queue.Queue | None = None
raise ValueError(f"Invalid WPM: {value}") from e
def _set_state(state: str, message: str = '', *, enqueue: bool = True, extra: dict[str, Any] | None = None) -> None:
@morse_bp.route('/morse/start', methods=['POST']) """Update lifecycle state and optionally emit a status queue event."""
def start_morse() -> Response: global morse_state, morse_state_message, morse_state_since
global morse_active_device morse_state = state
morse_state_message = message or state
with app_module.morse_lock: morse_state_since = time.monotonic()
if app_module.morse_process:
return jsonify({'status': 'error', 'message': 'Morse decoder already running'}), 409 if not enqueue:
return
data = request.json or {}
payload: dict[str, Any] = {
# Validate standard SDR inputs 'type': 'status',
try: 'status': state,
freq = validate_frequency(data.get('frequency', '14.060'), min_mhz=0.5, max_mhz=30.0) 'state': state,
gain = validate_gain(data.get('gain', '0')) 'message': morse_state_message,
ppm = validate_ppm(data.get('ppm', '0')) 'session_id': morse_session_id,
device = validate_device_index(data.get('device', '0')) 'timestamp': time.strftime('%H:%M:%S'),
except ValueError as e: }
return jsonify({'status': 'error', 'message': str(e)}), 400 if extra:
payload.update(extra)
# Validate Morse-specific inputs with contextlib.suppress(queue.Full):
try: app_module.morse_queue.put_nowait(payload)
tone_freq = _validate_tone_freq(data.get('tone_freq', '700'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 def _drain_queue(q: queue.Queue) -> None:
while not q.empty():
try: try:
wpm = _validate_wpm(data.get('wpm', '15')) q.get_nowait()
except ValueError as e: except queue.Empty:
return jsonify({'status': 'error', 'message': str(e)}), 400 break
# Claim SDR device
device_int = int(device) def _join_thread(worker: threading.Thread | None, timeout_s: float) -> bool:
error = app_module.claim_sdr_device(device_int, 'morse') if worker is None:
if error: return True
return jsonify({ worker.join(timeout=timeout_s)
'status': 'error', return not worker.is_alive()
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409 def _close_pipe(pipe_obj: Any) -> None:
morse_active_device = device_int if pipe_obj is None:
return
# Clear queue with contextlib.suppress(Exception):
while not app_module.morse_queue.empty(): pipe_obj.close()
try:
app_module.morse_queue.get_nowait()
except queue.Empty: def _bool_value(value: Any, default: bool = False) -> bool:
break if isinstance(value, bool):
return value
# Build rtl_fm USB demodulation command if value is None:
sdr_type_str = data.get('sdr_type', 'rtlsdr') return default
try: text = str(value).strip().lower()
sdr_type = SDRType(sdr_type_str) if text in {'1', 'true', 'yes', 'on'}:
except ValueError: return True
sdr_type = SDRType.RTL_SDR if text in {'0', 'false', 'no', 'off'}:
return False
sdr_device = SDRFactory.create_default_device(sdr_type, index=device) return default
builder = SDRFactory.get_builder(sdr_device.sdr_type)
sample_rate = 8000 def _float_value(value: Any, default: float) -> float:
bias_t = data.get('bias_t', False) try:
return float(value)
# RTL-SDR needs direct sampling mode for HF frequencies below 24 MHz except (TypeError, ValueError):
direct_sampling = 2 if freq < 24.0 else None return float(default)
rtl_cmd = builder.build_fm_demod_command(
device=sdr_device, def _validate_tone_freq(value: Any) -> float:
frequency_mhz=freq, """Validate CW tone frequency (300-1200 Hz)."""
sample_rate=sample_rate, try:
gain=float(gain) if gain and gain != '0' else None, freq = float(value)
ppm=int(ppm) if ppm and ppm != '0' else None, if not 300 <= freq <= 1200:
modulation='usb', raise ValueError('Tone frequency must be between 300 and 1200 Hz')
bias_t=bias_t, return freq
direct_sampling=direct_sampling, except (ValueError, TypeError) as e:
) raise ValueError(f'Invalid tone frequency: {value}') from e
full_cmd = ' '.join(rtl_cmd)
logger.info(f"Morse decoder running: {full_cmd}") def _validate_wpm(value: Any) -> int:
"""Validate words per minute (5-50)."""
try: try:
rtl_process = subprocess.Popen( wpm = int(value)
rtl_cmd, if not 5 <= wpm <= 50:
stdout=subprocess.PIPE, raise ValueError('WPM must be between 5 and 50')
stderr=subprocess.PIPE, return wpm
bufsize=0, except (ValueError, TypeError) as e:
) raise ValueError(f'Invalid WPM: {value}') from e
register_process(rtl_process)
# Start threads IMMEDIATELY so stdout is read before pipe fills. def _validate_bandwidth(value: Any) -> int:
# Forward rtl_fm stderr to queue so frontend can display diagnostics try:
def monitor_stderr(): bw = int(value)
for line in rtl_process.stderr: if bw not in (50, 100, 200, 400):
err_text = line.decode('utf-8', errors='replace').strip() raise ValueError('Bandwidth must be one of 50, 100, 200, 400 Hz')
if err_text: return bw
logger.debug(f"[rtl_fm/morse] {err_text}") except (TypeError, ValueError) as e:
with contextlib.suppress(queue.Full): raise ValueError(f'Invalid bandwidth: {value}') from e
app_module.morse_queue.put_nowait({
'type': 'info',
'text': f'[rtl_fm] {err_text}', def _validate_threshold_mode(value: Any) -> str:
}) mode = str(value or 'auto').strip().lower()
if mode not in {'auto', 'manual'}:
stderr_thread = threading.Thread(target=monitor_stderr) raise ValueError('threshold_mode must be auto or manual')
stderr_thread.daemon = True return mode
stderr_thread.start()
# Start Morse decoder thread before sleep so it reads stdout immediately def _validate_wpm_mode(value: Any) -> str:
stop_event = threading.Event() mode = str(value or 'auto').strip().lower()
decoder_thread = threading.Thread( if mode not in {'auto', 'manual'}:
target=morse_decoder_thread, raise ValueError('wpm_mode must be auto or manual')
args=( return mode
rtl_process.stdout,
app_module.morse_queue,
stop_event, def _validate_threshold_multiplier(value: Any) -> float:
sample_rate, try:
tone_freq, multiplier = float(value)
wpm, if not 1.1 <= multiplier <= 8.0:
), raise ValueError('threshold_multiplier must be between 1.1 and 8.0')
) return multiplier
decoder_thread.daemon = True except (TypeError, ValueError) as e:
decoder_thread.start() raise ValueError(f'Invalid threshold multiplier: {value}') from e
# Detect immediate startup failure (e.g. device busy, no device)
time.sleep(0.35) def _validate_non_negative_float(value: Any, field_name: str) -> float:
if rtl_process.poll() is not None: try:
stop_event.set() parsed = float(value)
stderr_text = '' if parsed < 0:
try: raise ValueError(f'{field_name} must be non-negative')
if rtl_process.stderr: return parsed
stderr_text = rtl_process.stderr.read().decode( except (TypeError, ValueError) as e:
'utf-8', errors='replace' raise ValueError(f'Invalid {field_name}: {value}') from e
).strip()
except Exception:
stderr_text = '' def _validate_signal_gate(value: Any) -> float:
msg = stderr_text or f'rtl_fm exited immediately (code {rtl_process.returncode})' try:
logger.error(f"Morse rtl_fm startup failed: {msg}") gate = float(value)
unregister_process(rtl_process) if not 0.0 <= gate <= 1.0:
if morse_active_device is not None: raise ValueError('signal_gate must be between 0.0 and 1.0')
app_module.release_sdr_device(morse_active_device) return gate
morse_active_device = None except (TypeError, ValueError) as e:
return jsonify({'status': 'error', 'message': msg}), 500 raise ValueError(f'Invalid signal gate: {value}') from e
app_module.morse_process = rtl_process
app_module.morse_process._stop_decoder = stop_event def _snapshot_live_resources() -> list[str]:
app_module.morse_process._decoder_thread = decoder_thread alive: list[str] = []
if morse_decoder_worker and morse_decoder_worker.is_alive():
app_module.morse_queue.put({'type': 'status', 'status': 'started'}) alive.append('decoder_thread')
with contextlib.suppress(queue.Full): if morse_stderr_worker and morse_stderr_worker.is_alive():
app_module.morse_queue.put_nowait({ alive.append('stderr_thread')
'type': 'info', if app_module.morse_process and app_module.morse_process.poll() is None:
'text': f'[cmd] {full_cmd}', alive.append('rtl_process')
}) return alive
return jsonify({
'status': 'started', @morse_bp.route('/morse/start', methods=['POST'])
'command': full_cmd, def start_morse() -> Response:
'tone_freq': tone_freq, global morse_active_device, morse_decoder_worker, morse_stderr_worker
'wpm': wpm, global morse_stop_event, morse_control_queue, morse_runtime_config
}) global morse_last_error, morse_session_id
except FileNotFoundError as e: data = request.json or {}
if morse_active_device is not None:
app_module.release_sdr_device(morse_active_device) # Validate standard SDR inputs
morse_active_device = None try:
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}), 400 freq = validate_frequency(data.get('frequency', '14.060'), min_mhz=0.5, max_mhz=30.0)
gain = validate_gain(data.get('gain', '0'))
except Exception as e: ppm = validate_ppm(data.get('ppm', '0'))
# Clean up rtl_fm if it was started device = validate_device_index(data.get('device', '0'))
try: except ValueError as e:
rtl_process.terminate() return jsonify({'status': 'error', 'message': str(e)}), 400
rtl_process.wait(timeout=2)
except Exception: # Validate Morse-specific inputs
with contextlib.suppress(Exception): try:
rtl_process.kill() tone_freq = _validate_tone_freq(data.get('tone_freq', '700'))
unregister_process(rtl_process) wpm = _validate_wpm(data.get('wpm', '15'))
if morse_active_device is not None: bandwidth_hz = _validate_bandwidth(data.get('bandwidth_hz', '200'))
app_module.release_sdr_device(morse_active_device) threshold_mode = _validate_threshold_mode(data.get('threshold_mode', 'auto'))
morse_active_device = None wpm_mode = _validate_wpm_mode(data.get('wpm_mode', 'auto'))
return jsonify({'status': 'error', 'message': str(e)}), 500 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')
@morse_bp.route('/morse/stop', methods=['POST']) min_signal_gate = _validate_signal_gate(data.get('signal_gate', '0'))
def stop_morse() -> Response: auto_tone_track = _bool_value(data.get('auto_tone_track', True), True)
global morse_active_device tone_lock = _bool_value(data.get('tone_lock', False), False)
wpm_lock = _bool_value(data.get('wpm_lock', False), False)
with app_module.morse_lock: except ValueError as e:
if app_module.morse_process: return jsonify({'status': 'error', 'message': str(e)}), 400
# Signal decoder thread to stop
stop_event = getattr(app_module.morse_process, '_stop_decoder', None) with app_module.morse_lock:
if stop_event: if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}:
stop_event.set() return jsonify({
'status': 'error',
safe_terminate(app_module.morse_process) 'message': f'Morse decoder is {morse_state}',
unregister_process(app_module.morse_process) 'state': morse_state,
app_module.morse_process = None }), 409
if morse_active_device is not None: # Claim SDR device
app_module.release_sdr_device(morse_active_device) device_int = int(device)
morse_active_device = None error = app_module.claim_sdr_device(device_int, 'morse')
if error:
app_module.morse_queue.put({'type': 'status', 'status': 'stopped'}) return jsonify({
return jsonify({'status': 'stopped'}) 'status': 'error',
'error_type': 'DEVICE_BUSY',
return jsonify({'status': 'not_running'}) 'message': error,
}), 409
@morse_bp.route('/morse/status') morse_active_device = device_int
def morse_status() -> Response: morse_last_error = ''
with app_module.morse_lock: morse_session_id += 1
running = (
app_module.morse_process is not None _drain_queue(app_module.morse_queue)
and app_module.morse_process.poll() is None
) _set_state(MORSE_STARTING, 'Starting decoder...')
return jsonify({'running': running})
sample_rate = 8000
bias_t = _bool_value(data.get('bias_t', False), False)
@morse_bp.route('/morse/stream')
def morse_stream() -> Response: # RTL-SDR needs direct sampling mode for HF frequencies below 24 MHz
def _on_msg(msg: dict[str, Any]) -> None: direct_sampling = 2 if freq < 24.0 else None
process_event('morse', msg, msg.get('type'))
sdr_type_str = data.get('sdr_type', 'rtlsdr')
response = Response( try:
sse_stream_fanout( sdr_type = SDRType(sdr_type_str)
source_queue=app_module.morse_queue, except ValueError:
channel_key='morse', sdr_type = SDRType.RTL_SDR
timeout=1.0,
keepalive_interval=30.0, sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
on_message=_on_msg, builder = SDRFactory.get_builder(sdr_device.sdr_type)
),
mimetype='text/event-stream', rtl_cmd = builder.build_fm_demod_command(
) device=sdr_device,
response.headers['Cache-Control'] = 'no-cache' frequency_mhz=freq,
response.headers['X-Accel-Buffering'] = 'no' sample_rate=sample_rate,
response.headers['Connection'] = 'keep-alive' gain=float(gain) if gain and gain != '0' else None,
return response 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

View File

@@ -1,118 +1,201 @@
/* Morse Code / CW Decoder Styles */ /* Morse Code / CW Decoder Styles */
/* Scope canvas container */ .morse-mode-help,
.morse-scope-container { .morse-help-text {
background: var(--bg-primary); font-size: 11px;
border: 1px solid var(--border-color); color: var(--text-dim);
border-radius: 6px; }
padding: 8px;
margin-bottom: 12px; .morse-help-text {
} margin-top: 4px;
display: block;
.morse-scope-container canvas { }
width: 100%;
height: 80px; .morse-hf-note {
display: block; font-size: 11px;
border-radius: 4px; color: #ffaa00;
} line-height: 1.5;
}
/* Decoded text panel */
.morse-decoded-panel { .morse-presets {
background: var(--bg-primary); display: flex;
border: 1px solid var(--border-color); flex-wrap: wrap;
border-radius: 6px; gap: 4px;
padding: 12px; }
min-height: 120px;
max-height: 400px; .morse-actions-row {
overflow-y: auto; display: flex;
font-family: var(--font-mono); gap: 8px;
font-size: 18px; flex-wrap: wrap;
line-height: 1.6; }
color: var(--text-primary);
word-wrap: break-word; .morse-file-row {
} display: flex;
gap: 8px;
.morse-decoded-panel:empty::before { align-items: center;
content: 'Decoded text will appear here...'; flex-wrap: wrap;
color: var(--text-dim); }
font-size: 14px;
font-style: italic; .morse-file-row input[type='file'] {
} width: 100%;
max-width: 100%;
/* Individual decoded character with fade-in */ }
.morse-char {
display: inline; .morse-status {
animation: morseFadeIn 0.3s ease-out; display: flex;
position: relative; align-items: center;
} gap: 8px;
font-size: 12px;
@keyframes morseFadeIn { color: var(--text-dim);
from { }
opacity: 0;
color: var(--accent-cyan); .morse-status #morseCharCount {
} margin-left: auto;
to { }
opacity: 1;
color: var(--text-primary); .morse-ref-grid {
} transition: max-height 0.3s ease, opacity 0.3s ease;
} max-height: 560px;
opacity: 1;
/* Small Morse notation above character */ overflow: hidden;
.morse-char-morse { font-family: var(--font-mono);
font-size: 9px; font-size: 10px;
color: var(--text-dim); line-height: 1.8;
letter-spacing: 1px; columns: 2;
display: block; column-gap: 12px;
line-height: 1; color: var(--text-dim);
margin-bottom: -2px; }
}
.morse-ref-grid.collapsed {
/* Reference grid */ max-height: 0;
.morse-ref-grid { opacity: 0;
transition: max-height 0.3s ease, opacity 0.3s ease; }
max-height: 500px;
opacity: 1; .morse-ref-toggle {
overflow: hidden; font-size: 10px;
} color: var(--text-dim);
}
.morse-ref-grid.collapsed {
max-height: 0; .morse-ref-divider {
opacity: 0; margin-top: 4px;
} border-top: 1px solid var(--border-color);
padding-top: 4px;
/* Toolbar: export/copy/clear */ }
.morse-toolbar {
display: flex; .morse-decoded-panel {
gap: 6px; background: var(--bg-primary);
margin-bottom: 8px; border: 1px solid var(--border-color);
flex-wrap: wrap; border-radius: 6px;
} padding: 12px;
min-height: 120px;
.morse-toolbar .btn { max-height: 400px;
font-size: 11px; overflow-y: auto;
padding: 4px 10px; font-family: var(--font-mono);
} font-size: 18px;
line-height: 1.6;
/* Status bar at bottom */ color: var(--text-primary);
.morse-status-bar { word-wrap: break-word;
display: flex; }
justify-content: space-between;
align-items: center; .morse-decoded-panel:empty::before {
font-size: 11px; content: 'Decoded text will appear here...';
color: var(--text-dim); color: var(--text-dim);
padding: 6px 0; font-size: 14px;
border-top: 1px solid var(--border-color); font-style: italic;
margin-top: 8px; }
}
.morse-char {
.morse-status-bar .status-item { display: inline;
display: flex; animation: morseFadeIn 0.3s ease-out;
align-items: center; position: relative;
gap: 4px; }
}
@keyframes morseFadeIn {
/* Word space styling */ from {
.morse-word-space { opacity: 0;
display: inline; color: var(--accent-cyan);
width: 0.5em; }
} 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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3103,8 +3103,21 @@
</div> </div>
</div> </div>
<div id="morseDecodedText" class="morse-decoded-panel"></div> <div id="morseDecodedText" class="morse-decoded-panel"></div>
<div id="morseRawPanel" class="morse-raw-panel" style="display: none;">
<div class="morse-raw-label">Raw Elements</div>
<div id="morseRawText" class="morse-raw-text"></div>
</div>
<div id="morseMetricsPanel" class="morse-metrics-panel">
<span id="morseMetricState">STATE idle</span>
<span id="morseMetricTone">TONE -- Hz</span>
<span id="morseMetricLevel">LEVEL --</span>
<span id="morseMetricThreshold">THRESH --</span>
<span id="morseMetricNoise">NOISE --</span>
<span id="morseMetricStopMs">STOP -- ms</span>
</div>
<div class="morse-status-bar"> <div class="morse-status-bar">
<span class="status-item" id="morseStatusBarWpm">15 WPM</span> <span class="status-item" id="morseStatusBarState">IDLE</span>
<span class="status-item" id="morseStatusBarWpm">-- WPM</span>
<span class="status-item" id="morseStatusBarTone">700 Hz</span> <span class="status-item" id="morseStatusBarTone">700 Hz</span>
<span class="status-item" id="morseStatusBarChars">0 chars decoded</span> <span class="status-item" id="morseStatusBarChars">0 chars decoded</span>
</div> </div>
@@ -3863,6 +3876,11 @@
return { return {
pager: Boolean(isRunning), pager: Boolean(isRunning),
sensor: Boolean(isSensorRunning), sensor: Boolean(isSensorRunning),
morse: Boolean(
typeof MorseMode !== 'undefined'
&& typeof MorseMode.isActive === 'function'
&& MorseMode.isActive()
),
wifi: Boolean( wifi: Boolean(
((typeof WiFiMode !== 'undefined' && typeof WiFiMode.isScanning === 'function' && WiFiMode.isScanning()) || isWifiRunning) ((typeof WiFiMode !== 'undefined' && typeof WiFiMode.isScanning === 'function' && WiFiMode.isScanning()) || isWifiRunning)
), ),
@@ -3884,6 +3902,12 @@
if (isSensorRunning && typeof stopSensorDecoding === 'function') { if (isSensorRunning && typeof stopSensorDecoding === 'function') {
Promise.resolve(stopSensorDecoding()).catch(() => { }); 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 = ( const wifiScanActive = (
typeof WiFiMode !== 'undefined' typeof WiFiMode !== 'undefined'
@@ -4009,6 +4033,12 @@
if (isSensorRunning) { if (isSensorRunning) {
stopTasks.push(awaitStopAction('sensor', () => stopSensorDecoding(), LOCAL_STOP_TIMEOUT_MS)); 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 = ( const wifiScanActive = (
typeof WiFiMode !== 'undefined' typeof WiFiMode !== 'undefined'
&& typeof WiFiMode.isScanning === 'function' && typeof WiFiMode.isScanning === 'function'
@@ -4045,6 +4075,9 @@
if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') { if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') {
SubGhz.destroy(); SubGhz.destroy();
} }
if (typeof MorseMode !== 'undefined' && currentMode === 'morse' && mode !== 'morse' && typeof MorseMode.destroy === 'function') {
MorseMode.destroy();
}
currentMode = mode; currentMode = mode;
document.body.setAttribute('data-mode', mode); document.body.setAttribute('data-mode', mode);

View File

@@ -1,99 +1,166 @@
<!-- MORSE CODE MODE --> <!-- MORSE CODE MODE -->
<div id="morseMode" class="mode-content"> <div id="morseMode" class="mode-content">
<div class="section"> <div class="section">
<h3>CW/Morse Decoder</h3> <h3>CW/Morse Decoder</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;"> <p class="info-text morse-mode-help">
Decode CW (continuous wave) Morse code from amateur radio HF bands using USB demodulation Decode CW (continuous wave) Morse with USB demod + Goertzel tone detection.
and Goertzel tone detection. Start with 700 Hz tone and 200 Hz bandwidth.
</p> </p>
</div> </div>
<div class="section"> <div class="section">
<h3>Frequency</h3> <h3>Frequency</h3>
<div class="form-group"> <div class="form-group">
<label>Frequency (MHz)</label> <label>Frequency (MHz)</label>
<input type="number" id="morseFrequency" value="14.060" step="0.001" min="1" max="30" placeholder="e.g., 14.060"> <input type="number" id="morseFrequency" value="14.060" step="0.001" min="0.5" max="30" placeholder="e.g., 14.060">
<span class="help-text" style="font-size: 10px; color: var(--text-dim); margin-top: 2px; display: block;">Enter frequency in MHz (e.g., 7.030 for 40m CW)</span> <span class="help-text morse-help-text">Enter CW center frequency in MHz (e.g., 7.030 for 40m).</span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Band Presets</label> <label>Band Presets</label>
<div class="morse-presets" style="display: flex; flex-wrap: wrap; gap: 4px;"> <div class="morse-presets">
<button class="preset-btn" onclick="MorseMode.setFreq(3.560)">80m</button> <button class="preset-btn" onclick="MorseMode.setFreq(3.560)">80m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(7.030)">40m</button> <button class="preset-btn" onclick="MorseMode.setFreq(7.030)">40m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(10.116)">30m</button> <button class="preset-btn" onclick="MorseMode.setFreq(10.116)">30m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(14.060)">20m</button> <button class="preset-btn" onclick="MorseMode.setFreq(14.060)">20m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(18.080)">17m</button> <button class="preset-btn" onclick="MorseMode.setFreq(18.080)">17m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(21.060)">15m</button> <button class="preset-btn" onclick="MorseMode.setFreq(21.060)">15m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(24.910)">12m</button> <button class="preset-btn" onclick="MorseMode.setFreq(24.910)">12m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(28.060)">10m</button> <button class="preset-btn" onclick="MorseMode.setFreq(28.060)">10m</button>
</div> </div>
</div> </div>
</div> </div>
<div class="section"> <div class="section">
<h3>Settings</h3> <h3>Device</h3>
<div class="form-group"> <div class="form-group">
<label>Gain (dB)</label> <label>Gain (dB)</label>
<input type="number" id="morseGain" value="40" step="1" min="0" max="50"> <input type="number" id="morseGain" value="40" step="1" min="0" max="60">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>PPM Correction</label> <label>PPM Correction</label>
<input type="number" id="morsePPM" value="0" step="1" min="-100" max="100"> <input type="number" id="morsePPM" value="0" step="1" min="-200" max="200">
</div> </div>
</div> </div>
<div class="section"> <div class="section">
<h3>CW Settings</h3> <h3>CW Detector</h3>
<div class="form-group"> <div class="form-group">
<label>Tone Frequency: <span id="morseToneFreqLabel">700</span> Hz</label> <label>Tone Frequency: <span id="morseToneFreqLabel">700</span> Hz</label>
<input type="range" id="morseToneFreq" value="700" min="300" max="1200" step="10" <input type="range" id="morseToneFreq" value="700" min="300" max="1200" step="10"
oninput="document.getElementById('morseToneFreqLabel').textContent = this.value"> oninput="MorseMode.updateToneLabel(this.value)">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Speed: <span id="morseWpmLabel">15</span> WPM</label> <label>Bandwidth</label>
<input type="range" id="morseWpm" value="15" min="5" max="50" step="1" <select id="morseBandwidth">
oninput="document.getElementById('morseWpmLabel').textContent = this.value"> <option value="50">50 Hz</option>
</div> <option value="100">100 Hz</option>
</div> <option value="200" selected>200 Hz</option>
<option value="400">400 Hz</option>
<!-- Morse Reference --> </select>
<div class="section"> </div>
<h3 style="cursor: pointer;" onclick="this.parentElement.querySelector('.morse-ref-grid').classList.toggle('collapsed')"> <div class="form-group checkbox-group">
Morse Reference <span style="font-size: 10px; color: var(--text-dim);">(click to toggle)</span> <label><input type="checkbox" id="morseAutoToneTrack" checked> Auto Tone Track</label>
</h3> <label><input type="checkbox" id="morseToneLock"> Hold Tone Lock</label>
<div class="morse-ref-grid collapsed" style="font-family: var(--font-mono); font-size: 10px; line-height: 1.8; columns: 2; column-gap: 12px; color: var(--text-dim);"> </div>
<div>A .-</div><div>B -...</div><div>C -.-.</div><div>D -..</div> </div>
<div>E .</div><div>F ..-.</div><div>G --.</div><div>H ....</div>
<div>I ..</div><div>J .---</div><div>K -.-</div><div>L .-..</div> <div class="section">
<div>M --</div><div>N -.</div><div>O ---</div><div>P .--.</div> <h3>Threshold + WPM</h3>
<div>Q --.-</div><div>R .-.</div><div>S ...</div><div>T -</div> <div class="form-group">
<div>U ..-</div><div>V ...-</div><div>W .--</div><div>X -..-</div> <label>Threshold Mode</label>
<div>Y -.--</div><div>Z --..</div> <select id="morseThresholdMode" onchange="MorseMode.onThresholdModeChange()">
<div style="margin-top: 4px; border-top: 1px solid var(--border-color); padding-top: 4px;">0 -----</div> <option value="auto" selected>Auto</option>
<div style="margin-top: 4px; border-top: 1px solid var(--border-color); padding-top: 4px;">1 .----</div> <option value="manual">Manual</option>
<div>2 ..---</div><div>3 ...--</div><div>4 ....-</div> </select>
<div>5 .....</div><div>6 -....</div><div>7 --...</div> </div>
<div>8 ---..</div><div>9 ----.</div> <div class="form-group" id="morseThresholdAutoRow">
</div> <label>Threshold Multiplier</label>
</div> <input type="number" id="morseThresholdMultiplier" value="2.8" min="1.1" max="8" step="0.1">
</div>
<!-- Status --> <div class="form-group" id="morseThresholdOffsetRow">
<div class="section"> <label>Threshold Offset</label>
<div class="morse-status" style="display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim);"> <input type="number" id="morseThresholdOffset" value="0" min="0" step="0.1">
<span id="morseStatusIndicator" class="status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim);"></span> </div>
<span id="morseStatusText">Standby</span> <div class="form-group" id="morseManualThresholdRow" style="display: none;">
<span style="margin-left: auto;" id="morseCharCount">0 chars</span> <label>Manual Threshold</label>
</div> <input type="number" id="morseManualThreshold" value="0" min="0" step="0.1">
</div> </div>
<div class="form-group">
<!-- HF Antenna Note --> <label>Minimum Signal Gate</label>
<div class="section"> <input type="number" id="morseSignalGate" value="0.05" min="0" max="1" step="0.01">
<p class="info-text" style="font-size: 11px; color: #ffaa00; line-height: 1.5;"> </div>
CW operates on HF bands (1-30 MHz). Requires an HF-capable SDR with direct sampling <div class="form-group">
or an upconverter, plus an appropriate HF antenna (dipole, end-fed, or random wire). <label>WPM Mode</label>
</p> <select id="morseWpmMode" onchange="MorseMode.onWpmModeChange()">
</div> <option value="auto" selected>Auto</option>
<option value="manual">Manual</option>
<button class="run-btn" id="morseStartBtn" onclick="MorseMode.start()">Start Decoder</button> </select>
<button class="stop-btn" id="morseStopBtn" onclick="MorseMode.stop()" style="display: none;">Stop Decoder</button> </div>
</div> <div class="form-group" id="morseWpmManualRow" style="display: none;">
<label>Manual Speed: <span id="morseWpmLabel">15</span> WPM</label>
<input type="range" id="morseWpm" value="15" min="5" max="50" step="1"
oninput="MorseMode.updateWpmLabel(this.value)">
</div>
<div class="form-group checkbox-group">
<label><input type="checkbox" id="morseWpmLock"> Lock WPM Estimator</label>
</div>
</div>
<div class="section">
<h3>Output</h3>
<div class="form-group checkbox-group">
<label><input type="checkbox" id="morseShowRaw" checked> Show Raw Morse</label>
<label><input type="checkbox" id="morseShowDiag"> Show Decoder Logs</label>
</div>
<div class="morse-actions-row">
<button class="btn btn-sm btn-ghost" id="morseCalibrateBtn" onclick="MorseMode.calibrate()">Reset / Calibrate</button>
</div>
</div>
<div class="section">
<h3>Decode WAV File</h3>
<div class="morse-file-row">
<input type="file" id="morseFileInput" accept="audio/wav,.wav">
<button class="btn btn-sm btn-ghost" id="morseDecodeFileBtn" onclick="MorseMode.decodeFile()">Decode File</button>
</div>
<span class="help-text morse-help-text">Runs the same CW decoder pipeline against uploaded WAV audio.</span>
</div>
<div class="section">
<h3 style="cursor: pointer;" onclick="this.parentElement.querySelector('.morse-ref-grid').classList.toggle('collapsed')">
Morse Reference <span class="morse-ref-toggle">(click to toggle)</span>
</h3>
<div class="morse-ref-grid collapsed">
<div>A .-</div><div>B -...</div><div>C -.-.</div><div>D -..</div>
<div>E .</div><div>F ..-.</div><div>G --.</div><div>H ....</div>
<div>I ..</div><div>J .---</div><div>K -.-</div><div>L .-..</div>
<div>M --</div><div>N -.</div><div>O ---</div><div>P .--.</div>
<div>Q --.-</div><div>R .-.</div><div>S ...</div><div>T -</div>
<div>U ..-</div><div>V ...-</div><div>W .--</div><div>X -..-</div>
<div>Y -.--</div><div>Z --..</div>
<div class="morse-ref-divider">0 -----</div>
<div class="morse-ref-divider">1 .----</div>
<div>2 ..---</div><div>3 ...--</div><div>4 ....-</div>
<div>5 .....</div><div>6 -....</div><div>7 --...</div>
<div>8 ---..</div><div>9 ----.</div>
</div>
</div>
<div class="section">
<div class="morse-status">
<span id="morseStatusIndicator" class="status-dot"></span>
<span id="morseStatusText">Standby</span>
<span id="morseCharCount">0 chars</span>
</div>
</div>
<div class="section">
<p class="info-text morse-hf-note">
CW on HF (1-30 MHz) requires an HF-capable SDR path (direct sampling or upconverter)
and an appropriate antenna.
</p>
</div>
<button class="run-btn" id="morseStartBtn" onclick="MorseMode.start()">Start Decoder</button>
<button class="stop-btn" id="morseStopBtn" onclick="MorseMode.stop()" style="display: none;">Stop Decoder</button>
</div>

View File

@@ -1,467 +1,348 @@
"""Tests for Morse code decoder (utils/morse.py) and routes.""" """Tests for Morse code decoder pipeline and lifecycle routes."""
from __future__ import annotations from __future__ import annotations
import math import io
import queue import math
import struct import os
import threading import queue
import struct
import pytest import threading
import time
from utils.morse import ( import wave
CHAR_TO_MORSE, from collections import Counter
MORSE_TABLE,
GoertzelFilter, import pytest
MorseDecoder,
morse_decoder_thread, import app as app_module
) import routes.morse as morse_routes
from utils.morse import (
# --------------------------------------------------------------------------- CHAR_TO_MORSE,
# Helpers MORSE_TABLE,
# --------------------------------------------------------------------------- GoertzelFilter,
MorseDecoder,
def _login_session(client) -> None: decode_morse_wav_file,
"""Mark the Flask test session as authenticated.""" morse_decoder_thread,
with client.session_transaction() as sess: )
sess['logged_in'] = True
sess['username'] = 'test'
sess['role'] = 'admin' # ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
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.""" def _login_session(client) -> None:
n_samples = int(sample_rate * duration) """Mark the Flask test session as authenticated."""
samples = [] with client.session_transaction() as sess:
for i in range(n_samples): sess['logged_in'] = True
t = i / sample_rate sess['username'] = 'test'
val = int(amplitude * 32767 * math.sin(2 * math.pi * freq * t)) sess['role'] = 'admin'
samples.append(max(-32768, min(32767, val)))
return struct.pack(f'<{len(samples)}h', *samples)
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."""
def generate_silence(duration: float, sample_rate: int = 8000) -> bytes: n_samples = int(sample_rate * duration)
"""Generate silence as 16-bit LE PCM bytes.""" samples = []
n_samples = int(sample_rate * duration) for i in range(n_samples):
return b'\x00\x00' * n_samples t = i / sample_rate
val = int(amplitude * 32767 * math.sin(2 * math.pi * freq * t))
samples.append(max(-32768, min(32767, val)))
def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sample_rate: int = 8000) -> bytes: return struct.pack(f'<{len(samples)}h', *samples)
"""Generate PCM audio for a Morse-encoded string."""
dit_dur = 1.2 / wpm
dah_dur = 3 * dit_dur def generate_silence(duration: float, sample_rate: int = 8000) -> bytes:
element_gap = dit_dur """Generate silence as 16-bit LE PCM bytes."""
char_gap = 3 * dit_dur n_samples = int(sample_rate * duration)
word_gap = 7 * dit_dur return b'\x00\x00' * n_samples
audio = b''
words = text.upper().split() def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sample_rate: int = 8000) -> bytes:
for wi, word in enumerate(words): """Generate synthetic CW PCM for the given text."""
for ci, char in enumerate(word): dit_dur = 1.2 / wpm
morse = CHAR_TO_MORSE.get(char) dah_dur = 3 * dit_dur
if morse is None: element_gap = dit_dur
continue char_gap = 3 * dit_dur
for ei, element in enumerate(morse): word_gap = 7 * dit_dur
if element == '.':
audio += generate_tone(tone_freq, dit_dur, sample_rate) audio = b''
elif element == '-': words = text.upper().split()
audio += generate_tone(tone_freq, dah_dur, sample_rate) for wi, word in enumerate(words):
if ei < len(morse) - 1: for ci, char in enumerate(word):
audio += generate_silence(element_gap, sample_rate) morse = CHAR_TO_MORSE.get(char)
if ci < len(word) - 1: if morse is None:
audio += generate_silence(char_gap, sample_rate) continue
if wi < len(words) - 1:
audio += generate_silence(word_gap, sample_rate) for ei, element in enumerate(morse):
if element == '.':
# Add some leading/trailing silence for threshold settling audio += generate_tone(tone_freq, dit_dur, sample_rate)
silence = generate_silence(0.3, sample_rate) elif element == '-':
return silence + audio + silence audio += generate_tone(tone_freq, dah_dur, sample_rate)
if ei < len(morse) - 1:
# --------------------------------------------------------------------------- audio += generate_silence(element_gap, sample_rate)
# MORSE_TABLE tests
# --------------------------------------------------------------------------- if ci < len(word) - 1:
audio += generate_silence(char_gap, sample_rate)
class TestMorseTable:
def test_all_26_letters_present(self): if wi < len(words) - 1:
chars = set(MORSE_TABLE.values()) audio += generate_silence(word_gap, sample_rate)
for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
assert letter in chars, f"Missing letter: {letter}" # Leading/trailing silence for threshold settling.
return generate_silence(0.3, sample_rate) + audio + generate_silence(0.3, sample_rate)
def test_all_10_digits_present(self):
chars = set(MORSE_TABLE.values())
for digit in '0123456789': def write_wav(path, pcm_bytes: bytes, sample_rate: int = 8000) -> None:
assert digit in chars, f"Missing digit: {digit}" """Write mono 16-bit PCM bytes to a WAV file."""
with wave.open(str(path), 'wb') as wf:
def test_reverse_lookup_consistent(self): wf.setnchannels(1)
for morse, char in MORSE_TABLE.items(): wf.setsampwidth(2)
if char in CHAR_TO_MORSE: wf.setframerate(sample_rate)
assert CHAR_TO_MORSE[char] == morse wf.writeframes(pcm_bytes)
def test_no_duplicate_morse_codes(self):
"""Each morse pattern should map to exactly one character.""" def decode_text_from_events(events) -> str:
assert len(MORSE_TABLE) == len(set(MORSE_TABLE.keys())) out = []
for ev in events:
if ev.get('type') == 'morse_char':
# --------------------------------------------------------------------------- out.append(str(ev.get('char', '')))
# GoertzelFilter tests elif ev.get('type') == 'morse_space':
# --------------------------------------------------------------------------- out.append(' ')
return ''.join(out)
class TestGoertzelFilter:
def test_detects_target_frequency(self):
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160) # ---------------------------------------------------------------------------
# Generate 700 Hz tone # Unit tests
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}" class TestMorseTable:
def test_morse_table_contains_letters_and_digits(self):
def test_rejects_off_frequency(self): chars = set(MORSE_TABLE.values())
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160) for ch in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789':
# Generate 1500 Hz tone (well off target) assert ch in chars
samples = [0.8 * math.sin(2 * math.pi * 1500 * i / 8000) for i in range(160)]
mag_off = gf.magnitude(samples) def test_round_trip_morse_lookup(self):
for morse, char in MORSE_TABLE.items():
# Compare with on-target if char in CHAR_TO_MORSE:
samples_on = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(160)] assert CHAR_TO_MORSE[char] == morse
mag_on = gf.magnitude(samples_on)
assert mag_on > mag_off * 3, "Target freq should be significantly stronger than off-freq" class TestToneDetector:
def test_goertzel_prefers_target_frequency(self):
def test_silence_returns_near_zero(self): gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
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)]
samples = [0.0] * 160 off_tone = [0.8 * math.sin(2 * math.pi * 1500.0 * i / 8000.0) for i in range(160)]
mag = gf.magnitude(samples) assert gf.magnitude(on_tone) > gf.magnitude(off_tone) * 3.0
assert mag < 0.01, f"Expected near-zero for silence, got {mag}"
def test_different_block_sizes(self): class TestTimingAndWpmEstimator:
for block_size in [80, 160, 320]: def test_timing_classifier_distinguishes_dit_and_dah(self):
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=block_size) decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(block_size)] dit = 1.2 / 15.0
mag = gf.magnitude(samples) dah = dit * 3.0
assert mag > 5.0, f"Should detect tone with block_size={block_size}"
audio = (
generate_silence(0.35)
# --------------------------------------------------------------------------- + generate_tone(700.0, dit)
# MorseDecoder tests + generate_silence(dit * 1.5)
# --------------------------------------------------------------------------- + generate_tone(700.0, dah)
+ generate_silence(0.35)
class TestMorseDecoder: )
def _make_decoder(self, wpm=15):
"""Create decoder with warm-up phase completed for testing. events = decoder.process_block(audio)
events.extend(decoder.flush())
Feeds silence then tone then silence to get past the warm-up elements = [e['element'] for e in events if e.get('type') == 'morse_element']
blocks and establish a valid noise floor / signal peak.
""" assert '.' in elements
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=wpm) assert '-' in elements
# Feed enough audio to get past warm-up (50 blocks = 1 sec)
# Mix silence and tone so warm-up sees both noise and signal def test_wpm_estimator_sanity(self):
warmup_audio = generate_silence(0.6) + generate_tone(700.0, 0.4) + generate_silence(0.5) target_wpm = 18
decoder.process_block(warmup_audio) audio = generate_morse_audio('PARIS PARIS PARIS', wpm=target_wpm)
# Reset state machine after warm-up so tests start clean decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=12, wpm_mode='auto')
decoder._tone_on = False
decoder._current_symbol = '' events = decoder.process_block(audio)
decoder._tone_blocks = 0 events.extend(decoder.flush())
decoder._silence_blocks = 0
return decoder metrics = decoder.get_metrics()
assert metrics['wpm'] >= 12.0
def test_dit_detection(self): assert metrics['wpm'] <= 24.0
"""A single dit should produce a '.' in the symbol buffer."""
decoder = self._make_decoder()
dit_dur = 1.2 / 15 # ---------------------------------------------------------------------------
# Decoder thread tests
# Send a tone burst (dit) # ---------------------------------------------------------------------------
tone = generate_tone(700.0, dit_dur)
decoder.process_block(tone) class TestMorseDecoderThread:
def test_thread_emits_waiting_heartbeat_on_no_data(self):
# Send silence to trigger end of tone stop_event = threading.Event()
silence = generate_silence(dit_dur * 2) output_queue = queue.Queue(maxsize=64)
decoder.process_block(silence)
read_fd, write_fd = os.pipe()
# Symbol buffer should have a dot read_file = os.fdopen(read_fd, 'rb', 0)
assert '.' in decoder._current_symbol, f"Expected '.' in symbol, got '{decoder._current_symbol}'"
worker = threading.Thread(
def test_dah_detection(self): target=morse_decoder_thread,
"""A longer tone should produce a '-' in the symbol buffer.""" args=(read_file, output_queue, stop_event),
decoder = self._make_decoder() daemon=True,
dah_dur = 3 * 1.2 / 15 )
worker.start()
tone = generate_tone(700.0, dah_dur)
decoder.process_block(tone) got_waiting = False
deadline = time.monotonic() + 3.5
silence = generate_silence(dah_dur) while time.monotonic() < deadline:
decoder.process_block(silence) try:
msg = output_queue.get(timeout=0.3)
assert '-' in decoder._current_symbol, f"Expected '-' in symbol, got '{decoder._current_symbol}'" except queue.Empty:
continue
def test_decode_letter_e(self): if msg.get('type') == 'scope' and msg.get('waiting'):
"""E is a single dit - the simplest character.""" got_waiting = True
decoder = self._make_decoder() break
audio = generate_morse_audio('E', wpm=15)
events = decoder.process_block(audio) stop_event.set()
events.extend(decoder.flush()) os.close(write_fd)
read_file.close()
chars = [e for e in events if e['type'] == 'morse_char'] worker.join(timeout=2.0)
decoded = ''.join(e['char'] for e in chars)
assert 'E' in decoded, f"Expected 'E' in decoded text, got '{decoded}'" assert got_waiting is True
assert not worker.is_alive()
def test_decode_letter_t(self):
"""T is a single dah.""" def test_thread_produces_character_events(self):
decoder = self._make_decoder() stop_event = threading.Event()
audio = generate_morse_audio('T', wpm=15) output_queue = queue.Queue(maxsize=512)
events = decoder.process_block(audio) audio = generate_morse_audio('SOS', wpm=15)
events.extend(decoder.flush())
worker = threading.Thread(
chars = [e for e in events if e['type'] == 'morse_char'] target=morse_decoder_thread,
decoded = ''.join(e['char'] for e in chars) args=(io.BytesIO(audio), output_queue, stop_event),
assert 'T' in decoded, f"Expected 'T' in decoded text, got '{decoded}'" daemon=True,
)
def test_word_space_detection(self): worker.start()
"""A long silence between words should produce decoded chars with a space.""" worker.join(timeout=4.0)
decoder = self._make_decoder()
dit_dur = 1.2 / 15 events = []
# E = dit while not output_queue.empty():
audio = generate_tone(700.0, dit_dur) + generate_silence(7 * dit_dur * 1.5) events.append(output_queue.get_nowait())
# T = dah
audio += generate_tone(700.0, 3 * dit_dur) + generate_silence(3 * dit_dur) chars = [e for e in events if e.get('type') == 'morse_char']
events = decoder.process_block(audio) assert len(chars) >= 1
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" # Route lifecycle regression
# ---------------------------------------------------------------------------
def test_scope_events_generated(self):
"""Decoder should produce scope events for visualization.""" class TestMorseLifecycleRoutes:
audio = generate_morse_audio('SOS', wpm=15) def _reset_route_state(self):
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) with app_module.morse_lock:
app_module.morse_process = None
events = decoder.process_block(audio) while not app_module.morse_queue.empty():
try:
scope_events = [e for e in events if e['type'] == 'scope'] app_module.morse_queue.get_nowait()
assert len(scope_events) > 0, "Expected scope events" except queue.Empty:
# Check scope event structure break
se = scope_events[0]
assert 'amplitudes' in se morse_routes.morse_active_device = None
assert 'threshold' in se morse_routes.morse_decoder_worker = None
assert 'tone_on' in se morse_routes.morse_stderr_worker = None
morse_routes.morse_stop_event = None
def test_adaptive_threshold_adjusts(self): morse_routes.morse_control_queue = None
"""After processing enough audio to complete warm-up, threshold should be non-zero.""" morse_routes.morse_runtime_config = {}
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) morse_routes.morse_last_error = ''
morse_routes.morse_state = morse_routes.MORSE_IDLE
# Feed enough audio to complete the 50-block warm-up (~1 second) morse_routes.morse_state_message = 'Idle'
audio = generate_silence(0.6) + generate_tone(700.0, 0.4) + generate_silence(0.3)
decoder.process_block(audio) def test_start_stop_reaches_idle_and_releases_resources(self, client, monkeypatch):
_login_session(client)
assert decoder._threshold > 0, "Threshold should adapt above zero after warm-up" self._reset_route_state()
def test_flush_emits_pending_char(self): released_devices = []
"""flush() should emit any accumulated but not-yet-decoded symbol."""
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
decoder._current_symbol = '.' # Manually set pending dit monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
events = decoder.flush()
assert len(events) == 1 class DummyDevice:
assert events[0]['type'] == 'morse_char' sdr_type = morse_routes.SDRType.RTL_SDR
assert events[0]['char'] == 'E'
class DummyBuilder:
def test_flush_empty_returns_nothing(self): def build_fm_demod_command(self, **kwargs):
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) return ['rtl_fm', '-f', '14060000']
events = decoder.flush()
assert events == [] 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()))
def test_weak_signal_detection(self): monkeypatch.setattr(morse_routes.time, 'sleep', lambda _secs: None)
"""CW tone at only 3x noise magnitude should still decode characters."""
decoder = self._make_decoder(wpm=10) pcm = generate_morse_audio('E', wpm=15)
# Generate weak CW audio (low amplitude simulating weak HF signal)
audio = generate_morse_audio('SOS', wpm=10, sample_rate=8000) class FakeProc:
# Scale to low amplitude (simulating weak signal) def __init__(self):
n_samples = len(audio) // 2 self.stdout = io.BytesIO(pcm)
samples = struct.unpack(f'<{n_samples}h', audio) self.stderr = io.BytesIO(b'')
# Reduce to ~10% amplitude self.returncode = None
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) def poll(self):
return self.returncode
events = decoder.process_block(weak_audio)
events.extend(decoder.flush()) monkeypatch.setattr(morse_routes.subprocess, 'Popen', lambda *args, **kwargs: FakeProc())
monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
chars = [e for e in events if e['type'] == 'morse_char'] monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
decoded = ''.join(e['char'] for e in chars) monkeypatch.setattr(
# Should decode at least some characters from the weak signal morse_routes,
assert len(chars) >= 1, f"Expected decoded chars from weak signal, got '{decoded}'" 'safe_terminate',
lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
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) start_resp = client.post('/morse/start', json={
# Generate very quiet tone 'frequency': '14.060',
quiet_tone = generate_tone(700.0, 1.5, amplitude=0.01) # 1.5s of very quiet CW 'gain': '20',
events = decoder.process_block(quiet_tone) 'ppm': '0',
'device': '0',
scope_events = [e for e in events if e['type'] == 'scope'] 'tone_freq': '700',
assert len(scope_events) > 0, "Expected scope events from quiet signal" 'wpm': '15',
# AGC should have boosted the signal — amplitudes should be visible })
max_amp = max(max(se['amplitudes']) for se in scope_events) assert start_resp.status_code == 200
assert max_amp > 1.0, f"AGC should boost quiet signal to usable magnitude, got {max_amp}" assert start_resp.get_json()['status'] == 'started'
status_resp = client.get('/morse/status')
# --------------------------------------------------------------------------- assert status_resp.status_code == 200
# morse_decoder_thread tests assert status_resp.get_json()['state'] in {'running', 'starting', 'stopping', 'idle'}
# ---------------------------------------------------------------------------
stop_resp = client.post('/morse/stop')
class TestMorseDecoderThread: assert stop_resp.status_code == 200
def test_thread_stops_on_event(self): stop_data = stop_resp.get_json()
"""Thread should exit when stop_event is set.""" assert stop_data['status'] == 'stopped'
import io assert stop_data['state'] == 'idle'
# Create a fake stdout that blocks until stop assert stop_data['alive'] == []
stop = threading.Event()
q = queue.Queue(maxsize=100) final_status = client.get('/morse/status').get_json()
assert final_status['running'] is False
# Feed some audio then close assert final_status['state'] == 'idle'
audio = generate_morse_audio('E', wpm=15) assert 0 in released_devices
fake_stdout = io.BytesIO(audio)
t = threading.Thread( # ---------------------------------------------------------------------------
target=morse_decoder_thread, # Integration: synthetic CW -> WAV decode
args=(fake_stdout, q, stop), # ---------------------------------------------------------------------------
)
t.daemon = True class TestMorseIntegration:
t.start() def test_decode_morse_wav_contains_expected_phrase(self, tmp_path):
t.join(timeout=5) wav_path = tmp_path / 'cq_test_123.wav'
assert not t.is_alive(), "Thread should finish after reading all data" pcm = generate_morse_audio('CQ TEST 123', wpm=15, tone_freq=700.0)
write_wav(wav_path, pcm, sample_rate=8000)
def test_thread_heartbeat_on_no_data(self):
"""When rtl_fm produces no data, thread should emit waiting scope events.""" result = decode_morse_wav_file(
import os as _os wav_path,
stop = threading.Event() sample_rate=8000,
q = queue.Queue(maxsize=100) tone_freq=700.0,
wpm=15,
# Create a pipe that never gets written to (simulates rtl_fm with no output) bandwidth_hz=200,
read_fd, write_fd = _os.pipe() auto_tone_track=True,
read_file = _os.fdopen(read_fd, 'rb', 0) threshold_mode='auto',
wpm_mode='auto',
t = threading.Thread( min_signal_gate=0.0,
target=morse_decoder_thread, )
args=(read_file, q, stop),
) decoded = ' '.join(str(result.get('text', '')).split())
t.daemon = True assert 'CQ TEST 123' in decoded
t.start()
events = result.get('events', [])
# Wait up to 5 seconds for at least one heartbeat event event_counts = Counter(e.get('type') for e in events)
events = [] assert event_counts['morse_char'] >= len('CQTEST123')
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')

File diff suppressed because it is too large Load Diff