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:
@@ -4,11 +4,13 @@ import {
|
||||
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||
FlexMox, FlexAmpOperate,
|
||||
GetPGXLStatus, PGXLSetFanMode, GetPGXLSettings, GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel,
|
||||
GetACOMStatus, ACOMSetOperate, ACOMSetPower,
|
||||
GetPGXLStatus, PGXLSetFanMode,
|
||||
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
|
||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||
FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel,
|
||||
FlexSetSpeexNR, FlexSetSpeexNRLevel, FlexSetRNN, FlexSetANFT, FlexSetNRF, FlexSetNRFLevel,
|
||||
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
|
||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||
@@ -31,6 +33,10 @@ type FlexState = {
|
||||
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
|
||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
||||
wnb: boolean; wnb_level: number;
|
||||
// SmartSDR v4 DSP (8000/Aurora) — dsp_v4 is true once the radio reports them.
|
||||
lms_nr?: boolean; lms_nr_level?: number; lms_anf?: boolean; lms_anf_level?: number;
|
||||
speex_nr?: boolean; speex_nr_level?: number; rnn?: boolean; anft?: boolean;
|
||||
nrf?: boolean; nrf_level?: number; dsp_v4?: boolean;
|
||||
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
|
||||
mode?: string;
|
||||
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
||||
@@ -342,39 +348,31 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
// Configured amplifier type (pgxl / spe*), so we show the SPE control when the
|
||||
// operator runs an SPE Expert instead of the PowerGenius.
|
||||
const [ampType, setAmpType] = useState<string>('pgxl');
|
||||
const [ampEnabled, setAmpEnabled] = useState(false);
|
||||
// Configured amplifiers (Settings → Amplifier) — possibly SEVERAL (some ops
|
||||
// run two SPEs in parallel). The card shows ONE at a time; the dropdown picks
|
||||
// which, and the choice is remembered per panel.
|
||||
const [ampList, setAmpList] = useState<any[]>([]);
|
||||
const [ampSel, setAmpSel] = useState<string>(() => localStorage.getItem('opslog.ampSel.flex') || '');
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => GetPGXLSettings().then((s: any) => { if (alive) { setAmpType(s?.type || 'pgxl'); setAmpEnabled(!!s?.enabled); } }).catch(() => {});
|
||||
load();
|
||||
const id = window.setInterval(load, 5000);
|
||||
const tick = () => GetAmpStatuses().then((l: any) => alive && setAmpList((l ?? []) as any[])).catch(() => {});
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1500);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
const isACOM = ampEnabled && ampType.startsWith('acom');
|
||||
const isSPE = ampEnabled && !isACOM && ampType !== 'pgxl';
|
||||
// SPE Expert live status (only polled when an SPE amp is configured).
|
||||
const [spe, setSpe] = useState<any>({ connected: false });
|
||||
useEffect(() => {
|
||||
if (!isSPE) return;
|
||||
let alive = true;
|
||||
const tick = () => GetSPEStatus().then((s: any) => alive && setSpe(s || { connected: false })).catch(() => {});
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1500);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, [isSPE]);
|
||||
// ACOM live status (only polled when an ACOM amp is configured).
|
||||
const [acom, setAcom] = useState<any>({ connected: false });
|
||||
useEffect(() => {
|
||||
if (!isACOM) return;
|
||||
let alive = true;
|
||||
const tick = () => GetACOMStatus().then((s: any) => alive && setAcom(s || { connected: false })).catch(() => {});
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1500);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, [isACOM]);
|
||||
const selAmp = ampList.find((a: any) => a.id === ampSel) ?? ampList[0];
|
||||
const isSPE = !!selAmp?.spe;
|
||||
const isACOM = !!selAmp?.acom;
|
||||
const spe = selAmp?.spe ?? { connected: false };
|
||||
const acom = selAmp?.acom ?? { connected: false };
|
||||
const ampPicker = ampList.length > 1 && selAmp ? (
|
||||
<select value={selAmp.id}
|
||||
onChange={(e) => { setAmpSel(e.target.value); localStorage.setItem('opslog.ampSel.flex', e.target.value); }}
|
||||
title={t('flxp.ampPick')}
|
||||
className="h-8 rounded-md border border-border bg-card px-2 text-xs font-semibold outline-none">
|
||||
{ampList.map((a: any) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
) : null;
|
||||
|
||||
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
||||
hold.current[key] = Date.now() + 900;
|
||||
@@ -777,6 +775,49 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
||||
)}
|
||||
</div>
|
||||
{/* SmartSDR v4 DSP (8000/Aurora series) — NRL/ANFL (legacy LMS), NRS
|
||||
(spectral subtraction), NRF (NR w/ filter), RNN (AI NR), ANFT (FFT
|
||||
notch). Only shown when the radio actually reports these slice keys
|
||||
(older 6000s never do). API keys per the FlexLib slice docs. */}
|
||||
{st.dsp_v4 && (
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<LevelRow label="NRL" on={!!st.lms_nr} disabled={rxOff} value={st.lms_nr_level ?? 0} accent="cyan" sliderAccent="#0e7490"
|
||||
onToggle={() => change('lms_nr', !st.lms_nr, () => FlexSetLMSNR(!st.lms_nr))}
|
||||
onLevel={(v) => change('lms_nr_level', v, () => FlexSetLMSNRLevel(v))} />
|
||||
<LevelRow label="NRS" on={!!st.speex_nr} disabled={rxOff} value={st.speex_nr_level ?? 0} accent="cyan" sliderAccent="#06b6d4"
|
||||
onToggle={() => change('speex_nr', !st.speex_nr, () => FlexSetSpeexNR(!st.speex_nr))}
|
||||
onLevel={(v) => change('speex_nr_level', v, () => FlexSetSpeexNRLevel(v))} />
|
||||
<LevelRow label="NRF" on={!!st.nrf} disabled={rxOff} value={st.nrf_level ?? 0} accent="cyan" sliderAccent="#0369a1"
|
||||
onToggle={() => change('nrf', !st.nrf, () => FlexSetNRF(!st.nrf))}
|
||||
onLevel={(v) => change('nrf_level', v, () => FlexSetNRFLevel(v))} />
|
||||
{/* Notch filters target carriers in voice — hidden in CW like ANF. */}
|
||||
{!isCW && (
|
||||
<LevelRow label="ANFL" on={!!st.lms_anf} disabled={rxOff} value={st.lms_anf_level ?? 0} accent="violet" sliderAccent="#8b5cf6"
|
||||
onToggle={() => change('lms_anf', !st.lms_anf, () => FlexSetLMSANF(!st.lms_anf))}
|
||||
onLevel={(v) => change('lms_anf_level', v, () => FlexSetLMSANFLevel(v))} />
|
||||
)}
|
||||
{/* RNN and ANFT are on/off only — no level in the API. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground" title={t('flxp.dspV4Hint')}>AI/FFT</span>
|
||||
<button type="button" disabled={rxOff}
|
||||
title={t('flxp.rnnHint')}
|
||||
onClick={() => change('rnn', !st.rnn, () => FlexSetRNN(!st.rnn))}
|
||||
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
|
||||
st.rnn ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
RNN
|
||||
</button>
|
||||
{!isCW && (
|
||||
<button type="button" disabled={rxOff}
|
||||
title={t('flxp.anftHint')}
|
||||
onClick={() => change('anft', !st.anft, () => FlexSetANFT(!st.anft))}
|
||||
className={cn('px-2.5 py-1 rounded-md border text-[11px] font-bold transition-colors disabled:opacity-30',
|
||||
st.anft ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
ANFT
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isCW && (
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a"
|
||||
@@ -830,10 +871,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
The Flex doesn't report SPE amps, so this card is driven by OpsLog's own
|
||||
SPE link rather than the Flex amplifier object. */}
|
||||
{isSPE && (
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')} · SPE${spe.model ? ' ' + spe.model : ''}`} accent="#ea580c">
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${selAmp?.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{ampPicker}
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => SPESetOperate(!spe.operate).catch(() => {})}
|
||||
onClick={() => AmpOperate(selAmp.id, !spe.operate).catch(() => {})}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||
@@ -841,10 +883,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => SPESetPower(true).catch(() => {})}
|
||||
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => SPESetPower(false).catch(() => {})}
|
||||
onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||
</div>
|
||||
{/* Output power level: Low / Mid / High. */}
|
||||
@@ -853,7 +895,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
const active = (spe.power_level || '').trim().toUpperCase() === lvl;
|
||||
return (
|
||||
<button key={lvl} type="button" disabled={!spe.connected}
|
||||
onClick={() => SPESetPowerLevel(lvl).catch(() => {})}
|
||||
onClick={() => AmpPowerLevel(selAmp.id, lvl).catch(() => {})}
|
||||
className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border',
|
||||
active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{powerLevelLabel(lvl)}
|
||||
@@ -887,10 +929,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
{/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven
|
||||
by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */}
|
||||
{isACOM && (
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')} · ACOM${acom.model ? ' ' + acom.model : ''}`} accent="#ea580c">
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${selAmp?.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{ampPicker}
|
||||
<button type="button" disabled={!acom.connected}
|
||||
onClick={() => ACOMSetOperate(!acom.operate).catch(() => {})}
|
||||
onClick={() => AmpOperate(selAmp.id, !acom.operate).catch(() => {})}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
acom.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{acom.operate ? 'OPERATE' : 'STANDBY'}
|
||||
@@ -899,11 +942,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
is off (the port stays open), so gate on port_open not connected. */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
|
||||
onClick={() => ACOMSetPower(true).catch(() => {})}
|
||||
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
|
||||
title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS lines — not available over a network bridge'}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||
<button type="button" disabled={!acom.connected}
|
||||
onClick={() => ACOMSetPower(false).catch(() => {})}
|
||||
onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||
</div>
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||
@@ -935,6 +978,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
{st.amp_available && !isSPE && !isACOM && (
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3">
|
||||
{ampPicker}
|
||||
<button type="button" disabled={off}
|
||||
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
@@ -947,7 +991,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
||||
PowerGenius). The dot shows the direct-connection state; the
|
||||
selector is disabled until connected (hover it for the error). */}
|
||||
{ampType === 'pgxl' && (pg.host || pg.connected) && (
|
||||
{selAmp?.type === 'pgxl' && (pg.host || pg.connected) && (
|
||||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||||
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-muted-foreground">{t('flxp.fan')}</span>
|
||||
|
||||
Reference in New Issue
Block a user