diff --git a/changelog.json b/changelog.json index 76a8ea3..7cd9338 100644 --- a/changelog.json +++ b/changelog.json @@ -30,7 +30,8 @@ "Icom network audio: the stream now opens regardless of the speaker choice — speakers-off (or a failed output device) was silently disabling the whole stream: no audio session, no recordings, no voice keyer, despite the RX-audio option being ticked.", "Icom network: a radio in standby keeps its session and the console’s ON button — the quiet-CI-V recovery was tearing the session down every 15 s, so a radio that was off when OpsLog started could never be powered on.", "Icom console: PTT and Split move up under the dial — one PTT button, and Split as OFF/+1k/+5k/+10k, DXpedition style. Engaging split also aligns the TX VFO’s mode with the main.", - "Icom console: the PTT button carries your microphone on a network station — it used to only key the rig, which transmitted silence over the LAN." + "Icom console: the PTT button carries your microphone on a network station — it used to only key the rig, which transmitted silence over the LAN.", + "Icom console: the spectrum scope is removed. Every model streams its waveform differently — and on the IC-7760 enabling it killed the whole CI-V link, audio included. The radio’s own scope does it better." ], "fr": [ "Console Elecraft : le S-mètre est calibré sur un vrai K3 — S9 et les +dB correspondent désormais à l’affichage de la radio (il lisait environ deux points S trop bas).", @@ -60,7 +61,8 @@ "Audio réseau Icom : le flux s’ouvre désormais indépendamment du choix d’écoute — enceintes coupées (ou périphérique de sortie en échec) désactivait silencieusement tout le flux : pas de session audio, ni enregistrements, ni voice keyer, malgré la case RX audio cochée.", "Réseau Icom : une radio en veille garde sa session et le bouton ON de la console — la récupération du CI-V muet détruisait la session toutes les 15 s, donc une radio éteinte au lancement d’OpsLog ne pouvait jamais être allumée.", "Console Icom : le PTT et le Split remontent sous le cadran — un bouton PTT, et le Split en OFF/+1k/+5k/+10k, façon DXpedition. Activer le split aligne aussi le mode du VFO TX sur le main.", - "Console Icom : le bouton PTT emporte votre micro sur une station réseau — il ne faisait que keyer la radio, ce qui émettait du silence par le LAN." + "Console Icom : le bouton PTT emporte votre micro sur une station réseau — il ne faisait que keyer la radio, ce qui émettait du silence par le LAN.", + "Console Icom : le scope spectral est retiré. Chaque modèle streame sa forme d’onde différemment — et sur l’IC-7760 son activation tuait tout le lien CI-V, audio compris. Le scope de la radio fait ça mieux." ] }, { diff --git a/frontend/src/components/IcomPanel.tsx b/frontend/src/components/IcomPanel.tsx index 066c3f3..8c2db71 100644 --- a/frontend/src/components/IcomPanel.tsx +++ b/frontend/src/components/IcomPanel.tsx @@ -6,7 +6,7 @@ import { IcomSetANF, IcomSetAPF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter, AudioMonitorActive, AudioStartMonitor, AudioStopMonitor, IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomSetSplitOffset, IcomTune, IcomConsolePTT, - IcomSetScope, IcomScopeData, IcomSetScopeMode, IcomSetScopeEdges, GetCATState, SetCATFrequency, SetCATMode, + GetCATState, SetCATFrequency, SetCATMode, IcomSetRIT, IcomSetRITOn, IcomSetXITOn, IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos, IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel, @@ -309,293 +309,13 @@ function wfColor(v: number): [number, number, number] { return WF_STOPS[WF_STOPS.length - 1][1]; } -// ScopePanadapter — enables the rig's spectrum-scope stream and draws the -// reassembled sweep as a modern SDR panadapter: a glowing filled spectrum trace -// on top and a scrolling colour waterfall below. Amplitudes are raw rig scale -// (~0-160), normalised to the tallest recent peak so the trace fills the height. -function ScopePanadapter() { - const { t } = useI18n(); - const [on, setOn] = useState(false); - const [fixed, setFixed] = useState(true); - const canvasRef = useRef(null); - const wfRef = useRef(null); // waterfall - const peakRef = useRef(160); // running amplitude ceiling for auto-scale - const holdRef = useRef([]); // per-bin peak-hold line - // Some radios control their scope over CI-V but never stream it (IC-7851). - // Saying so beats a black rectangle, which reads as a bug in OpsLog. - const [unsupported, setUnsupported] = useState(false); - const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune - const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune - const centerRef = useRef(0); // scope centre we last set (for pan ◀/▶) +// The spectrum scope is GONE, deliberately. Every Icom streams its waveform +// differently — the IC-7851 controls a scope it never streams, and a real +// IC-7760 stops answering CI-V altogether a few frames in, taking CAT and +// audio down with it — and chasing a per-model frame layout for a decoration +// is not worth a console that drops the link. The radio has a better scope +// on its own front panel. - const toggle = () => { - const next = !on; - setOn(next); - IcomSetScope(next).catch(() => {}); - }; - - // Centre/pan the FIXED scope: set the edges to centre ±50 kHz (a 100 kHz - // window). "Centre" uses the live VFO; ◀/▶ shift the window by 50 kHz. This - // just writes the rig's fixed edges — simple and independent of the waveform - // decode. - const SCOPE_HALF = 50_000; - const applyEdges = (center: number) => { - if (center <= 0) return; - centerRef.current = center; - setFixed(true); - IcomSetScopeEdges(center - SCOPE_HALF, center + SCOPE_HALF).catch(() => {}); - }; - const centerOnVfo = async () => { - let c = vfoRef.current; - if (c <= 0) { try { const cs = await GetCATState(); c = cs?.freq_hz || 0; } catch {} } - applyEdges(c); - }; - const pan = (dir: number) => applyEdges((centerRef.current || vfoRef.current) + dir * SCOPE_HALF); - const setMode = (nextFixed: boolean) => { - setFixed(nextFixed); - IcomSetScopeMode(nextFixed).catch(() => {}); - }; - // Stop the stream when the panel unmounts. - useEffect(() => () => { IcomSetScope(false).catch(() => {}); }, []); - - useEffect(() => { - if (!on) return; - let raf = 0, lastSeq = -1, alive = true; - const tick = async () => { - if (!alive) return; - try { - const sw = await IcomScopeData(); - if (sw?.unsupported) setUnsupported(true); - if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) { - lastSeq = sw.seq; - setFixed(sw.fixed); - spanRef.current = { low: sw.low_hz, high: sw.high_hz }; - let vfo = 0; - try { - const cs = await GetCATState(); - vfo = cs?.split && cs.freq_rx_hz ? cs.freq_rx_hz : (cs?.freq_hz || 0); - } catch {} - if (vfo > 0) vfoRef.current = vfo; - draw(sw.amp, sw.low_hz, sw.high_hz, vfoRef.current, sw.fixed); - } - } catch {} - if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number; - }; - raf = requestAnimationFrame(tick); - return () => { alive = false; cancelAnimationFrame(raf); window.clearTimeout(raf); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [on]); - - // Double-click tunes the rig to the clicked frequency. - const onDblClick = (e: React.MouseEvent) => { - const cv = canvasRef.current; - const { low, high } = spanRef.current; - if (!cv || !(low > 0 && high > low)) return; - const rect = cv.getBoundingClientRect(); - const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - const hz = Math.round((low + frac * (high - low)) / 100) * 100; // nearest 100 Hz - SetCATFrequency(hz).catch(() => {}); - }; - - // Mouse-wheel over the scope QSYs ±100 Hz. Non-passive listener so we can - // preventDefault (else the page scrolls); optimistic vfoRef so quick spins - // accumulate before the poll reconciles. - useEffect(() => { - const el = canvasRef.current; - if (!el || !on) return; - const onWheel = (e: WheelEvent) => { - if (!vfoRef.current) return; - e.preventDefault(); - const next = vfoRef.current + (e.deltaY < 0 ? 100 : -100); - vfoRef.current = next; - SetCATFrequency(next).catch(() => {}); - }; - el.addEventListener('wheel', onWheel, { passive: false }); - return () => el.removeEventListener('wheel', onWheel); - }, [on]); - - const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number, fixedMode: boolean) => { - const cv = canvasRef.current; - if (!cv) return; - const dpr = window.devicePixelRatio || 1; - const w = cv.clientWidth, h = cv.clientHeight; - if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr; } - const ctx = cv.getContext('2d'); - if (!ctx) return; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - - // Auto-scale: track the peak, decaying slowly so the floor doesn't jump. - const peak = Math.max(...amp); - peakRef.current = Math.max(peak, peakRef.current * 0.95, 40); - const scale = peakRef.current; - const n = amp.length; - - // Background — deep navy vertical gradient. - const bg = ctx.createLinearGradient(0, 0, 0, h); - bg.addColorStop(0, '#0b1220'); bg.addColorStop(1, '#05070e'); - ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); - - // Grid. - ctx.strokeStyle = 'rgba(120,150,200,0.08)'; - ctx.lineWidth = 1; - for (let i = 1; i < 4; i++) { const y = (h * i) / 4; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } - for (let i = 1; i < 8; i++) { const x = (w * i) / 8; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } - - const xOf = (i: number) => (i / (n - 1)) * w; - const yOf = (v: number) => h - Math.min(1, v / scale) * h; - - // Peak-hold line (slow decay) — a faint ghost of recent maxima. - const hold = holdRef.current; - if (hold.length !== n) hold.length = n, hold.fill(0); - for (let i = 0; i < n; i++) hold[i] = Math.max(amp[i], hold[i] * 0.92); - - // Filled spectrum area. - ctx.beginPath(); - ctx.moveTo(0, h); - for (let i = 0; i < n; i++) ctx.lineTo(xOf(i), yOf(amp[i])); - ctx.lineTo(w, h); ctx.closePath(); - const grad = ctx.createLinearGradient(0, 0, 0, h); - grad.addColorStop(0, 'rgba(56,189,248,0.40)'); - grad.addColorStop(1, 'rgba(56,189,248,0.02)'); - ctx.fillStyle = grad; ctx.fill(); - - // Peak-hold trace (thin, faint). - ctx.beginPath(); - for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(hold[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } - ctx.strokeStyle = 'rgba(148,197,255,0.35)'; ctx.lineWidth = 1; ctx.stroke(); - - // Live spectrum trace with a soft glow. - ctx.save(); - ctx.shadowColor = 'rgba(56,189,248,0.7)'; ctx.shadowBlur = 6; - ctx.beginPath(); - for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(amp[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } - ctx.strokeStyle = '#7dd3fc'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.stroke(); - ctx.restore(); - - // VFO marker: you should ALWAYS see where you are. Exact position when the VFO - // is inside the span; the centre in CTR mode; clamped to the nearest edge with - // a sideways arrow in FIX mode when the fixed scope doesn't cover the VFO (so - // you can tell which way to tune to get it back on-screen). - const haveVfo = vfoHz > 0 && lowHz > 0 && highHz > lowHz; - const inSpan = haveVfo && vfoHz >= lowHz && vfoHz <= highHz; - let markerX = -1; - let offEdge = 0; // -1 = VFO off the left edge, +1 = off the right - if (inSpan) markerX = ((vfoHz - lowHz) / (highHz - lowHz)) * w; - else if (!fixedMode) markerX = w / 2; - else if (haveVfo) { offEdge = vfoHz < lowHz ? -1 : 1; markerX = offEdge < 0 ? 1 : w - 1; } - if (markerX >= 0) { - const x = markerX; - ctx.fillStyle = 'rgba(244,63,94,0.10)'; ctx.fillRect(x - 5, 0, 10, h); - ctx.strokeStyle = 'rgba(244,63,94,0.9)'; ctx.lineWidth = 1.25; - ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); - ctx.fillStyle = 'rgba(244,63,94,0.95)'; - if (offEdge === 0) { - ctx.beginPath(); ctx.moveTo(x - 4, 0); ctx.lineTo(x + 4, 0); ctx.lineTo(x, 6); ctx.closePath(); ctx.fill(); - } else { - const yh = 8; ctx.beginPath(); ctx.moveTo(x, yh - 5); ctx.lineTo(x + offEdge * 7, yh); ctx.lineTo(x, yh + 5); ctx.closePath(); ctx.fill(); - } - } - - // Frequency scale. In fixed mode the rig reports usable edge frequencies, so - // we label low/centre/high from them. In centre mode the header frame's edge - // pair isn't a usable low..high range, but the scope is centred on the VFO — - // so we always label the centre with the live VFO frequency (which we fetch - // each sweep), and only add edge labels when the reported edges genuinely - // bracket the VFO. That guarantees you always see your frequency in CTR. - const mhz = (hz: number) => (hz / 1e6).toFixed(3); - ctx.font = '10px ui-monospace, monospace'; - ctx.textBaseline = 'bottom'; - ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3; - ctx.fillStyle = 'rgba(226,232,240,0.85)'; - const label = (txt: string, x: number, align: CanvasTextAlign) => { ctx.textAlign = align; ctx.fillText(txt, x, h - 3); }; - const validEdges = lowHz > 0 && highHz > lowHz; - if (fixedMode) { - if (validEdges) { - label(mhz(lowHz), 4, 'left'); - label(mhz((lowHz + highHz) / 2), w / 2, 'center'); - label(mhz(highHz), w - 4, 'right'); - } - } else { - if (validEdges && vfoHz >= lowHz && vfoHz <= highHz) { - label(mhz(lowHz), 4, 'left'); - label(mhz(highHz), w - 4, 'right'); - } - if (vfoHz > 0) label(mhz(vfoHz), w / 2, 'center'); - } - ctx.shadowBlur = 0; - - drawWaterfall(amp, scale); - }; - - // drawWaterfall scrolls the history down one row and paints the newest sweep - // as a colour-mapped line at the top. - const drawWaterfall = (amp: number[], scale: number) => { - const cv = wfRef.current; - if (!cv) return; - const w = Math.max(1, cv.clientWidth), h = Math.max(1, cv.clientHeight); - if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; } - const ctx = cv.getContext('2d'); - if (!ctx) return; - // Scroll everything down by one pixel row. - ctx.drawImage(cv, 0, 0, w, h - 1, 0, 1, w, h - 1); - // Paint the new top row. - const row = ctx.createImageData(w, 1); - const n = amp.length; - for (let x = 0; x < w; x++) { - const i = Math.min(n - 1, Math.round((x / (w - 1)) * (n - 1))); - const [r, g, b] = wfColor(amp[i] / scale); - const o = x * 4; - row.data[o] = r; row.data[o + 1] = g; row.data[o + 2] = b; row.data[o + 3] = 255; - } - ctx.putImageData(row, 0, 0); - }; - - // Collapsible card: when the scope is off, only the header band shows (the - // canvas is hidden entirely) so it doesn't waste vertical space. The CTR/FIX - // and ON/OFF controls live in the header itself. - return ( -
-
- - {t('icmp.spectrum')} -
- {on && ( -
- - - -
- )} - {on && ( - setMode(v === 'FIX')} /> - )} - -
-
- {on && unsupported && ( -
{t('icmp.scopeNoStream')}
- )} - {on && !unsupported && ( -
-
- - -
-
- )} -
- ); -} - -// IcomPanel — full control surface (RX DSP + TX) for an Icom on the CI-V backend. -// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are -// read every cache cycle; DSP set-controls are optimistic and reconcile on the -// next poll. Front-panel knob changes for DSP show after ↻ Refresh. export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) { // The speaker toggle lives HERE, next to ON/OFF, because that is where the // operator is looking — burying "stop listening" behind Settings → Audio @@ -830,9 +550,6 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} /> - {/* Spectrum panadapter (full width). */} - -
{/* Band buttons + antenna selection. */}