feat: multiple amplifiers + SmartSDR v4 DSP filters

Multi-amp: Settings->Amplifier becomes a list (amps.json, legacy single-amp keys auto-migrate to entry #1); one client per enabled amp with per-id bindings (GetAmplifiers/SaveAmplifiers/GetAmpStatuses/AmpOperate/AmpPower/AmpPowerLevel/AmpFanMode); legacy a.pgxl/a.spe/a.acom point at the first enabled amp of each family. Amp cards in FlexPanel and Station Control gain a dropdown to pick the amp; the status bar shows one clickable chip per amp. Use case: two SPEs run in parallel.

Flex v4 DSP (8000/Aurora): NRL/ANFL (lms_nr/lms_anf), NRS (speex_nr), NRF (nrf) with level sliders, RNN (rnnoise) and ANFT on/off — keys per the FlexLib slice docs; the section only shows when the radio reports these keys (dsp_v4 flag), so 6000-series panels are unchanged.
This commit is contained in:
2026-07-22 17:25:10 +02:00
parent 4c75680689
commit b7dd8d4852
12 changed files with 1051 additions and 411 deletions
+34 -56
View File
@@ -43,7 +43,7 @@ import {
GetAwardDefs,
GetUIPref, GetActiveProfile, QuitApp,
ReportLiveActivity, LiveLastQSOAgeSec,
GetPGXLSettings, GetPGXLStatus, GetSPEStatus, GetACOMStatus, SPESetOperate, ACOMSetOperate, PGXLSetOperate,
GetAmpStatuses, AmpOperate,
GetFlexState, FlexAmpOperate,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
@@ -441,45 +441,22 @@ export default function App() {
// click reverts the UI and the click looks like it did nothing.
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
// Amplifier chip in the status bar: name + green OPERATE / orange STANDBY /
// red offline. Config re-read every 5s (so enabling the amp in Settings makes
// the chip appear); status polled every 2s while enabled.
const [ampCfg, setAmpCfg] = useState<{ enabled: boolean; type: string }>({ enabled: false, type: 'pgxl' });
const [ampSt, setAmpSt] = useState<any>({ connected: false });
// Amplifier chips in the status bar — ONE PER configured amp (several are
// possible: some ops run two SPEs in parallel). Green OPERATE / orange
// STANDBY / red offline; click toggles. The Flex state rides along because a
// PGXL's OPERATE state comes from the radio when it reports the amp.
const [ampSts, setAmpSts] = useState<any[]>([]);
const [flexAmp, setFlexAmp] = useState<any>(null);
useEffect(() => {
let alive = true;
const load = () => GetPGXLSettings().then((s: any) => { if (alive) setAmpCfg({ enabled: !!s?.enabled, type: s?.type || 'pgxl' }); }).catch(() => {});
load();
const id = window.setInterval(load, 5000);
return () => { alive = false; window.clearInterval(id); };
}, []);
useEffect(() => {
if (!ampCfg.enabled) { setAmpSt({ connected: false }); return; }
let alive = true;
// PGXL: the Flex reports the amp's OPERATE state authoritatively (the direct
// GSCP status doesn't carry it), so merge GetFlexState in when a Flex sees
// the amp — state AND toggle then go through the radio; the direct TCP link
// remains the fallback (fan mode always uses it).
const tick = ampCfg.type === 'pgxl'
? () => Promise.all([
GetPGXLStatus().catch(() => ({ connected: false })),
GetFlexState().catch(() => null),
]).then(([pg, fx]: any[]) => {
if (!alive) return;
const viaFlex = !!fx?.amp_available;
setAmpSt({
...(pg || {}),
connected: !!pg?.connected || viaFlex,
operate: viaFlex ? !!fx.amp_operate : !!pg?.operate,
via_flex: viaFlex,
});
})
: () => (ampCfg.type.startsWith('acom') ? GetACOMStatus : GetSPEStatus)()
.then((s: any) => alive && setAmpSt(s || { connected: false })).catch(() => {});
const tick = () => Promise.all([
GetAmpStatuses().catch(() => []),
GetFlexState().catch(() => null),
]).then(([l, fx]: any[]) => { if (alive) { setAmpSts((l ?? []) as any[]); setFlexAmp(fx); } });
tick();
const id = window.setInterval(tick, 2000);
return () => { alive = false; window.clearInterval(id); };
}, [ampCfg.enabled, ampCfg.type]);
}, []);
// Multi-op "who's on air" widget: every operator's live status from the shared
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
@@ -5188,38 +5165,39 @@ export default function App() {
disabled={!rotatorHeading.enabled}
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
/>
{/* Amplifier chip: green = OPERATE, orange = STANDBY, red = offline.
CLICK toggles OPERATE STANDBY on every backend (PGXL included
its direct TCP link takes operate=0/1); optimistic flip, the 2s
poll reconciles. Offline click opens the settings instead. */}
{ampCfg.enabled && (() => {
const isPGXL = ampCfg.type === 'pgxl';
const name = isPGXL ? 'PGXL'
: ampCfg.type.startsWith('acom') ? `ACOM ${ampSt.model || ''}`.trim()
: `SPE ${ampSt.model || ''}`.trim();
const dot = !ampSt.connected ? 'bg-danger' : ampSt.operate ? 'bg-success' : 'bg-warning';
const state = !ampSt.connected ? (ampSt.last_error || 'offline') : ampSt.operate ? 'OPERATE' : 'STANDBY';
{/* Amplifier chips one per configured amp: green = OPERATE, orange =
STANDBY, red = offline. CLICK toggles OPERATE STANDBY (optimistic
flip, the 2s poll reconciles); offline click opens the settings. */}
{ampSts.map((a: any) => {
const isPGXL = !a.spe && !a.acom;
const viaFlex = isPGXL && !!flexAmp?.amp_available;
const raw = a.spe ?? a.acom ?? a.pgxl ?? { connected: false };
const connected = !!raw.connected || viaFlex;
const operate = viaFlex ? !!flexAmp.amp_operate : !!raw.operate;
const dot = !connected ? 'bg-danger' : operate ? 'bg-success' : 'bg-warning';
const state = !connected ? (raw.last_error || 'offline') : operate ? 'OPERATE' : 'STANDBY';
const toggle = () => {
// Offline — nothing to command; open the settings to fix the link.
if (!ampSt.connected) { setSettingsSection('pgxl'); setShowSettings(true); return; }
const want = !ampSt.operate;
setAmpSt((s: any) => ({ ...s, operate: want }));
(isPGXL ? (ampSt.via_flex ? FlexAmpOperate(want) : PGXLSetOperate(want))
: ampCfg.type.startsWith('acom') ? ACOMSetOperate(want)
: SPESetOperate(want)).catch(() => {});
if (!connected) { setSettingsSection('pgxl'); setShowSettings(true); return; }
const want = !operate;
if (viaFlex) setFlexAmp((f: any) => ({ ...f, amp_operate: want }));
else setAmpSts((l) => l.map((x: any) => x.id === a.id
? { ...x, spe: x.spe && { ...x.spe, operate: want }, acom: x.acom && { ...x.acom, operate: want }, pgxl: x.pgxl && { ...x.pgxl, operate: want } }
: x));
(viaFlex ? FlexAmpOperate(want) : AmpOperate(a.id, want)).catch(() => {});
};
return (
<button
key={a.id}
type="button"
title={`${name}: ${state}${ampSt.connected ? (ampSt.operate ? ' — click for STANDBY' : ' — click for OPERATE') : ' — click for settings'}`}
title={`${a.name}: ${state}${connected ? (operate ? ' — click for STANDBY' : ' — click for OPERATE') : ' — click for settings'}`}
onClick={toggle}
className="inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors border-border hover:bg-muted cursor-pointer"
>
<span className={cn('size-2 rounded-full', dot)} />
{name}
{a.name}
</button>
);
})()}
})}
{/* ON AIR badge: "did I log a QSO in the last 5 min" meaningful on ANY
logbook backend (only the live_status PUBLISHING is MySQL-specific),
so it is always shown. Gating it on MySQL made it vanish for