🎉 live server seems to be working now

This commit is contained in:
2026-05-14 14:29:57 +02:00
commit d72e439fd9
181 changed files with 47406 additions and 0 deletions

155
internal/pipewire/nodes.go Normal file
View File

@@ -0,0 +1,155 @@
package pipewire
import (
"encoding/json"
"fmt"
"os/exec"
"strings"
)
type Node struct {
ID int
Name string
Description string
MediaClass string
}
func (n Node) String() string {
if n.Description != "" && n.Description != n.Name {
return fmt.Sprintf("%s [%s]", n.Description, n.Name)
}
return n.Name
}
type pwObject struct {
ID int `json:"id"`
Type string `json:"type"`
Info struct {
Props struct {
MediaClass string `json:"media.class"`
NodeName string `json:"node.name"`
NodeDesc string `json:"node.description"`
} `json:"props"`
} `json:"info"`
}
func listAllNodes() ([]Node, error) {
out, err := exec.Command("pw-dump").Output()
if err != nil {
return nil, fmt.Errorf("pw-dump: %w", err)
}
var objects []pwObject
if err := json.Unmarshal(out, &objects); err != nil {
return nil, fmt.Errorf("parse pw-dump: %w", err)
}
var nodes []Node
for _, o := range objects {
if o.Type != "PipeWire:Interface:Node" {
continue
}
if !strings.HasPrefix(o.Info.Props.MediaClass, "Audio/") || o.Info.Props.NodeName == "" {
continue
}
nodes = append(nodes, Node{
ID: o.ID,
Name: o.Info.Props.NodeName,
Description: o.Info.Props.NodeDesc,
MediaClass: o.Info.Props.MediaClass,
})
}
return nodes, nil
}
func ListSinks() ([]Node, error) {
all, err := listAllNodes()
if err != nil {
return nil, err
}
var out []Node
for _, n := range all {
if n.MediaClass == "Audio/Sink" {
out = append(out, n)
}
}
return out, nil
}
func ListSources() ([]Node, error) {
all, err := listAllNodes()
if err != nil {
return nil, err
}
var out []Node
for _, n := range all {
switch n.MediaClass {
case "Audio/Source", "Audio/Source.Virtual":
out = append(out, n)
}
}
return out, nil
}
// CaptureTarget is a node that can be fed to `pw-cat --capture --target`.
// Real microphones / line-ins / virtual sources have IsMonitor=false.
// Audio sinks are surfaced with IsMonitor=true: pw-cat reads the monitor
// port when given a sink as --target, so the live server can stream
// whatever audio the host is currently playing.
type CaptureTarget struct {
Name string
Description string
IsMonitor bool
}
// ListCaptureTargets returns every PipeWire node usable as audio input
// for the live server. Sinks are surfaced as monitor capture targets
// (pw-cat captures the monitor port when given a sink as --target);
// `.monitor` pseudo-sources exposed by the PulseAudio compatibility
// layer are also included since `pw-cat --target <name>.monitor` is
// the canonical way to capture playback on many setups.
//
// Order: real microphones / line-ins first, then monitors (the things
// most users actually want for "stream what's playing").
func ListCaptureTargets() ([]CaptureTarget, error) {
all, err := listAllNodes()
if err != nil {
return nil, err
}
var realSources, monitorSources, sinkMonitors []CaptureTarget
monitorNames := map[string]bool{}
for _, n := range all {
switch n.MediaClass {
case "Audio/Source", "Audio/Source.Virtual":
if strings.HasSuffix(n.Name, ".monitor") {
monitorSources = append(monitorSources, CaptureTarget{
Name: n.Name, Description: n.Description, IsMonitor: true,
})
monitorNames[n.Name] = true
} else {
realSources = append(realSources, CaptureTarget{
Name: n.Name, Description: n.Description,
})
}
case "Audio/Sink":
// Skip the sink itself when its corresponding ".monitor"
// source already covers the same capture path — pick one
// canonical name (the source) to avoid two indistinguishable
// dropdown entries.
if monitorNames[n.Name+".monitor"] {
continue
}
sinkMonitors = append(sinkMonitors, CaptureTarget{
Name: n.Name, Description: n.Description, IsMonitor: true,
})
}
}
out := make([]CaptureTarget, 0, len(realSources)+len(monitorSources)+len(sinkMonitors))
out = append(out, realSources...)
out = append(out, monitorSources...)
out = append(out, sinkMonitors...)
return out, nil
}

165
internal/pipewire/output.go Normal file
View File

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

484
internal/pipewire/source.go Normal file
View File

