/** * ws.js — WebSocket client + Opus audio decoder (Web Audio API) * * Opus audio pipeline (RX — receiving from the radio): * Server → Opus binary frames (WS) → OpusDecoderWorker (WASM) → PCM → AudioContext * * Opus audio pipeline (TX — transmitting via the radio): * Microphone → MediaStream → ScriptProcessor/AudioWorklet → PCM → opusEncoder (JS WASM) → WS → server */ (function() { 'use strict'; const S = window.AppState; let ws = null; let reconnectTimer = null; // DIAGNOSTICS: PerformanceObserver on 'longtask' - the browser itself // reports EVERY main JS thread block >50ms, regardless of the cause. Used // to settle the question "is a WS badge spike JS jank or a real network // delay (LTE)" - if there's NO entry here at the moment of a WS spike, // the JS thread was not slow and the cause is network/server-side, not frontend. // Compare the timestamp with the "[ws] HIGH RTT" warnings (case 'pong' below). if (window.PerformanceObserver) { try { new PerformanceObserver((list) => { for (const e of list.getEntries()) { const t = new Date(); console.warn(`[longtask] ${Math.round(e.duration)}ms at ${t.toLocaleTimeString('pl-PL', {hour12:false})}.${String(t.getMilliseconds()).padStart(3,'0')} (start offset ${Math.round(e.startTime)}ms from navigation)`); } }).observe({ entryTypes: ['longtask'] }); } catch(e) { /* longtask API unavailable in this browser */ } } // ── Audio (Opus RX) ─────────────────────────────────────────────────────────── let audioCtx = null; Object.defineProperty(window, 'audioCtx', { get: () => audioCtx, set: v => { audioCtx = v; } }); let audioQueue = []; let isPlayingAudio = false; let opusWorker = null; // Web Worker with the opus-decoder WASM decoder window._audioEnabled = false; // Picks the RX output device (saved choice > first non-virtual real // device > 'default') and applies it to the shared audioCtx. Called at // context creation AND on every 'devicechange' event (see // initAudioContext) so a headphone plug/unplug mid-session actually // re-routes audio instead of leaving it stuck on whatever was the // default at page-load time. function pickOutputSinkId() { return navigator.mediaDevices.enumerateDevices().then(devs => { const outputs = devs.filter(d => d.kind === 'audiooutput'); const saved = localStorage.getItem('ham_audio_sinkId'); const savedStillExists = saved && outputs.some(d => d.deviceId === saved); const nonVB = outputs.find(d => d.deviceId !== 'default' && d.deviceId !== 'communications' && !d.label.toLowerCase().includes('virtual') && !d.label.toLowerCase().includes('cable') ); return savedStillExists ? saved : ((nonVB && nonVB.deviceId) || 'default'); }); } window.pickOutputSinkId = pickOutputSinkId; // Applies the shared headphones/speaker choice to an arbitrary // AudioContext — used here for the main RX context, and externally // (tx_eq.js's SSB monitor, cw_sidetone.js) so the MON button's local // monitor follows the SAME device as normal RX instead of always // landing on whatever the browser considers its own separate "default". function applyOutputSinkId(ctx) { if (!ctx || !ctx.setSinkId) return Promise.resolve(); return pickOutputSinkId().then(target => ctx.setSinkId(target).then(() => target) ).catch(e => { console.warn('[audio] setSinkId error:', e); }); } window.applyOutputSinkId = applyOutputSinkId; function _applyOutputSinkId() { applyOutputSinkId(audioCtx).then(target => { if (target) console.log('[audio] sinkId set:', target); }); } // Re-applies the output device to the RX context AND to the SSB/CW // local-monitor contexts (if currently active) on the same event, so a // headphone plug/unplug mid-session re-routes MON the same way it // re-routes normal RX audio, not just the RX path alone. function _onOutputDeviceChange() { _applyOutputSinkId(); window.TxEq?.reapplyOutputSink?.(); window.CwSidetone?.reapplyOutputSink?.(); } function initAudioContext() { if (audioCtx) { if (audioCtx.state === 'suspended') audioCtx.resume(); return; } try { audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 48000, }); window._masterGain = audioCtx.createGain(); window._masterGain.gain.value = 0.7; // fallback until the real per-user level lands below // _masterGain is created HERE, only once audio actually starts (user // gesture / autoplay policy) — that is, AFTER initRxVol() already // tried once to apply the saved level (app:ready, see below in this // file). At that point _masterGain didn't exist yet, so setRxVol() // only updated the slider in the UI while the real gain stayed at // the hardcoded 0.7 from the line above — the slider looked // remembered, but the volume always reverted to 70% anyway. Calling // it again now, once _masterGain already exists, applies the saved // per-user value to the actual sound. window.AudioControls?.initRxVol?.(); // Mute RX during OUR OWN TX (FT8/FT4). With MONI enabled, the radio // puts its TX signal on the USB-out — in the browser you hear // squealing tones of your own transmission (fatiguing). Duck: gain=0 // during TX, restore the previous level afterward. Called from // wsjtx.js (ft8_tx_status). window.setTxAudioDuck = function(on) { const g = window._masterGain; if (!g) return; if (window._duckUnmuteTimer) { clearTimeout(window._duckUnmuteTimer); window._duckUnmuteTimer = null; } if (on) { if (window._duckPrevGain == null) window._duckPrevGain = g.gain.value; g.gain.value = 0.0; } else if (window._duckPrevGain != null) { // FIX: un-muting used to happen the INSTANT PTT went off. With MONI // enabled the radio's own TX audio rides the RX stream, which sits // behind the RX jitter buffer (_audioTarget, ~180-400ms, below) — // so what's about to play right after PTT-off isn't "now", it's // whatever was captured _audioTarget ago, i.e. still the tail end // of what we just said. Restoring gain instantly surfaced that // queued tail as "hearing myself with a delay right after I // stopped talking" (reported live). Hold the mute for one more // buffer-length so that stale audio drains silently first. const delayMs = Math.round((_audioTarget || 0.18) * 1000); window._duckUnmuteTimer = setTimeout(() => { window._duckUnmuteTimer = null; if (window._duckPrevGain != null) { g.gain.value = window._duckPrevGain; window._duckPrevGain = null; } }, delayMs); } }; window._audioCompressor = audioCtx.createDynamicsCompressor(); window._audioCompressor.threshold.value = -24; window._audioCompressor.knee.value = 10; window._audioCompressor.ratio.value = 6; window._audioCompressor.attack.value = 0.003; window._audioCompressor.release.value = 0.15; window._masterGain.connect(window._audioCompressor); window._audioCompressor.connect(audioCtx.destination); if (audioCtx.state === 'suspended') audioCtx.resume(); // Set the output device (not VB-Cable) // We look for Realtek/Speakers as "default" — not communications/VB if (audioCtx.setSinkId) { _applyOutputSinkId(); // FIX: reported live - RX audio (incl. the SSB MONI echo) kept // playing through the laptop's built-in speakers even with // headphones plugged in and PROFIL's output device left on // "Domyślne" (default). Root cause: an AudioContext's implicit // 'default' sink does NOT automatically follow later OS // default-output changes in Chromium - it stays bound to whatever // was the default at the moment the context was created (page // load), so plugging in headphones AFTER that point doesn't // re-route audio that's already flowing. Re-applying setSinkId // on every 'devicechange' event (headphones plugged/unplugged, // any output added/removed) forces it to pick up the CURRENT // default instead of a stale one from page-load time. if (navigator.mediaDevices?.addEventListener) { navigator.mediaDevices.addEventListener('devicechange', _onOutputDeviceChange); } } } catch(e) { console.warn('[audio] AudioContext error:', e); } } // Load the per-user RX volume and TX GAIN after login window.addEventListener('app:ready', () => { window.AudioControls?.initRxVol?.(); window.AudioControls?.initTxGain?.(); }); // REVERTED (2026-08-21) - this used to auto-start the TX microphone // stream right after login so PTT alone would be enough for SSB. Live // test: as soon as the mic stream opened (getUserMedia), RX audio went // silent/near-silent for the WHOLE session, not just during PTT. Root // cause is a Windows OS setting, not this app - Control Panel > Sound > // Communications tab has "Reduce the volume of other sounds by 80%" / // "Mute all other sounds" as options, and Windows applies that to ALL // other system audio (including our RX playback) for as long as ANY app // holds the microphone open - which an always-on mic does permanently, // not just while actually transmitting. Back to manual: the "Nadawanie // TX - mikrofon" button in RADIO must be clicked once before SSB, same // as before this session. If the user sets that Windows option to "Do // nothing", auto-start can safely come back - ask before re-adding it. // (click, touch, key) — the browser's autoplay policy function _resumeAudioOnGesture() { if (audioCtx) { audioCtx.resume(); } else { initAudioContext(); } document.removeEventListener('click', _resumeAudioOnGesture); document.removeEventListener('touchstart', _resumeAudioOnGesture); document.removeEventListener('keydown', _resumeAudioOnGesture); } document.addEventListener('click', _resumeAudioOnGesture, { once: true }); document.addEventListener('touchstart', _resumeAudioOnGesture, { once: true }); document.addEventListener('keydown', _resumeAudioOnGesture, { once: true }); // Browsers create the AudioContext in 'suspended' state if there's no // prior user gesture (click/touch/key) — since RX audio now turns on // automatically when the WS connects (no button), the context can get // stuck in 'suspended' even though frames are decoding correctly. We // resume it on the first interaction with the page. let _audioResumeBound = false; function _bindAudioResumeOnFirstInteraction() { if (_audioResumeBound) return; _audioResumeBound = true; const resume = () => { if (audioCtx && audioCtx.state === 'suspended') { audioCtx.resume().then(() => console.log('[audio] AudioContext resumed after interaction')); } }; ['click', 'keydown', 'touchstart'].forEach(ev => document.addEventListener(ev, resume, { once: true, passive: true })); } // Opus decoder via the Web Codecs AudioDecoder API (native, no WASM) // Confirmed working: 960 samples/frame, 48kHz, 20ms frame function initOpusDecoder() { if (!window.AudioDecoder) { console.warn('[audio] AudioDecoder (Web Codecs) unavailable in this browser'); window._opusDecoder = null; return; } try { const dec = new AudioDecoder({ output: (frame) => { if (!audioCtx || frame.numberOfFrames === 0) { frame.close(); return; } try { const buf = audioCtx.createBuffer( frame.numberOfChannels, frame.numberOfFrames, frame.sampleRate ); for (let ch = 0; ch < frame.numberOfChannels; ch++) { frame.copyTo(buf.getChannelData(ch), { planeIndex: ch }); } frame.close(); _scheduleAudioBuffer(buf); } catch(e) { frame.close(); } }, error: (e) => console.error('[audio] AudioDecoder error:', e), }); dec.configure({ codec: 'opus', sampleRate: 48000, numberOfChannels: 1 }); // Interface compatible with the previous _opusDecoder API window._opusDecoder = { _dec: dec, _ts: 0, _first: true, decodeFrame(opusData) { this._dec.decode(new EncodedAudioChunk({ type: this._first ? 'key' : 'delta', timestamp: this._ts, data: opusData, })); this._first = false; this._ts += 20000; // 20ms in microseconds }, ready: Promise.resolve(), }; console.log('[audio] AudioDecoder (Web Codecs) ready'); } catch(e) { console.error('[audio] AudioDecoder init error:', e); window._opusDecoder = null; } } // ── Audio scheduler — smooth playback with no gaps between frames ─────────── // Problem: src.start(audioCtx.currentTime) plays each frame "now", but // "now" shifts between frames due to network/JS event loop delays, which // causes micro-gaps (clicks) in the played sound. // Solution: schedule each frame at a precisely defined time in the // future, back to back with no gaps. _nextAudioTime acts as a "pointer" // to where the previous frame ends. let _nextAudioTime = 0; let _aheadAvg = 0; // A fixed 180ms target left ALMOST NO margin against the real jitter // observed live by the user over an LTE tunnel (sq3mzmremote.duckdns.org) // - a single 183ms RTT spike (no JS blocking, no reaction from the // adaptive bitrate in ham_audio.exe - too small to overflow the 5.12s // Rust buffer) was fully enough to drain this buffer and produce an // audible glitch. Instead of hard-raising the target (which pays higher // latency EVEN when the link happens to be good), the target is now // ADAPTIVE: starts low (base), grows in steps after each real shortage // (ahead<0 in _scheduleAudioBuffer), and slowly decays back to base once // the link has been clean for a while — the same hysteresis pattern as // the adaptive HI/LO bitrate in ham_audio.exe (see RECOVERY_CLEAN_SECS in main.rs). let _audioTarget = 0.18; // current target — starts at base const _AUDIO_TARGET_BASE = 0.18; // lower bound of adaptation (good link) const _AUDIO_TARGET_CEIL = 0.30; // upper bound of adaptation (200-300ms budget, RCForb) const _AUDIO_TARGET_STEP = 0.03; // growth step after a shortage (30ms) const _AUDIO_TARGET_DECAY_STEP = 0.02; // step back down when calm // Live-observed shortages repeated at EXACTLY ~60s intervals // (13:26:10.289 -> 13:27:10.289, then 13:31:10.311 - too precise for // random jitter, looks like a network infrastructure cycle, e.g. LTE // RRC/DRX). With 20s of silence before decaying, the buffer would return // to base BEFORE the next identical ~60s cycle hit, so it re-learned // from scratch every time instead of remembering. Extended to 90s, so // the learned value survives at least one full 60s cycle before we start // decaying. const _AUDIO_TARGET_CLEAN_MS = 90000; // ms without a shortage before we start decaying let _lastUnderrunAt = 0; const _AUDIO_MIN = 0.05; const _AUDIO_MAX = 0.40; // hard ceiling (outside adaptation) — above it, TRIM the buffer let _audioBadgeAt = 0; // throttle DOM updates (frames arrive every 20ms) // Slow return to a low buffer once the link calms down. Checked every // 5s, regardless of whether audio is currently playing (harmless when disabled). setInterval(() => { if (_audioTarget <= _AUDIO_TARGET_BASE) return; if (performance.now() - _lastUnderrunAt < _AUDIO_TARGET_CLEAN_MS) return; const before = _audioTarget; _audioTarget = Math.max(_AUDIO_TARGET_BASE, _audioTarget - _AUDIO_TARGET_DECAY_STEP); if (_audioTarget !== before) { console.log(`[audio] link calm for ${Math.round(_AUDIO_TARGET_CLEAN_MS/1000)}s — buffer target ${Math.round(before*1000)}ms -> ${Math.round(_audioTarget*1000)}ms`); } }, 5000); function _updateAudioLatencyBadge() { const now = performance.now(); if (now - _audioBadgeAt < 500) return; _audioBadgeAt = now; const badge = document.getElementById('badge-audio-latency'); if (!badge) return; if (!window._audioEnabled || _aheadAvg <= 0) { badge.textContent = '--'; badge.style.color = 'var(--dim)'; badge.style.borderColor = 'var(--border2)'; return; } const ms = Math.round(_aheadAvg * 1000); badge.textContent = ms + ' ms'; // Adaptive target 180-300ms (_audioTarget), hard ceiling 400ms // (_AUDIO_MAX) — above it, the scheduler trims the buffer anyway, so // red = the buffer keeps hitting the ceiling regardless of the current adaptation target. badge.style.color = ms < 300 ? 'var(--green)' : ms < 400 ? 'var(--amber)' : 'var(--red)'; badge.style.borderColor = ms < 300 ? 'var(--green2)' : ms < 400 ? 'rgba(240,180,41,0.4)' : 'rgba(var(--red-rgb),0.4)'; } function _scheduleAudioBuffer(audioBuffer) { if (!audioCtx) return; const now = audioCtx.currentTime; let ahead = _nextAudioTime - now; if (_aheadAvg === 0) _aheadAvg = ahead > 0 ? ahead : _audioTarget; _aheadAvg = _aheadAvg * 0.9 + ahead * 0.1; let rate = 1.0; if (ahead > 0) { const err = _aheadAvg - _audioTarget; // playbackRate changes PITCH as well as tempo, so an audible speed-up sounds // like the tone rising — very noticeable on CW/SSB where pitch carries // meaning. Keep correction tiny (max 0.3%, inaudible) and only engage it // outside a wide dead zone, so normal LTE jitter never shifts the pitch. // Real starvation/overflow is handled by the hard reset below, not by rate. if (Math.abs(err) > 0.04) { const corr = Math.max(-0.003, Math.min(0.003, err * 0.02)); rate = 1.0 + corr; } } // TWO-SIDED clamp — key to keeping the buffer from growing unbounded. // When it drops too low: top it up (against clicks). // When it grows too high: TRIM it (against latency creeping up to 700ms). if (ahead > 0 && ahead < _AUDIO_MIN) { _nextAudioTime = now + _audioTarget; ahead = _audioTarget; _aheadAvg = _audioTarget; } else if (ahead > _AUDIO_MAX) { // the buffer bloated (Python choked) — cut the excess, return to the target _nextAudioTime = now + _audioTarget; ahead = _audioTarget; _aheadAvg = _audioTarget; } if (ahead < 0) { // Real shortage — the buffer completely drained, this is an AUDIBLE // glitch. Proof the current target is too tight for the link's // current jitter — raise it (up to the adaptation ceiling), instead // of waiting for the next identical glitch. The interval above handles the decay back down. const grown = Math.min(_AUDIO_TARGET_CEIL, _audioTarget + _AUDIO_TARGET_STEP); if (grown > _audioTarget) { console.warn(`[audio] buffer shortage — target ${Math.round(_audioTarget*1000)}ms -> ${Math.round(grown*1000)}ms`); _audioTarget = grown; } _lastUnderrunAt = performance.now(); _nextAudioTime = now + _audioTarget; ahead = _audioTarget; _aheadAvg = _audioTarget; rate = 1.0; } const src = audioCtx.createBufferSource(); src.buffer = audioBuffer; if (rate !== 1.0) src.playbackRate.value = rate; src.connect(window._masterGain || audioCtx.destination); src.start(_nextAudioTime); _nextAudioTime += audioBuffer.duration / rate; _updateAudioLatencyBadge(); } function playOpusFrame(buffer) { if (!window._audioEnabled || !audioCtx || !window._opusDecoder) return; // Header format: // Rust ham_audio: [0xA1][seq 4B LE][opus...] — skip 5 bytes // Python fallback: [0xA1][opus...] — skip 1 byte const view = new Uint8Array(buffer); const skip = (view[0] === 0xA1 && window._rustAudio) ? 5 : 1; const opusData = view.slice(skip); window._opusDecoder.ready.then(() => { try { window._opusDecoder.decodeFrame(opusData); } catch(e) {} }); } // Debounce helper — limits how many messages get sent function _debounce(fn, ms) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; } // VFO change buffer — send at most once per 50ms (20Hz) instead of on every scroll tick const _sendFreqDebounced = _debounce((freq) => { // Check the radio lock before sending the freq const lock = window.AppState?.radio_lock; const myUid = String(window.AppState?.my_uid || window.CurrentUser?.id || ''); const role = window.CurrentUser?.role; if (role !== 'admin' && (!lock?.locked || String(lock.user_id) !== myUid)) { // Revert the display to the radio's actual freq if (window.AppState?.freq) { window.AppState.freq = window.AppState.freq; // trigger re-render window.UI?.updateFreqDisplay?.(); } const holder = lock.callsign || lock.username || '?'; window.UI?.showToast(`⛔ Radio zajęte przez ${holder} — przejmij TRX`, 'error'); return; } WS.send({ type:'freq', freq }); }, 50); // ── WebSocket ───────────────────────────────────────────────────────────────── function connect() { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const token = localStorage.getItem('token') || sessionStorage.getItem('ham_token') || ''; // WebSocket on the same host as HTTP, endpoint /ws // Works locally and through the Replit proxy (wss://hostname/ws) const wsBase = `${proto}://${location.host}/ws`; const wsUrl = token ? `${wsBase}?token=${encodeURIComponent(token)}` : wsBase; ws = new WebSocket(wsUrl); window._mainWS = ws; ws.binaryType = 'arraybuffer'; ws.onopen = () => { console.log('[ws] Connected'); // Auto-ping every 30s for ongoing latency measurement clearInterval(window._autoPingInterval); window._autoPingInterval = setInterval(() => { if (window.WS) window.WS.ping(); }, 30000); setTimeout(() => { if (window.WS) window.WS.ping(); }, 500); S.connected = true; window.UI?.updateConnectionStatus(true); // Subscribe to channels per the CURRENTLY active tab (not just // 'control'). Without this, after a reconnect the client wouldn't get // scope_frame/ft8_waterfall until switching tabs. const activePage = document.querySelector('.tab-btn.active')?.getAttribute('onclick')?.match(/'(\w+)'/)?.[1] || 'radio'; const channelsForPage = { radio: ['control', 'scope'], wsjtx: ['control', 'scope', 'ft8'], dxcluster: ['control', 'dxcluster'], }; const channels = channelsForPage[activePage] || ['control']; ws.send(JSON.stringify({ type: 'subscribe', channels })); // RX audio is always enabled (the manual enable button was removed — // the server now auto-starts the physical audio stream on startup, // so the client subscribes to receive it right away). _bindAudioResumeOnFirstInteraction(); window.WS?.enableAudio(true); }; ws.onclose = () => { console.log('[ws] Disconnected'); S.connected = false; window.UI?.updateConnectionStatus(false); reconnectTimer = setTimeout(connect, 3000); }; ws.onerror = (e) => { console.warn('[ws] Error:', e); }; ws.onmessage = (e) => { if (e.data instanceof ArrayBuffer) { // Binary = Opus audio frame playOpusFrame(e.data); return; } try { const msg = JSON.parse(e.data); handleMessage(msg); } catch(err) { console.warn('[ws] Parse error:', err); } }; } // Keeps the "#tx-mic-btn" button (PROFILE/RADIO) in sync with the actual // TX microphone state, regardless of what triggered it - a manual click // (onclick in index.html) or the remote WSJT-X bridge (wsjtx_tx_start/stop // below). Same visual states as the previous onclick, so there's no // mismatch. function _syncTxMicButton(active) { const btn = document.getElementById('tx-mic-btn'); if (!btn) return; btn.textContent = active ? '⏹ Zatrzymaj TX mikrofon' : '🎤 Nadawanie TX — mikrofon'; btn.style.color = active ? 'var(--red)' : 'var(--dim)'; btn.style.borderColor = active ? 'var(--red)' : 'rgba(var(--red-rgb),0.3)'; } function handleMessage(msg) { switch (msg.type) { case 'init': { Object.assign(S, { freq: msg.freq ?? S.freq, freqB: msg.freqB ?? S.freqB, mode: msg.mode || S.mode, bandwidth: msg.bandwidth || S.bandwidth, ptt: msg.ptt ?? false, rfPower: msg.rfPower ?? S.rfPower, afGain: msg.afGain ?? S.afGain, squelch: msg.squelch ?? S.squelch, split: msg.split ?? false, vfo: msg.vfo || 'VFOA', sim: msg.sim ?? false, tuner: msg.tuner ?? false, connected: msg.connected ?? false, rigDisconnectedSince: msg.rigDisconnectedSince ?? null, models: msg.models || {}, // CRITICAL: the backend (webapp.py) NEVER sends a "bands" key in // the init message — the band list arrives separately via // loadBandsConfig() (-> /api/config/bands), called independently // after 'app:ready'. 'init' (WS) and 'app:ready' (auth.js) are two // INDEPENDENT async events that can arrive in any order. The // previous code (`msg.bands || {}`) unconditionally overwrote // S.bands with an EMPTY object when 'init' arrived AFTER // loadBandsConfig() had already correctly set 14 bands — which // triggered the fallback in buildBandGrid() showing only '20m'. // Now: when msg.bands doesn't exist, we keep whatever is already // in S.bands (the same defensive pattern as freq/mode/etc. below, ?? instead of ||). bands: msg.bands ?? S.bands, // FIX (2026-10-22, live report with [modes-debug] console logs: // regular users intermittently saw ALL modes instead of the 4 the // admin granted). msg.modes is the FULL hardcoded mode list, not the // admin's enabled set - this overwrote the correct S.modes that // loadBandsConfig() had already set, on every init (login AND every // WS reconnect). Same bug class as bands above. Use the enabled list // init already carries, and keep the current one if it's missing. modes: (msg.enabledModes && msg.enabledModes.length) ? msg.enabledModes : S.modes, rigs: msg.rigs || [], callsign: msg.callsign || '', stationLocator: msg.stationLocator || S.stationLocator, operatorLocator: msg.operatorLocator || S.operatorLocator, }); window.UI?.fullRefresh(); // Highlight the VFO A/B and SPLIT buttons per the state from init — // a new user must see which VFO is active and whether the radio is // split from the moment they log in (previously everything stayed // dimmed until the first click). window.UI?.updateVFOBadges?.(); window.RadioFunctions?.syncStates?.({vfo: S.vfo, split: S.split}); window.Settings?.populateModels(S.models, S.rigs); if (msg.rotators && typeof window.RotW?.handleWS === 'function') RotW.handleWS(msg); // Radio power state from get_status - CRUCIAL when a new user logs // in: they must see whether the radio is ON/OFF matching reality // (otherwise the button shows ON when someone else turned the radio off). if (msg.rigPowerOn !== undefined) { if (window.AppState) window.AppState.rigPowerOn = !!msg.rigPowerOn; window.RadioFunctions?.handlePowerState?.(!!msg.rigPowerOn); // FIX (2026-10-21, live report: "pokazuje sie [RADIO WYLACZONE] w // momencie wylaczenia ale sie wyloguje zaloguje i napis znika") - // the waterfall's own "RADIO WYLACZONE" source badge only ever // reacted to a LIVE power-off event (Waterfall.onPowerReset(), // elsewhere in this file) or a live scope frame resuming - a fresh // client that logs in while the radio is ALREADY off never gets // either of those (no transition happens during ITS session), so // the badge sat on its default "● SIM" placeholder, same class of // bug as rigPowerOn above (which already had this exact fix). if (!msg.rigPowerOn) window.Waterfall?.onPowerReset?.(); } // Set the radio lock state from init if (window.AppState && msg.locked !== undefined) { window.AppState.radio_lock = { locked: msg.locked, user_id: msg.user_id, username: msg.username, callsign: msg.callsign, }; } // Pass the operator state to OpPanel if (window.OpPanel?.handleWS) window.OpPanel.handleWS(msg); // OVF (front-end overdrive) lamp — show the real current state // immediately on (re)connect instead of defaulting to "off". document.getElementById('ovf-badge')?.style.setProperty('display', msg.ovf ? 'inline-block' : 'none'); break; } case 'telemetry': S.freq = msg.freq ?? S.freq; S.freqB = msg.freqB ?? S.freqB; S.mode = msg.mode ?? S.mode; S.bandwidth = msg.bandwidth ?? S.bandwidth; S.sMeter = msg.sMeter ?? 0; S.ptt = msg.ptt ?? false; S.vfo = msg.vfo ?? S.vfo; S.split = msg.split ?? S.split; S.connected = msg.connected ?? S.connected; S.sim = msg.sim ?? S.sim; window.UI?.updateTelemetry(); window.AudioControls?.syncRxGainToMode?.(); // digi MUTE gate — see ws.js break; case 'rig_disconnected': // civ.py _start_sim()/_open() - fires right away on a CI-V drop or // reconnect, independent of telemetry (which stops entirely while // disconnected - see ui.js's updateDisconnectBadge). S.rigDisconnectedSince = msg.since ?? null; window.UI?.updateDisconnectBadge?.(); break; case 'level_value': // Slider value change (the CIV poller detected a change on the // radio, or another admin moved the slider — WS broadcast to everyone) console.log(`[ws] level_value id=${msg.id} value=${msg.value}`); window.RadioFunctions?.handleLevelValue?.(msg); break; case 'rig_slider_ack': // ACK of a slider change from another user - update the slider for // whoever didn't change it (skip=ws on the server skips the author of the change). console.log(`[ws] rig_slider_ack id=${msg.id} value=${msg.value}`); window.RadioFunctions?.handleLevelValue?.(msg); break; case 'func_state': window.RadioFunctions?.handleFuncState?.(msg); break; case 'cw_sending': case 'cw_done': case 'cw_stopped': case 'cw_error': // FIX (reported live 2026-08-24: "bardzo dlugo czeka zanim mi // pozwoli makro znow nadac" - every CW send left the keyer looking // "busy" for ~20s). cw.js's own handleWS() (clears cwActive on // cw_done/cw_stopped/cw_error, sets it on cw_sending) was never // wired into this dispatcher on desktop at all - only mobile.js // forwarded these types to CW.handleWS. Without it, cwActive here // could only ever be cleared by cw.js's own 20s safety-timeout // fallback (added earlier the same day for a different failure mode), // never by the real, near-instant confirmation - so EVERY // successful send left the "tap again = stop" state armed until // that timeout finally expired, not until the transmission actually // finished a few seconds later as intended. window.CW?.handleWS?.(msg); break; case 'rig_features': window.RadioFunctions?.handleWsMessage?.(msg); break; case 'audio_ready': window._rustAudio = !!(msg.status && msg.status.rust); console.log('[audio] ready rust=' + window._rustAudio); // After ham_audio restarts (e.g. a card change), the Opus stream // starts over. The WebCodecs decoder must treat the new stream's // first frame as a 'key', not a 'delta' - otherwise it loses sync // and there's no sound even though the WS audio connection is up // and frames are arriving. Resetting _first forces a keyframe. if (window._opusDecoder) { window._opusDecoder._first = true; window._opusDecoder._ts = 0; } // Force an audio WS reconnect to the new Rust process (the old stream died) if (window._audioEnabled) { try { if (window._audioWs) window._audioWs.close(); } catch(e) {} setTimeout(() => { if (typeof _connectAudioWs === 'function') _connectAudioWs(); }, 500); } break; case 'relay_state': window.RelayUI?.onWSMessage(msg); break; case 'dx_spot': window.DXCluster?.handleSpot?.(msg); break; case 'dx_status': window.DXCluster?.handleStatus?.(msg); break; case 'webrtc_answer': _txMic.onAnswer(msg); break; case 'webrtc_ice': _txMic.onRemoteIce(msg); break; case 'webrtc_error': console.warn('[txmic] server:', msg.error); _txMic.teardown(); break; case 'webrtc_rx_offer': _onRxOffer(msg); break; case 'webrtc_rx_error': _onRxWebrtcError(msg); break; case 'wsjtx_tx_start': // An external WSJT-X/JTDX (via the wsjtx_local.py bridge + Hamlib // emulation) turned on PTT - start the microphone stream (here: a // virtual audio cable like VB-Audio, selected in PROFILE as "TX // MICROPHONE"), the same as with manual PTT. This used to be // completely unhandled - the radio got PTT but with no audio at all (silence on air). _txMic.start(); _syncTxMicButton(true); break; case 'wsjtx_tx_stop': _txMic.stop(); _syncTxMicButton(false); break; case 'smeter': window.UI?.updateSMeter(msg.value ?? 0); break; case 'pong': { const rtt = Date.now() - (window._pingT0 || Date.now()); clearTimeout(window._pingTimeout); const badge = document.getElementById('badge-latency'); if (badge) { badge.textContent = rtt + ' ms'; badge.style.color = rtt < 50 ? 'var(--green)' : rtt < 150 ? 'var(--amber)' : 'var(--red)'; badge.style.borderColor = rtt < 50 ? 'var(--green2)' : rtt < 150 ? 'rgba(240,180,41,0.4)' : 'rgba(var(--red-rgb),0.4)'; } // DIAGNOSTICS: log every suspiciously high RTT with the exact // wall-clock time, so it can be compared with the ham_audio.exe // console log (LOW/HIGH bitrate markers) and the longtask observer // below - settles whether a spike is a JS thread block or a real network delay. if (rtt >= 150) { console.warn(`[ws] HIGH RTT ${rtt}ms at ${new Date().toLocaleTimeString('pl-PL', {hour12:false})}.${String(new Date().getMilliseconds()).padStart(3,'0')}`); } break; } case 'txmeter': window.UI?.updateTxMeter(msg); break; case 'freq': // Grace period: ignore an incoming freq from the server for ~1s // after our own, local change (sendFreq). Without this: during fast // scroll-tuning (many steps in a short time), the CI-V radio may // not have physically settled between consecutive steps yet, and // the server (civ.py transceive handler) can, in that window, catch // an "intermediate"/stale frequency reading from the radio and // broadcast it to ALL clients (including the one that was just // scrolling) — without this guard it overwrote S.freq with the old // value, which reverted the band-button highlight to the previous band. if (S._localFreqSetAt && (Date.now() - S._localFreqSetAt) < 1000) break; S.freq = msg.freq; window.UI?.updateFreqDisplay(); window.UI?.updateVFOBadges?.(); // also update the band badge window.UI?.updatePTT?.(); // cross-band guard break; case 'mode': S.mode = msg.mode; S.bandwidth = msg.bandwidth || S.bandwidth; if (msg.filterNum) { const sel = document.getElementById('bw-select'); if (sel) sel.value = String(msg.filterNum); } window.UI?.updateModeButtons(); window.UI?.updateVFOBadges?.(); // also update the mode badge window.AudioControls?.syncRxGainToMode?.(); // digi MUTE gate — see ws.js break; case 'ptt': S.ptt = msg.ptt; window.UI?.updatePTT(); // A real transmission starting (manual SSB/CW/FT8, OR an automatic // FT8/Hound auto-QSO cycle deciding on its own to send) is proof the // station is actively working, not unattended - reset the FT8 // safety timer (WSJT-X "Tx Watchdog") here too, not just on manual // clicks (_selectRow/manual TX macro). FIX (live-seen 2026-09-02): // without this, fully automated CQ/auto-answer operation - the // whole point of the feature - never touched the timer at all // (nobody's clicking anything while it runs itself), so it expired // and blocked the automation on a normal, correctly-working session. if (msg.ptt) window.FT8Timer?.reset(); break; case 'tuner': S.tuner = msg.value; document.getElementById('tuner-btn')?.classList.toggle('active', !!msg.value); window.RadioFunctions?.handleLegacyFunc?.('tuner', msg); break; case 'preamp': window.RadioFunctions?.handleLegacyFunc?.('preamp', msg); break; case 'attenuator': window.RadioFunctions?.handleLegacyFunc?.('attenuator', msg); break; case 'power_state': window.RadioFunctions?.handlePowerState?.(!!msg.value); break; case 'ovf': document.getElementById('ovf-badge')?.style.setProperty('display', msg.value ? 'inline-block' : 'none'); break; case 'toast': window.UI?.showToast(msg.message || msg.msg || ''); break; case 'level': if (msg.param === 'RFPOWER') S.rfPower = msg.value; if (msg.param === 'AF') S.afGain = msg.value; if (msg.param === 'SQL') S.squelch = msg.value; break; case 'freqB': S.freqB = msg.freqB; window.UI?.updateFreqB?.(); window.UI?.updatePTT?.(); // cross-band guard break; case 'split': S.split = msg.split; S.freqB = msg.freqB || S.freqB; window.UI?.updateFreqB?.(); window.UI?.updatePTT?.(); // cross-band guard window.UI?.updateVFOBadges?.(); window.RadioFunctions?.syncStates?.({split: S.split}); break; case 'init_patch': // Partial state update (e.g. the admin changed the station // locator). Without a page reload — the rotator azimuth recomputes on its own. if (msg.stationLocator !== undefined) { S.stationLocator = msg.stationLocator; if (window.AppState) window.AppState.stationLocator = msg.stationLocator; } break; case 'vfo': case 'vfo_sel': // Active VFO state from the server — highlight the A/B buttons for // ALL clients (not just the one who clicked). S.vfo = msg.vfo || 'VFOA'; window.UI?.updateFreqB?.(); window.UI?.updateVFOBadges?.(); window.RadioFunctions?.syncStates?.({vfo: S.vfo}); break; case 'config_update': // The admin changed the list of enabled bands in settings — refresh // S.bands and the button grid without reloading the page. window.UI?.loadBandsConfig?.(); break; case 'online_update': case 'radio_lock_state': // Save the lock state to AppState — used by VFO and sendFreq if (window.AppState) { window.AppState.radio_lock = { locked: msg.locked, user_id: msg.user_id, username: msg.username, callsign: msg.callsign, }; } if (typeof window.OpPanel?.handleWS === 'function') window.OpPanel.handleWS(msg); // Update the visual graying of the Radio panel (readonly mode) window.UI?.applyRadioLockUI?.(); break; case 'radio_request': case 'radio_request_received': if (typeof window.OpPanel?.handleRequest === 'function') window.OpPanel.handleRequest(msg); break; case 'radio_request_rejected': if (typeof window.OpPanel?.handleRejected === 'function') window.OpPanel.handleRejected(msg); break; case 'qso_new': window.QSOLog?.prependEntry(msg.entry); break; case 'error': window.UI?.showToast('✗ ' + msg.message, 'error'); break; case 'scope_frame': case 'scope_data': if (typeof window.Waterfall?.handleScopeData === 'function') Waterfall.handleScopeData(msg); break; case 'scope_reset': // Radio turned off - clear the waterfall and show the OFF state if (typeof window.Waterfall?.onPowerReset === 'function') Waterfall.onPowerReset(); break; case 'rotator_update': if (typeof window.RotW?.handleWS === 'function') RotW.handleWS(msg); if (typeof window.Rotator?.handleWS === 'function') Rotator.handleWS(msg); // This type has its own case (above), so it NEVER fell through to // 'default' below, where it would normally reach WSJTX.handleWS — // the live rotator position reading in the WSJT-X panel (ANTENNA // row) never got ongoing updates, only a one-time fetch from // init(). Reported live as: "the rotator position only refreshes // when you switch tabs" — in reality that was the only time it ever got a value at all. if (typeof window.WSJTX?.handleWS === 'function') WSJTX.handleWS(msg); break; case 'deepcw_text': window.DeepCW?.handleText?.(msg); break; case 'deepcw_vu': window.DeepCW?.handleVU?.(msg.level); break; case 'deepcw_progress': { const l = document.getElementById('deepcw-admin-log'); const b = document.getElementById('deepcw-admin-bar'); if (l) { const detail = msg.detail ? ` (${msg.detail})` : ''; const path = msg.savePath ? `
→ ${msg.savePath}` : ''; l.innerHTML = (msg.pct < 0 ? `${msg.msg}` : msg.msg) + detail + path; } if (b) { b.style.display = (msg.pct >= 0 && msg.pct < 100) ? 'block' : 'none'; b.querySelector('.deepcw-bar-fill').style.width = Math.max(0, msg.pct) + '%'; } if (msg.pct === 100) setTimeout(() => typeof deepcwAdminRefreshStatus==='function' && deepcwAdminRefreshStatus(), 800); } break; case 'deepcw_update': window.UI?.showToast('🧠 ' + msg.msg, 'warning'); if (typeof deepcwAdminRefreshStatus==='function') deepcwAdminRefreshStatus(); break; case 'tunnel_update': if (typeof window.Tunnel?.handleWS === 'function') Tunnel.handleWS(msg); break; case 'cw_tx_start': window.CwSidetone?.handleWS(msg); break; default: // WSJTX and other modules if (typeof window.WSJTX?.handleWS === 'function') WSJTX.handleWS(msg); if (typeof window.Chat?.handleWS === 'function') Chat.handleWS(msg); if (typeof window._wsRotatorHandler === 'function') window._wsRotatorHandler(msg); break; } } // ── Public API ───────────────────────────────────────────────────────────── // ── Local audio (when FFmpeg is unavailable on the server) ───────────────── // Uses getUserMedia → Web Audio API → plays through the local sound card // Works when the browser and radio are on the same computer async function initLocalAudio() { if (!navigator.mediaDevices?.getUserMedia) { console.warn('[audio] getUserMedia unavailable'); return false; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false, sampleRate: 48000, } }); if (!audioCtx) initAudioContext(); const src = audioCtx.createMediaStreamSource(stream); const gain = audioCtx.createGain(); gain.gain.value = 1.0; src.connect(gain); gain.connect(window._masterGain || audioCtx.destination); window._localAudioStream = stream; window._localAudioGain = gain; console.log('[audio] Local audio active (getUserMedia)'); return true; } catch(e) { console.warn('[audio] getUserMedia error:', e.message); return false; } } function stopLocalAudio() { if (window._localAudioStream) { window._localAudioStream.getTracks().forEach(t => t.stop()); window._localAudioStream = null; } } function setLocalAudioGain(val) { if (window._localAudioGain) window._localAudioGain.gain.value = Math.max(0, Math.min(3, val)); } // ── AudioControls — TX GAIN with a VU meter + RX VOL ───────────────────────── window.AudioControls = (function() { let _txGain = 1.0; let _rxVol = 0.7; let _analyser = null; let _vuFrame = null; let _txActive = false; // ── TX GAIN ──────────────────────────────────────────────────────────────── // Per-user save in localStorage (same pattern as RX VOL below) — without // this, the slider reverted after every refresh/relogin to the // hardcoded 0.15 from the HTML, so every user had to manually re-set // their SSB modulation level every time. function _txGainKey() { const uid = window.AppState?.my_uid || window.CurrentUser?.id || 'default'; return `txGain_${uid}`; } function _loadTxGain() { try { const saved = localStorage.getItem(_txGainKey()); return saved !== null ? parseFloat(saved) : 0.15; } catch(e) { return 0.15; } } function setTxGain(val) { _txGain = Math.max(0.01, Math.min(1.0, val)); window._txGain = _txGain; // Apply to the GainNode if it exists (TX active) if (window._txGainNode) window._txGainNode.gain.value = _txGain; // Update the UI const el = document.getElementById('tx-gain-val'); if (el) el.textContent = _txGain.toFixed(2) + 'x'; const sl = document.getElementById('tx-gain-slider'); if (sl) sl.value = _txGain; _updateSliderColor(_txGain); try { localStorage.setItem(_txGainKey(), _txGain); } catch(e) {} } function initTxGain() { setTxGain(_loadTxGain()); } // TX GAIN slider color by level function _updateSliderColor(gain) { const sl = document.getElementById('tx-gain-slider'); if (!sl) return; if (gain < 1.2) sl.style.accentColor = 'var(--green)'; else if (gain < 1.6) sl.style.accentColor = 'var(--amber)'; else sl.style.accentColor = 'var(--red)'; } // ── RX VOL ───────────────────────────────────────────────────────────────── function _rxVolKey() { const uid = window.AppState?.my_uid || window.CurrentUser?.id || 'default'; return `rxVol_${uid}`; } function _loadRxVol() { try { const saved = localStorage.getItem(_rxVolKey()); return saved !== null ? parseFloat(saved) : 70; } catch(e) { return 70; } } // A mode string counts as "digi" only for the IC-7300 DATA sub-modes // actually used for FT8/FT4 on this station (USB-D/LSB-D/PKTUSB/PKTLSB - // confirmed live 2026-09-06) - NOT plain USB, which this station also // uses for ordinary SSB voice. Matches the same "-D"/"PKT" convention // dxcluster.js already uses to spot digital-mode spots. function _isDigiMode(mode) { if (!mode) return false; const m = mode.toUpperCase(); return m.endsWith('-D') || m === 'PKTUSB' || m === 'PKTLSB' || m === 'DATA' || m === 'PKT'; } // Applies the actual audible gain, folding in mute — the slider/localStorage // still remember the pre-mute volume (_rxVol), only the OUTPUT is forced to // 0 while muted, so unmuting restores exactly where the user left it. // // RX MUTE (below) is scoped to DIGI ONLY (2026-09-06, explicit live // requirement: "ten przycisk ma tylko pracowac w zakladce ft8/wjstx i nie // dotykac ssb i cw" - SSB/CW already have their own audio control, MON/ // mic monitor, and must never go silent because of this button). So the // mute only actually takes effect while the radio's CURRENT mode is a // digi sub-mode - checked here, on every call, using the live // AppState.mode (kept in sync by the 'mode'/'telemetry' WS handlers // below, which both re-run this after updating S.mode). function _applyRxGain() { const muteActive = _rxMuted && _isDigiMode(window.AppState?.mode); const gain = muteActive ? 0 : _rxVol * 0.9; if (window._masterGain) window._masterGain.gain.value = gain; // BELT AND SUSPENDERS (2026-08-24): reported live - masterGain.gain // confirmed set to 0 (via window._debugAudio()) while RX audio kept // playing at full volume regardless. The RX