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 }