@@ -0,0 +1,484 @@
package pipewire
import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
// Capture-jitter telemetry: log a stats line every readStatsInterval reads
// (avg/min/max ReadFull latency) plus an immediate warning whenever a single
// read exceeds readSpikeThreshold. pw-cat produces samples in real time, so
// the expected per-read latency is ~ChunkDurationMs (20ms). Spikes there are
// the most likely on-device cause of audible drops.
const (
readSpikeThreshold = 40 * time.Millisecond
readStatsInterval = 250 // ~5s at 20ms chunks
)
// Source implements the sendspin AudioSource interface via pw-cat --record.
//
// To avoid PipeWire's session manager auto-connecting our recorder to whatever
// the "default" source is (typically the mic on hardware that has one — which
// silently steals capture from a requested `.monitor` target), we start pw-cat
// with node.autoconnect=false and a unique node.name, then look up both nodes
// in pw-dump and pw-link the target's output ports to our input ports
// manually.
type Source struct {
target string
sampleRate int
channels int
mu sync.Mutex
cmd *exec.Cmd
stdout io.ReadCloser
buf []byte
// dataCh carries raw byte chunks from a background goroutine that
// drains pw-cat as fast as bytes appear. Read assembles ChunkDurationMs
// worth of samples from this stream — without this decoupling, Read
// would block ~21ms per call because PipeWire's default graph quantum
// (1024 samples at 48kHz) exceeds our 20ms tick budget, pushing the
// engine ~1.3ms behind real-time per chunk and eroding the 500ms
// client buffer-ahead until chunks land late and audio drops.
dataCh chan []byte
leftover []byte
stopDrain chan struct{}
drainErr error
drainMu sync.Mutex
// jitter telemetry — accessed only from Read so no extra locking
readCount int
readSumNanos int64
readMaxNanos int64
readMinNanos int64
}
func NewSource(target string, sampleRate, channels int) *Source {
return &Source{
target: target,
sampleRate: sampleRate,
channels: channels,
}
}
func (s *Source) start() error {
uid := fmt.Sprintf("sendspin-cap-%d", time.Now().UnixNano())
uidJSON, _ := json.Marshal(uid)
// node.latency requests a 20ms quantum (960 samples at 48kHz). Without
// this, PipeWire defaults to its graph quantum (typically 1024 samples
// / 21.33ms), which means pw-cat delivers in bursts that don't align
// with the sendspin engine's 20ms ticker — every chunk ends up 1.3ms
// over budget and the 500ms client buffer-ahead drifts away.
chunkSamples := (s.sampleRate * 20) / 1000
props := fmt.Sprintf(
`{"node.name":%s,"node.description":"Sendspin capture","node.autoconnect":false,"node.latency":"%d/%d"}`,
uidJSON, chunkSamples, s.sampleRate,
)
args := []string{
"--record",
"--raw",
"--format", "s32",
"--rate", strconv.Itoa(s.sampleRate),
"--channels", strconv.Itoa(s.channels),
"--target", s.target,
"--properties", props,
"-",
}
log.Printf("pw-cat %s", strings.Join(args, " "))
cmd := exec.Command("pw-cat", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("pw-cat stdout: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("pw-cat stderr: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("pw-cat start: %w", err)
}
go func(t string) {
buf, _ := io.ReadAll(stderr)
if msg := strings.TrimSpace(string(buf)); msg != "" {
log.Printf("pw-cat[%s] stderr: %s", t, msg)
}
}(s.target)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
outIDs, inIDs, linkErr := waitAndMatchPorts(ctx, s.target, uid)
if linkErr != nil {
_ = stdout.Close()
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
return fmt.Errorf("link target ports: %w", linkErr)
}
for i := range outIDs {
lc := exec.Command("pw-link", strconv.Itoa(outIDs[i]), strconv.Itoa(inIDs[i]))
if out, err := lc.CombinedOutput(); err != nil {
log.Printf("pw-link %d→%d: %v: %s", outIDs[i], inIDs[i], err, out)
}
}
s.cmd = cmd
s.stdout = stdout
s.buf = make([]byte, 4096)
// Buffered channel sized to ~1s of audio (50 × 20ms chunks). Plenty
// of headroom for pw-cat's quantum bursts; if it ever fills, pw-cat
// blocks on its stdout write — at which point the engine is so far
// behind that backpressure is the right behavior.
s.dataCh = make(chan []byte, 50)
s.stopDrain = make(chan struct{})
go s.drain(stdout, s.stopDrain)
// Pre-buffer ~60ms (3 quanta) before letting Read return. Two quanta
// (~43ms) is the minimum to absorb the 1024-vs-960-sample beat
// pattern; 60ms gives a small safety margin for scheduler jitter
// without adding meaningful startup latency.
preBufferBytes := (60 * s.sampleRate / 1000) * 4 * s.channels
prebufStart := time.Now()
have := 0
var queued [][]byte
for have < preBufferBytes {
select {
case b := <-s.dataCh:
queued = append(queued, b)
have += len(b)
case <-time.After(2 * time.Second):
_ = stdout.Close()
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
return fmt.Errorf("pw-cat pre-buffer timeout (got %d/%d bytes in %s)",
have, preBufferBytes, time.Since(prebufStart))
}
}
// Re-prime the channel with what we already drained so Read sees
// the full pre-buffer. Channel capacity (50) > expected queue size
// (~5 slices), so this never blocks.
for _, b := range queued {
s.dataCh <- b
}
log.Printf("pw-cat pre-buffered %d bytes (%dms) in %s",
have, have/(48*4*2), time.Since(prebufStart).Round(time.Millisecond))
return nil
}
// drain continuously reads from pw-cat's stdout and pushes byte slices
// onto dataCh. Runs until stdout closes or stop is signaled. Each iteration
// allocates a fresh slice so the receiver owns it — pool/reuse here would
// race with Read.
func (s *Source) drain(stdout io.ReadCloser, stop <-chan struct{}) {
buf := make([]byte, 8192)
for {
n, err := stdout.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
select {
case s.dataCh <- chunk:
case <-stop:
return
}
}
if err != nil {
s.drainMu.Lock()
s.drainErr = err
s.drainMu.Unlock()
close(s.dataCh)
return
}
select {
case <-stop:
return
default:
}
}
}
func (s *Source) Read(samples []int32) (int, error) {
s.mu.Lock()
if s.cmd == nil {
if err := s.start(); err != nil {
s.mu.Unlock()
return 0, err
}
}
need := len(samples) * 4
if len(s.buf) < need {
s.buf = make([]byte, need)
}
buf := s.buf[:need]
s.mu.Unlock()
readStart := time.Now()
filled := 0
// Drain any leftover bytes from the previous Read first. pw-cat's
// quantum (1024 samples) doesn't align with our request (960 samples),
// so every other call carries 64 samples' worth of bytes forward.
if len(s.leftover) > 0 {
copied := copy(buf[filled:], s.leftover)
filled += copied
s.leftover = s.leftover[copied:]
}
for filled < need {
chunk, ok := <-s.dataCh
if !ok {
s.drainMu.Lock()
err := s.drainErr
s.drainMu.Unlock()
s.mu.Lock()
s.killUnlocked()
s.mu.Unlock()
if err == nil {
err = io.EOF
}
return filled / 4, err
}
copied := copy(buf[filled:], chunk)
filled += copied
if copied < len(chunk) {
s.leftover = chunk[copied:]
}
}
s.recordReadTiming(time.Since(readStart), need)
count := filled / 4
// pw-cat emits full-range int32; sendspin's AudioSource contract is
// int32 samples right-justified to 24 bits (encodePCM packs the low
// three bytes; opus does `int16(s >> 8)`). Without this shift the
// signal's top byte is discarded and only low-byte dither survives,
// producing noise that tracks input amplitude.
for i := range count {
samples[i] = int32(binary.LittleEndian.Uint32(buf[i*4:i*4+4])) >> 8
}
return count, nil
}
// recordReadTiming accumulates per-read latency and emits both spike
// warnings and periodic stats summaries. The expected baseline is roughly
// the chunk duration the engine asks for, since pw-cat produces real-time
// samples and io.ReadFull blocks until that many bytes are available.
func (s *Source) recordReadTiming(dur time.Duration, byteCount int) {
d := dur.Nanoseconds()
s.readCount++
s.readSumNanos += d
if d > s.readMaxNanos {
s.readMaxNanos = d
}
if s.readMinNanos == 0 || d < s.readMinNanos {
s.readMinNanos = d
}
if dur > readSpikeThreshold {
log.Printf("pw-cat read spike: %s for %d bytes (threshold %s)",
dur.Round(time.Microsecond), byteCount, readSpikeThreshold)
}
if s.readCount >= readStatsInterval {
avg := time.Duration(s.readSumNanos / int64(s.readCount))
log.Printf("pw-cat read stats over %d reads: avg=%s min=%s max=%s",
s.readCount,
avg.Round(time.Microsecond),
time.Duration(s.readMinNanos).Round(time.Microsecond),
time.Duration(s.readMaxNanos).Round(time.Microsecond),
)
s.readCount = 0
s.readSumNanos = 0
s.readMaxNanos = 0
s.readMinNanos = 0
}
}
// killUnlocked tears down the running pw-cat without holding the mutex.
// Callers must hold s.mu.
func (s *Source) killUnlocked() {
if s.stopDrain != nil {
// Signal the drainer to exit. It will also exit on its own when
// stdout closes, but signaling first avoids a hung send into
// dataCh if the buffer is full at shutdown.
close(s.stopDrain)
s.stopDrain = nil
}
if s.stdout != nil {
s.stdout.Close()
s.stdout = nil
}
if s.cmd != nil && s.cmd.Process != nil {
s.cmd.Process.Kill()
s.cmd.Wait()
s.cmd = nil
}
s.leftover = nil
}
func (s *Source) SampleRate() int { return s.sampleRate }
func (s *Source) Channels() int { return s.channels }
func (s *Source) Metadata() (string, string, string) { return "", "", "" }
func (s *Source) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
s.killUnlocked()
return nil
}
type portEntry struct {
id int
channel string
}
type dumpObject struct {
ID int `json:"id"`
Type string `json:"type"`
Info json.RawMessage `json:"info"`
}
type dumpNodeInfo struct {
Props json.RawMessage `json:"props"`
}
type dumpPortInfo struct {
Direction string `json:"direction"`
Props json.RawMessage `json:"props"`
}
// waitAndMatchPorts polls pw-dump until both the target node and our pw-cat
// node are present, then pairs target output ports to our input ports.
func waitAndMatchPorts(ctx context.Context, targetName, ourName string) (outIDs, inIDs []int, err error) {
for {
select {
case <-ctx.Done():
return nil, nil, ctx.Err()
default:
}
raw, dumpErr := exec.Command("pw-dump").Output()
if dumpErr != nil {
time.Sleep(100 * time.Millisecond)
continue
}
var objects []dumpObject
if err := json.Unmarshal(raw, &objects); err != nil {
time.Sleep(100 * time.Millisecond)
continue
}
nodeIDs := make(map[string]int)
for _, obj := range objects {
if obj.Type != "PipeWire:Interface:Node" {
continue
}
var info dumpNodeInfo
if json.Unmarshal(obj.Info, &info) != nil {
continue
}
var props map[string]any
if json.Unmarshal(info.Props, &props) != nil {
continue
}
if name, ok := props["node.name"].(string); ok {
nodeIDs[name] = obj.ID
}
}
targetID, targetOK := nodeIDs[targetName]
ourID, ourOK := nodeIDs[ourName]
if !targetOK || !ourOK {
time.Sleep(100 * time.Millisecond)
continue
}
var targetOuts, ourIns []portEntry
for _, obj := range objects {
if obj.Type != "PipeWire:Interface:Port" {
continue
}
var info dumpPortInfo
if json.Unmarshal(obj.Info, &info) != nil {
continue
}
var props map[string]any
if json.Unmarshal(info.Props, &props) != nil {
continue
}
nodeIDf, ok := props["node.id"].(float64)
if !ok {
continue
}
nodeID := int(nodeIDf)
ch, _ := props["audio.channel"].(string)
switch {
case nodeID == targetID && info.Direction == "output":
targetOuts = append(targetOuts, portEntry{obj.ID, ch})
case nodeID == ourID && info.Direction == "input":
ourIns = append(ourIns, portEntry{obj.ID, ch})
}
}
if len(targetOuts) == 0 || len(ourIns) == 0 {
time.Sleep(100 * time.Millisecond)
continue
}
pairs := matchPorts(targetOuts, ourIns)
if len(pairs) == 0 {
return nil, nil, fmt.Errorf("no matching ports between %q and %q", targetName, ourName)
}
for _, p := range pairs {
outIDs = append(outIDs, p[0])
inIDs = append(inIDs, p[1])
}
return outIDs, inIDs, nil
}
}
// matchPorts pairs output ports to input ports by audio.channel name,
// falling back to positional order when channel names are missing or unmatched.
func matchPorts(outs, ins []portEntry) [][2]int {
inByChannel := make(map[string]int, len(ins))
for _, p := range ins {
if p.channel != "" {
inByChannel[p.channel] = p.id
}
}
used := make(map[int]bool)
var result [][2]int
for _, o := range outs {
if inID, ok := inByChannel[o.channel]; ok && !used[inID] {
result = append(result, [2]int{o.id, inID})
used[inID] = true
}
}
if len(result) > 0 {
return result
}
for i := range min(len(outs), len(ins)) {
result = append(result, [2]int{outs[i].id, ins[i].id})
}
return result
}