mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Fix Morse mode lifecycle stop hangs and rebuild CW decoder
This commit is contained in:
34
README.md
34
README.md
@@ -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
|
||||||
|
|||||||
984
routes/morse.py
984
routes/morse.py
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')
|
|
||||||
|
|||||||
1202
utils/morse.py
1202
utils/morse.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user