229 lines
5.4 KiB
Go
229 lines
5.4 KiB
Go
package pipewire
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os/exec"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Muter keeps a single PipeWire node muted or unmuted to match a desired
|
|
// state, re-asserting periodically so transient failures (or external
|
|
// interference) self-heal. Resolves the numeric node ID lazily because
|
|
// PipeWire IDs change across restarts.
|
|
//
|
|
// Failure modes worth knowing:
|
|
// - wpctl missing: the muter logs once at construction and becomes a no-op.
|
|
// - Target node not present yet: resolution is retried on every tick,
|
|
// so it's fine to start the client before the loopback exists.
|
|
// - Process killed with SIGKILL: nothing can unmute. SIGINT/SIGTERM and
|
|
// normal exits run Stop, which forces unmute synchronously.
|
|
type Muter struct {
|
|
nodeName string
|
|
hasWpctl bool
|
|
|
|
mu sync.Mutex
|
|
desired bool // desired muted state
|
|
lastApplied int // -1 = unknown, 0 = unmuted, 1 = muted
|
|
lastResolved int // cached node ID, -1 = unknown
|
|
lastResolveErrLogged bool
|
|
|
|
wake chan struct{}
|
|
stop chan struct{}
|
|
done chan struct{}
|
|
}
|
|
|
|
// NewMuter constructs a Muter for the given node.name. The control loop
|
|
// starts immediately and the node is forced to unmuted on startup as a
|
|
// defensive measure against a previous run that crashed mid-mute.
|
|
func NewMuter(nodeName string) *Muter {
|
|
m := &Muter{
|
|
nodeName: nodeName,
|
|
lastApplied: -1,
|
|
lastResolved: -1,
|
|
wake: make(chan struct{}, 1),
|
|
stop: make(chan struct{}),
|
|
done: make(chan struct{}),
|
|
}
|
|
if _, err := exec.LookPath("wpctl"); err != nil {
|
|
log.Printf("muter: wpctl not found (%v); loopback mute disabled", err)
|
|
} else {
|
|
m.hasWpctl = true
|
|
}
|
|
go m.run()
|
|
return m
|
|
}
|
|
|
|
// SetDesiredMute updates the target state. Cheap and non-blocking; the
|
|
// control loop applies it asynchronously and re-asserts on a ticker.
|
|
func (m *Muter) SetDesiredMute(muted bool) {
|
|
m.mu.Lock()
|
|
changed := m.desired != muted
|
|
m.desired = muted
|
|
m.mu.Unlock()
|
|
if changed {
|
|
m.kick()
|
|
}
|
|
}
|
|
|
|
func (m *Muter) kick() {
|
|
select {
|
|
case m.wake <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (m *Muter) run() {
|
|
defer close(m.done)
|
|
if !m.hasWpctl {
|
|
<-m.stop
|
|
return
|
|
}
|
|
|
|
// Eager startup unmute so a previous crashed run can't leave the
|
|
// loopback stuck muted before the player has reported its state.
|
|
m.applyOnce(false)
|
|
|
|
ticker := time.NewTicker(3 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-m.stop:
|
|
return
|
|
case <-m.wake:
|
|
case <-ticker.C:
|
|
}
|
|
m.mu.Lock()
|
|
want := m.desired
|
|
m.mu.Unlock()
|
|
m.applyOnce(want)
|
|
}
|
|
}
|
|
|
|
// applyOnce resolves the node ID if needed and issues `wpctl set-mute`
|
|
// when the desired state differs from what we last successfully applied.
|
|
// On apply failure, lastApplied is left untouched so the next tick retries.
|
|
func (m *Muter) applyOnce(muted bool) {
|
|
id, err := m.resolveID()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
m.mu.Lock()
|
|
last := m.lastApplied
|
|
m.mu.Unlock()
|
|
|
|
want := 0
|
|
if muted {
|
|
want = 1
|
|
}
|
|
if last == want {
|
|
return
|
|
}
|
|
|
|
if err := setMute(id, muted); err != nil {
|
|
log.Printf("muter: wpctl set-mute %d %d failed: %v", id, want, err)
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
m.lastApplied = want
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// resolveID returns the cached node ID, refreshing from pw-dump on first
|
|
// use or after a wpctl failure invalidated it. The "not found" case is
|
|
// logged once per outage so a missing loopback doesn't spam the log every
|
|
// 3 seconds, but a successful resolution rearms the warning.
|
|
func (m *Muter) resolveID() (int, error) {
|
|
m.mu.Lock()
|
|
cached := m.lastResolved
|
|
m.mu.Unlock()
|
|
if cached >= 0 {
|
|
return cached, nil
|
|
}
|
|
|
|
id, err := FindNodeIDByName(m.nodeName)
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if err != nil {
|
|
if !m.lastResolveErrLogged {
|
|
log.Printf("muter: %v (will keep retrying)", err)
|
|
m.lastResolveErrLogged = true
|
|
}
|
|
return -1, err
|
|
}
|
|
if m.lastResolveErrLogged {
|
|
log.Printf("muter: node.name=%q resolved to id %d", m.nodeName, id)
|
|
m.lastResolveErrLogged = false
|
|
}
|
|
m.lastResolved = id
|
|
// Force the next apply to re-issue the wpctl command, since a new ID
|
|
// almost certainly means a fresh node whose mute state we don't know.
|
|
m.lastApplied = -1
|
|
return id, nil
|
|
}
|
|
|
|
func setMute(id int, muted bool) error {
|
|
arg := "0"
|
|
if muted {
|
|
arg = "1"
|
|
}
|
|
cmd := exec.Command("wpctl", "set-mute", strconv.Itoa(id), arg)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %s", err, string(out))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Stop tears the muter down. It forces an unmute (retried a few times)
|
|
// before returning so a normal-exit path leaves the loopback in the
|
|
// expected state even if wpctl was briefly flaky. Safe to call multiple
|
|
// times.
|
|
func (m *Muter) Stop(ctx context.Context) {
|
|
select {
|
|
case <-m.stop:
|
|
return
|
|
default:
|
|
}
|
|
close(m.stop)
|
|
<-m.done
|
|
|
|
if !m.hasWpctl {
|
|
return
|
|
}
|
|
if err := m.forceUnmute(ctx); err != nil {
|
|
log.Printf("muter: WARNING failed to unmute %q on shutdown: %v — loopback may be stuck muted", m.nodeName, err)
|
|
}
|
|
}
|
|
|
|
func (m *Muter) forceUnmute(ctx context.Context) error {
|
|
var lastErr error
|
|
backoff := 100 * time.Millisecond
|
|
for attempt := 0; attempt < 3; attempt++ {
|
|
if ctx.Err() != nil {
|
|
return errors.Join(lastErr, ctx.Err())
|
|
}
|
|
id, err := FindNodeIDByName(m.nodeName)
|
|
if err != nil {
|
|
lastErr = err
|
|
} else if err := setMute(id, false); err != nil {
|
|
lastErr = err
|
|
} else {
|
|
return nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return errors.Join(lastErr, ctx.Err())
|
|
case <-time.After(backoff):
|
|
}
|
|
backoff *= 2
|
|
}
|
|
return lastErr
|
|
}
|