fix(tci): wide level rows, honest filter labels, and mute read both ways

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.
This commit is contained in:
2026-08-26 20:54:28 +02:00
parent fc79be7c05
commit fbe01bf19d
4 changed files with 138 additions and 46 deletions
+1 -1
View File
@@ -1 +1 @@
f9b41e192918fa2511f68cd1b361fcd3 704fe1bf370b669665df0606fae8a69d
+72
View File
@@ -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<string | null>(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 (
<div className="flex items-center gap-3">
<span className="w-24 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
{label}
</span>
<WheelRange
min={min} max={max} step={step} value={value} disabled={disabled} accent={accent}
onChange={onSet}
className="h-2.5 flex-1 [&::-webkit-slider-thumb]:size-4"
/>
<div className={cn('flex items-center gap-0.5 shrink-0', disabled && 'opacity-40')}>
<button type="button" disabled={disabled} onClick={() => onSet(clamp(value - step))}
className="px-1.5 text-sm font-bold text-muted-foreground hover:text-foreground disabled:opacity-40"></button>
{editing !== null ? (
<input
autoFocus
value={editing}
onChange={(e) => 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"
/>
) : (
<button type="button" disabled={disabled} onClick={() => setEditing(String(value))}
className="w-14 text-right text-xs font-mono tabular-nums hover:text-primary disabled:cursor-default">
{value}{unit}
</button>
)}
<button type="button" disabled={disabled} onClick={() => onSet(clamp(value + step))}
className="px-1.5 text-sm font-bold text-muted-foreground hover:text-foreground disabled:opacity-40">+</button>
</div>
</div>
);
}
+56 -44
View File
@@ -13,6 +13,7 @@ import { sMeterRST } from '@/lib/rst';
import { MeterBar } from '@/components/MeterBar'; import { MeterBar } from '@/components/MeterBar';
import { WheelRange } from '@/components/WheelRange'; import { WheelRange } from '@/components/WheelRange';
import { ShiftRow } from '@/components/ShiftRow'; import { ShiftRow } from '@/components/ShiftRow';
import { LevelRow } from '@/components/LevelRow';
type TCIState = { type TCIState = {
connected: boolean; device?: string; protocol?: string; 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, 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 // The widths worth a button. TCI wants the two EDGES, not a width, so the
// edges rather than a width, which is more than a console needs: an operator // edges are computed from the width and the mode — and the button says the
// picks "CW" or "SSB", not a pair of numbers. // width, which had better be the width you get.
const FILTERS: { label: string; lo: number; hi: number }[] = [ //
{ label: '250', lo: 300, hi: 550 }, // It did not: "250" set 300-550, which is 250 Hz wide but sitting where a CW
{ label: '500', lo: 300, hi: 800 }, // note is not, and the row underneath said "300-550 Hz" while the button said
{ label: '1.0k', lo: 200, hi: 1200 }, // 250. Now a narrow filter is CENTRED ON THE CW NOTE and a wide one starts at
{ label: '1.8k', lo: 100, hi: 1900 }, // the bottom of the voice band, which is what each is for.
{ label: '2.4k', lo: 100, hi: 2500 }, const CW_PITCH = 700; // ExpertSDR3's default sidetone, and where its CW filters sit
{ label: '2.8k', lo: 100, hi: 2900 }, const WIDTHS = [250, 500, 1000, 1800, 2400, 2800, 3500];
{ label: '3.5k', lo: 100, hi: 3600 },
]; 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, // 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 // 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 */} {/* Transmit */}
<Card icon={SlidersHorizontal} title={t('tcip.transmit')}> <Card icon={SlidersHorizontal} title={t('tcip.transmit')}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> {/* One level per ROW, full width. Two half-width sliders side by side
<Row label={t('tcip.drive')} value={`${drive}%`}> left each of them a couple of centimetres long — small enough that
<WheelRange min={0} max={100} disabled={off} value={drive} setting 15% took aim. */}
onChange={(v) => { setHold('drive', v); call(() => SetTCIDrive(v)); }} /> <LevelRow label={t('tcip.drive')} unit="%" value={drive} disabled={off}
</Row> onSet={(v) => { setHold('drive', v); call(() => SetTCIDrive(v)); }} />
<Row label={t('tcip.tuneDrive')} value={`${tuneDrive}%`}> <LevelRow label={t('tcip.tuneDrive')} unit="%" value={tuneDrive} disabled={off} accent="#f59e0b"
<WheelRange min={0} max={100} disabled={off} value={tuneDrive} accent="#f59e0b" onSet={(v) => { setHold('tune_drive', v); call(() => SetTCITuneDrive(v)); }} />
onChange={(v) => { setHold('tune_drive', v); call(() => SetTCITuneDrive(v)); }} />
</Row>
</div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{/* TUNE transmits, and at the tune drive rather than the main one — {/* 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 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
<span className="text-[11px] text-muted-foreground">{t('tcip.txDisabled')}</span> <span className="text-[11px] text-muted-foreground">{t('tcip.txDisabled')}</span>
)} )}
</div> </div>
<Row label={t('tcip.mic')} value={`${mic}%`}> <LevelRow label={t('tcip.mic')} unit="%" value={mic} disabled={off} accent="#a855f7"
<WheelRange min={0} max={100} disabled={off} value={mic} accent="#a855f7" onSet={(v) => { setHold('mic', v); call(() => SetTCIMicLevel(v)); }} />
onChange={(v) => { setHold('mic', v); call(() => SetTCIMicLevel(v)); }} />
</Row>
</Card> </Card>
{/* Receive */} {/* Receive */}
<Card icon={AudioLines} title={t('tcip.receive')}> <Card icon={AudioLines} title={t('tcip.receive')}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> {/* Both in the radio's OWN units — volume in dB, negative, and the
{/* TCI's volume is dB and NEGATIVE — 0 is full, -60 inaudible. Shown squelch as a dBm threshold — so the numbers match the ones in
as the radio's own number rather than converted to a percentage, ExpertSDR3's window rather than being percentages of something. */}
so it matches the figure in ExpertSDR3's window. */} <LevelRow label={t('tcip.volume')} unit=" dB" min={-60} max={0} value={vol} disabled={off}
<Row label={t('tcip.volume')} value={`${vol} dB`}> onSet={(v) => { setHold('vol', v); call(() => SetTCIVolume(v)); }} />
<WheelRange min={-60} max={0} disabled={off} value={vol} <LevelRow label={t('tcip.squelch')} unit=" dBm" min={-140} max={0} value={sql}
onChange={(v) => { setHold('vol', v); call(() => SetTCIVolume(v)); }} /> disabled={off || !sqlOn} accent="#38bdf8"
</Row> onSet={(v) => { setHold('sql', v); call(() => SetTCISquelchLevel(v)); }} />
<Row label={t('tcip.squelch')} value={sqlOn ? `${sql} dBm` : t('tcip.off')}>
<WheelRange min={-140} max={0} disabled={off || !sqlOn} value={sql}
onChange={(v) => { setHold('sql', v); call(() => SetTCISquelchLevel(v)); }} />
</Row>
</div>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2"> <div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
<Toggle label="NB" on={nb} off={off} onClick={() => { setHold('nb', !nb); call(() => SetTCINB(!nb)); }} /> <Toggle label="NB" on={nb} off={off} onClick={() => { setHold('nb', !nb); call(() => SetTCINB(!nb)); }} />
<Toggle label="NR" on={nr} off={off} onClick={() => { setHold('nr', !nr); call(() => SetTCINR(!nr)); }} /> <Toggle label="NR" on={nr} off={off} onClick={() => { setHold('nr', !nr); call(() => SetTCINR(!nr)); }} />
@@ -308,11 +312,19 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
<span className="text-xs font-mono tabular-nums">{st.filter_lo}{st.filter_hi} Hz</span> <span className="text-xs font-mono tabular-nums">{st.filter_lo}{st.filter_hi} Hz</span>
</div> </div>
<div className="grid grid-cols-4 sm:grid-cols-7 gap-2"> <div className="grid grid-cols-4 sm:grid-cols-7 gap-2">
{FILTERS.map((f) => ( {WIDTHS.map((w) => {
<Toggle key={f.label} label={f.label} const e = edgesFor(w, mode);
on={st.filter_lo === f.lo && st.filter_hi === f.hi} off={off} // Lit by the WIDTH the radio is actually using, not by an exact
onClick={() => call(() => SetTCIFilter(f.lo, f.hi))} /> // 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 (
<Toggle key={w} label={widthLabel(w)} on={on} off={off}
title={`${e.lo}${e.hi} Hz`}
onClick={() => call(() => SetTCIFilter(e.lo, e.hi))} />
);
})}
</div> </div>
</div> </div>
</Card> </Card>
+8
View File
@@ -120,7 +120,15 @@ func (t *TCI) handlePanel(name string, get func(int) string, args string) bool {
p.Volume = n p.Volume = n
} }
case "mute": case "mute":
// 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)) p.Mute = yes(get(1))
} else {
p.Mute = yes(get(0))
}
case "agc_mode": case "agc_mode":
if forRX0() { if forRX0() {
p.AGC = strings.ToLower(strings.TrimSpace(get(1))) p.AGC = strings.ToLower(strings.TrimSpace(get(1)))