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)))