- Replace blocking stdout.read() with select()-based non-blocking reads
so the decode thread responds to stop within 0.5s
- Make stop() non-blocking by releasing the lock before terminating the
process and removing the redundant wait()
- Move initial scanning SSE event from start() into the decode thread so
it fires after the frontend EventSource connects
- Update frontend stop() to give immediate UI feedback before the fetch
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
rtl_fm subprocess failures (missing tool, no SDR hardware) were silent —
add tool-path check and post-spawn health check in _start_pipeline(),
show errors prominently in the strip status bar (red text + red dot),
and include error detail in scheduler skip events.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Flash the Start button itself with amber pulse when clicked without a
station selected, and show "Select Station" in the strip status text
right next to the button so the error is immediately visible.
Add a 24-hour timeline bar with broadcast window markers, red UTC time
cursor, and countdown boxes (HRS/MIN/SEC) that tick down to the next
broadcast. Broadcasts show as amber blocks on the timeline track with
imminent/active visual states matching the weather satellite pattern.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fix silent failure when starting without station/frequency selected by
flashing amber on status text and dropdowns. Add auto-capture scheduler
that uses fixed UTC broadcast schedules from station data to
automatically start/stop WeFax decoding at broadcast times.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement HF radiofax decoding with custom Python DSP pipeline
(rtl_fm USB → Goertzel/Hilbert demodulation), 33-station database
with broadcast schedules, audio waveform scope, live image preview,
and decoded image gallery. Amber/gold UI theme for HF distinction.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1. Stop Monitor button was disabled during shared monitor retunes
because _syncMonitorButtons disabled the button whenever
_startingMonitor was true, even if the monitor was already active.
Now only disables during initial start (not retunes).
2. Click-to-tune was inconsistent because the shared monitor retune
(rearm after capture restart) captured the center frequency early
in _startMonitorInternal, then sent it via POST to /audio/start.
If the user clicked a new frequency during the async reconnect,
the POST carried the stale frequency and could override the click.
Now retunes use the live _monitorFreqMhz and send a WS tune sync
after reconnecting to ensure the backend has the latest VFO.
When restarting capture for a new frequency, the USB handle from the
just-killed process may not be released by the kernel in time for the
rtl_test probe inside claim_sdr_device. Add retry logic (up to 4
attempts with 0.4s backoff) matching the pattern already used by the
audio start endpoint.
Also clean up stale shared-monitor state in the frontend error handler
so the monitor button is not left disabled when the capture restart
fails.
When changing frequency with shared monitor active, the monitor retune
could be silently dropped if a previous retune was still in-flight,
leaving the UI stuck on "Starting <freq>". After stopping and restarting
the waterfall, the monitor button could remain disabled because
_startingMonitor was never reset and _monitorRetuneTimer was not cleared.
- Cancel in-flight monitor start when queuing a new retune
- Always clear _pendingSharedMonitorRearm in started handler
- Clear _monitorRetuneTimer and reset _startingMonitor in stop()
stop() sets _ws = null before the async onclose fires, so the handler
now early-returns when _ws is null instead of showing the misleading
"WebSocket closed before ready" retry message.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- _waitForPlayback now only succeeds on playing/timeupdate events, not
loadeddata/canplay which fire from just the WAV header before real
audio arrives
- stopMonitor() pauses audio and updates UI immediately instead of
blocking on the backend stop request (1+ second delay)
- Reduced backend audio stop sleep from 1.0s to 0.15s; the start
retry loop already handles USB contention
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Waterfall: load waterfall.css eagerly in <head> instead of lazily on
mode switch; the lazy inject raced with the panel becoming visible,
leaving unstyled HTML for up to 20 s on cold cache
- WebSDR: await a requestAnimationFrame before calling Globe()(mapEl) so
the browser has committed the display:flex layout and clientWidth/
clientHeight are non-zero; previously the globe WebGL renderer was
created at 0×0 (especially on warm-cache refreshes) and could not
recover via the deferred resize calls
- Bump version to 2.22.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hit area: was Math.max(dotSize * 2, 15) — up to 24px radius around a 4px
dot. Now the CSS hover-flicker is fixed the large hit area is unnecessary
and was the reason dots activated when merely nearby. Changed to dotSize + 4
(proportional, 4px padding around the visual circle).
Overlap spread: compute all band positions first, then run an iterative
push-apart pass (spreadOverlappingDots) that nudges any two dots whose
arc gap is smaller than 2 * maxHitArea + 2px apart. Positions within a
band are stable across renders (same hash angle, same band = same output
before spreading) so dots don't shuffle on every update.
Z-order: sort visible devices by rssi_current ascending before rendering
so the strongest signal lands last in SVG order and receives clicks when
dots stack.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace continuous estimated_distance_m-based radius with proximity band
snapping (immediate/near/far/unknown → fixed radius ratios of 0.15/0.40/
0.70/0.90). The proximity_band is computed server-side from rssi_ema which
is already smoothed, so it changes infrequently — dots now only move when
a device genuinely crosses a band boundary rather than on every RSSI
fluctuation.
Also removes the client-side EMA and positionCache added in the previous
commit, and reverts CSS style.transform back to SVG transform attribute to
avoid coordinate-system mismatch when the SVG is displayed at a scaled size.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The remaining jitter after the in-place DOM rewrite was caused by RSSI
fluctuations propagating directly into dot positions on every 200ms
update cycle.
Two fixes:
1. Client-side EMA (alpha=0.25) on x/y coordinates per device. Each
render blends 25% toward the new raw position and retains 75% of the
smoothed position, filtering high-frequency RSSI noise without hiding
genuine distance changes. positionCache is keyed by device_key and
cleared on device removal or radar reset.
2. CSS transition (transform 0.6s ease-out) on each wrapper element.
Switching from SVG transform attribute to style.transform enables
native CSS transitions, so any remaining position change (e.g. a band
crossing) animates smoothly rather than snapping.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Instead of rebuilding devicesGroup.innerHTML on every render, mutate
existing SVG elements in-place (update transforms, attributes, class
names) and only create/remove elements when devices genuinely appear
or disappear from the visible set.
This eliminates the root cause of both the jitter and the blank-radar
regression: hover state can never be disrupted by a render because the
DOM elements under the cursor are never destroyed. The isHovered /
renderPending / interactionLockUntil state machine and its associated
mouseover/mouseout listeners are removed entirely — they are no longer
needed. A shared buildSelectRing() helper deduplicates the animated
selection ring construction used by renderDevices() and
applySelectionToElement(). Closes#143.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace capture-phase mouseenter/mouseleave with bubbling mouseover/mouseout
for tracking hover state in the ProximityRadar component.
The capture-phase approach caused two problems:
1. Moving between sibling child elements (hit-area → dot circle) fired
mouseleave, prematurely clearing isHovered and triggering a DOM rebuild
that caused visible jitter.
2. When renderDevices() rebuilt innerHTML, the browser fired mouseleave for
the destroyed element with relatedTarget pointing at the newly created
element at the same position, leaving isHovered permanently stuck at true
and suppressing all future renders.
The fix uses mouseover/mouseout (which bubble) with devicesGroup.contains()
to reliably detect whether the cursor genuinely left the device group, immune
to innerHTML rebuilds. Fixes both WiFi and Bluetooth proximity radars as they
share this component. Closes#143.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>