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
+336 -36
View File
@@ -469,8 +469,10 @@ type App struct {
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
spe *spe.Client // SPE Expert amplifier (serial/TCP); nil when disabled or not SPE
acom *acom.Client // ACOM 500S/600S/700S/1200S/2020S amplifier (serial/TCP); nil when disabled or not ACOM
spe *spe.Client // legacy pointer: FIRST enabled SPE amp (kept for the pre-multi bindings)
acom *acom.Client // legacy pointer: FIRST enabled ACOM amp
ampsMu sync.Mutex // guards ampInsts
ampInsts map[string]*ampInst // one running client per enabled configured amplifier, by config ID
audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
@@ -1073,7 +1075,7 @@ func (a *App) startup(ctx context.Context) {
// Antenna Genius switch: connect in the background if enabled.
a.startAntGenius()
// PowerGenius XL amp fan control: connect in the background if enabled.
a.startPGXL()
a.startAmps()
// Autostart: launch the active profile's configured external programs that
// aren't already running (WSJT-X, JTAlert, rotator control, …). Background
@@ -10895,6 +10897,69 @@ func (a *App) FlexSetWNBLevel(l int) error {
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetWNBLevel(l) })
}
// ── SmartSDR v4 DSP (8000/Aurora series): NRL / ANFL / NRS / RNN / ANFT / NRF ──
func (a *App) FlexSetLMSNR(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSNR(on) })
}
func (a *App) FlexSetLMSNRLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSNRLevel(l) })
}
func (a *App) FlexSetLMSANF(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSANF(on) })
}
func (a *App) FlexSetLMSANFLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSANFLevel(l) })
}
func (a *App) FlexSetSpeexNR(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSpeexNR(on) })
}
func (a *App) FlexSetSpeexNRLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSpeexNRLevel(l) })
}
func (a *App) FlexSetRNN(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRNN(on) })
}
func (a *App) FlexSetANFT(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetANFT(on) })
}
func (a *App) FlexSetNRF(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNRF(on) })
}
func (a *App) FlexSetNRFLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNRFLevel(l) })
}
func (a *App) FlexSetTXFilter(low, high int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
@@ -12459,53 +12524,288 @@ func (a *App) SavePGXLSettings(s PGXLSettings) error {
return err
}
}
a.startPGXL()
a.startAmps()
return nil
}
// startPGXL stops any existing client and starts a fresh one if enabled.
func (a *App) startPGXL() {
if a.pgxl != nil {
// ── Multiple amplifiers ────────────────────────────────────────────────
// Operators can run SEVERAL amps (even two SPEs combined for more power), so
// the config is a LIST. The legacy single-amp keyPGXL* settings migrate into
// entry #1 the first time the list is read. Each enabled entry gets its own
// client; the legacy a.pgxl/a.spe/a.acom fields point at the FIRST enabled amp
// of each family so the pre-multi bindings (and the Flex-panel merge) keep
// working unchanged.
const keyAmpsList = "amps.json"
// AmpConfig is one configured amplifier in Settings → Amplifier.
type AmpConfig struct {
ID string `json:"id"`
Name string `json:"name"` // user label, e.g. "SPE left"
Enabled bool `json:"enabled"`
Type string `json:"type"` // "pgxl" | "spe13"|"spe15"|"spe2k" | "acom500"…"acom2020"
Transport string `json:"transport"` // "tcp" | "serial"
Host string `json:"host"`
Port int `json:"port"`
ComPort string `json:"com_port"`
Baud int `json:"baud"`
}
type ampInst struct {
cfg AmpConfig
pgxl *powergenius.Client
spe *spe.Client
acom *acom.Client
}
func (i *ampInst) stopAll() {
if i.pgxl != nil {
i.pgxl.Stop()
}
if i.spe != nil {
i.spe.Stop()
}
if i.acom != nil {
i.acom.Stop()
}
}
// ampTypeLabel is the default display name for an amp type.
func ampTypeLabel(t string) string {
switch {
case t == "" || t == "pgxl":
return "PowerGenius XL"
case strings.HasPrefix(t, "spe"):
return "SPE " + map[string]string{"spe13": "1.3K-FA", "spe15": "1.5K-FA", "spe2k": "2K-FA"}[t]
case strings.HasPrefix(t, "acom"):
return "ACOM " + strings.TrimPrefix(t, "acom") + "S"
}
return t
}
// GetAmplifiers returns the configured amplifier list. When none was ever
// saved, the legacy single-amp settings (if any) are presented as entry #1 —
// persisted on the next SaveAmplifiers.
func (a *App) GetAmplifiers() ([]AmpConfig, error) {
if a.settings == nil {
return nil, fmt.Errorf("db not initialized")
}
raw, _ := a.settings.Get(a.ctx, keyAmpsList)
if strings.TrimSpace(raw) != "" {
var list []AmpConfig
if err := json.Unmarshal([]byte(raw), &list); err == nil {
return list, nil
}
}
s, err := a.GetPGXLSettings()
if err != nil || (!s.Enabled && strings.TrimSpace(s.Host) == "" && strings.TrimSpace(s.ComPort) == "") {
return []AmpConfig{}, nil // never configured
}
return []AmpConfig{{
ID: "amp-1", Name: ampTypeLabel(s.Type), Enabled: s.Enabled,
Type: s.Type, Transport: s.Transport, Host: s.Host, Port: s.Port, ComPort: s.ComPort, Baud: s.Baud,
}}, nil
}
// SaveAmplifiers persists the amplifier list and (re)starts the clients.
func (a *App) SaveAmplifiers(list []AmpConfig) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
for i := range list {
c := &list[i]
if strings.TrimSpace(c.ID) == "" {
c.ID = fmt.Sprintf("amp-%d-%d", time.Now().Unix(), i)
}
if c.Type == "" {
c.Type = "pgxl"
}
if c.Type == "pgxl" || c.Transport != "serial" {
c.Transport = "tcp"
}
if c.Port <= 0 || c.Port > 65535 {
c.Port = 9008
}
if c.Baud <= 0 {
if strings.HasPrefix(c.Type, "acom") {
c.Baud = 9600
} else {
c.Baud = 115200
}
}
if strings.TrimSpace(c.Name) == "" {
c.Name = ampTypeLabel(c.Type)
}
}
b, err := json.Marshal(list)
if err != nil {
return err
}
if err := a.settings.Set(a.ctx, keyAmpsList, string(b)); err != nil {
return err
}
a.startAmps()
return nil
}
// startAmps stops every running amp client and starts one per enabled entry.
func (a *App) startAmps() {
a.ampsMu.Lock()
old := a.ampInsts
a.ampInsts = map[string]*ampInst{}
a.ampsMu.Unlock()
for _, inst := range old {
// Stop() can block up to the dial timeout waiting for an in-progress
// connect; tear down in the background so saving Settings (this runs on
// the Wails RPC goroutine) doesn't freeze the UI.
go a.pgxl.Stop()
a.pgxl = nil
go inst.stopAll()
}
if a.spe != nil {
go a.spe.Stop()
a.spe = nil
}
if a.acom != nil {
go a.acom.Stop()
a.acom = nil
}
s, err := a.GetPGXLSettings()
if err != nil || !s.Enabled {
a.pgxl, a.spe, a.acom = nil, nil, nil
list, err := a.GetAmplifiers()
if err != nil {
return
}
if s.Type == "" || s.Type == "pgxl" {
if strings.TrimSpace(s.Host) == "" {
return
for _, c := range list {
if !c.Enabled {
continue
}
a.pgxl = powergenius.New(s.Host, s.Port)
_ = a.pgxl.Start()
return
isPGXL := c.Type == "" || c.Type == "pgxl"
if !isPGXL && c.Transport == "serial" && strings.TrimSpace(c.ComPort) == "" {
continue
}
if (isPGXL || c.Transport == "tcp") && strings.TrimSpace(c.Host) == "" {
continue
}
inst := &ampInst{cfg: c}
switch {
case isPGXL:
inst.pgxl = powergenius.New(c.Host, c.Port)
_ = inst.pgxl.Start()
if a.pgxl == nil {
a.pgxl = inst.pgxl
}
case strings.HasPrefix(c.Type, "acom"):
inst.acom = acom.New(acom.Config{Model: strings.TrimPrefix(c.Type, "acom") + "S", Transport: c.Transport, ComPort: c.ComPort, Baud: c.Baud, Host: c.Host, Port: c.Port})
_ = inst.acom.Start()
if a.acom == nil {
a.acom = inst.acom
}
default: // spe*
inst.spe = spe.New(spe.Config{Transport: c.Transport, ComPort: c.ComPort, Baud: c.Baud, Host: c.Host, Port: c.Port})
_ = inst.spe.Start()
if a.spe == nil {
a.spe = inst.spe
}
}
a.ampsMu.Lock()
a.ampInsts[c.ID] = inst
a.ampsMu.Unlock()
}
// SPE Expert / ACOM — USB serial or an RS232-to-Ethernet bridge.
if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" {
return
}
// AmpStatus is one amp's live state for the UI poll — exactly one of the
// per-family payloads is set, per the amp's type.
type AmpStatus struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
PGXL *powergenius.Status `json:"pgxl,omitempty"`
SPE *spe.Status `json:"spe,omitempty"`
ACOM *acom.Status `json:"acom,omitempty"`
}
// GetAmpStatuses returns the live state of every ENABLED amplifier, in the
// configured order — one poll feeds every amp card and the status-bar chips.
func (a *App) GetAmpStatuses() []AmpStatus {
list, _ := a.GetAmplifiers()
a.ampsMu.Lock()
insts := a.ampInsts
a.ampsMu.Unlock()
out := []AmpStatus{}
for _, c := range list {
if !c.Enabled {
continue
}
st := AmpStatus{ID: c.ID, Name: c.Name, Type: c.Type}
if inst, ok := insts[c.ID]; ok {
switch {
case inst.pgxl != nil:
v := inst.pgxl.GetStatus()
st.PGXL = &v
case inst.spe != nil:
v := inst.spe.GetStatus()
st.SPE = &v
case inst.acom != nil:
v := inst.acom.GetStatus()
st.ACOM = &v
}
}
out = append(out, st)
}
if s.Transport == "tcp" && strings.TrimSpace(s.Host) == "" {
return
return out
}
func (a *App) ampInstByID(id string) *ampInst {
a.ampsMu.Lock()
defer a.ampsMu.Unlock()
return a.ampInsts[id]
}
// AmpOperate puts the given amp in OPERATE (true) or STANDBY (false).
func (a *App) AmpOperate(id string, on bool) error {
inst := a.ampInstByID(id)
if inst == nil {
return fmt.Errorf("amplifier not running — check Settings → Amplifier")
}
if model, ok := strings.CutPrefix(s.Type, "acom"); ok {
a.acom = acom.New(acom.Config{Model: model + "S", Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port})
_ = a.acom.Start()
return
switch {
case inst.pgxl != nil:
return inst.pgxl.SetOperate(on)
case inst.spe != nil:
return inst.spe.Operate(on)
case inst.acom != nil:
return inst.acom.Operate(on)
}
a.spe = spe.New(spe.Config{Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port})
_ = a.spe.Start()
return fmt.Errorf("amplifier not running")
}
// AmpPower turns the given amp on/off (SPE and ACOM; the PGXL has no power
// command on its direct link).
func (a *App) AmpPower(id string, on bool) error {
inst := a.ampInstByID(id)
if inst == nil {
return fmt.Errorf("amplifier not running — check Settings → Amplifier")
}
switch {
case inst.spe != nil:
if on {
return inst.spe.PowerOn()
}
return inst.spe.PowerOff()
case inst.acom != nil:
if on {
return inst.acom.PowerOn()
}
return inst.acom.PowerOff()
}
return fmt.Errorf("power on/off is not available for this amplifier")
}
// AmpPowerLevel selects the output power level — SPE only (L/M/H).
func (a *App) AmpPowerLevel(id, level string) error {
inst := a.ampInstByID(id)
if inst == nil || inst.spe == nil {
return fmt.Errorf("power level is an SPE feature")
}
return inst.spe.SetPowerLevel(level)
}
// AmpFanMode sets the fan mode — PGXL only (STANDARD/CONTEST/BROADCAST).
func (a *App) AmpFanMode(id, mode string) error {
inst := a.ampInstByID(id)
if inst == nil || inst.pgxl == nil {
return fmt.Errorf("fan mode is a PowerGenius feature")
}
return inst.pgxl.SetFanMode(mode)
}
// GetSPEStatus returns the SPE Expert amplifier state for the UI poll.