feat: added RIT and XIT for Icom
This commit is contained in:
@@ -7842,6 +7842,30 @@ func (a *App) IcomSetScopeMode(fixed bool) error {
|
|||||||
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetScopeMode(fixed) })
|
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 {
|
func (a *App) IcomSetPTT(on bool) error {
|
||||||
if a.cat == nil {
|
if a.cat == nil {
|
||||||
return fmt.Errorf("cat not initialized")
|
return fmt.Errorf("cat not initialized")
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
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 {
|
import {
|
||||||
GetIcomState, IcomRefresh,
|
GetIcomState, IcomRefresh,
|
||||||
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
||||||
IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
||||||
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
|
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
|
||||||
IcomSetScope, IcomScopeData, IcomSetScopeMode, GetCATState, SetCATFrequency,
|
IcomSetScope, IcomScopeData, IcomSetScopeMode, GetCATState, SetCATFrequency,
|
||||||
|
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -19,6 +20,7 @@ type IcomState = {
|
|||||||
af_gain: number; rf_gain: number;
|
af_gain: number; rf_gain: number;
|
||||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean;
|
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean;
|
||||||
agc?: string; preamp: number; att: number; filter: number;
|
agc?: string; preamp: number; att: number; filter: number;
|
||||||
|
rit_hz: number; rit_on: boolean; xit_on: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ZERO: IcomState = {
|
const ZERO: IcomState = {
|
||||||
@@ -27,6 +29,7 @@ const ZERO: IcomState = {
|
|||||||
af_gain: 0, rf_gain: 0,
|
af_gain: 0, rf_gain: 0,
|
||||||
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false,
|
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false,
|
||||||
preamp: 0, att: 0, filter: 1,
|
preamp: 0, att: 0, filter: 1,
|
||||||
|
rit_hz: 0, rit_on: false, xit_on: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
|
function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
|
||||||
@@ -145,6 +148,58 @@ function Meter({ label, value, accent, scale, onClick, title }: { label: string;
|
|||||||
return <div className="flex items-center gap-2">{body}</div>;
|
return <div className="flex items-center gap-2">{body}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 = (
|
||||||
|
<>
|
||||||
|
<span className="w-5 shrink-0 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||||
|
<div className="w-24 sm:w-32 h-2 rounded-full bg-muted/70 overflow-hidden">
|
||||||
|
<div className="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
|
||||||
|
</div>
|
||||||
|
<span className="w-12 text-right text-[11px] font-mono font-bold tabular-nums" style={{ color: accent }}>{scale}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
if (onClick) return <button type="button" onClick={onClick} title={title} className="flex items-center gap-1.5 rounded-md hover:bg-muted/60 px-1.5 py-0.5 -my-0.5">{body}</button>;
|
||||||
|
return <div className="flex items-center gap-1.5 px-1.5">{body}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<HTMLDivElement>(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 (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip on={on} onClick={onToggle} label={label} />
|
||||||
|
<div ref={ref} title="Wheel / ± to shift"
|
||||||
|
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none cursor-ns-resize',
|
||||||
|
on ? 'border-border bg-muted/40' : 'border-border/60 bg-muted/20 opacity-60')}>
|
||||||
|
<button type="button" onClick={() => onDelta(-10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">−</button>
|
||||||
|
<span className="text-sm font-mono font-bold tabular-nums" style={{ color: on ? accent : undefined }}>
|
||||||
|
{hz > 0 ? '+' : hz < 0 ? '−' : ''}{Math.abs(hz)} Hz
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => onDelta(10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">+</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClear}
|
||||||
|
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">0</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the
|
// 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
|
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
|
||||||
// the RST-tx value on click.
|
// 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}` };
|
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
|
// 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);
|
// reassembled sweep as a modern SDR panadapter: a glowing filled spectrum trace
|
||||||
// we normalise to the tallest recent peak so the trace fills the height.
|
// 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() {
|
function ScopePanadapter() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [on, setOn] = useState(false);
|
const [on, setOn] = useState(false);
|
||||||
const [fixed, setFixed] = useState(true);
|
const [fixed, setFixed] = useState(true);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const wfRef = useRef<HTMLCanvasElement>(null); // waterfall
|
||||||
const peakRef = useRef(160); // running amplitude ceiling for auto-scale
|
const peakRef = useRef(160); // running amplitude ceiling for auto-scale
|
||||||
|
const holdRef = useRef<number[]>([]); // per-bin peak-hold line
|
||||||
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
|
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 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);
|
vfo = cs?.split && cs.freq_rx_hz ? cs.freq_rx_hz : (cs?.freq_hz || 0);
|
||||||
} catch {}
|
} catch {}
|
||||||
if (vfo > 0) vfoRef.current = vfo;
|
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 {}
|
} catch {}
|
||||||
if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number;
|
if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number;
|
||||||
@@ -236,7 +312,7 @@ function ScopePanadapter() {
|
|||||||
return () => el.removeEventListener('wheel', onWheel);
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
}, [on]);
|
}, [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;
|
const cv = canvasRef.current;
|
||||||
if (!cv) return;
|
if (!cv) return;
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
@@ -245,66 +321,133 @@ function ScopePanadapter() {
|
|||||||
const ctx = cv.getContext('2d');
|
const ctx = cv.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
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.
|
// Auto-scale: track the peak, decaying slowly so the floor doesn't jump.
|
||||||
const peak = Math.max(...amp);
|
const peak = Math.max(...amp);
|
||||||
peakRef.current = Math.max(peak, peakRef.current * 0.95, 40);
|
peakRef.current = Math.max(peak, peakRef.current * 0.95, 40);
|
||||||
const scale = peakRef.current;
|
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.
|
// Grid.
|
||||||
ctx.strokeStyle = 'rgba(120,120,120,0.15)';
|
ctx.strokeStyle = 'rgba(120,150,200,0.08)';
|
||||||
ctx.lineWidth = 1;
|
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 < 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(); }
|
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 xOf = (i: number) => (i / (n - 1)) * w;
|
||||||
const n = amp.length;
|
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.beginPath();
|
||||||
ctx.moveTo(0, h);
|
ctx.moveTo(0, h);
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) ctx.lineTo(xOf(i), yOf(amp[i]));
|
||||||
const x = (i / (n - 1)) * w;
|
ctx.lineTo(w, h); ctx.closePath();
|
||||||
const y = h - Math.min(1, amp[i] / scale) * h;
|
|
||||||
ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
ctx.lineTo(w, h);
|
|
||||||
ctx.closePath();
|
|
||||||
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
||||||
grad.addColorStop(0, 'rgba(56,189,248,0.55)');
|
grad.addColorStop(0, 'rgba(56,189,248,0.40)');
|
||||||
grad.addColorStop(1, 'rgba(56,189,248,0.05)');
|
grad.addColorStop(1, 'rgba(56,189,248,0.02)');
|
||||||
ctx.fillStyle = grad;
|
ctx.fillStyle = grad; ctx.fill();
|
||||||
ctx.fill();
|
|
||||||
ctx.strokeStyle = '#38bdf8';
|
|
||||||
ctx.lineWidth = 1.25;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// VFO marker: a vertical line at the current frequency within the span.
|
// Peak-hold trace (thin, faint).
|
||||||
if (vfoHz > 0 && lowHz > 0 && highHz > lowHz && vfoHz >= lowHz && vfoHz <= highHz) {
|
ctx.beginPath();
|
||||||
const x = ((vfoHz - lowHz) / (highHz - lowHz)) * w;
|
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(239,68,68,0.95)';
|
ctx.strokeStyle = 'rgba(148,197,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
|
// 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.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.
|
// Frequency scale. In fixed mode the rig reports usable edge frequencies, so
|
||||||
if (lowHz > 0 && highHz > lowHz) {
|
// we label low/centre/high from them. In centre mode the header frame's edge
|
||||||
const mhz = (hz: number) => (hz / 1e6).toFixed(3);
|
// pair isn't a usable low..high range, but the scope is centred on the VFO —
|
||||||
ctx.fillStyle = 'rgba(226,232,240,0.75)';
|
// so we always label the centre with the live VFO frequency (which we fetch
|
||||||
ctx.font = '10px ui-monospace, monospace';
|
// each sweep), and only add edge labels when the reported edges genuinely
|
||||||
ctx.textBaseline = 'bottom';
|
// bracket the VFO. That guarantees you always see your frequency in CTR.
|
||||||
ctx.textAlign = 'left'; ctx.fillText(mhz(lowHz), 3, h - 2);
|
const mhz = (hz: number) => (hz / 1e6).toFixed(3);
|
||||||
ctx.textAlign = 'center'; ctx.fillText(mhz((lowHz + highHz) / 2), w / 2, h - 2);
|
ctx.font = '10px ui-monospace, monospace';
|
||||||
ctx.textAlign = 'right'; ctx.fillText(mhz(highHz), w - 3, h - 2);
|
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 (
|
return (
|
||||||
<Card icon={Activity} title={t('icmp.spectrum')} accent="#38bdf8">
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
<span className="text-[11px] text-muted-foreground truncate">
|
<Activity className="size-4" style={{ color: '#38bdf8' }} />
|
||||||
{on ? (fixed ? t('icmp.scopeFixed') : t('icmp.scopeCenter')) : t('icmp.scopeOff')}
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('icmp.spectrum')}</span>
|
||||||
</span>
|
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
{on && (
|
{on && (
|
||||||
<Segmented value={fixed ? 'FIX' : 'CTR'} options={[{ v: 'CTR', l: 'CTR' }, { v: 'FIX', l: 'FIX' }]}
|
<Segmented value={fixed ? 'FIX' : 'CTR'} options={[{ v: 'CTR', l: 'CTR' }, { v: 'FIX', l: 'FIX' }]}
|
||||||
onChange={(v) => setMode(v === 'FIX')} />
|
onChange={(v) => setMode(v === 'FIX')} />
|
||||||
@@ -312,11 +455,16 @@ function ScopePanadapter() {
|
|||||||
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
|
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-border bg-black/70 overflow-hidden">
|
{on && (
|
||||||
<canvas ref={canvasRef} onDoubleClick={onDblClick}
|
<div className="p-3">
|
||||||
className="w-full block cursor-crosshair" style={{ height: 150 }} />
|
<div className="rounded-xl overflow-hidden ring-1 ring-sky-500/20 shadow-lg shadow-sky-500/5 bg-[#05070e]">
|
||||||
</div>
|
<canvas ref={canvasRef} onDoubleClick={onDblClick}
|
||||||
</Card>
|
className="w-full block cursor-crosshair" style={{ height: 140 }} />
|
||||||
|
<canvas ref={wfRef} className="w-full block" style={{ height: 96 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +478,7 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [tuning, setTuning] = useState(false);
|
const [tuning, setTuning] = useState(false);
|
||||||
const txRef = useRef(false);
|
const txRef = useRef(false);
|
||||||
|
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
|
||||||
|
|
||||||
const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
|
const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
@@ -364,6 +513,27 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
window.setTimeout(() => setTuning(false), 4000);
|
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) {
|
if (!st.available) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
|
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
|
||||||
@@ -389,6 +559,16 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
{st.mode ? <span className="text-xs font-mono text-muted-foreground">{st.mode}</span> : null}
|
{st.mode ? <span className="text-xs font-mono text-muted-foreground">{st.mode}</span> : null}
|
||||||
{st.split ? <span className="rounded-md bg-amber-500/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-amber-600">Split</span> : null}
|
{st.split ? <span className="rounded-md bg-amber-500/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-amber-600">Split</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Live meter in the header band: S when receiving (click → RST tx), Po when transmitting. */}
|
||||||
|
<div className="hidden md:flex items-center">
|
||||||
|
{tx ? (
|
||||||
|
<HdrMeter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter}%`} />
|
||||||
|
) : (() => { const sp = sParts(st.s_meter); return (
|
||||||
|
<HdrMeter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
|
||||||
|
title={onReportRST ? t('rst.clickToFill') : undefined}
|
||||||
|
onClick={onReportRST ? () => onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
|
||||||
|
); })()}
|
||||||
|
</div>
|
||||||
<button type="button" onClick={refresh} disabled={busy}
|
<button type="button" onClick={refresh} disabled={busy}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-xs hover:bg-muted disabled:opacity-40">
|
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-xs hover:bg-muted disabled:opacity-40">
|
||||||
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('icmp.refresh')}
|
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('icmp.refresh')}
|
||||||
@@ -399,18 +579,21 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
<ScopePanadapter />
|
<ScopePanadapter />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
{/* Live meters. */}
|
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
|
||||||
<Card icon={Zap} title={t('icmp.meters')} accent="#f59e0b">
|
<Card icon={SlidersHorizontal} title={t('icmp.clarifiers')} accent="#8b5cf6">
|
||||||
{tx ? (
|
<ShiftRow label="RIT" accent="#8b5cf6" on={st.rit_on} hz={st.rit_hz}
|
||||||
<>
|
onToggle={() => set({ rit_on: !st.rit_on }, () => IcomSetRITOn(!st.rit_on))}
|
||||||
|
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
||||||
|
<ShiftRow label="ΔTX" accent="#f59e0b" on={st.xit_on} hz={st.rit_hz}
|
||||||
|
onToggle={() => set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))}
|
||||||
|
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('icmp.ritHint')}</p>
|
||||||
|
{tx && (
|
||||||
|
<div className="pt-1 border-t border-border/60 space-y-3">
|
||||||
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter}%`} />
|
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter}%`} />
|
||||||
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
|
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
|
||||||
</>
|
</div>
|
||||||
) : (() => { const sp = sParts(st.s_meter); return (
|
)}
|
||||||
<Meter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
|
|
||||||
title={onReportRST ? t('rst.clickToFill') : undefined}
|
|
||||||
onClick={onReportRST ? () => onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
|
|
||||||
); })()}
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Transmit controls. */}
|
{/* Transmit controls. */}
|
||||||
|
|||||||
@@ -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.',
|
'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.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',
|
'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',
|
'rst.clickToFill': 'Click to set RST tx from the signal',
|
||||||
'qrz.openTitle': 'Open {call} on QRZ.com',
|
'qrz.openTitle': 'Open {call} on QRZ.com',
|
||||||
// Misc panels/modals (alerts / send-spot / net / udp / filter / details)
|
// 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.',
|
'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.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',
|
'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',
|
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
|
||||||
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
|
'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',
|
'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',
|
||||||
|
|||||||
Vendored
+6
@@ -370,12 +370,18 @@ export function IcomSetRFGain(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function IcomSetRFPower(arg1:number):Promise<void>;
|
export function IcomSetRFPower(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetRIT(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetRITOn(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetScope(arg1:boolean):Promise<void>;
|
export function IcomSetScope(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetScopeMode(arg1:boolean):Promise<void>;
|
export function IcomSetScopeMode(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function IcomSetSplit(arg1:boolean):Promise<void>;
|
export function IcomSetSplit(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetXITOn(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function IcomTune():Promise<void>;
|
export function IcomTune():Promise<void>;
|
||||||
|
|
||||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
||||||
|
|||||||
@@ -702,6 +702,14 @@ export function IcomSetRFPower(arg1) {
|
|||||||
return window['go']['main']['App']['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) {
|
export function IcomSetScope(arg1) {
|
||||||
return window['go']['main']['App']['IcomSetScope'](arg1);
|
return window['go']['main']['App']['IcomSetScope'](arg1);
|
||||||
}
|
}
|
||||||
@@ -714,6 +722,10 @@ export function IcomSetSplit(arg1) {
|
|||||||
return window['go']['main']['App']['IcomSetSplit'](arg1);
|
return window['go']['main']['App']['IcomSetSplit'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IcomSetXITOn(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetXITOn'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function IcomTune() {
|
export function IcomTune() {
|
||||||
return window['go']['main']['App']['IcomTune']();
|
return window['go']['main']['App']['IcomTune']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -676,6 +676,9 @@ export namespace cat {
|
|||||||
s_meter: number;
|
s_meter: number;
|
||||||
power_meter: number;
|
power_meter: number;
|
||||||
swr_meter: number;
|
swr_meter: number;
|
||||||
|
rit_hz: number;
|
||||||
|
rit_on: boolean;
|
||||||
|
xit_on: boolean;
|
||||||
rf_power: number;
|
rf_power: number;
|
||||||
mic_gain: number;
|
mic_gain: number;
|
||||||
af_gain: number;
|
af_gain: number;
|
||||||
@@ -704,6 +707,9 @@ export namespace cat {
|
|||||||
this.s_meter = source["s_meter"];
|
this.s_meter = source["s_meter"];
|
||||||
this.power_meter = source["power_meter"];
|
this.power_meter = source["power_meter"];
|
||||||
this.swr_meter = source["swr_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.rf_power = source["rf_power"];
|
||||||
this.mic_gain = source["mic_gain"];
|
this.mic_gain = source["mic_gain"];
|
||||||
this.af_gain = source["af_gain"];
|
this.af_gain = source["af_gain"];
|
||||||
|
|||||||
@@ -382,6 +382,10 @@ type IcomTXState struct {
|
|||||||
SMeter int `json:"s_meter"` // 0-100 (raw 0-255; S9≈120)
|
SMeter int `json:"s_meter"` // 0-100 (raw 0-255; S9≈120)
|
||||||
PowerMeter int `json:"power_meter"` // 0-100 (TX Po)
|
PowerMeter int `json:"power_meter"` // 0-100 (TX Po)
|
||||||
SWRMeter int `json:"swr_meter"` // 0-100 (TX SWR)
|
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.
|
// Set controls.
|
||||||
RFPower int `json:"rf_power"` // 0-100 (TX output)
|
RFPower int `json:"rf_power"` // 0-100 (TX output)
|
||||||
MicGain int `json:"mic_gain"` // 0-100
|
MicGain int `json:"mic_gain"` // 0-100
|
||||||
@@ -422,6 +426,9 @@ type IcomController interface {
|
|||||||
SetScope(bool) error // enable/disable the spectrum-scope waveform stream
|
SetScope(bool) error // enable/disable the spectrum-scope waveform stream
|
||||||
SetScopeMode(bool) error // true = fixed span, false = center-on-VFO
|
SetScopeMode(bool) error // true = fixed span, false = center-on-VFO
|
||||||
ScopeData() ScopeSweep // latest assembled sweep (empty until enabled)
|
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
|
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ const (
|
|||||||
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
|
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
|
||||||
CmdATU = 0x1C // sub 0x01 = antenna tuner (0x00 off, 0x01 through, 0x02 tune)
|
CmdATU = 0x1C // sub 0x01 = antenna tuner (0x00 off, 0x01 through, 0x02 tune)
|
||||||
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
|
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
|
SubDataMode = 0x06
|
||||||
SubPTT = 0x00
|
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)
|
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
|
// ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the
|
||||||
// attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…).
|
// attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…).
|
||||||
func ByteToBCD(v int) byte {
|
func ByteToBCD(v int) byte {
|
||||||
|
|||||||
@@ -277,6 +277,12 @@ func (b *IcomSerial) SetPTT(on bool) error {
|
|||||||
// ── helpers ───────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (b *IcomSerial) write(payload ...byte) error {
|
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
|
// 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
|
// 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).
|
// or an unsolicited dial-turn update being mistaken for our response).
|
||||||
@@ -514,6 +520,68 @@ func (b *IcomSerial) SetScopeMode(fixed bool) error {
|
|||||||
return nil
|
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.
|
// ScopeData returns a copy of the latest reassembled sweep as a number array.
|
||||||
func (b *IcomSerial) ScopeData() ScopeSweep {
|
func (b *IcomSerial) ScopeData() ScopeSweep {
|
||||||
b.scopeMu.Lock()
|
b.scopeMu.Lock()
|
||||||
@@ -743,6 +811,7 @@ func (b *IcomSerial) readDSP() {
|
|||||||
if _, f, ok := b.readModeFilter(); ok {
|
if _, f, ok := b.readModeFilter(); ok {
|
||||||
st.Filter = int(f)
|
st.Filter = int(f)
|
||||||
}
|
}
|
||||||
|
b.readRIT(&st)
|
||||||
|
|
||||||
b.dspMu.Lock()
|
b.dspMu.Lock()
|
||||||
b.dsp = st
|
b.dsp = st
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ type Decoder struct {
|
|||||||
state bool // true = mark (key down)
|
state bool // true = mark (key down)
|
||||||
stateHops int
|
stateHops int
|
||||||
dotHops float64 // adaptive dot length, in hops
|
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
|
elem []byte // current "." / "-" run for the in-progress character
|
||||||
charEmitted bool
|
charEmitted bool
|
||||||
wordEmitted bool
|
wordEmitted bool
|
||||||
@@ -145,6 +146,7 @@ func (d *Decoder) Reset() {
|
|||||||
d.state = false
|
d.state = false
|
||||||
d.stateHops = 0
|
d.stateHops = 0
|
||||||
d.dotHops = 15
|
d.dotHops = 15
|
||||||
|
d.markCount = 0
|
||||||
d.elem = d.elem[:0]
|
d.elem = d.elem[:0]
|
||||||
d.charEmitted, d.wordEmitted = false, false
|
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
|
// 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
|
// 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).
|
// 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.lockIdx = maxIdx
|
||||||
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
|
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
|
||||||
d.quietHops = 0
|
d.quietHops = 0
|
||||||
|
d.markCount = 0 // relearn WPM fast for this new signal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if d.lockIdx >= 0 {
|
if d.lockIdx >= 0 {
|
||||||
@@ -267,6 +270,17 @@ func (d *Decoder) step() {
|
|||||||
} else {
|
} else {
|
||||||
d.quietHops++
|
d.quietHops++
|
||||||
if d.quietHops > d.relockHops {
|
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
|
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
|
// 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) {
|
func (d *Decoder) adaptDot(obs float64) {
|
||||||
d.dotHops = d.dotHops*0.8 + obs*0.2 // slower: a few odd marks can't yank it
|
alpha := 0.2
|
||||||
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
|
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
|
d.dotHops = 5
|
||||||
}
|
}
|
||||||
if d.dotHops > 55 {
|
if d.dotHops > 55 {
|
||||||
|
|||||||
Reference in New Issue
Block a user