diff --git a/frontend/src/components/TCIPanel.tsx b/frontend/src/components/TCIPanel.tsx index 03c8d4d..0425751 100644 --- a/frontend/src/components/TCIPanel.tsx +++ b/frontend/src/components/TCIPanel.tsx @@ -32,16 +32,27 @@ const ZERO: TCIState = { rit: false, rit_offset: 0, xit: false, xit_offset: 0, lock: false, split: false, smeter: 0, }; -// The widths worth a button. TCI wants the two EDGES, not a width, so the -// edges are computed from the width and the mode — and the button says the -// width, which had better be the width you get. +// The widths worth a button, PER MODE — because 250 Hz is useless in SSB and +// 2.8 kHz is useless in CW, and a row offering both is a row where half the +// buttons are never pressed. // -// It did not: "250" set 300-550, which is 250 Hz wide but sitting where a CW -// note is not, and the row underneath said "300-550 Hz" while the button said -// 250. Now a narrow filter is CENTRED ON THE CW NOTE and a wide one starts at -// the bottom of the voice band, which is what each is for. -const CW_PITCH = 700; // ExpertSDR3's default sidetone, and where its CW filters sit -const WIDTHS = [250, 500, 1000, 1800, 2400, 2800, 3500]; +// CW gets the narrow end, where the difference between 250 and 500 is the +// difference between one signal and three. Voice gets the range a passband is +// actually shaped over. Digital sits between: wide enough for a whole FT8 +// sub-band, narrow enough for RTTY. +const WIDTHS_CW = [100, 250, 400, 500, 700, 1000, 1800]; +const WIDTHS_SSB = [1800, 2100, 2400, 2700, 2800, 3000, 3500]; +const WIDTHS_DIGI = [500, 1000, 1800, 2400, 2800, 3000, 3500]; + +// widthsFor picks the row from the mode the radio reports. +function widthsFor(mode: string): number[] { + if (/CW/i.test(mode)) return WIDTHS_CW; + if (/SSB|USB|LSB|AM|FM/i.test(mode)) return WIDTHS_SSB; + return WIDTHS_DIGI; +} + +// isCW says whether the CW-only controls belong on screen at all. +function isCW(mode: string): boolean { return /CW/i.test(mode); } function widthLabel(w: number): string { return w >= 1000 ? `${(w / 1000).toFixed(1)}k` : String(w); @@ -141,14 +152,33 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void // announces afterwards — the new value, or a refusal that leaves the old one // — wins once the hold expires, which keeps a clamped or rejected setting // honest without making every working one look broken. - const holdRef = useRef>({}); + // Held UNTIL THE RADIO SPEAKS, not for a fixed moment. + // + // A timeout was wrong in both directions. Too short and a setting the radio + // never echoes — AGC is one — snapped back to its old value a second after + // the click. Too long and a setting the radio REFUSES looked accepted: a real + // log shows this one answering 'sql_enable:0,false' and then, eighty + // milliseconds later, 'sql_enable:0,true' — it puts the squelch straight back + // on. Holding through that would have shown the operator a lie. + // + // So the requested value stands while the radio says nothing about it, and + // the instant it reports ANY change for that setting, its word replaces ours. + const holdRef = useRef>({}); const [, forceRender] = useState(0); const hold = (key: string, reported: T): T => { const h = holdRef.current[key]; - return h && Date.now() < h.until ? (h.v as T) : reported; + if (!h) return reported; + // The radio has said something different from what it was saying when the + // click happened — whether that is our value or a refusal, it is now the + // truth and the hold is over. + if (reported !== h.reported) { + delete holdRef.current[key]; + return reported; + } + return h.v as T; }; - const setHold = (key: string, v: any) => { - holdRef.current[key] = { v, until: Date.now() + 1200 }; + const setHold = (key: string, v: any, reported: any) => { + holdRef.current[key] = { v, reported }; forceRender((n) => n + 1); }; @@ -200,7 +230,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void if (r.off || !r.on) return; e.preventDefault(); const v = r.hz + (e.key === 'ArrowRight' ? 10 : -10); - setHold('rit_hz', v); + setHold('rit_hz', v, ritRef.current.hz); SetTCIRITOffset(v).catch(() => {}); }; window.addEventListener('keydown', onKey); @@ -255,9 +285,9 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void left each of them a couple of centimetres long — small enough that setting 15% took aim. */} { setHold('drive', v); call(() => SetTCIDrive(v)); }} /> + onSet={(v) => { setHold('drive', v, st.drive); call(() => SetTCIDrive(v)); }} /> { setHold('tune_drive', v); call(() => SetTCITuneDrive(v)); }} /> + onSet={(v) => { setHold('tune_drive', v, st.tune_drive); call(() => SetTCITuneDrive(v)); }} />
{/* TUNE transmits, and at the tune drive rather than the main one — which is why both numbers are above the button rather than one of @@ -274,7 +304,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void )}
{ setHold('mic', v); call(() => SetTCIMicLevel(v)); }} /> + onSet={(v) => { setHold('mic', v, st.mic_level); call(() => SetTCIMicLevel(v)); }} /> {/* Receive */} @@ -283,17 +313,22 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void squelch as a dBm threshold — so the numbers match the ones in ExpertSDR3's window rather than being percentages of something. */} { setHold('vol', v); call(() => SetTCIVolume(v)); }} /> + onSet={(v) => { setHold('vol', v, st.volume); call(() => SetTCIVolume(v)); }} /> { setHold('sql', v); call(() => SetTCISquelchLevel(v)); }} /> -
- { setHold('nb', !nb); call(() => SetTCINB(!nb)); }} /> - { setHold('nr', !nr); call(() => SetTCINR(!nr)); }} /> - { setHold('anf', !anf); call(() => SetTCIANF(!anf)); }} /> - { setHold('apf', !apf); call(() => SetTCIAPF(!apf)); }} /> - { setHold('sql_on', !sqlOn); call(() => SetTCISquelch(!sqlOn)); }} /> - { setHold('mute', !muted); call(() => SetTCIMute(!muted)); }} /> + onSet={(v) => { setHold('sql', v, st.squelch); call(() => SetTCISquelchLevel(v)); }} /> +
+ { setHold('nb', !nb, st.nb); call(() => SetTCINB(!nb)); }} /> + { setHold('nr', !nr, st.nr); call(() => SetTCINR(!nr)); }} /> + { setHold('anf', !anf, st.anf); call(() => SetTCIANF(!anf)); }} /> + {/* APF is an audio PEAK filter — it rings a single tone out of the + noise, which is a CW tool and nothing else. Shown only there: + off CW it is not a control, it is a puzzle. */} + {isCW(mode) && ( + { setHold('apf', !apf, st.apf); call(() => SetTCIAPF(!apf)); }} /> + )} + { setHold('sql_on', !sqlOn, st.squelch_on); call(() => SetTCISquelch(!sqlOn)); }} /> + { setHold('mute', !muted, st.mute); call(() => SetTCIMute(!muted)); }} />
{t('tcip.agc')} @@ -303,7 +338,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
{['off', 'slow', 'med', 'fast'].map((m) => ( { setHold('agc', m); call(() => SetTCIAGC(m)); }} /> + onClick={() => { setHold('agc', m, st.agc || ''); call(() => SetTCIAGC(m)); }} /> ))}
@@ -323,7 +358,7 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void onSet={(v) => call(() => SetTCIFilter(st.filter_lo, v))} />
- {WIDTHS.map((w) => { + {widthsFor(mode).map((w) => { const e = edgesFor(w, mode); // Lit by the WIDTH the radio is actually using, not by an exact // pair of edges: the operator may have moved one edge on the @@ -346,11 +381,11 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
{ setHold('rit', !rit); call(() => SetTCIRIT(!rit)); }} - onSet={(v) => { setHold('rit_hz', v); call(() => SetTCIRITOffset(v)); }} /> + onToggle={() => { setHold('rit', !rit, st.rit); call(() => SetTCIRIT(!rit)); }} + onSet={(v) => { setHold('rit_hz', v, st.rit_offset); call(() => SetTCIRITOffset(v)); }} /> { setHold('xit', !xit); call(() => SetTCIXIT(!xit)); }} - onSet={(v) => { setHold('xit_hz', v); call(() => SetTCIXITOffset(v)); }} /> + onToggle={() => { setHold('xit', !xit, st.xit); call(() => SetTCIXIT(!xit)); }} + onSet={(v) => { setHold('xit_hz', v, st.xit_offset); call(() => SetTCIXITOffset(v)); }} />