Files
stems/README.md
T
Bendik Aagaard LynghaugandClaude Fable 5.1 9484d0d578
Release / build (aarch64, aarch64, /var/lib/gitea-runner/sccache, aarch64-unknown-linux-gnu) (push) Successful in 1m36s
Release / publish (push) Successful in 14s
Release / build (x86_64, ubuntu-latest, /sccache, x86_64-unknown-linux-gnu) (push) Successful in 1m50s
stems: multitrack PipeWire recorder
Records every source feeding the default sink into sample-aligned float WAV
stems plus the sink mix, so sessions can be mixed and mastered later.
Release flow mirrors galdr: tag -> build tarballs on both arches -> Gitea
release -> publish to the uhhm Arch registry (no AUR package).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 17:11:47 +02:00

2.8 KiB

stems

Multitrack recorder for PipeWire. Run it, play your session, press Ctrl-C: every source that fed the default sink has its own WAV stem, plus the sink's mix as 00-master.wav, all the same length and sample-aligned. Drop the folder into any DAW and mix or master later.

pw-play / galdr / browser / mic loopback ──▶ default sink ──▶ speakers
          │            │           │              │
          └── stems taps each source port ────────┴── 00-master (sink monitor)

Usage

stems [--dir <path>] [--sink <node.name>] [--no-master] [--list]
  • --dir session root, default ~/Recordings/stems. Each run writes <dir>/<YYYY-MM-DD_HH-MM-SS>/NN-<source>.wav.
  • --sink record the sources of this sink instead of the default one.
  • --no-master skip 00-master.wav.
  • --list print the sources that would be recorded right now and exit.

Files are 32-bit float WAV at the graph rate, one per source node, with the source's channel count (mono sources stay mono). Sources that start mid-session get a track the moment they link to the sink; sources that stop keep their file. Ctrl-C (or SIGTERM) finalizes everything and prints a table:

#   track                                     ch     length  starts at  dropped
00  00-master.wav                              2      42.67s      0.04s        0
01  01-galdr.wav                               2      42.67s      0.00s        0
02  02-fc.zoom.playback.wav                    2      42.67s      0.00s        0
03  03-pw-play.wav                             2      42.67s     12.30s        0

How alignment works

PipeWire stamps every graph cycle with one timestamp that all streams in that cycle see (pw_time.now). Each stem's first buffer is placed at that timestamp's distance from the session origin, cycles a stream missed are filled with silence, and at the end every file is padded to the common length. So frame N in one file is frame N in every other file; "starts at" in the table is just how much leading silence a late-joining track got.

Each track is a capture stream linked by hand (link-factory) to the source's output ports, so the session manager never re-routes it, and the realtime callback only copies frames into a lock-free ring that a writer thread drains.

Limitations

  • A source that appears mid-session is tapped a few graph cycles after it starts (the link has to be created once the graph reports it), so roughly the first 30 to 50 ms of a new source are only in the master, not in its stem.
  • The default sink is read once at start; switching sinks during a session is not followed. Use --sink to pin one.
  • Stems capture what the source sends to the sink, pre-fader: the sink's per-stream volume is not applied (the master has it).

Build

Rust, PipeWire 1.x. cargo build --release; the binary is stems.