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 64a0cf44a4
commit 41e115a0e4
8 changed files with 3453 additions and 1816 deletions
+22
View File
@@ -55,6 +55,28 @@ Support the developer of this open-source project
--- ---
## 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 ## Installation / Debian / Ubuntu / MacOS
**1. Clone and run:** **1. Clone and run:**
+562 -154
View File
@@ -5,8 +5,10 @@ from __future__ import annotations
import contextlib import contextlib
import queue import queue
import subprocess import subprocess
import tempfile
import threading import threading
import time import time
from pathlib import Path
from typing import Any from typing import Any
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
@@ -14,7 +16,7 @@ from flask import Blueprint, Response, jsonify, request
import app as app_module import app as app_module
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.morse import morse_decoder_thread from utils.morse import decode_morse_wav_file, morse_decoder_thread
from utils.process import register_process, safe_terminate, unregister_process from utils.process import register_process, safe_terminate, unregister_process
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
from utils.sse import sse_stream_fanout from utils.sse import sse_stream_fanout
@@ -30,16 +32,101 @@ morse_bp = Blueprint('morse', __name__)
# Track which device is being used # Track which device is being used
morse_active_device: int | None = None 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: def _validate_tone_freq(value: Any) -> float:
"""Validate CW tone frequency (300-1200 Hz).""" """Validate CW tone frequency (300-1200 Hz)."""
try: try:
freq = float(value) freq = float(value)
if not 300 <= freq <= 1200: if not 300 <= freq <= 1200:
raise ValueError("Tone frequency must be between 300 and 1200 Hz") raise ValueError('Tone frequency must be between 300 and 1200 Hz')
return freq return freq
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
raise ValueError(f"Invalid tone frequency: {value}") from e raise ValueError(f'Invalid tone frequency: {value}') from e
def _validate_wpm(value: Any) -> int: def _validate_wpm(value: Any) -> int:
@@ -47,41 +134,118 @@ def _validate_wpm(value: Any) -> int:
try: try:
wpm = int(value) wpm = int(value)
if not 5 <= wpm <= 50: if not 5 <= wpm <= 50:
raise ValueError("WPM must be between 5 and 50") raise ValueError('WPM must be between 5 and 50')
return wpm return wpm
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
raise ValueError(f"Invalid WPM: {value}") from 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']) @morse_bp.route('/morse/start', methods=['POST'])
def start_morse() -> Response: def start_morse() -> Response:
global morse_active_device 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: with app_module.morse_lock:
if app_module.morse_process: if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}:
return jsonify({'status': 'error', 'message': 'Morse decoder already running'}), 409 return jsonify({
'status': 'error',
data = request.json or {} 'message': f'Morse decoder is {morse_state}',
'state': morse_state,
# Validate standard SDR inputs }), 409
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 # Claim SDR device
device_int = int(device) device_int = int(device)
@@ -92,169 +256,402 @@ def start_morse() -> Response:
'error_type': 'DEVICE_BUSY', 'error_type': 'DEVICE_BUSY',
'message': error, 'message': error,
}), 409 }), 409
morse_active_device = device_int morse_active_device = device_int
morse_last_error = ''
morse_session_id += 1
# Clear queue _drain_queue(app_module.morse_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 _set_state(MORSE_STARTING, 'Starting decoder...')
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) sample_rate = 8000
builder = SDRFactory.get_builder(sdr_device.sdr_type) bias_t = _bool_value(data.get('bias_t', False), False)
sample_rate = 8000 # RTL-SDR needs direct sampling mode for HF frequencies below 24 MHz
bias_t = data.get('bias_t', False) direct_sampling = 2 if freq < 24.0 else None
# RTL-SDR needs direct sampling mode for HF frequencies below 24 MHz sdr_type_str = data.get('sdr_type', 'rtlsdr')
direct_sampling = 2 if freq < 24.0 else None try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
rtl_cmd = builder.build_fm_demod_command( sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
device=sdr_device, builder = SDRFactory.get_builder(sdr_device.sdr_type)
frequency_mhz=freq,
sample_rate=sample_rate, rtl_cmd = builder.build_fm_demod_command(
gain=float(gain) if gain and gain != '0' else None, device=sdr_device,
ppm=int(ppm) if ppm and ppm != '0' else None, frequency_mhz=freq,
modulation='usb', sample_rate=sample_rate,
bias_t=bias_t, gain=float(gain) if gain and gain != '0' else None,
direct_sampling=direct_sampling, 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)
full_cmd = ' '.join(rtl_cmd) stop_event = threading.Event()
logger.info(f"Morse decoder running: {full_cmd}") control_queue: queue.Queue = queue.Queue(maxsize=16)
try: def monitor_stderr() -> None:
rtl_process = subprocess.Popen( if not rtl_process or rtl_process.stderr is None:
rtl_cmd, return
stdout=subprocess.PIPE, for line in rtl_process.stderr:
stderr=subprocess.PIPE, if stop_event.is_set():
bufsize=0, break
) err_text = line.decode('utf-8', errors='replace').strip()
register_process(rtl_process) 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}',
})
# Start threads IMMEDIATELY so stdout is read before pipe fills. stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr')
# Forward rtl_fm stderr to queue so frontend can display diagnostics stderr_thread.start()
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) decoder_thread = threading.Thread(
stderr_thread.daemon = True target=morse_decoder_thread,
stderr_thread.start() 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()
# Start Morse decoder thread before sleep so it reads stdout immediately # Detect immediate startup failure (e.g. device busy, no device)
stop_event = threading.Event() time.sleep(0.30)
decoder_thread = threading.Thread( if rtl_process.poll() is not None:
target=morse_decoder_thread, stop_event.set()
args=( stderr_text = ''
rtl_process.stdout, try:
app_module.morse_queue, if rtl_process.stderr:
stop_event, stderr_text = rtl_process.stderr.read().decode('utf-8', errors='replace').strip()
sample_rate, except Exception:
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 = '' stderr_text = ''
try: msg = stderr_text or f'rtl_fm exited immediately (code {rtl_process.returncode})'
if rtl_process.stderr: logger.error(f'Morse rtl_fm startup failed: {msg}')
stderr_text = rtl_process.stderr.read().decode( safe_terminate(rtl_process, timeout=0.4)
'utf-8', errors='replace' unregister_process(rtl_process)
).strip() _join_thread(decoder_thread, timeout_s=0.25)
except Exception: _join_thread(stderr_thread, timeout_s=0.25)
stderr_text = '' with app_module.morse_lock:
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: if morse_active_device is not None:
app_module.release_sdr_device(morse_active_device) app_module.release_sdr_device(morse_active_device)
morse_active_device = None morse_active_device = None
return jsonify({'status': 'error', 'message': msg}), 500 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 = rtl_process
app_module.morse_process._stop_decoder = stop_event app_module.morse_process._stop_decoder = stop_event
app_module.morse_process._decoder_thread = decoder_thread app_module.morse_process._decoder_thread = decoder_thread
app_module.morse_process._stderr_thread = stderr_thread
app_module.morse_process._control_queue = control_queue
app_module.morse_queue.put({'type': 'status', 'status': 'started'}) morse_stop_event = stop_event
with contextlib.suppress(queue.Full): morse_control_queue = control_queue
app_module.morse_queue.put_nowait({ morse_decoder_worker = decoder_thread
'type': 'info', morse_stderr_worker = stderr_thread
'text': f'[cmd] {full_cmd}', morse_runtime_config = dict(runtime_config)
}) _set_state(MORSE_RUNNING, 'Listening')
return jsonify({ with contextlib.suppress(queue.Full):
'status': 'started', app_module.morse_queue.put_nowait({
'command': full_cmd, 'type': 'info',
'tone_freq': tone_freq, 'text': f'[cmd] {full_cmd}',
'wpm': wpm,
}) })
except FileNotFoundError as e: return jsonify({
if morse_active_device is not None: 'status': 'started',
app_module.release_sdr_device(morse_active_device) 'state': MORSE_RUNNING,
morse_active_device = None 'command': full_cmd,
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}), 400 'tone_freq': tone_freq,
'wpm': wpm,
'config': runtime_config,
'session_id': morse_session_id,
})
except Exception as e: except FileNotFoundError as e:
# Clean up rtl_fm if it was started if rtl_process is not None:
try:
rtl_process.terminate()
rtl_process.wait(timeout=2)
except Exception:
with contextlib.suppress(Exception):
rtl_process.kill()
unregister_process(rtl_process) unregister_process(rtl_process)
with app_module.morse_lock:
if morse_active_device is not None: if morse_active_device is not None:
app_module.release_sdr_device(morse_active_device) app_module.release_sdr_device(morse_active_device)
morse_active_device = None morse_active_device = None
return jsonify({'status': 'error', 'message': str(e)}), 500 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']) @morse_bp.route('/morse/stop', methods=['POST'])
def stop_morse() -> Response: def stop_morse() -> Response:
global morse_active_device 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: with app_module.morse_lock:
if app_module.morse_process: if morse_state == MORSE_STOPPING:
# Signal decoder thread to stop return jsonify({'status': 'stopping', 'state': MORSE_STOPPING}), 202
stop_event = getattr(app_module.morse_process, '_stop_decoder', None)
if stop_event:
stop_event.set()
safe_terminate(app_module.morse_process) proc = app_module.morse_process
unregister_process(app_module.morse_process) stop_event = morse_stop_event or getattr(proc, '_stop_decoder', None)
app_module.morse_process = 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 morse_active_device is not None: if not proc and not stop_event and not decoder_thread and not stderr_thread:
app_module.release_sdr_device(morse_active_device) _set_state(MORSE_IDLE, 'Idle', enqueue=False)
morse_active_device = None return jsonify({'status': 'not_running', 'state': MORSE_IDLE})
app_module.morse_queue.put({'type': 'status', 'status': 'stopped'}) # Prevent new starts while cleanup is in progress.
return jsonify({'status': 'stopped'}) _set_state(MORSE_STOPPING, 'Stopping decoder...')
return jsonify({'status': 'not_running'}) # 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') @morse_bp.route('/morse/status')
@@ -263,8 +660,19 @@ def morse_status() -> Response:
running = ( running = (
app_module.morse_process is not None app_module.morse_process is not None
and app_module.morse_process.poll() is None and app_module.morse_process.poll() is None
and morse_state in {MORSE_RUNNING, MORSE_STARTING, MORSE_STOPPING}
) )
return jsonify({'running': running}) 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') @morse_bp.route('/morse/stream')
+128 -45
View File
@@ -1,22 +1,87 @@
/* 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-scope-container canvas { .morse-help-text {
width: 100%; margin-top: 4px;
height: 80px;
display: block; display: block;
border-radius: 4px;
} }
/* Decoded text panel */ .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 { .morse-decoded-panel {
background: var(--bg-primary); background: var(--bg-primary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -39,7 +104,6 @@
font-style: italic; font-style: italic;
} }
/* Individual decoded character with fade-in */
.morse-char { .morse-char {
display: inline; display: inline;
animation: morseFadeIn 0.3s ease-out; animation: morseFadeIn 0.3s ease-out;
@@ -57,43 +121,55 @@
} }
} }
/* Small Morse notation above character */ .morse-word-space {
.morse-char-morse { display: inline;
font-size: 9px; width: 0.5em;
color: var(--text-dim);
letter-spacing: 1px;
display: block;
line-height: 1;
margin-bottom: -2px;
} }
/* Reference grid */ .morse-raw-panel {
.morse-ref-grid { margin-top: 8px;
transition: max-height 0.3s ease, opacity 0.3s ease; padding: 8px;
max-height: 500px; border: 1px solid #1a1a2e;
opacity: 1; border-radius: 4px;
overflow: hidden; background: #080812;
} }
.morse-ref-grid.collapsed { .morse-raw-label {
max-height: 0; font-size: 10px;
opacity: 0; letter-spacing: 0.08em;
text-transform: uppercase;
color: #667;
margin-bottom: 4px;
} }
/* Toolbar: export/copy/clear */ .morse-raw-text {
.morse-toolbar { min-height: 30px;
display: flex; max-height: 90px;
gap: 6px; overflow-y: auto;
margin-bottom: 8px; font-family: var(--font-mono);
flex-wrap: wrap;
}
.morse-toolbar .btn {
font-size: 11px; font-size: 11px;
padding: 4px 10px; 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);
} }
/* Status bar at bottom */
.morse-status-bar { .morse-status-bar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -103,6 +179,8 @@
padding: 6px 0; padding: 6px 0;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
margin-top: 8px; margin-top: 8px;
gap: 8px;
flex-wrap: wrap;
} }
.morse-status-bar .status-item { .morse-status-bar .status-item {
@@ -111,8 +189,13 @@
gap: 4px; gap: 4px;
} }
/* Word space styling */ @media (max-width: 768px) {
.morse-word-space { .morse-metrics-panel {
display: inline; grid-template-columns: repeat(2, minmax(0, 1fr));
width: 0.5em; }
.morse-file-row {
flex-direction: column;
align-items: stretch;
}
} }
+939 -254
View File
File diff suppressed because it is too large Load Diff
+34 -1
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);
+94 -27
View File
@@ -2,9 +2,9 @@
<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>
@@ -12,12 +12,12 @@
<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>
@@ -31,37 +31,106 @@
</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>
<option value="100">100 Hz</option>
<option value="200" selected>200 Hz</option>
<option value="400">400 Hz</option>
</select>
</div>
<div class="form-group checkbox-group">
<label><input type="checkbox" id="morseAutoToneTrack" checked> Auto Tone Track</label>
<label><input type="checkbox" id="morseToneLock"> Hold Tone Lock</label>
</div> </div>
</div> </div>
<!-- Morse Reference --> <div class="section">
<h3>Threshold + WPM</h3>
<div class="form-group">
<label>Threshold Mode</label>
<select id="morseThresholdMode" onchange="MorseMode.onThresholdModeChange()">
<option value="auto" selected>Auto</option>
<option value="manual">Manual</option>
</select>
</div>
<div class="form-group" id="morseThresholdAutoRow">
<label>Threshold Multiplier</label>
<input type="number" id="morseThresholdMultiplier" value="2.8" min="1.1" max="8" step="0.1">
</div>
<div class="form-group" id="morseThresholdOffsetRow">
<label>Threshold Offset</label>
<input type="number" id="morseThresholdOffset" value="0" min="0" step="0.1">
</div>
<div class="form-group" id="morseManualThresholdRow" style="display: none;">
<label>Manual Threshold</label>
<input type="number" id="morseManualThreshold" value="0" min="0" step="0.1">
</div>
<div class="form-group">
<label>Minimum Signal Gate</label>
<input type="number" id="morseSignalGate" value="0.05" min="0" max="1" step="0.01">
</div>
<div class="form-group">
<label>WPM Mode</label>
<select id="morseWpmMode" onchange="MorseMode.onWpmModeChange()">
<option value="auto" selected>Auto</option>
<option value="manual">Manual</option>
</select>
</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"> <div class="section">
<h3 style="cursor: pointer;" onclick="this.parentElement.querySelector('.morse-ref-grid').classList.toggle('collapsed')"> <h3 style="cursor: pointer;" onclick="this.parentElement.querySelector('.morse-ref-grid').classList.toggle('collapsed')">
Morse Reference <span style="font-size: 10px; color: var(--text-dim);">(click to toggle)</span> Morse Reference <span class="morse-ref-toggle">(click to toggle)</span>
</h3> </h3>
<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 class="morse-ref-grid collapsed">
<div>A .-</div><div>B -...</div><div>C -.-.</div><div>D -..</div> <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>E .</div><div>F ..-.</div><div>G --.</div><div>H ....</div>
<div>I ..</div><div>J .---</div><div>K -.-</div><div>L .-..</div> <div>I ..</div><div>J .---</div><div>K -.-</div><div>L .-..</div>
@@ -69,28 +138,26 @@
<div>Q --.-</div><div>R .-.</div><div>S ...</div><div>T -</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>U ..-</div><div>V ...-</div><div>W .--</div><div>X -..-</div>
<div>Y -.--</div><div>Z --..</div> <div>Y -.--</div><div>Z --..</div>
<div style="margin-top: 4px; border-top: 1px solid var(--border-color); padding-top: 4px;">0 -----</div> <div class="morse-ref-divider">0 -----</div>
<div style="margin-top: 4px; border-top: 1px solid var(--border-color); padding-top: 4px;">1 .----</div> <div class="morse-ref-divider">1 .----</div>
<div>2 ..---</div><div>3 ...--</div><div>4 ....-</div> <div>2 ..---</div><div>3 ...--</div><div>4 ....-</div>
<div>5 .....</div><div>6 -....</div><div>7 --...</div> <div>5 .....</div><div>6 -....</div><div>7 --...</div>
<div>8 ---..</div><div>9 ----.</div> <div>8 ---..</div><div>9 ----.</div>
</div> </div>
</div> </div>
<!-- Status -->
<div class="section"> <div class="section">
<div class="morse-status" style="display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim);"> <div class="morse-status">
<span id="morseStatusIndicator" class="status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim);"></span> <span id="morseStatusIndicator" class="status-dot"></span>
<span id="morseStatusText">Standby</span> <span id="morseStatusText">Standby</span>
<span style="margin-left: auto;" id="morseCharCount">0 chars</span> <span id="morseCharCount">0 chars</span>
</div> </div>
</div> </div>
<!-- HF Antenna Note -->
<div class="section"> <div class="section">
<p class="info-text" style="font-size: 11px; color: #ffaa00; line-height: 1.5;"> <p class="info-text morse-hf-note">
CW operates on HF bands (1-30 MHz). Requires an HF-capable SDR with direct sampling CW on HF (1-30 MHz) requires an HF-capable SDR path (direct sampling or upconverter)
or an upconverter, plus an appropriate HF antenna (dipole, end-fed, or random wire). and an appropriate antenna.
</p> </p>
</div> </div>
+223 -342
View File
@@ -1,22 +1,31 @@
"""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 io
import math import math
import os
import queue import queue
import struct import struct
import threading import threading
import time
import wave
from collections import Counter
import pytest import pytest
import app as app_module
import routes.morse as morse_routes
from utils.morse import ( from utils.morse import (
CHAR_TO_MORSE, CHAR_TO_MORSE,
MORSE_TABLE, MORSE_TABLE,
GoertzelFilter, GoertzelFilter,
MorseDecoder, MorseDecoder,
decode_morse_wav_file,
morse_decoder_thread, morse_decoder_thread,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -47,7 +56,7 @@ def generate_silence(duration: float, sample_rate: int = 8000) -> bytes:
def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sample_rate: int = 8000) -> bytes: def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sample_rate: int = 8000) -> bytes:
"""Generate PCM audio for a Morse-encoded string.""" """Generate synthetic CW PCM for the given text."""
dit_dur = 1.2 / wpm dit_dur = 1.2 / wpm
dah_dur = 3 * dit_dur dah_dur = 3 * dit_dur
element_gap = dit_dur element_gap = dit_dur
@@ -61,407 +70,279 @@ def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sam
morse = CHAR_TO_MORSE.get(char) morse = CHAR_TO_MORSE.get(char)
if morse is None: if morse is None:
continue continue
for ei, element in enumerate(morse): for ei, element in enumerate(morse):
if element == '.': if element == '.':
audio += generate_tone(tone_freq, dit_dur, sample_rate) audio += generate_tone(tone_freq, dit_dur, sample_rate)
elif element == '-': elif element == '-':
audio += generate_tone(tone_freq, dah_dur, sample_rate) audio += generate_tone(tone_freq, dah_dur, sample_rate)
if ei < len(morse) - 1: if ei < len(morse) - 1:
audio += generate_silence(element_gap, sample_rate) audio += generate_silence(element_gap, sample_rate)
if ci < len(word) - 1: if ci < len(word) - 1:
audio += generate_silence(char_gap, sample_rate) audio += generate_silence(char_gap, sample_rate)
if wi < len(words) - 1: if wi < len(words) - 1:
audio += generate_silence(word_gap, sample_rate) audio += generate_silence(word_gap, sample_rate)
# Add some leading/trailing silence for threshold settling # Leading/trailing silence for threshold settling.
silence = generate_silence(0.3, sample_rate) return generate_silence(0.3, sample_rate) + audio + generate_silence(0.3, sample_rate)
return silence + audio + silence
def write_wav(path, pcm_bytes: bytes, sample_rate: int = 8000) -> None:
"""Write mono 16-bit PCM bytes to a WAV file."""
with wave.open(str(path), 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(pcm_bytes)
def decode_text_from_events(events) -> str:
out = []
for ev in events:
if ev.get('type') == 'morse_char':
out.append(str(ev.get('char', '')))
elif ev.get('type') == 'morse_space':
out.append(' ')
return ''.join(out)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# MORSE_TABLE tests # Unit tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMorseTable: class TestMorseTable:
def test_all_26_letters_present(self): def test_morse_table_contains_letters_and_digits(self):
chars = set(MORSE_TABLE.values()) chars = set(MORSE_TABLE.values())
for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ': for ch in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789':
assert letter in chars, f"Missing letter: {letter}" assert ch in chars
def test_all_10_digits_present(self): def test_round_trip_morse_lookup(self):
chars = set(MORSE_TABLE.values())
for digit in '0123456789':
assert digit in chars, f"Missing digit: {digit}"
def test_reverse_lookup_consistent(self):
for morse, char in MORSE_TABLE.items(): for morse, char in MORSE_TABLE.items():
if char in CHAR_TO_MORSE: if char in CHAR_TO_MORSE:
assert CHAR_TO_MORSE[char] == morse assert CHAR_TO_MORSE[char] == morse
def test_no_duplicate_morse_codes(self):
"""Each morse pattern should map to exactly one character."""
assert len(MORSE_TABLE) == len(set(MORSE_TABLE.keys()))
class TestToneDetector:
# --------------------------------------------------------------------------- def test_goertzel_prefers_target_frequency(self):
# GoertzelFilter tests
# ---------------------------------------------------------------------------
class TestGoertzelFilter:
def test_detects_target_frequency(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)
# Generate 700 Hz tone on_tone = [0.8 * math.sin(2 * math.pi * 700.0 * i / 8000.0) for i in range(160)]
samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(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 > 10.0, f"Expected high magnitude for target freq, got {mag}"
def test_rejects_off_frequency(self):
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
# Generate 1500 Hz tone (well off target)
samples = [0.8 * math.sin(2 * math.pi * 1500 * i / 8000) for i in range(160)]
mag_off = gf.magnitude(samples)
# Compare with on-target class TestTimingAndWpmEstimator:
samples_on = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(160)] def test_timing_classifier_distinguishes_dit_and_dah(self):
mag_on = gf.magnitude(samples_on) decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
dit = 1.2 / 15.0
dah = dit * 3.0
assert mag_on > mag_off * 3, "Target freq should be significantly stronger than off-freq" audio = (
generate_silence(0.35)
+ generate_tone(700.0, dit)
+ generate_silence(dit * 1.5)
+ generate_tone(700.0, dah)
+ generate_silence(0.35)
)
def test_silence_returns_near_zero(self): events = decoder.process_block(audio)
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160) events.extend(decoder.flush())
samples = [0.0] * 160 elements = [e['element'] for e in events if e.get('type') == 'morse_element']
mag = gf.magnitude(samples)
assert mag < 0.01, f"Expected near-zero for silence, got {mag}"
def test_different_block_sizes(self): assert '.' in elements
for block_size in [80, 160, 320]: assert '-' in elements
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=block_size)
samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(block_size)] def test_wpm_estimator_sanity(self):
mag = gf.magnitude(samples) target_wpm = 18
assert mag > 5.0, f"Should detect tone with block_size={block_size}" audio = generate_morse_audio('PARIS PARIS PARIS', wpm=target_wpm)
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=12, wpm_mode='auto')
events = decoder.process_block(audio)
events.extend(decoder.flush())
metrics = decoder.get_metrics()
assert metrics['wpm'] >= 12.0
assert metrics['wpm'] <= 24.0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# MorseDecoder tests # Decoder thread tests
# ---------------------------------------------------------------------------
class TestMorseDecoder:
def _make_decoder(self, wpm=15):
"""Create decoder with warm-up phase completed for testing.
Feeds silence then tone then silence to get past the warm-up
blocks and establish a valid noise floor / signal peak.
"""
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=wpm)
# Feed enough audio to get past warm-up (50 blocks = 1 sec)
# Mix silence and tone so warm-up sees both noise and signal
warmup_audio = generate_silence(0.6) + generate_tone(700.0, 0.4) + generate_silence(0.5)
decoder.process_block(warmup_audio)
# Reset state machine after warm-up so tests start clean
decoder._tone_on = False
decoder._current_symbol = ''
decoder._tone_blocks = 0
decoder._silence_blocks = 0
return decoder
def test_dit_detection(self):
"""A single dit should produce a '.' in the symbol buffer."""
decoder = self._make_decoder()
dit_dur = 1.2 / 15
# Send a tone burst (dit)
tone = generate_tone(700.0, dit_dur)
decoder.process_block(tone)
# Send silence to trigger end of tone
silence = generate_silence(dit_dur * 2)
decoder.process_block(silence)
# Symbol buffer should have a dot
assert '.' in decoder._current_symbol, f"Expected '.' in symbol, got '{decoder._current_symbol}'"
def test_dah_detection(self):
"""A longer tone should produce a '-' in the symbol buffer."""
decoder = self._make_decoder()
dah_dur = 3 * 1.2 / 15
tone = generate_tone(700.0, dah_dur)
decoder.process_block(tone)
silence = generate_silence(dah_dur)
decoder.process_block(silence)
assert '-' in decoder._current_symbol, f"Expected '-' in symbol, got '{decoder._current_symbol}'"
def test_decode_letter_e(self):
"""E is a single dit - the simplest character."""
decoder = self._make_decoder()
audio = generate_morse_audio('E', wpm=15)
events = decoder.process_block(audio)
events.extend(decoder.flush())
chars = [e for e in events if e['type'] == 'morse_char']
decoded = ''.join(e['char'] for e in chars)
assert 'E' in decoded, f"Expected 'E' in decoded text, got '{decoded}'"
def test_decode_letter_t(self):
"""T is a single dah."""
decoder = self._make_decoder()
audio = generate_morse_audio('T', wpm=15)
events = decoder.process_block(audio)
events.extend(decoder.flush())
chars = [e for e in events if e['type'] == 'morse_char']
decoded = ''.join(e['char'] for e in chars)
assert 'T' in decoded, f"Expected 'T' in decoded text, got '{decoded}'"
def test_word_space_detection(self):
"""A long silence between words should produce decoded chars with a space."""
decoder = self._make_decoder()
dit_dur = 1.2 / 15
# E = dit
audio = generate_tone(700.0, dit_dur) + generate_silence(7 * dit_dur * 1.5)
# T = dah
audio += generate_tone(700.0, 3 * dit_dur) + generate_silence(3 * dit_dur)
events = decoder.process_block(audio)
events.extend(decoder.flush())
spaces = [e for e in events if e['type'] == 'morse_space']
assert len(spaces) >= 1, "Expected at least one word space"
def test_scope_events_generated(self):
"""Decoder should produce scope events for visualization."""
audio = generate_morse_audio('SOS', wpm=15)
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
events = decoder.process_block(audio)
scope_events = [e for e in events if e['type'] == 'scope']
assert len(scope_events) > 0, "Expected scope events"
# Check scope event structure
se = scope_events[0]
assert 'amplitudes' in se
assert 'threshold' in se
assert 'tone_on' in se
def test_adaptive_threshold_adjusts(self):
"""After processing enough audio to complete warm-up, threshold should be non-zero."""
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
# Feed enough audio to complete the 50-block warm-up (~1 second)
audio = generate_silence(0.6) + generate_tone(700.0, 0.4) + generate_silence(0.3)
decoder.process_block(audio)
assert decoder._threshold > 0, "Threshold should adapt above zero after warm-up"
def test_flush_emits_pending_char(self):
"""flush() should emit any accumulated but not-yet-decoded symbol."""
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
decoder._current_symbol = '.' # Manually set pending dit
events = decoder.flush()
assert len(events) == 1
assert events[0]['type'] == 'morse_char'
assert events[0]['char'] == 'E'
def test_flush_empty_returns_nothing(self):
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
events = decoder.flush()
assert events == []
def test_weak_signal_detection(self):
"""CW tone at only 3x noise magnitude should still decode characters."""
decoder = self._make_decoder(wpm=10)
# Generate weak CW audio (low amplitude simulating weak HF signal)
audio = generate_morse_audio('SOS', wpm=10, sample_rate=8000)
# Scale to low amplitude (simulating weak signal)
n_samples = len(audio) // 2
samples = struct.unpack(f'<{n_samples}h', audio)
# Reduce to ~10% amplitude
weak_samples = [max(-32768, min(32767, int(s * 0.1))) for s in samples]
weak_audio = struct.pack(f'<{len(weak_samples)}h', *weak_samples)
events = decoder.process_block(weak_audio)
events.extend(decoder.flush())
chars = [e for e in events if e['type'] == 'morse_char']
decoded = ''.join(e['char'] for e in chars)
# Should decode at least some characters from the weak signal
assert len(chars) >= 1, f"Expected decoded chars from weak signal, got '{decoded}'"
def test_agc_boosts_quiet_signal(self):
"""Very quiet PCM (amplitude 0.01) should still produce usable Goertzel magnitudes."""
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
# Generate very quiet tone
quiet_tone = generate_tone(700.0, 1.5, amplitude=0.01) # 1.5s of very quiet CW
events = decoder.process_block(quiet_tone)
scope_events = [e for e in events if e['type'] == 'scope']
assert len(scope_events) > 0, "Expected scope events from quiet signal"
# AGC should have boosted the signal — amplitudes should be visible
max_amp = max(max(se['amplitudes']) for se in scope_events)
assert max_amp > 1.0, f"AGC should boost quiet signal to usable magnitude, got {max_amp}"
# ---------------------------------------------------------------------------
# morse_decoder_thread tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMorseDecoderThread: class TestMorseDecoderThread:
def test_thread_stops_on_event(self): def test_thread_emits_waiting_heartbeat_on_no_data(self):
"""Thread should exit when stop_event is set.""" stop_event = threading.Event()
import io output_queue = queue.Queue(maxsize=64)
# Create a fake stdout that blocks until stop
stop = threading.Event()
q = queue.Queue(maxsize=100)
# Feed some audio then close read_fd, write_fd = os.pipe()
audio = generate_morse_audio('E', wpm=15) read_file = os.fdopen(read_fd, 'rb', 0)
fake_stdout = io.BytesIO(audio)
t = threading.Thread( worker = threading.Thread(
target=morse_decoder_thread, target=morse_decoder_thread,
args=(fake_stdout, q, stop), args=(read_file, output_queue, stop_event),
daemon=True,
) )
t.daemon = True worker.start()
t.start()
t.join(timeout=5)
assert not t.is_alive(), "Thread should finish after reading all data"
def test_thread_heartbeat_on_no_data(self): got_waiting = False
"""When rtl_fm produces no data, thread should emit waiting scope events.""" deadline = time.monotonic() + 3.5
import os as _os while time.monotonic() < deadline:
stop = threading.Event()
q = queue.Queue(maxsize=100)
# Create a pipe that never gets written to (simulates rtl_fm with no output)
read_fd, write_fd = _os.pipe()
read_file = _os.fdopen(read_fd, 'rb', 0)
t = threading.Thread(
target=morse_decoder_thread,
args=(read_file, q, stop),
)
t.daemon = True
t.start()
# Wait up to 5 seconds for at least one heartbeat event
events = []
import time as _time
deadline = _time.monotonic() + 5.0
while _time.monotonic() < deadline:
try: try:
ev = q.get(timeout=0.5) msg = output_queue.get(timeout=0.3)
events.append(ev)
if ev.get('waiting'):
break
except queue.Empty: except queue.Empty:
continue continue
if msg.get('type') == 'scope' and msg.get('waiting'):
got_waiting = True
break
stop.set() stop_event.set()
_os.close(write_fd) os.close(write_fd)
read_file.close() read_file.close()
t.join(timeout=3) worker.join(timeout=2.0)
waiting_events = [e for e in events if e.get('type') == 'scope' and e.get('waiting')] assert got_waiting is True
assert len(waiting_events) >= 1, f"Expected waiting heartbeat events, got {events}" assert not worker.is_alive()
ev = waiting_events[0]
assert ev['amplitudes'] == []
assert ev['threshold'] == 0
assert ev['tone_on'] is False
def test_thread_produces_events(self): def test_thread_produces_character_events(self):
"""Thread should push character events to the queue.""" stop_event = threading.Event()
import io output_queue = queue.Queue(maxsize=512)
from unittest.mock import patch audio = generate_morse_audio('SOS', wpm=15)
stop = threading.Event()
q = queue.Queue(maxsize=1000)
# Generate audio with pre-warmed decoder in mind worker = threading.Thread(
# The thread creates a fresh decoder, so generate lots of audio target=morse_decoder_thread,
audio = generate_silence(0.5) + generate_morse_audio('SOS', wpm=10) + generate_silence(1.0) args=(io.BytesIO(audio), output_queue, stop_event),
fake_stdout = io.BytesIO(audio) daemon=True,
)
# Patch SCOPE_INTERVAL to 0 so scope events aren't throttled in fast reads worker.start()
with patch('utils.morse.time') as mock_time: worker.join(timeout=4.0)
# 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 = [] events = []
while not q.empty(): while not output_queue.empty():
events.append(q.get_nowait()) events.append(output_queue.get_nowait())
# Should have at least some events (scope or char) chars = [e for e in events if e.get('type') == 'morse_char']
assert len(events) > 0, "Expected events from thread" assert len(chars) >= 1
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Route tests # Route lifecycle regression
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMorseRoutes: class TestMorseLifecycleRoutes:
def test_start_missing_required_fields(self, client): def _reset_route_state(self):
"""Start should succeed with defaults.""" with app_module.morse_lock:
_login_session(client) app_module.morse_process = None
with pytest.MonkeyPatch.context() as m: while not app_module.morse_queue.empty():
m.setattr('app.morse_process', None) try:
# Should fail because rtl_fm won't be found in test env app_module.morse_queue.get_nowait()
resp = client.post('/morse/start', json={'frequency': '14.060'}) except queue.Empty:
assert resp.status_code in (200, 400, 409, 500) break
def test_stop_when_not_running(self, client): morse_routes.morse_active_device = None
"""Stop when nothing is running should return not_running.""" morse_routes.morse_decoder_worker = None
_login_session(client) morse_routes.morse_stderr_worker = None
with pytest.MonkeyPatch.context() as m: morse_routes.morse_stop_event = None
m.setattr('app.morse_process', None) morse_routes.morse_control_queue = None
resp = client.post('/morse/stop') morse_routes.morse_runtime_config = {}
data = resp.get_json() morse_routes.morse_last_error = ''
assert data['status'] == 'not_running' morse_routes.morse_state = morse_routes.MORSE_IDLE
morse_routes.morse_state_message = 'Idle'
def test_status_when_not_running(self, client): def test_start_stop_reaches_idle_and_releases_resources(self, client, monkeypatch):
"""Status should report not running."""
_login_session(client) _login_session(client)
with pytest.MonkeyPatch.context() as m: self._reset_route_state()
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): released_devices = []
"""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): monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
"""WPM outside range should be rejected.""" monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
_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): class DummyDevice:
"""Stream endpoint should return SSE content type.""" sdr_type = morse_routes.SDRType.RTL_SDR
_login_session(client)
resp = client.get('/morse/stream') class DummyBuilder:
assert resp.content_type.startswith('text/event-stream') def build_fm_demod_command(self, **kwargs):
return ['rtl_fm', '-f', '14060000']
monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice()))
monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
monkeypatch.setattr(morse_routes.time, 'sleep', lambda _secs: None)
pcm = generate_morse_audio('E', wpm=15)
class FakeProc:
def __init__(self):
self.stdout = io.BytesIO(pcm)
self.stderr = io.BytesIO(b'')
self.returncode = None
def poll(self):
return self.returncode
monkeypatch.setattr(morse_routes.subprocess, 'Popen', lambda *args, **kwargs: FakeProc())
monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
monkeypatch.setattr(
morse_routes,
'safe_terminate',
lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
)
start_resp = client.post('/morse/start', json={
'frequency': '14.060',
'gain': '20',
'ppm': '0',
'device': '0',
'tone_freq': '700',
'wpm': '15',
})
assert start_resp.status_code == 200
assert start_resp.get_json()['status'] == 'started'
status_resp = client.get('/morse/status')
assert status_resp.status_code == 200
assert status_resp.get_json()['state'] in {'running', 'starting', 'stopping', 'idle'}
stop_resp = client.post('/morse/stop')
assert stop_resp.status_code == 200
stop_data = stop_resp.get_json()
assert stop_data['status'] == 'stopped'
assert stop_data['state'] == 'idle'
assert stop_data['alive'] == []
final_status = client.get('/morse/status').get_json()
assert final_status['running'] is False
assert final_status['state'] == 'idle'
assert 0 in released_devices
# ---------------------------------------------------------------------------
# Integration: synthetic CW -> WAV decode
# ---------------------------------------------------------------------------
class TestMorseIntegration:
def test_decode_morse_wav_contains_expected_phrase(self, tmp_path):
wav_path = tmp_path / 'cq_test_123.wav'
pcm = generate_morse_audio('CQ TEST 123', wpm=15, tone_freq=700.0)
write_wav(wav_path, pcm, sample_rate=8000)
result = decode_morse_wav_file(
wav_path,
sample_rate=8000,
tone_freq=700.0,
wpm=15,
bandwidth_hz=200,
auto_tone_track=True,
threshold_mode='auto',
wpm_mode='auto',
min_signal_gate=0.0,
)
decoded = ' '.join(str(result.get('text', '')).split())
assert 'CQ TEST 123' in decoded
events = result.get('events', [])
event_counts = Counter(e.get('type') for e in events)
assert event_counts['morse_char'] >= len('CQTEST123')
+650 -192
View File
File diff suppressed because it is too large Load Diff