const freqEl = document.getElementById("freq"); const modeEl = document.getElementById("mode"); const bandLabel = document.getElementById("band-label"); const powerBtn = document.getElementById("power-btn"); const powerHint = document.getElementById("power-hint"); const vfoPicker = document.getElementById("vfo-picker"); const signalGraph = document.getElementById("signal-graph"); const signalBar = document.getElementById("signal-bar"); const signalValue = document.getElementById("signal-value"); const pttBtn = document.getElementById("ptt-btn"); const freqBtn = document.getElementById("freq-apply"); const modeBtn = document.getElementById("mode-apply"); const txLimitInput = document.getElementById("tx-limit"); const txLimitBtn = document.getElementById("tx-limit-btn"); const txLimitRow = document.getElementById("tx-limit-row"); const lockBtn = document.getElementById("lock-btn"); const txMeters = document.getElementById("tx-meters"); const pwrBar = document.getElementById("pwr-bar"); const pwrValue = document.getElementById("pwr-value"); const swrBar = document.getElementById("swr-bar"); const swrValue = document.getElementById("swr-value"); const loadingEl = document.getElementById("loading"); const contentEl = document.getElementById("content"); const serverSubtitle = document.getElementById("server-subtitle"); const loadingTitle = document.getElementById("loading-title"); const loadingSub = document.getElementById("loading-sub"); let lastControl; let lastTxEn = null; let lastRendered = null; let rigName = "Rig"; let hintTimer = null; const signalHistory = []; const SIGNAL_HISTORY_MAX = 120; let lastFreqHz = null; let jogStep = 1000; // default 1 kHz let jogAngle = 0; let lastClientCount = null; let lastLocked = false; const originalTitle = document.title; function readyText() { return lastClientCount !== null ? `Ready \u00b7 ${lastClientCount} user${lastClientCount !== 1 ? "s" : ""}` : "Ready"; } function showHint(msg, duration) { powerHint.textContent = msg; if (hintTimer) clearTimeout(hintTimer); if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration); } let supportedModes = []; let supportedBands = []; let freqDirty = false; let modeDirty = false; let initialized = false; let lastEventAt = Date.now(); let es; let esHeartbeat; function formatFreq(hz) { if (!Number.isFinite(hz)) return "--"; if (hz >= 1_000_000_000) { return `${(hz / 1_000_000_000).toFixed(3)} GHz`; } if (hz >= 10_000_000) { return `${(hz / 1_000_000).toFixed(3)} MHz`; } return `${(hz / 1_000).toFixed(1)} kHz`; } function parseFreqInput(val) { if (!val) return null; const trimmed = val.trim().toLowerCase(); const match = trimmed.match(/^([0-9]+(?:[.,][0-9]+)?)\s*([kmg]hz|[kmg]|hz)?$/); if (!match) return null; let num = parseFloat(match[1].replace(",", ".")); const unit = match[2] || ""; if (Number.isNaN(num)) return null; if (unit.startsWith("gh") || unit === "g") { num *= 1_000_000_000; } else if (unit.startsWith("mh") || unit === "m") { num *= 1_000_000; } else if (unit.startsWith("kh") || unit === "k") { num *= 1_000; } else if (!unit) { // Heuristic when no unit is provided: large numbers are kHz/Hz, small numbers are MHz. if (num >= 1_000_000) { // Assume already Hz. } else if (num >= 1_000) { num *= 1_000; // treat as kHz } else { num *= 1_000_000; // treat as MHz } } return Math.round(num); } function normalizeMode(modeVal) { if (typeof modeVal === "string") return modeVal; if (modeVal && typeof modeVal === "object") { const entries = Object.entries(modeVal); if (entries.length > 0) { const [variant, value] = entries[0]; if (variant === "Other" && typeof value === "string") return value; return variant; } } return ""; } function updateSupportedBands(cap) { if (cap && Array.isArray(cap.supported_bands)) { supportedBands = cap.supported_bands .filter((b) => typeof b.low_hz === "number" && typeof b.high_hz === "number" && b.tx_allowed === true) .map((b) => ({ low: b.low_hz, high: b.high_hz })); } else { supportedBands = []; } } function freqAllowed(hz) { if (!Number.isFinite(hz)) return false; if (supportedBands.length === 0) return true; // if unknown, don't block return supportedBands.some((b) => hz >= b.low && hz <= b.high); } function setDisabled(disabled) { [freqEl, modeEl, freqBtn, modeBtn, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => { if (el) el.disabled = disabled; }); } function render(update) { if (!update) return; if (update.info && update.info.model) { rigName = update.info.model; } document.getElementById("rig-title").textContent = `${rigName} status`; initialized = !!update.initialized; if (!initialized) { const manu = (update.info && update.info.manufacturer) || rigName || "Rig"; const model = (update.info && update.info.model) || rigName || "Rig"; const rev = (update.info && update.info.revision) || ""; const parts = [manu, model, rev].filter(Boolean).join(" "); loadingTitle.textContent = `Initializing ${parts}…`; loadingSub.textContent = ""; console.info("Rig initializing:", { manufacturer: manu, model, revision: rev }); loadingEl.style.display = ""; if (contentEl) contentEl.style.display = "none"; powerHint.textContent = "Initializing rig…"; setDisabled(true); return; } else { loadingEl.style.display = "none"; if (contentEl) contentEl.style.display = ""; } // Server subtitle: "trx-server vX.Y.Z hosted by CALL" if (update.server_version || update.server_callsign) { let parts = "trx-server"; if (update.server_version) parts += ` v${update.server_version}`; if (update.server_callsign) { const cs = update.server_callsign; serverSubtitle.innerHTML = `${parts} hosted by ${cs}`; document.title = `${cs} - ${originalTitle}`; } else { serverSubtitle.textContent = parts; } } setDisabled(false); if (update.info && update.info.capabilities && Array.isArray(update.info.capabilities.supported_modes)) { const modes = update.info.capabilities.supported_modes.map(normalizeMode).filter(Boolean); if (JSON.stringify(modes) !== JSON.stringify(supportedModes)) { supportedModes = modes; modeEl.innerHTML = ""; const empty = document.createElement("option"); empty.value = ""; empty.textContent = "--"; modeEl.appendChild(empty); supportedModes.forEach((m) => { const opt = document.createElement("option"); opt.value = m; opt.textContent = m; modeEl.appendChild(opt); }); } } if (update.info && update.info.capabilities) { updateSupportedBands(update.info.capabilities); } if (update.status && update.status.freq && typeof update.status.freq.hz === "number") { lastFreqHz = update.status.freq.hz; if (!freqDirty) { freqEl.value = formatFreq(update.status.freq.hz); } } if (!modeDirty && update.status && update.status.mode) { const mode = normalizeMode(update.status.mode); modeEl.value = mode ? mode.toUpperCase() : ""; } if (update.status && typeof update.status.tx_en === "boolean") { lastTxEn = update.status.tx_en; pttBtn.textContent = update.status.tx_en ? "PTT On" : "PTT Off"; if (update.status.tx_en) { pttBtn.style.background = "var(--accent-red)"; pttBtn.style.borderColor = "var(--accent-red)"; pttBtn.style.color = "white"; } else { pttBtn.style.background = ""; pttBtn.style.borderColor = ""; pttBtn.style.color = ""; } } if (update.status && update.status.vfo && Array.isArray(update.status.vfo.entries)) { const entries = update.status.vfo.entries; const activeIdx = Number.isInteger(update.status.vfo.active) ? update.status.vfo.active : null; vfoPicker.innerHTML = ""; entries.forEach((entry, idx) => { const hz = entry && entry.freq && typeof entry.freq.hz === "number" ? entry.freq.hz : null; if (hz === null) return; const mode = entry.mode ? normalizeMode(entry.mode) : ""; const modeText = mode ? ` [${mode}]` : ""; const label = `${entry.name || String.fromCharCode(65 + idx)}: ${formatFreq(hz)}${modeText}`; const btn = document.createElement("button"); btn.type = "button"; btn.textContent = label; if (activeIdx === idx) btn.classList.add("active"); else btn.addEventListener("click", async () => { btn.disabled = true; showHint("Toggling VFO…"); try { await postPath("/toggle_vfo"); showHint("VFO toggled", 1200); } catch (err) { showHint("VFO toggle failed", 2000); console.error(err); } finally { btn.disabled = false; } }); vfoPicker.appendChild(btn); }); } else { vfoPicker.innerHTML = ""; } if (update.status && update.status.rx && typeof update.status.rx.sig === "number") { const raw = Math.max(0, update.status.rx.sig); let pct; let label; if (raw <= 9) { pct = Math.max(0, Math.min(100, (raw / 9) * 100)); label = `S${raw.toFixed(1)}`; } else { const overDb = (raw - 9) * 10; pct = 100; label = `S9 + ${overDb.toFixed(0)}dB`; } signalBar.style.width = `${pct}%`; signalValue.textContent = label; signalHistory.push(raw); if (signalHistory.length > SIGNAL_HISTORY_MAX) signalHistory.shift(); } else { signalBar.style.width = "0%"; signalValue.textContent = "--"; signalHistory.push(0); if (signalHistory.length > SIGNAL_HISTORY_MAX) signalHistory.shift(); } drawSignalGraph(); bandLabel.textContent = typeof update.band === "string" ? update.band : "--"; if (typeof update.enabled === "boolean") { powerBtn.disabled = false; powerBtn.textContent = update.enabled ? "Power Off" : "Power On"; } else { powerBtn.disabled = true; powerBtn.textContent = "Toggle Power"; powerHint.textContent = "State unknown"; } lastControl = update.enabled; if (update.status && update.status.tx && typeof update.status.tx.limit === "number") { txLimitInput.value = update.status.tx.limit; txLimitRow.style.display = ""; } else { txLimitInput.value = ""; txLimitRow.style.display = "none"; } if (typeof update.clients === "number") lastClientCount = update.clients; powerHint.textContent = readyText(); lastLocked = update.status && update.status.lock === true; lockBtn.textContent = lastLocked ? "Unlock" : "Lock"; const tx = update.status && update.status.tx ? update.status.tx : null; txMeters.style.display = ""; if (tx && typeof tx.power === "number") { const pct = Math.max(0, Math.min(100, tx.power)); pwrBar.style.width = `${pct}%`; pwrValue.textContent = `PWR ${tx.power.toFixed(0)}%`; } else { pwrBar.style.width = "0%"; pwrValue.textContent = "PWR --"; } if (tx && typeof tx.swr === "number") { const swr = Math.max(1, tx.swr); const pct = Math.max(0, Math.min(100, ((swr - 1) / 2) * 100)); swrBar.style.width = `${pct}%`; swrValue.textContent = `SWR ${tx.swr.toFixed(2)}`; } else { swrBar.style.width = "0%"; swrValue.textContent = "SWR --"; } } function drawSignalGraph() { if (!signalGraph) return; const ctx = signalGraph.getContext("2d"); const w = signalGraph.width; const h = signalGraph.height; ctx.clearRect(0, 0, w, h); if (signalHistory.length < 2) return; const maxVal = 12; // S9+30dB in S-units const len = signalHistory.length; const step = w / (SIGNAL_HISTORY_MAX - 1); const offsetX = (SIGNAL_HISTORY_MAX - len) * step; ctx.beginPath(); ctx.moveTo(offsetX, h); for (let i = 0; i < len; i++) { const val = Math.min(signalHistory[i], maxVal); const x = offsetX + i * step; const y = h - (val / maxVal) * h; ctx.lineTo(x, y); } ctx.lineTo(offsetX + (len - 1) * step, h); ctx.closePath(); const grad = ctx.createLinearGradient(0, h, 0, 0); grad.addColorStop(0, "rgba(0,209,127,0.25)"); grad.addColorStop(0.6, "rgba(240,173,78,0.35)"); grad.addColorStop(1, "rgba(229,83,83,0.45)"); ctx.fillStyle = grad; ctx.fill(); ctx.beginPath(); for (let i = 0; i < len; i++) { const val = Math.min(signalHistory[i], maxVal); const x = offsetX + i * step; const y = h - (val / maxVal) * h; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.strokeStyle = "rgba(0,209,127,0.8)"; ctx.lineWidth = 1.5; ctx.stroke(); } function connect() { if (es) { es.close(); } if (esHeartbeat) { clearInterval(esHeartbeat); } es = new EventSource("/events"); lastEventAt = Date.now(); es.onmessage = (evt) => { try { if (evt.data === lastRendered) return; const data = JSON.parse(evt.data); lastRendered = evt.data; render(data); lastEventAt = Date.now(); if (data.initialized) { powerHint.textContent = readyText(); } } catch (e) { console.error("Bad event data", e); } }; es.onerror = () => { powerHint.textContent = "Disconnected, retrying…"; es.close(); setTimeout(connect, 1000); }; esHeartbeat = setInterval(() => { const now = Date.now(); if (now - lastEventAt > 15000) { es.close(); connect(); } }, 5000); } async function postPath(path) { const resp = await fetch(path, { method: "POST" }); if (!resp.ok) { const text = await resp.text(); throw new Error(text || resp.statusText); } return resp; } powerBtn.addEventListener("click", async () => { powerBtn.disabled = true; showHint("Sending..."); try { await postPath("/toggle_power"); showHint("Toggled, waiting for update…"); } catch (err) { showHint("Toggle failed", 2000); console.error(err); } finally { powerBtn.disabled = false; } }); pttBtn.addEventListener("click", async () => { pttBtn.disabled = true; showHint("Toggling PTT…"); try { const desired = lastTxEn ? "false" : "true"; await postPath(`/set_ptt?ptt=${desired}`); showHint("PTT command sent", 1500); } catch (err) { showHint("PTT toggle failed", 2000); console.error(err); } finally { pttBtn.disabled = false; } }); freqBtn.addEventListener("click", async () => { const parsed = parseFreqInput(freqEl.value); if (parsed === null) { showHint("Freq missing", 1500); return; } if (!freqAllowed(parsed)) { showHint("Out of supported bands", 1500); return; } freqDirty = false; freqBtn.disabled = true; showHint("Setting frequency…"); try { await postPath(`/set_freq?hz=${parsed}`); showHint("Freq set", 1500); } catch (err) { showHint("Set freq failed", 2000); console.error(err); } finally { freqBtn.disabled = false; } }); freqEl.addEventListener("keydown", (e) => { freqDirty = true; if (e.key === "Enter") { e.preventDefault(); freqBtn.click(); } }); // --- Jog wheel --- const jogWheel = document.getElementById("jog-wheel"); const jogIndicator = document.getElementById("jog-indicator"); const jogDownBtn = document.getElementById("jog-down"); const jogUpBtn = document.getElementById("jog-up"); const jogStepEl = document.getElementById("jog-step"); async function jogFreq(direction) { if (lastLocked) { showHint("Locked", 1500); return; } if (lastFreqHz === null) return; const newHz = lastFreqHz + direction * jogStep; if (!freqAllowed(newHz)) { showHint("Out of supported bands", 1500); return; } jogAngle = (jogAngle + direction * 15) % 360; jogIndicator.style.transform = `translateX(-50%) rotate(${jogAngle}deg)`; showHint("Setting frequency…"); try { await postPath(`/set_freq?hz=${newHz}`); showHint("Freq set", 1000); } catch (err) { showHint("Set freq failed", 2000); console.error(err); } } jogDownBtn.addEventListener("click", () => jogFreq(-1)); jogUpBtn.addEventListener("click", () => jogFreq(1)); jogWheel.addEventListener("wheel", (e) => { e.preventDefault(); const direction = e.deltaY < 0 ? 1 : -1; jogFreq(direction); }, { passive: false }); // Touch drag on jog wheel let jogTouchY = null; jogWheel.addEventListener("touchstart", (e) => { e.preventDefault(); jogTouchY = e.touches[0].clientY; }, { passive: false }); jogWheel.addEventListener("touchmove", (e) => { e.preventDefault(); if (jogTouchY === null) return; const dy = jogTouchY - e.touches[0].clientY; if (Math.abs(dy) > 12) { jogFreq(dy > 0 ? 1 : -1); jogTouchY = e.touches[0].clientY; } }, { passive: false }); jogWheel.addEventListener("touchend", () => { jogTouchY = null; }); // Mouse drag on jog wheel let jogMouseY = null; jogWheel.addEventListener("mousedown", (e) => { e.preventDefault(); jogMouseY = e.clientY; jogWheel.style.cursor = "grabbing"; }); window.addEventListener("mousemove", (e) => { if (jogMouseY === null) return; const dy = jogMouseY - e.clientY; if (Math.abs(dy) > 10) { jogFreq(dy > 0 ? 1 : -1); jogMouseY = e.clientY; } }); window.addEventListener("mouseup", () => { jogMouseY = null; if (jogWheel) jogWheel.style.cursor = "grab"; }); // Step selector jogStepEl.addEventListener("click", (e) => { const btn = e.target.closest("button[data-step]"); if (!btn) return; jogStep = parseInt(btn.dataset.step, 10); jogStepEl.querySelectorAll("button").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); }); modeBtn.addEventListener("click", async () => { const mode = modeEl.value || ""; if (!mode) { showHint("Mode missing", 1500); return; } modeDirty = false; modeBtn.disabled = true; showHint("Setting mode…"); try { await postPath(`/set_mode?mode=${encodeURIComponent(mode)}`); showHint("Mode set", 1500); } catch (err) { showHint("Set mode failed", 2000); console.error(err); } finally { modeBtn.disabled = false; } }); modeEl.addEventListener("input", () => { modeDirty = true; }); txLimitInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); txLimitBtn.click(); } }); txLimitBtn.addEventListener("click", async () => { const limit = txLimitInput.value; if (limit === "" || limit === "--") { showHint("Limit missing", 1500); return; } txLimitBtn.disabled = true; showHint("Setting TX limit…"); try { await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`); showHint("TX limit set", 1500); } catch (err) { showHint("TX limit failed", 2000); console.error(err); } finally { txLimitBtn.disabled = false; } }); lockBtn.addEventListener("click", async () => { lockBtn.disabled = true; showHint("Toggling lock…"); try { const nextLock = lockBtn.textContent === "Lock"; await postPath(nextLock ? "/lock" : "/unlock"); showHint("Lock toggled", 1500); } catch (err) { showHint("Lock toggle failed", 2000); console.error(err); } finally { lockBtn.disabled = false; } }); connect(); // --- Audio streaming --- const rxAudioBtn = document.getElementById("rx-audio-btn"); const txAudioBtn = document.getElementById("tx-audio-btn"); const audioStatus = document.getElementById("audio-status"); const audioLevelFill = document.getElementById("audio-level-fill"); const audioRow = document.getElementById("audio-row"); // Hide audio row if audio is not configured on the server fetch("/audio", { method: "GET" }).then((r) => { if (r.status === 404) audioRow.style.display = "none"; }).catch(() => {}); let audioWs = null; let audioCtx = null; let rxActive = false; let txActive = false; let txStream = null; let txProcessor = null; let streamInfo = null; let opusDecoder = null; let txEncoder = null; let nextPlayTime = 0; let lastLevelUpdate = 0; let rxGainNode = null; let txGainNode = null; const rxVolSlider = document.getElementById("rx-vol"); const txVolSlider = document.getElementById("tx-vol"); const TX_TIMEOUT_SECS = 120; let txTimeoutTimer = null; let txTimeoutRemaining = 0; let txTimeoutInterval = null; const hasWebCodecs = typeof AudioDecoder !== "undefined" && typeof AudioEncoder !== "undefined"; // Show compatibility warning for non-Chromium browsers if (!hasWebCodecs) { rxAudioBtn.disabled = true; txAudioBtn.disabled = true; audioStatus.textContent = "Audio requires Chrome/Edge"; } function resetTxTimeout() { txTimeoutRemaining = TX_TIMEOUT_SECS; if (txTimeoutTimer) clearTimeout(txTimeoutTimer); txTimeoutTimer = setTimeout(() => { console.warn("PTT safety timeout — stopping TX"); stopTxAudio(); }, TX_TIMEOUT_SECS * 1000); } function startTxTimeoutCountdown() { txTimeoutRemaining = TX_TIMEOUT_SECS; if (txTimeoutInterval) clearInterval(txTimeoutInterval); txTimeoutInterval = setInterval(() => { txTimeoutRemaining--; if (txTimeoutRemaining <= 10 && txTimeoutRemaining > 0 && txActive) { audioStatus.textContent = `TX timeout ${txTimeoutRemaining}s`; } }, 1000); } function clearTxTimeout() { if (txTimeoutTimer) { clearTimeout(txTimeoutTimer); txTimeoutTimer = null; } if (txTimeoutInterval) { clearInterval(txTimeoutInterval); txTimeoutInterval = null; } txTimeoutRemaining = 0; } function startRxAudio() { if (rxActive) { stopRxAudio(); return; } if (!hasWebCodecs) { audioStatus.textContent = "Audio requires Chrome/Edge"; return; } const proto = location.protocol === "https:" ? "wss:" : "ws:"; audioWs = new WebSocket(`${proto}//${location.host}/audio`); audioWs.binaryType = "arraybuffer"; audioStatus.textContent = "Connecting…"; audioWs.onopen = () => { audioStatus.textContent = "Connected"; }; audioWs.onmessage = (evt) => { if (typeof evt.data === "string") { // Stream info JSON try { streamInfo = JSON.parse(evt.data); audioCtx = new AudioContext({ sampleRate: streamInfo.sample_rate || 48000 }); rxGainNode = audioCtx.createGain(); rxGainNode.gain.value = rxVolSlider.value / 100; rxGainNode.connect(audioCtx.destination); rxActive = true; rxAudioBtn.style.borderColor = "#00d17f"; rxAudioBtn.style.color = "#00d17f"; audioStatus.textContent = "RX"; } catch (e) { console.error("Audio stream info parse error", e); } return; } // Binary Opus data — decode via WebCodecs AudioDecoder if available if (!audioCtx) return; const data = new Uint8Array(evt.data); // Throttle level indicator updates to max 10/sec const now = Date.now(); if (now - lastLevelUpdate >= 100) { const level = Math.min(100, (data.length / 120) * 100); audioLevelFill.style.width = `${level}%`; lastLevelUpdate = now; } // Use WebCodecs AudioDecoder for Opus if available if (typeof AudioDecoder !== "undefined" && !opusDecoder) { try { const channels = (streamInfo && streamInfo.channels) || 1; const sampleRate = (streamInfo && streamInfo.sample_rate) || 48000; opusDecoder = new AudioDecoder({ output: (frame) => { const buf = new Float32Array(frame.numberOfFrames * frame.numberOfChannels); frame.copyTo(buf, { planeIndex: 0 }); const ab = audioCtx.createBuffer(frame.numberOfChannels, frame.numberOfFrames, frame.sampleRate); for (let ch = 0; ch < frame.numberOfChannels; ch++) { const chData = new Float32Array(frame.numberOfFrames); for (let i = 0; i < frame.numberOfFrames; i++) { chData[i] = buf[i * frame.numberOfChannels + ch]; } ab.copyToChannel(chData, ch); } const src = audioCtx.createBufferSource(); src.buffer = ab; src.connect(rxGainNode); const now = audioCtx.currentTime; const schedTime = Math.max(now, (nextPlayTime || now)); src.start(schedTime); nextPlayTime = schedTime + ab.duration; frame.close(); }, error: (e) => { console.error("AudioDecoder error", e); } }); opusDecoder.configure({ codec: "opus", sampleRate: sampleRate, numberOfChannels: channels, }); } catch (e) { console.warn("WebCodecs AudioDecoder not available for Opus", e); opusDecoder = null; } } if (opusDecoder) { try { opusDecoder.decode(new EncodedAudioChunk({ type: "key", timestamp: performance.now() * 1000, data: data, })); } catch (e) { // Ignore decode errors for individual frames } } }; audioWs.onclose = () => { // If TX was active when WS closed, release PTT if (txActive) { stopTxAudio(); } rxActive = false; rxAudioBtn.style.borderColor = ""; rxAudioBtn.style.color = ""; audioStatus.textContent = "Off"; audioLevelFill.style.width = "0%"; rxGainNode = null; if (opusDecoder) { try { opusDecoder.close(); } catch(e) {} opusDecoder = null; } nextPlayTime = 0; }; audioWs.onerror = () => { audioStatus.textContent = "Error"; }; } function stopRxAudio() { rxActive = false; if (audioWs) { audioWs.close(); audioWs = null; } if (audioCtx) { audioCtx.close(); audioCtx = null; } rxGainNode = null; if (opusDecoder) { try { opusDecoder.close(); } catch(e) {} opusDecoder = null; } nextPlayTime = 0; rxAudioBtn.style.borderColor = ""; rxAudioBtn.style.color = ""; audioStatus.textContent = "Off"; audioLevelFill.style.width = "0%"; } function startTxAudio() { if (txActive) { stopTxAudio(); return; } if (!hasWebCodecs) { audioStatus.textContent = "Audio requires Chrome/Edge"; return; } if (!audioWs || audioWs.readyState !== WebSocket.OPEN) { audioStatus.textContent = "RX first"; return; } if (!streamInfo) return; navigator.mediaDevices.getUserMedia({ audio: { sampleRate: streamInfo.sample_rate || 48000, channelCount: streamInfo.channels || 1 } }).then(async (stream) => { txStream = stream; txActive = true; txAudioBtn.style.borderColor = "#e55353"; txAudioBtn.style.color = "#e55353"; audioStatus.textContent = "RX+TX"; // Start PTT safety timeout resetTxTimeout(); startTxTimeoutCountdown(); // Engage PTT automatically try { await postPath("/set_ptt?ptt=true"); } catch (e) { console.error("PTT on failed", e); } const sampleRate = streamInfo.sample_rate || 48000; const channels = streamInfo.channels || 1; const encoder = new AudioEncoder({ output: (chunk) => { const buf = new ArrayBuffer(chunk.byteLength); chunk.copyTo(buf); if (audioWs && audioWs.readyState === WebSocket.OPEN) { audioWs.send(buf); } }, error: (e) => { console.error("AudioEncoder error", e); } }); encoder.configure({ codec: "opus", sampleRate: sampleRate, numberOfChannels: channels, bitrate: (streamInfo.bitrate_bps || 24000), }); txEncoder = encoder; // Use AudioWorklet or ScriptProcessor to feed encoder if (!audioCtx) audioCtx = new AudioContext({ sampleRate: sampleRate }); const source = audioCtx.createMediaStreamSource(stream); const frameDuration = (streamInfo.frame_duration_ms || 20) / 1000; const frameSize = Math.floor(sampleRate * frameDuration); // Use ScriptProcessorNode (deprecated but widely supported) const processor = audioCtx.createScriptProcessor(frameSize, channels, channels); let tsCounter = 0; processor.onaudioprocess = (e) => { if (!txActive || !txEncoder) return; const input = e.inputBuffer; // Reset PTT safety timeout on each audio callback resetTxTimeout(); // Use mono (channel 0) for f32-planar format const monoData = input.getChannelData(0); try { const frame = new AudioData({ format: "f32-planar", sampleRate: input.sampleRate, numberOfFrames: input.length, numberOfChannels: 1, timestamp: tsCounter, data: monoData, }); tsCounter += (input.length / input.sampleRate) * 1_000_000; txEncoder.encode(frame); frame.close(); } catch (e) { // Ignore } }; txGainNode = audioCtx.createGain(); txGainNode.gain.value = txVolSlider.value / 100; source.connect(txGainNode); txGainNode.connect(processor); processor.connect(audioCtx.destination); txProcessor = { source, processor }; }).catch((err) => { console.error("getUserMedia failed:", err); audioStatus.textContent = "Mic denied"; }); } async function stopTxAudio() { if (!txActive) return; txActive = false; clearTxTimeout(); // Release PTT automatically try { await postPath("/set_ptt?ptt=false"); } catch (e) { console.error("PTT off failed", e); } if (txStream) { txStream.getTracks().forEach(t => t.stop()); txStream = null; } if (txProcessor) { txProcessor.source.disconnect(); txProcessor.processor.disconnect(); txProcessor = null; } if (txEncoder) { try { txEncoder.close(); } catch(e) {} txEncoder = null; } txGainNode = null; txAudioBtn.style.borderColor = ""; txAudioBtn.style.color = ""; audioStatus.textContent = rxActive ? "RX" : "Off"; } rxAudioBtn.addEventListener("click", startRxAudio); txAudioBtn.addEventListener("click", startTxAudio); const rxVolPct = document.getElementById("rx-vol-pct"); const txVolPct = document.getElementById("tx-vol-pct"); function updateVolSlider(slider, pctEl, gainNode) { pctEl.textContent = `${slider.value}%`; if (gainNode) gainNode.gain.value = slider.value / 100; } rxVolSlider.addEventListener("input", () => updateVolSlider(rxVolSlider, rxVolPct, rxGainNode)); txVolSlider.addEventListener("input", () => updateVolSlider(txVolSlider, txVolPct, txGainNode)); function volWheel(slider, pctEl, getGain) { slider.addEventListener("wheel", (e) => { e.preventDefault(); const step = e.deltaY < 0 ? 2 : -2; slider.value = Math.max(0, Math.min(100, Number(slider.value) + step)); updateVolSlider(slider, pctEl, getGain()); }, { passive: false }); } volWheel(rxVolSlider, rxVolPct, () => rxGainNode); volWheel(txVolSlider, txVolPct, () => txGainNode); document.getElementById("copyright-year").textContent = new Date().getFullYear(); // Release PTT on page unload to prevent stuck transmit window.addEventListener("beforeunload", () => { if (txActive) { navigator.sendBeacon("/set_ptt?ptt=false", ""); } });