feat(amp): command linked amplifiers together for a combiner

The SPE CO1-2 combiner is an RF device: it sums two amplifiers and commands
nothing. So "combined" operation is really two amplifiers that must be held in
the same state, and one left in STANDBY while the other keys means the combiner
sees power on a single input.

A switch in Settings → Amplifier makes ON, OFF and OPERATE act on every
configured amplifier. Offered only with two or more: coupling one amplifier to
itself is a switch that cannot do anything.

The METERS stay per amplifier, deliberately. Two amps combined are still two
amps, and an operator watching for one of them to run away needs to see them
apart — a summed bar would hide exactly the fault worth catching.

Fanned out in AmpOperate/AmpPower rather than in the UI: the card and the docked
widget both call these, and a coupling built into one would be missing from the
other, which on a combiner means one amplifier keyed and one not. The clicked
amplifier goes first, so a partial failure still did what the operator asked
before it stopped.

While linked, an amplifier with no power command — a PGXL on its direct link —
is skipped silently rather than reported: it would make a successful pair look
broken.
This commit is contained in:
2026-08-13 14:02:34 +02:00
parent f0026b8bd3
commit d0c6e420d2
7 changed files with 145 additions and 5 deletions
+74
View File
@@ -16226,6 +16226,19 @@ func (a *App) ampInstByID(id string) *ampInst {
// AmpOperate puts the given amp in OPERATE (true) or STANDBY (false).
func (a *App) AmpOperate(id string, on bool) error {
// Fan out here rather than in the UI: the card and the docked widget both
// call this, and a coupling implemented in one of them would be missing from
// the other — which on a combiner means one amplifier keyed and one not.
var firstErr error
for _, tid := range a.ampTargets(id, a.GetAmpsLinked()) {
if err := a.ampOperateOne(tid, on); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (a *App) ampOperateOne(id string, on bool) error {
inst := a.ampInstByID(id)
if inst == nil {
return fmt.Errorf("amplifier not running — check Settings → Amplifier")
@@ -16250,6 +16263,17 @@ func (a *App) AmpPower(id string, on bool) (err error) {
applog.Printf("amp %s: power %v failed: %v", id, on, err)
}
}()
linked := a.GetAmpsLinked()
var firstErr error
for _, tid := range a.ampTargets(id, linked) {
if e := a.ampPowerOne(tid, on, linked); e != nil && firstErr == nil {
firstErr = e
}
}
return firstErr
}
func (a *App) ampPowerOne(id string, on, linked bool) error {
inst := a.ampInstByID(id)
if inst == nil {
return fmt.Errorf("amplifier not running — check Settings → Amplifier")
@@ -16266,6 +16290,12 @@ func (a *App) AmpPower(id string, on bool) (err error) {
}
return inst.acom.PowerOff()
}
// Not an error worth surfacing when linked: a PGXL alongside two SPEs simply
// has no power command on its direct link, and reporting that as a failure
// would make a successful pair look broken.
if linked {
return nil
}
return fmt.Errorf("power on/off is not available for this amplifier")
}
@@ -17810,3 +17840,47 @@ func (a *App) SetCompactHeight(h int) {
wruntime.WindowSetMinSize(a.ctx, 900, h)
wruntime.WindowSetSize(a.ctx, w, h)
}
// keyAmpsLinked couples the configured amplifiers so ON, OFF and OPERATE act on
// all of them at once — the SPE CO1-2 combiner case.
//
// The combiner is an RF device: it sums two amplifiers and commands nothing. So
// "combined" operation is really two amplifiers that must be kept in the same
// state, and leaving one in OPERATE while the other sits in STANDBY is exactly
// what must not happen — the combiner would see power on one input only.
const keyAmpsLinked = "amps.linked"
// GetAmpsLinked reports whether the amplifiers are commanded together.
func (a *App) GetAmpsLinked() bool { return a.settingOr(keyAmpsLinked, "") == "1" }
// SetAmpsLinked turns coupling on or off.
func (a *App) SetAmpsLinked(on bool) error {
a.setSetting(keyAmpsLinked, boolStr(on))
return nil
}
// ampTargets returns the amplifier ids a command should reach: the one asked
// for, or every running amplifier when they are linked.
//
// The METERS are deliberately untouched by this — each amplifier keeps its own
// bars. Two amps combined are still two amps, and an operator watching for one
// of them to run away needs to see them apart.
func (a *App) ampTargets(id string, linked bool) []string {
if !linked {
return []string{id}
}
a.ampsMu.Lock()
defer a.ampsMu.Unlock()
out := make([]string, 0, len(a.ampInsts))
// The one that was asked for goes FIRST, so a partial failure still did what
// the operator clicked before it stopped.
if _, ok := a.ampInsts[id]; ok {
out = append(out, id)
}
for k := range a.ampInsts {
if k != id {
out = append(out, k)
}
}
return out
}