package pipewire import ( "encoding/binary" "encoding/json" "fmt" "io" "log" "os/exec" "strconv" "strings" "sync" ) // Output implements the sendspin output.Output interface via pw-cat --playback. type Output struct { target string nodeName string mu sync.Mutex cmd *exec.Cmd stdin io.WriteCloser sampleRate int channels int bitShift uint buf []byte volume float64 muted bool } func NewOutput(target, nodeName string) *Output { return &Output{ target: target, nodeName: nodeName, volume: 1.0, } } func (o *Output) Open(sampleRate, channels, bitDepth int) error { o.mu.Lock() defer o.mu.Unlock() o.sampleRate = sampleRate o.channels = channels // Samples from sendspin are right-justified in int32; shift to fill // the full 32-bit range so pw-cat receives proper amplitude. if bitDepth > 0 && bitDepth < 32 { o.bitShift = uint(32 - bitDepth) } else { o.bitShift = 0 } o.killUnlocked() return o.startUnlocked() } func (o *Output) startUnlocked() error { nameJSON, _ := json.Marshal(o.nodeName) props := fmt.Sprintf(`{"node.name":%s}`, nameJSON) args := []string{ "--playback", "--raw", "--format", "s32", "--rate", strconv.Itoa(o.sampleRate), "--channels", strconv.Itoa(o.channels), "--properties", props, } if o.target != "" { args = append(args, "--target", o.target) } args = append(args, "-") cmd := exec.Command("pw-cat", args...) stderr, _ := cmd.StderrPipe() stdin, err := cmd.StdinPipe() if err != nil { return fmt.Errorf("pw-cat stdin: %w", err) } if err := cmd.Start(); err != nil { return fmt.Errorf("pw-cat start: %w", err) } go func() { b, _ := io.ReadAll(stderr) if s := strings.TrimSpace(string(b)); s != "" { log.Printf("pw-cat stderr: %s", s) } }() o.cmd = cmd o.stdin = stdin if o.buf == nil { o.buf = make([]byte, 0, 4096) } return nil } func (o *Output) killUnlocked() { if o.stdin != nil { o.stdin.Close() o.stdin = nil } if o.cmd != nil && o.cmd.Process != nil { o.cmd.Process.Kill() o.cmd.Wait() o.cmd = nil } } func (o *Output) Write(samples []int32) error { o.mu.Lock() defer o.mu.Unlock() if o.stdin == nil { return fmt.Errorf("output not opened") } need := len(samples) * 4 if cap(o.buf) < need { o.buf = make([]byte, need) } o.buf = o.buf[:need] for i, s := range samples { if o.muted { s = 0 } else if o.volume != 1.0 { s = int32(float64(s) * o.volume) } s <<= o.bitShift binary.LittleEndian.PutUint32(o.buf[i*4:], uint32(s)) } _, err := o.stdin.Write(o.buf) if err != nil { log.Printf("pw-cat pipe broken (%v), restarting", err) o.killUnlocked() if rerr := o.startUnlocked(); rerr != nil { return fmt.Errorf("pw-cat restart: %w", rerr) } _, err = o.stdin.Write(o.buf) } return err } func (o *Output) SetVolume(volume int) { o.mu.Lock() o.volume = float64(volume) / 100.0 o.mu.Unlock() } func (o *Output) SetMuted(muted bool) { o.mu.Lock() o.muted = muted o.mu.Unlock() } func (o *Output) Close() error { o.mu.Lock() defer o.mu.Unlock() o.killUnlocked() return nil }