feat: add PipeWire muter functionality and service files for Sendspin audio player

This commit is contained in:
2026-06-14 12:17:47 +02:00
parent 6e22834c5c
commit 1054d4b9d0
12 changed files with 410 additions and 3 deletions

228
internal/pipewire/muter.go Normal file
View File

@@ -0,0 +1,228 @@
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
}