Files
-rpi-sendspin/internal/pipewire/output.go

168 lines
3.1 KiB
Go

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)
latency := fmt.Sprintf("960/%d", o.sampleRate)
props := fmt.Sprintf(`{"node.name":%s,"node.latency":"%s"}`, nameJSON, latency)
args := []string{
"--playback",
"--raw",
"--format", "s32",
"--rate", strconv.Itoa(o.sampleRate),
"--channels", strconv.Itoa(o.channels),
"--latency", latency,
"--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
}