From fbe01bf19dcc83e2816841b3d44e5024093f6b56 Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 26 Aug 2026 20:54:28 +0200 Subject: [PATCH] fix(tci): wide level rows, honest filter labels, and mute read both ways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things reported from the radio, all of them mine. THE LEVELS. Two half-width sliders side by side left each of them a couple of centimetres long, which is not enough to set 15% with. One level per row now, full width, with the value typed in or stepped with ± — and in the radio's own units, so the volume is dB and the squelch a dBm threshold, matching what ExpertSDR3's own window shows. THE FILTERS. The button said 250 and the radio was set to 300-550: 250 Hz wide, but sitting where no CW note is. The edges are computed from the width now, and a narrow filter is CENTRED ON THE CW NOTE — a 250 Hz filter from 100 to 350 would put the note outside its own passband. A button also lights on the WIDTH the radio reports rather than on an exact pair of edges, so moving one edge on the radio no longer darkens every button. MUTE. Read from one shape only, while this radio reports the other ('mute:0,false'), so the button showed the opposite of the truth. Both are accepted now. None of this should have reached main before somebody had a radio in front of it. --- frontend/package.json.md5 | 2 +- frontend/src/components/LevelRow.tsx | 72 +++++++++++++++++++ frontend/src/components/TCIPanel.tsx | 100 +++++++++++++++------------ internal/cat/tci_panel.go | 10 ++- 4 files changed, 138 insertions(+), 46 deletions(-) create mode 100644 frontend/src/components/LevelRow.tsx diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 693b40b..b826f3b 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -f9b41e192918fa2511f68cd1b361fcd3 \ No newline at end of file +704fe1bf370b669665df0606fae8a69d \ No newline at end of file diff --git a/frontend/src/components/LevelRow.tsx b/frontend/src/components/LevelRow.tsx new file mode 100644 index 0000000..8e10ec2 --- /dev/null +++ b/frontend/src/components/LevelRow.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import { cn } from '@/lib/utils'; +import { WheelRange } from '@/components/WheelRange'; + +// LevelRow — a named level with a slider, a value you can type into, and ±. +// +// Shared, like ShiftRow, and for the same complaint: the consoles each drew +// their levels their own way. This is the wide shape — one row per level, the +// slider taking the width it needs — rather than two half-width sliders side by +// side, which is what "c'est laid et elles sont toutes petites" was about. +// +// Four ways to move it, so nobody has to learn ours: drag, wheel over the +// track, ± for one step, or click the number and type. Typing matters for the +// levels TCI reports in real units — a squelch at -95 dBm is a value an +// operator knows, not a position to hunt for with a mouse. +export function LevelRow({ + label, value, min = 0, max = 100, step = 1, unit = '', accent, disabled, onSet, +}: { + label: string; + value: number; + min?: number; + max?: number; + step?: number; + unit?: string; + accent?: string; + disabled?: boolean; + onSet: (v: number) => void; +}) { + const [editing, setEditing] = useState(null); + const clamp = (v: number) => Math.max(min, Math.min(max, v)); + const commit = (raw: string) => { + setEditing(null); + const v = parseInt(raw.replace(/[^0-9+-]/g, ''), 10); + if (!Number.isNaN(v)) onSet(clamp(v)); + }; + return ( +
+ + {label} + + +
+ + {editing !== null ? ( + setEditing(e.target.value)} + onBlur={(e) => commit(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commit((e.target as HTMLInputElement).value); + else if (e.key === 'Escape') setEditing(null); + }} + className="w-14 rounded border border-border bg-background px-1 text-right text-xs font-mono tabular-nums outline-none" + /> + ) : ( + + )} + +
+
+ ); +} diff --git a/frontend/src/components/TCIPanel.tsx b/frontend/src/components/TCIPanel.tsx index ebddcf2..103b191 100644 --- a/frontend/src/components/TCIPanel.tsx +++ b/frontend/src/components/TCIPanel.tsx @@ -13,6 +13,7 @@ import { sMeterRST } from '@/lib/rst'; import { MeterBar } from '@/components/MeterBar'; import { WheelRange } from '@/components/WheelRange'; import { ShiftRow } from '@/components/ShiftRow'; +import { LevelRow } from '@/components/LevelRow'; type TCIState = { connected: boolean; device?: string; protocol?: string; @@ -31,18 +32,31 @@ const ZERO: TCIState = { rit: false, rit_offset: 0, xit: false, xit_offset: 0, lock: false, split: false, smeter: 0, }; -// Passbands worth a button, as edges relative to the carrier. TCI takes the two -// edges rather than a width, which is more than a console needs: an operator -// picks "CW" or "SSB", not a pair of numbers. -const FILTERS: { label: string; lo: number; hi: number }[] = [ - { label: '250', lo: 300, hi: 550 }, - { label: '500', lo: 300, hi: 800 }, - { label: '1.0k', lo: 200, hi: 1200 }, - { label: '1.8k', lo: 100, hi: 1900 }, - { label: '2.4k', lo: 100, hi: 2500 }, - { label: '2.8k', lo: 100, hi: 2900 }, - { label: '3.5k', lo: 100, hi: 3600 }, -]; +// 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. +// +// 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]; + +function widthLabel(w: number): string { + return w >= 1000 ? `${(w / 1000).toFixed(1)}k` : String(w); +} + +// edgesFor turns a width into the pair TCI wants. Narrow filters are centred on +// the CW note: a 250 Hz filter from 100 to 350 would put the note outside its +// own passband. +function edgesFor(w: number, mode: string): { lo: number; hi: number } { + const cw = /CW/i.test(mode); + if (cw || w <= 1000) { + return { lo: Math.max(0, CW_PITCH - Math.round(w / 2)), hi: CW_PITCH + Math.round(w / 2) }; + } + return { lo: 100, hi: 100 + w }; +} // dBm → S units. TCI reports a real signal level rather than a meter position, // which is the useful way round: S9 is -73 dBm by the IARU definition and every @@ -236,16 +250,13 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void {/* Transmit */} -
- - { setHold('drive', v); call(() => SetTCIDrive(v)); }} /> - - - { setHold('tune_drive', v); call(() => SetTCITuneDrive(v)); }} /> - -
+ {/* One level per ROW, full width. Two half-width sliders side by side + left each of them a couple of centimetres long — small enough that + setting 15% took aim. */} + { setHold('drive', v); call(() => SetTCIDrive(v)); }} /> + { setHold('tune_drive', v); 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 @@ -261,27 +272,20 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void {t('tcip.txDisabled')} )}
- - { setHold('mic', v); call(() => SetTCIMicLevel(v)); }} /> - + { setHold('mic', v); call(() => SetTCIMicLevel(v)); }} />
{/* Receive */} -
- {/* TCI's volume is dB and NEGATIVE — 0 is full, -60 inaudible. Shown - as the radio's own number rather than converted to a percentage, - so it matches the figure in ExpertSDR3's window. */} - - { setHold('vol', v); call(() => SetTCIVolume(v)); }} /> - - - { setHold('sql', v); call(() => SetTCISquelchLevel(v)); }} /> - -
+ {/* Both in the radio's OWN units — volume in dB, negative, and the + 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)); }} /> + { setHold('sql', v); call(() => SetTCISquelchLevel(v)); }} />
{ setHold('nb', !nb); call(() => SetTCINB(!nb)); }} /> { setHold('nr', !nr); call(() => SetTCINR(!nr)); }} /> @@ -308,11 +312,19 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void {st.filter_lo}–{st.filter_hi} Hz
- {FILTERS.map((f) => ( - call(() => SetTCIFilter(f.lo, f.hi))} /> - ))} + {WIDTHS.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 + // radio, and a button that only lights on our own numbers would + // go dark for a filter that is plainly 500 Hz wide. + const on = Math.abs((st.filter_hi - st.filter_lo) - w) <= 50; + return ( + call(() => SetTCIFilter(e.lo, e.hi))} /> + ); + })}
diff --git a/internal/cat/tci_panel.go b/internal/cat/tci_panel.go index 8e318a9..bbe8fca 100644 --- a/internal/cat/tci_panel.go +++ b/internal/cat/tci_panel.go @@ -120,7 +120,15 @@ func (t *TCI) handlePanel(name string, get func(int) string, args string) bool { p.Volume = n } case "mute": - p.Mute = yes(get(1)) + // Both shapes. This radio reports "mute:0,false" and the reference shows + // "mute:true" elsewhere — reading only one of them left the button + // showing the opposite of the truth, which is worse than showing + // nothing. + if get(1) != "" { + p.Mute = yes(get(1)) + } else { + p.Mute = yes(get(0)) + } case "agc_mode": if forRX0() { p.AGC = strings.ToLower(strings.TrimSpace(get(1)))