diff --git a/app.go b/app.go
index 064cd18..235d3d8 100644
--- a/app.go
+++ b/app.go
@@ -7842,6 +7842,30 @@ func (a *App) IcomSetScopeMode(fixed bool) error {
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetScopeMode(fixed) })
}
+// IcomSetRIT sets the RIT/ΔTX offset in signed Hz.
+func (a *App) IcomSetRIT(hz int) error {
+ if a.cat == nil {
+ return fmt.Errorf("cat not initialized")
+ }
+ return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRIT(hz) })
+}
+
+// IcomSetRITOn toggles RIT.
+func (a *App) IcomSetRITOn(on bool) error {
+ if a.cat == nil {
+ return fmt.Errorf("cat not initialized")
+ }
+ return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRITOn(on) })
+}
+
+// IcomSetXITOn toggles ΔTX (XIT).
+func (a *App) IcomSetXITOn(on bool) error {
+ if a.cat == nil {
+ return fmt.Errorf("cat not initialized")
+ }
+ return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetXITOn(on) })
+}
+
func (a *App) IcomSetPTT(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
diff --git a/frontend/src/components/IcomPanel.tsx b/frontend/src/components/IcomPanel.tsx
index d1587a3..820af85 100644
--- a/frontend/src/components/IcomPanel.tsx
+++ b/frontend/src/components/IcomPanel.tsx
@@ -1,11 +1,12 @@
import { useEffect, useRef, useState } from 'react';
-import { Radio, AudioLines, RefreshCw, Mic, Zap, Activity } from 'lucide-react';
+import { Radio, AudioLines, RefreshCw, Mic, Activity, SlidersHorizontal } from 'lucide-react';
import {
GetIcomState, IcomRefresh,
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
IcomSetScope, IcomScopeData, IcomSetScopeMode, GetCATState, SetCATFrequency,
+ IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
} from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
@@ -19,6 +20,7 @@ type IcomState = {
af_gain: number; rf_gain: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean;
agc?: string; preamp: number; att: number; filter: number;
+ rit_hz: number; rit_on: boolean; xit_on: boolean;
};
const ZERO: IcomState = {
@@ -27,6 +29,7 @@ const ZERO: IcomState = {
af_gain: 0, rf_gain: 0,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false,
preamp: 0, att: 0, filter: 1,
+ rit_hz: 0, rit_on: false, xit_on: false,
};
function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
@@ -145,6 +148,58 @@ function Meter({ label, value, accent, scale, onClick, title }: { label: string;
return
{body}
;
}
+// HdrMeter — a compact live meter for the model header band (S when receiving,
+// Po/SWR when transmitting). Clickable variant sends the S reading to RST tx.
+function HdrMeter({ label, value, accent, scale, onClick, title }: {
+ label: string; value: number; accent: string; scale: string; onClick?: () => void; title?: string;
+}) {
+ const v = Math.max(0, Math.min(100, value));
+ const body = (
+ <>
+ {label}
+
+ {scale}
+ >
+ );
+ if (onClick) return {body} ;
+ return {body}
;
+}
+
+// ShiftRow — a RIT / ΔTX offset control: on/off chip + a wheel-adjustable signed
+// offset (±10 Hz per notch or per ± button) + a clear (0) button.
+function ShiftRow({ label, on, hz, accent, onToggle, onDelta, onClear }: {
+ label: string; on: boolean; hz: number; accent: string;
+ onToggle: () => void; onDelta: (d: number) => void; onClear: () => void;
+}) {
+ const ref = useRef(null);
+ const cb = useRef(onDelta); cb.current = onDelta;
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const onWheel = (e: WheelEvent) => { e.preventDefault(); cb.current(e.deltaY < 0 ? 10 : -10); };
+ el.addEventListener('wheel', onWheel, { passive: false });
+ return () => el.removeEventListener('wheel', onWheel);
+ }, []);
+ return (
+
+
+
+ onDelta(-10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">−
+
+ {hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz)} Hz
+
+ onDelta(10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">+
+
+
0
+
+ );
+}
+
// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
// the RST-tx value on click.
@@ -157,15 +212,36 @@ function sParts(v: number): { s: number; over: number; label: string } {
return { s, over: 0, label: `S${s}` };
}
+// wfColor maps a 0-1 amplitude to a classic waterfall colour ramp
+// (near-black → blue → cyan → green → amber → red).
+const WF_STOPS: [number, [number, number, number]][] = [
+ [0.0, [8, 12, 28]], [0.22, [26, 58, 138]], [0.42, [0, 150, 190]],
+ [0.62, [46, 200, 120]], [0.80, [240, 210, 70]], [1.0, [244, 63, 60]],
+];
+function wfColor(v: number): [number, number, number] {
+ v = Math.max(0, Math.min(1, Math.pow(v, 0.7))); // gamma-lift so the noise floor still has hue
+ for (let i = 1; i < WF_STOPS.length; i++) {
+ if (v <= WF_STOPS[i][0]) {
+ const [a, ca] = WF_STOPS[i - 1], [b, cb] = WF_STOPS[i];
+ const f = (v - a) / (b - a || 1);
+ return [Math.round(ca[0] + (cb[0] - ca[0]) * f), Math.round(ca[1] + (cb[1] - ca[1]) * f), Math.round(ca[2] + (cb[2] - ca[2]) * f)];
+ }
+ }
+ return WF_STOPS[WF_STOPS.length - 1][1];
+}
+
// ScopePanadapter — enables the rig's spectrum-scope stream and draws the
-// reassembled sweep on a canvas. The amplitude array is raw rig scale (~0-160);
-// we normalise to the tallest recent peak so the trace fills the height.
+// 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
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
@@ -198,7 +274,7 @@ function ScopePanadapter() {
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, 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;
@@ -236,7 +312,7 @@ function ScopePanadapter() {
return () => el.removeEventListener('wheel', onWheel);
}, [on]);
- const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number) => {
+ const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number, fixedMode: boolean) => {
const cv = canvasRef.current;
if (!cv) return;
const dpr = window.devicePixelRatio || 1;
@@ -245,66 +321,133 @@ function ScopePanadapter() {
const ctx = cv.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
- ctx.clearRect(0, 0, w, h);
// 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,120,120,0.15)';
+ 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(); }
- // Spectrum trace, filled under the line.
- const n = amp.length;
+ 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++) {
- const x = (i / (n - 1)) * w;
- const y = h - Math.min(1, amp[i] / scale) * h;
- ctx.lineTo(x, y);
- }
- ctx.lineTo(w, h);
- ctx.closePath();
+ 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.55)');
- grad.addColorStop(1, 'rgba(56,189,248,0.05)');
- ctx.fillStyle = grad;
- ctx.fill();
- ctx.strokeStyle = '#38bdf8';
- ctx.lineWidth = 1.25;
- ctx.stroke();
+ grad.addColorStop(0, 'rgba(56,189,248,0.40)');
+ grad.addColorStop(1, 'rgba(56,189,248,0.02)');
+ ctx.fillStyle = grad; ctx.fill();
- // VFO marker: a vertical line at the current frequency within the span.
- if (vfoHz > 0 && lowHz > 0 && highHz > lowHz && vfoHz >= lowHz && vfoHz <= highHz) {
- const x = ((vfoHz - lowHz) / (highHz - lowHz)) * w;
- ctx.strokeStyle = 'rgba(239,68,68,0.95)';
- ctx.lineWidth = 1.5;
+ // 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: translucent band + crisp line + top marker triangle. In centre
+ // mode the span tracks the VFO, so fall back to the exact centre when the
+ // reported freq isn't inside the (just-updated) span — you always see where
+ // you are.
+ const inSpan = vfoHz > 0 && lowHz > 0 && highHz > lowHz && vfoHz >= lowHz && vfoHz <= highHz;
+ const markerX = inSpan ? ((vfoHz - lowHz) / (highHz - lowHz)) * w : (!fixedMode ? w / 2 : -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)';
+ ctx.beginPath(); ctx.moveTo(x - 4, 0); ctx.lineTo(x + 4, 0); ctx.lineTo(x, 6); ctx.closePath(); ctx.fill();
}
- // Frequency scale (edges + centre), only when the rig reported the span.
- if (lowHz > 0 && highHz > lowHz) {
- const mhz = (hz: number) => (hz / 1e6).toFixed(3);
- ctx.fillStyle = 'rgba(226,232,240,0.75)';
- ctx.font = '10px ui-monospace, monospace';
- ctx.textBaseline = 'bottom';
- ctx.textAlign = 'left'; ctx.fillText(mhz(lowHz), 3, h - 2);
- ctx.textAlign = 'center'; ctx.fillText(mhz((lowHz + highHz) / 2), w / 2, h - 2);
- ctx.textAlign = 'right'; ctx.fillText(mhz(highHz), w - 3, h - 2);
+ // 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 (
-
-
-
- {on ? (fixed ? t('icmp.scopeFixed') : t('icmp.scopeCenter')) : t('icmp.scopeOff')}
-
-
+
+
+
+
{t('icmp.spectrum')}
+
{on && (
setMode(v === 'FIX')} />
@@ -312,11 +455,16 @@ function ScopePanadapter() {
-
-
-
-
+ {on && (
+
+ )}
+
);
}
@@ -330,6 +478,7 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
const [busy, setBusy] = useState(false);
const [tuning, setTuning] = useState(false);
const txRef = useRef(false);
+ const stRef = useRef
(ZERO); stRef.current = st;
const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
const refresh = async () => {
@@ -364,6 +513,27 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
window.setTimeout(() => setTuning(false), 4000);
};
+ // RIT/ΔTX offset (signed Hz, clamped ±9999). Optimistic like the DSP controls.
+ const setRit = (hz: number) => {
+ const v = Math.max(-9999, Math.min(9999, hz));
+ set({ rit_hz: v }, () => IcomSetRIT(v));
+ };
+
+ // Ctrl+Left/Right shifts the RIT by ±10 Hz while RIT is active — a keyboard
+ // clarifier for zero-beating a caller without touching the mouse.
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
+ const s = stRef.current;
+ if (!s.available || !s.rit_on) return;
+ e.preventDefault();
+ setRit(s.rit_hz + (e.key === 'ArrowRight' ? 10 : -10));
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
if (!st.available) {
return (
@@ -389,6 +559,16 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
{st.mode ? {st.mode} : null}
{st.split ? Split : null}
+ {/* Live meter in the header band: S when receiving (click → RST tx), Po when transmitting. */}
+
+ {tx ? (
+
+ ) : (() => { const sp = sParts(st.s_meter); return (
+ onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
+ ); })()}
+
{t('icmp.refresh')}
@@ -399,18 +579,21 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
- {/* Live meters. */}
-
- {tx ? (
- <>
+ {/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
+
+ set({ rit_on: !st.rit_on }, () => IcomSetRITOn(!st.rit_on))}
+ onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
+ set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))}
+ onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
+ {t('icmp.ritHint')}
+ {tx && (
+
0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
- >
- ) : (() => { const sp = sParts(st.s_meter); return (
- onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
- ); })()}
+
+ )}
{/* Transmit controls. */}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx
index f10f7c0..9c8bf83 100644
--- a/frontend/src/lib/i18n.tsx
+++ b/frontend/src/lib/i18n.tsx
@@ -183,7 +183,7 @@ const en: Dict = {
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.',
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER',
- 'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter',
+ 'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active',
'rst.clickToFill': 'Click to set RST tx from the signal',
'qrz.openTitle': 'Open {call} on QRZ.com',
// Misc panels/modals (alerts / send-spot / net / udp / filter / details)
@@ -359,7 +359,7 @@ const fr: Dict = {
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.',
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR',
- 'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto',
+ 'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif',
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer',
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts
index 2c25e5c..2191703 100644
--- a/frontend/wailsjs/go/main/App.d.ts
+++ b/frontend/wailsjs/go/main/App.d.ts
@@ -370,12 +370,18 @@ export function IcomSetRFGain(arg1:number):Promise;
export function IcomSetRFPower(arg1:number):Promise;
+export function IcomSetRIT(arg1:number):Promise;
+
+export function IcomSetRITOn(arg1:boolean):Promise;
+
export function IcomSetScope(arg1:boolean):Promise;
export function IcomSetScopeMode(arg1:boolean):Promise;
export function IcomSetSplit(arg1:boolean):Promise;
+export function IcomSetXITOn(arg1:boolean):Promise;
+
export function IcomTune():Promise;
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise;
diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js
index 4fb764e..8612fe4 100644
--- a/frontend/wailsjs/go/main/App.js
+++ b/frontend/wailsjs/go/main/App.js
@@ -702,6 +702,14 @@ export function IcomSetRFPower(arg1) {
return window['go']['main']['App']['IcomSetRFPower'](arg1);
}
+export function IcomSetRIT(arg1) {
+ return window['go']['main']['App']['IcomSetRIT'](arg1);
+}
+
+export function IcomSetRITOn(arg1) {
+ return window['go']['main']['App']['IcomSetRITOn'](arg1);
+}
+
export function IcomSetScope(arg1) {
return window['go']['main']['App']['IcomSetScope'](arg1);
}
@@ -714,6 +722,10 @@ export function IcomSetSplit(arg1) {
return window['go']['main']['App']['IcomSetSplit'](arg1);
}
+export function IcomSetXITOn(arg1) {
+ return window['go']['main']['App']['IcomSetXITOn'](arg1);
+}
+
export function IcomTune() {
return window['go']['main']['App']['IcomTune']();
}
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index 7787b54..cfcc435 100644
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -676,6 +676,9 @@ export namespace cat {
s_meter: number;
power_meter: number;
swr_meter: number;
+ rit_hz: number;
+ rit_on: boolean;
+ xit_on: boolean;
rf_power: number;
mic_gain: number;
af_gain: number;
@@ -704,6 +707,9 @@ export namespace cat {
this.s_meter = source["s_meter"];
this.power_meter = source["power_meter"];
this.swr_meter = source["swr_meter"];
+ this.rit_hz = source["rit_hz"];
+ this.rit_on = source["rit_on"];
+ this.xit_on = source["xit_on"];
this.rf_power = source["rf_power"];
this.mic_gain = source["mic_gain"];
this.af_gain = source["af_gain"];
diff --git a/internal/cat/cat.go b/internal/cat/cat.go
index 95136ec..92ad8e5 100644
--- a/internal/cat/cat.go
+++ b/internal/cat/cat.go
@@ -382,6 +382,10 @@ type IcomTXState struct {
SMeter int `json:"s_meter"` // 0-100 (raw 0-255; S9≈120)
PowerMeter int `json:"power_meter"` // 0-100 (TX Po)
SWRMeter int `json:"swr_meter"` // 0-100 (TX SWR)
+ // RIT / ΔTX (XIT).
+ RITHz int `json:"rit_hz"` // RIT/XIT offset, signed Hz
+ RITOn bool `json:"rit_on"`
+ XITOn bool `json:"xit_on"`
// Set controls.
RFPower int `json:"rf_power"` // 0-100 (TX output)
MicGain int `json:"mic_gain"` // 0-100
@@ -422,6 +426,9 @@ type IcomController interface {
SetScope(bool) error // enable/disable the spectrum-scope waveform stream
SetScopeMode(bool) error // true = fixed span, false = center-on-VFO
ScopeData() ScopeSweep // latest assembled sweep (empty until enabled)
+ SetRIT(int) error // RIT/ΔTX offset in signed Hz
+ SetRITOn(bool) error // RIT on/off
+ SetXITOn(bool) error // ΔTX (XIT) on/off
}
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
diff --git a/internal/cat/civ/civ.go b/internal/cat/civ/civ.go
index 57ffa6f..64d5b27 100644
--- a/internal/cat/civ/civ.go
+++ b/internal/cat/civ/civ.go
@@ -44,6 +44,11 @@ const (
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
CmdATU = 0x1C // sub 0x01 = antenna tuner (0x00 off, 0x01 through, 0x02 tune)
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
+ CmdRIT = 0x21 // RIT/ΔTX: sub 0x00 offset freq, 0x01 RIT on/off, 0x02 ΔTX(XIT) on/off
+
+ SubRITFreq = 0x00 // RIT/ΔTX offset: 2 BCD bytes (LE, 0-9999) + sign byte (00 +, 01 -)
+ SubRITOn = 0x01 // RIT on/off (00/01)
+ SubXITOn = 0x02 // ΔTX (XIT) on/off (00/01)
SubDataMode = 0x06
SubPTT = 0x00
@@ -154,6 +159,38 @@ func BCDToLevel(b []byte) int {
return int(b[0])*100 + int(b[1]>>4)*10 + int(b[1]&0x0F)
}
+// RITToBCD encodes a RIT/ΔTX offset (Hz, −9999..9999) as the 3 bytes CI-V
+// command 0x21 0x00 uses: 2 little-endian BCD bytes of the magnitude followed by
+// a sign byte (0x00 positive, 0x01 negative).
+func RITToBCD(hz int) []byte {
+ neg := hz < 0
+ if neg {
+ hz = -hz
+ }
+ if hz > 9999 {
+ hz = 9999
+ }
+ lo := byte(hz%10 | (hz/10%10)<<4)
+ hi := byte(hz/100%10 | (hz/1000%10)<<4)
+ sign := byte(0)
+ if neg {
+ sign = 1
+ }
+ return []byte{lo, hi, sign}
+}
+
+// BCDToRIT decodes the 3 offset bytes of a 0x21 0x00 response back to signed Hz.
+func BCDToRIT(b []byte) int {
+ if len(b) < 3 {
+ return 0
+ }
+ v := int(b[0]&0x0F) + int(b[0]>>4)*10 + int(b[1]&0x0F)*100 + int(b[1]>>4)*1000
+ if b[2] != 0 {
+ return -v
+ }
+ return v
+}
+
// ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the
// attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…).
func ByteToBCD(v int) byte {
diff --git a/internal/cat/icomserial.go b/internal/cat/icomserial.go
index d065472..d0a2ce2 100644
--- a/internal/cat/icomserial.go
+++ b/internal/cat/icomserial.go
@@ -277,6 +277,12 @@ func (b *IcomSerial) SetPTT(on bool) error {
// ── helpers ───────────────────────────────────────────────────────────────
func (b *IcomSerial) write(payload ...byte) error {
+ // Not connected (rig off / port dropped): fail cleanly instead of
+ // dereferencing a nil port — a Set* dispatched while disconnected (e.g.
+ // clicking Scope ON with the radio off) would otherwise panic the app.
+ if b.port == nil {
+ return fmt.Errorf("icom: not connected")
+ }
// Drop any stale/unsolicited frames buffered from before this command so
// recv() only sees the reply to THIS request (avoids a previous command's ack
// or an unsolicited dial-turn update being mistaken for our response).
@@ -514,6 +520,68 @@ func (b *IcomSerial) SetScopeMode(fixed bool) error {
return nil
}
+// SetRIT sets the RIT/ΔTX offset (signed Hz, ±9999).
+func (b *IcomSerial) SetRIT(hz int) error {
+ if err := b.exec(append([]byte{civ.CmdRIT, civ.SubRITFreq}, civ.RITToBCD(hz)...)...); err != nil {
+ return err
+ }
+ if hz < -9999 {
+ hz = -9999
+ }
+ if hz > 9999 {
+ hz = 9999
+ }
+ b.setCache(func(s *IcomTXState) { s.RITHz = hz })
+ return nil
+}
+
+func (b *IcomSerial) SetRITOn(on bool) error {
+ if err := b.exec(civ.CmdRIT, civ.SubRITOn, boolByte(on)); err != nil {
+ return err
+ }
+ b.setCache(func(s *IcomTXState) { s.RITOn = on })
+ return nil
+}
+
+func (b *IcomSerial) SetXITOn(on bool) error {
+ if err := b.exec(civ.CmdRIT, civ.SubXITOn, boolByte(on)); err != nil {
+ return err
+ }
+ b.setCache(func(s *IcomTXState) { s.XITOn = on })
+ return nil
+}
+
+// readRIT reads the offset + RIT/ΔTX on-off flags into st (best-effort).
+func (b *IcomSerial) readRIT(st *IcomTXState) {
+ if err := b.write(civ.CmdRIT, civ.SubRITFreq); err == nil {
+ if f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
+ return d.Cmd == civ.CmdRIT && len(d.Data) >= 4 && d.Data[0] == civ.SubRITFreq
+ }); err == nil {
+ st.RITHz = civ.BCDToRIT(f.Data[1:4])
+ }
+ }
+ if v, ok := b.readSwitchSub(civ.CmdRIT, civ.SubRITOn); ok {
+ st.RITOn = v != 0
+ }
+ if v, ok := b.readSwitchSub(civ.CmdRIT, civ.SubXITOn); ok {
+ st.XITOn = v != 0
+ }
+}
+
+// readSwitchSub reads a 1-byte on/off value for cmd+sub (generalises readSwitch).
+func (b *IcomSerial) readSwitchSub(cmd, sub byte) (byte, bool) {
+ if err := b.write(cmd, sub); err != nil {
+ return 0, false
+ }
+ f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
+ return d.Cmd == cmd && len(d.Data) >= 2 && d.Data[0] == sub
+ })
+ if err != nil {
+ return 0, false
+ }
+ return f.Data[1], true
+}
+
// ScopeData returns a copy of the latest reassembled sweep as a number array.
func (b *IcomSerial) ScopeData() ScopeSweep {
b.scopeMu.Lock()
@@ -743,6 +811,7 @@ func (b *IcomSerial) readDSP() {
if _, f, ok := b.readModeFilter(); ok {
st.Filter = int(f)
}
+ b.readRIT(&st)
b.dspMu.Lock()
b.dsp = st
diff --git a/internal/cwdecode/cwdecode.go b/internal/cwdecode/cwdecode.go
index 02b4911..894a322 100644
--- a/internal/cwdecode/cwdecode.go
+++ b/internal/cwdecode/cwdecode.go
@@ -60,6 +60,7 @@ type Decoder struct {
state bool // true = mark (key down)
stateHops int
dotHops float64 // adaptive dot length, in hops
+ markCount int // marks seen since lock (fast WPM adaptation while small)
elem []byte // current "." / "-" run for the in-progress character
charEmitted bool
wordEmitted bool
@@ -145,6 +146,7 @@ func (d *Decoder) Reset() {
d.state = false
d.stateHops = 0
d.dotHops = 15
+ d.markCount = 0
d.elem = d.elem[:0]
d.charEmitted, d.wordEmitted = false, false
}
@@ -213,10 +215,11 @@ func (d *Decoder) analyze() {
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we
// don't eat the first element of a strong signal), a marginal/weak tone
// locks after a couple of stable hops (so we don't lock onto pure noise).
- if snr > d.strongSNR || (d.candHops >= 3 && snr > d.acqSNR) {
+ if snr > d.strongSNR || (d.candHops >= 2 && snr > d.acqSNR) {
d.lockIdx = maxIdx
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
d.quietHops = 0
+ d.markCount = 0 // relearn WPM fast for this new signal
}
}
if d.lockIdx >= 0 {
@@ -267,6 +270,17 @@ func (d *Decoder) step() {
} else {
d.quietHops++
if d.quietHops > d.relockHops {
+ // End of the over: flush any pending character and drop a word
+ // space so the next transmission starts a fresh word (the word-gap
+ // timer above can't fire once the lock is gone).
+ if len(d.elem) > 0 && !d.charEmitted {
+ d.flushChar()
+ d.charEmitted = true
+ }
+ if !d.wordEmitted && d.onChar != nil {
+ d.onChar(" ")
+ d.wordEmitted = true
+ }
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
}
}
@@ -311,10 +325,18 @@ func (d *Decoder) endMark(hops int) {
}
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
-// to ~5–100 WPM).
+// to ~5–100 WPM). The first marks of a new signal adapt FAST so the WPM (and
+// therefore the character/word gap thresholds) converge within the first
+// character or two — otherwise a wrong seed mis-times early gaps and runs
+// characters/words together.
func (d *Decoder) adaptDot(obs float64) {
- d.dotHops = d.dotHops*0.8 + obs*0.2 // slower: a few odd marks can't yank it
- if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
+ alpha := 0.2
+ if d.markCount < 6 {
+ alpha = 0.5 // fast convergence on the opening marks
+ }
+ d.markCount++
+ d.dotHops = d.dotHops*(1-alpha) + obs*alpha
+ if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
d.dotHops = 5
}
if d.dotHops > 55 {