stems: multitrack PipeWire recorder
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

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>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-10-08 17:11:47 +02:00
co-authored by Claude Fable 5.1
commit 9484d0d578
11 changed files with 2091 additions and 0 deletions
+158
View File
@@ -0,0 +1,158 @@
name: Release
on:
push:
tags:
- 'v*'
env:
CARGO_TERM_COLOR: always
jobs:
build:
strategy:
matrix:
include:
- arch: x86_64
target: x86_64-unknown-linux-gnu
runs-on: ubuntu-latest
sccache_dir: /sccache
- arch: aarch64
target: aarch64-unknown-linux-gnu
runs-on: aarch64
sccache_dir: /var/lib/gitea-runner/sccache
runs-on: ${{ matrix.runs-on }}
steps:
- uses: builtin:checkout
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
# Compiled-crate cache via sccache. x86_64 uses /sccache (a persistent
# host dir bind-mounted into the job container by the runner config);
# aarch64 runs on the klokka host and uses a runner-owned dir.
- name: Set up sccache
run: |
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
echo "SCCACHE_DIR=${{ matrix.sccache_dir }}" >> "$GITHUB_ENV"
mkdir -p "${{ matrix.sccache_dir }}"
if command -v sccache >/dev/null 2>&1; then sccache --version; exit 0; fi
# Bare runners (aarch64 = klokka host) are pre-provisioned; CI must not
# install onto them (and this tarball is x86_64-only). Only the
# ephemeral x86_64 container installs; a bare host missing it fails loud.
if [ "${{ matrix.arch }}" != x86_64 ]; then
echo "::error::sccache missing on the bare ${{ matrix.arch }} runner — provision klokka; CI must not install on bare hosts"; exit 1
fi
V=0.8.2
curl -sSL "https://github.com/mozilla/sccache/releases/download/v${V}/sccache-v${V}-x86_64-unknown-linux-musl.tar.gz" | tar -xz
sudo install -m0755 "sccache-v${V}-x86_64-unknown-linux-musl/sccache" /usr/local/bin/sccache
sccache --version
- name: Ensure build deps (libpipewire, clang for bindgen)
run: |
if command -v apt-get >/dev/null; then
# Ephemeral container runner: safe to install into it.
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
$SUDO apt-get update
$SUDO apt-get install -y libpipewire-0.3-dev libspa-0.2-dev clang libclang-dev pkg-config
else
# Bare/host runner: never mutate it - require deps preinstalled.
missing=""
pkg-config --exists libpipewire-0.3 2>/dev/null || missing="$missing libpipewire(+dev headers)"
command -v clang >/dev/null || missing="$missing clang"
if [ -n "$missing" ]; then
echo "::error::bare runner missing build deps:$missing — install once on the host; CI does not modify bare runners"
exit 1
fi
echo "bare runner already has libpipewire + clang"
fi
- name: Test
run: cargo test --release --locked --target ${{ matrix.target }}
- name: Build
run: cargo build --release --locked --target ${{ matrix.target }}
- name: Package
run: |
TAG=${{ gitea.ref_name }}
TARBALL="stems-${TAG}-${{ matrix.arch }}.tar.gz"
mkdir pkg
cp target/${{ matrix.target }}/release/stems pkg/
cp LICENSE pkg/
tar -czf "${TARBALL}" -C pkg .
sha256sum "${TARBALL}" > "${TARBALL}.sha256"
echo "TARBALL=${TARBALL}" >> $GITHUB_ENV
- name: Create release
run: |
curl -sX POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \
-d "{\"tag_name\":\"${{ gitea.ref_name }}\",\"name\":\"${{ gitea.ref_name }}\"}" \
--fail-with-body || true
- name: Upload assets
run: |
RELEASE_ID=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/tags/${{ gitea.ref_name }}" \
| jq -r '.id')
for FILE in "${{ env.TARBALL }}" "${{ env.TARBALL }}.sha256"; do
EXISTING=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \
| jq -r ".[] | select(.name == \"${FILE}\") | .id")
for AID in $EXISTING; do
curl -sX DELETE \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets/${AID}"
done
curl -sX POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets?name=${FILE}" \
--data-binary "@${FILE}" --fail-with-body
done
publish:
needs: build
runs-on: aarch64
steps:
- uses: builtin:checkout
- name: Compute checksums and update PKGBUILD
run: |
TAG=${{ gitea.ref_name }}
BASE="${{ gitea.server_url }}/${{ gitea.repository }}/releases/download/${TAG}"
SUM_X86=$(curl -sL "${BASE}/stems-${TAG}-x86_64.tar.gz" | sha256sum | cut -d' ' -f1)
SUM_AARCH=$(curl -sL "${BASE}/stems-${TAG}-aarch64.tar.gz" | sha256sum | cut -d' ' -f1)
sed -i "s/sha256sums_x86_64=('.*')/sha256sums_x86_64=('${SUM_X86}')/" aur/PKGBUILD
sed -i "s/sha256sums_aarch64=('.*')/sha256sums_aarch64=('${SUM_AARCH}')/" aur/PKGBUILD
# Best-effort mirror to the instance Arch registry via a write:package
# REGISTRY_TOKEN secret; skipped + non-fatal when unset.
- name: Publish to the Arch package registry
continue-on-error: true
run: |
set -euo pipefail
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
echo "::warning::REGISTRY_TOKEN not set — skipping Arch registry publish"
exit 0
fi
cd aur
for carch in aarch64 x86_64; do
pkgfile=$(CARCH="$carch" makepkg --packagelist | tail -1)
CARCH="$carch" makepkg -f --nodeps --noconfirm --skipinteg
curl --fail-with-body --user "bl:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file "$pkgfile" \
"${{ gitea.server_url }}/api/packages/${{ gitea.repository_owner }}/arch/uhhm"
done
+1
View File
@@ -0,0 +1 @@
/target
Generated
+776
View File
@@ -0,0 +1,776 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "android_system_properties"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
dependencies = [
"libc",
]
[[package]]
name = "annotate-snippets"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4"
dependencies = [
"anstyle",
"unicode-width",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"annotate-snippets",
"bitflags",
"cexpr",
"clang-sys",
"itertools",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex 1.3.0",
"syn 2.0.119",
]
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
dependencies = [
"find-msvc-tools",
"shlex 2.0.1",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom 7.1.3",
]
[[package]]
name = "cfg-expr"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ba9e9ec16c447027685b1f897b720e18e9a8afd00bd7332c483537e38086c9f"
dependencies = [
"smallvec",
"target-lexicon",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
"windows-link",
]
[[package]]
name = "clang-sys"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "cookie-factory"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "either"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "glob"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hound"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "js-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "libspa"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882f7427e7989dcc9d388b7f05c4630390a1d7696f9ffa469cd4a7a48f0b4c40"
dependencies = [
"bitflags",
"cc",
"cookie-factory",
"libc",
"libspa-sys",
"nom 8.0.0",
"rustix",
"system-deps",
]
[[package]]
name = "libspa-sys"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b6e17bdaf63ed0d5e4144022624032b41fd9733112e8c74ac26fc9bf1291924"
dependencies = [
"bindgen",
"cc",
"system-deps",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pipewire"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde71084c4e25959d68f1ea54daa75e5ecdb338e5caf0b5510143b79baa32d5c"
dependencies = [
"bitflags",
"libc",
"libspa",
"libspa-sys",
"pipewire-sys",
"rustix",
]
[[package]]
name = "pipewire-sys"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce653f53e63e5b93853218092ee9a8906a5d082c92f3f1db26316955dd63ce0"
dependencies = [
"bindgen",
"libspa-sys",
"system-deps",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rtrb"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fae8ee26b0371a29a77d2b2d6b3ae13aa81def6f9bf1b1b92a32d279a5e709b7"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_spanned"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
dependencies = [
"serde_core",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "stems"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"hound",
"pipewire",
"rtrb",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "system-deps"
version = "7.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
dependencies = [
"cfg-expr",
"heck",
"pkg-config",
"toml",
"version-compare",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "toml"
version = "1.1.7+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7874cd34dd99040fc24e30923aa64a60a81ff3ddfe2b8129ccc84ba24949deed"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.4+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bed810ee007d8e7f9cf20b4774e7198142fa27186e0f805871da7f3c39eaf333"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "version-compare"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.6",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "stems"
version = "0.1.0"
edition = "2021"
description = "Multitrack PipeWire recorder: captures every source feeding the default sink into aligned per-source WAV stems"
license = "MIT"
repository = "https://project.uhhm.no/bl/stems"
[profile.release]
lto = true
codegen-units = 1
[dependencies]
anyhow = "1"
pipewire = { version = "0.10", features = ["v0_3_49"] }
hound = "3.5"
rtrb = "0.3"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Bendik Aagaard Lynghaug
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+64
View File
@@ -0,0 +1,64 @@
# 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`.
+21
View File
@@ -0,0 +1,21 @@
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
pkgname=stems
pkgver=0.1.0
pkgrel=1
pkgdesc="Multitrack PipeWire recorder: every source feeding the default sink becomes an aligned WAV stem"
arch=('x86_64' 'aarch64')
options=('!strip')
url="https://project.uhhm.no/bl/stems"
license=('MIT')
depends=('libpipewire')
provides=('stems')
conflicts=('stems-git' 'stems-bin')
source_x86_64=("stems-v${pkgver}-x86_64.tar.gz::https://project.uhhm.no/bl/stems/releases/download/v${pkgver}/stems-v${pkgver}-x86_64.tar.gz")
source_aarch64=("stems-v${pkgver}-aarch64.tar.gz::https://project.uhhm.no/bl/stems/releases/download/v${pkgver}/stems-v${pkgver}-aarch64.tar.gz")
sha256sums_x86_64=('SKIP')
sha256sums_aarch64=('SKIP')
package() {
install -Dm755 stems "$pkgdir/usr/bin/stems"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
+12
View File
@@ -0,0 +1,12 @@
publish = false
allow-branch = ["main"]
[[pre-release-replacements]]
file = "aur/PKGBUILD"
search = "pkgver=.*"
replace = "pkgver={{version}}"
[[pre-release-replacements]]
file = "aur/PKGBUILD"
search = "pkgrel=.*"
replace = "pkgrel=1"
+714
View File
@@ -0,0 +1,714 @@
//! stems - multitrack PipeWire recorder.
//!
//! Watches the graph, finds every node linked into the default sink, and
//! records each one to its own WAV stem (plus the sink's own mix as 00-master)
//! with sample-aligned starts, so a session can be mixed and mastered later.
//!
//! Design: one PipeWire main loop on the main thread owns all proxies. Graph
//! changes (registry globals, metadata) mark the state dirty and request a
//! core sync; when the server answers "done" every global of that burst has
//! arrived and `reconcile` builds or tears down tracks. Each track is a
//! capture stream linked by hand (link-factory) to the source's output ports,
//! so WirePlumber never re-routes it; its RT process callback only copies
//! frames into a ring that a writer thread drains to disk.
mod track;
mod util;
use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use pipewire as pw;
use pw::spa;
use pw::spa::pod::Pod;
use pw::{properties::properties, registry::GlobalObject, types::ObjectType};
use spa::param::audio::{AudioFormat, AudioInfoRaw, AudioInfoRawFlags};
use spa::utils::dict::DictRef;
use track::{push_frames, Report, Track, UNSET};
const HELP: &str = "\
stems - record every source of the default sink as aligned multitrack stems
Usage: stems [options]
Options:
--dir <path> Session root (default: ~/Recordings/stems); each run
writes <dir>/<YYYY-MM-DD_HH-MM-SS>/NN-<source>.wav
--sink <name> Record sources of this sink node instead of the default
--no-master Skip 00-master.wav (the sink's own mix)
--list Print the sources that would be recorded and exit
-h, --help Print this message
Stop with Ctrl-C. Every file in a session has the same length and starts at
the same instant, so the folder drops straight into a DAW.
";
struct Opts {
dir: PathBuf,
sink: Option<String>,
master: bool,
list: bool,
}
#[derive(Clone)]
struct NodeInfo {
name: String,
}
struct PortInfo {
node: u32,
port_id: u32,
output: bool,
monitor: bool,
}
struct LinkInfo {
out_node: u32,
out_port: u32,
in_node: u32,
}
/// A live capture: the stream, its listener, and the links we made for it.
struct Rec {
track: Track,
stream: pw::stream::StreamRc,
_listener: pw::stream::StreamListener<()>,
/// Source port global ids in channel order (empty for the master).
src_ports: Vec<u32>,
links: Vec<pw::link::Link>,
}
struct State {
opts: Opts,
nodes: HashMap<u32, NodeInfo>,
ports: HashMap<u32, PortInfo>,
links: HashMap<u32, LinkInfo>,
sink_name: Option<String>,
started: bool,
session_dir: PathBuf,
/// source node id -> recording
recs: HashMap<u32, Rec>,
master: Option<Rec>,
/// Tracks whose source went away; finished with the rest at exit.
orphans: Vec<Track>,
next_index: usize,
/// Session clock: max frames any track has accounted for.
clock: Arc<AtomicU64>,
/// Cycle timestamp (ns) of the first buffer any track received.
origin: Arc<AtomicU64>,
pending: Option<pw::spa::utils::result::AsyncSeq>,
_metadata: Option<(pw::metadata::Metadata, pw::metadata::MetadataListener)>,
}
fn parse_args() -> Result<Opts> {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let mut o = Opts {
dir: home.join("Recordings").join("stems"),
sink: None,
master: true,
list: false,
};
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--dir" => {
o.dir = PathBuf::from(args.next().ok_or_else(|| anyhow!("--dir needs a path"))?)
}
"--sink" => {
o.sink = Some(
args.next()
.ok_or_else(|| anyhow!("--sink needs a node name"))?,
)
}
"--no-master" => o.master = false,
"--list" => o.list = true,
"-h" | "--help" => {
print!("{HELP}");
std::process::exit(0);
}
other => return Err(anyhow!("unknown option: {other}\n\n{HELP}")),
}
}
Ok(o)
}
fn main() -> Result<()> {
let opts = parse_args()?;
pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None).context("mainloop")?;
let context = pw::context::ContextRc::new(&mainloop, None).context("context")?;
let core = context.connect_rc(None).context("connect to PipeWire")?;
let registry = core.get_registry_rc().context("registry")?;
let sink_name = opts.sink.clone();
let state = Rc::new(RefCell::new(State {
session_dir: opts
.dir
.join(chrono::Local::now().format("%Y-%m-%d_%H-%M-%S").to_string()),
opts,
nodes: HashMap::new(),
ports: HashMap::new(),
links: HashMap::new(),
sink_name,
started: false,
recs: HashMap::new(),
master: None,
orphans: Vec::new(),
next_index: 1,
clock: Arc::new(AtomicU64::new(0)),
origin: Arc::new(AtomicU64::new(UNSET)),
pending: None,
_metadata: None,
}));
// Registry: mirror nodes/ports/links, pick up the default-sink metadata,
// and request a sync after anything that may change the track set.
let _reg_listener = {
let (st, core1, reg) = (state.clone(), core.clone(), registry.clone());
let (st2, core2) = (state.clone(), core.clone());
registry
.add_listener_local()
.global(move |g| on_global(&st, &core1, &reg, g))
.global_remove(move |id| on_global_remove(&st2, &core2, id))
.register()
};
// Core "done" answers our sync: the burst of globals before it is complete.
let _core_listener = {
let (st, core_cb, ml) = (state.clone(), core.clone(), mainloop.clone());
core.add_listener_local()
.done(move |id, seq| {
if id != pw::core::PW_ID_CORE {
return;
}
let is_ours = st.borrow().pending == Some(seq);
if is_ours {
st.borrow_mut().pending = None;
reconcile(&st, &core_cb, &ml);
}
})
.register()
};
// Initial sync so a quiet graph still reaches reconcile once.
request_sync(&state, &core);
let _sigint = {
let ml = mainloop.clone();
mainloop
.loop_()
.add_signal_local(pw::loop_::Signal::INT, move || ml.quit())
};
let _sigterm = {
let ml = mainloop.clone();
mainloop
.loop_()
.add_signal_local(pw::loop_::Signal::TERM, move || ml.quit())
};
mainloop.run();
// Teardown: stop capturing, then let every writer pad to the common length.
let mut st = state.borrow_mut();
if st.opts.list {
return Ok(());
}
let mut tracks: Vec<Track> = Vec::new();
if let Some(m) = st.master.take() {
tracks.push(stop_rec(&core, m));
}
let ids: Vec<u32> = st.recs.keys().copied().collect();
for id in ids {
let r = st.recs.remove(&id).expect("key just listed");
tracks.push(stop_rec(&core, r));
}
tracks.append(&mut st.orphans);
// Only now is the clock final: no stream can add frames any more.
let final_frames = st.clock.load(Ordering::SeqCst);
let dir = st.session_dir.clone();
drop(st);
let mut reports: Vec<Report> = tracks
.into_iter()
.map(|t| t.finish(final_frames))
.collect::<Result<_>>()?;
reports.sort_by_key(|r| r.index);
print_report(&dir, &reports);
Ok(())
}
fn request_sync(state: &Rc<RefCell<State>>, core: &pw::core::CoreRc) {
let mut st = state.borrow_mut();
if st.pending.is_none() {
st.pending = core.sync(0).ok();
}
}
fn on_global(
state: &Rc<RefCell<State>>,
core: &pw::core::CoreRc,
registry: &pw::registry::RegistryRc,
g: &GlobalObject<&DictRef>,
) {
let Some(props) = g.props else { return };
let mut dirty = false;
match g.type_ {
ObjectType::Node => {
let name = props.get("node.name").unwrap_or("").to_string();
state.borrow_mut().nodes.insert(g.id, NodeInfo { name });
}
ObjectType::Port => {
let p = PortInfo {
node: props
.get("node.id")
.and_then(|v| v.parse().ok())
.unwrap_or(u32::MAX),
port_id: props
.get("port.id")
.and_then(|v| v.parse().ok())
.unwrap_or(u32::MAX),
output: props.get("port.direction") == Some("out"),
monitor: props.get("port.monitor") == Some("true"),
};
state.borrow_mut().ports.insert(g.id, p);
dirty = true; // may be one of our own capture ports -> link it
}
ObjectType::Link => {
let num = |k: &str| {
props
.get(k)
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(u32::MAX)
};
let l = LinkInfo {
out_node: num("link.output.node"),
out_port: num("link.output.port"),
in_node: num("link.input.node"),
};
state.borrow_mut().links.insert(g.id, l);
dirty = true;
}
ObjectType::Metadata if props.get("metadata.name") == Some("default") => {
if let Ok(md) = registry.bind::<pw::metadata::Metadata, _>(g) {
let (st, core) = (state.clone(), core.clone());
let listener = md
.add_listener_local()
.property(move |_subject, key, _type, value| {
if key == Some("default.audio.sink") {
let mut s = st.borrow_mut();
// --sink wins; otherwise follow the default until recording starts.
if s.opts.sink.is_none() && !s.started {
s.sink_name = value.and_then(util::parse_default_sink);
drop(s);
request_sync(&st, &core);
}
}
0
})
.register();
state.borrow_mut()._metadata = Some((md, listener));
}
}
_ => {}
}
if dirty {
request_sync(state, core);
}
}
fn on_global_remove(state: &Rc<RefCell<State>>, core: &pw::core::CoreRc, id: u32) {
let mut st = state.borrow_mut();
st.ports.remove(&id);
st.links.remove(&id);
if st.nodes.remove(&id).is_some() {
if let Some(rec) = st.recs.remove(&id) {
eprintln!(
"track {:02}: {} went away; keeping its file",
rec.track.index, rec.track.label
);
let t = stop_rec(core, rec);
st.orphans.push(t);
}
}
drop(st);
request_sync(state, core);
}
/// Disconnect the stream and destroy our links; the track (and its writer) live on.
fn stop_rec(core: &pw::core::CoreRc, rec: Rec) -> Track {
let _ = rec.stream.disconnect();
for l in rec.links {
let _ = core.destroy_object(l);
}
rec.track
}
/// Build the desired track set from the graph mirror. Idempotent.
fn reconcile(
state: &Rc<RefCell<State>>,
core: &pw::core::CoreRc,
mainloop: &pw::main_loop::MainLoopRc,
) {
let mut st = state.borrow_mut();
let Some(sink_name) = st.sink_name.clone() else {
return;
};
let Some(sink_id) = st
.nodes
.iter()
.find(|(_, n)| n.name == sink_name)
.map(|(id, _)| *id)
else {
return;
};
// Sources: output ports linked into the sink, grouped by node, in port order.
let mut sources: BTreeMap<u32, Vec<(u32, u32)>> = BTreeMap::new(); // node -> [(port.id, port global)]
for l in st.links.values().filter(|l| l.in_node == sink_id) {
let Some(p) = st.ports.get(&l.out_port) else {
continue;
};
if !p.output {
continue;
}
let Some(n) = st.nodes.get(&l.out_node) else {
continue;
};
if n.name.starts_with("stems") {
continue;
}
let v = sources.entry(l.out_node).or_default();
if !v.iter().any(|(_, g)| *g == l.out_port) {
v.push((p.port_id, l.out_port));
}
}
for v in sources.values_mut() {
v.sort();
}
if st.opts.list {
println!("sink: {sink_name}");
for (node, ports) in &sources {
println!(" {:<48} {} ch", st.nodes[node].name, ports.len());
}
mainloop.quit();
return;
}
if !st.started {
st.started = true;
if let Err(e) = std::fs::create_dir_all(&st.session_dir) {
eprintln!("cannot create {}: {e}", st.session_dir.display());
mainloop.quit();
return;
}
eprintln!("session: {} sink: {sink_name}", st.session_dir.display());
if st.opts.master {
let ch = st
.ports
.values()
.filter(|p| p.node == sink_id && !p.output && !p.monitor)
.count()
.max(1);
match start_master(&st, core, &sink_name, ch) {
Ok(rec) => {
eprintln!("track 00: master ({ch} ch, sink monitor)");
st.master = Some(rec);
}
Err(e) => eprintln!("master capture failed: {e}"),
}
}
}
// New sources -> new tracks.
for (node, ports) in &sources {
if st.recs.contains_key(node) || ports.is_empty() {
continue;
}
let label = util::sanitize(&st.nodes[node].name);
let index = st.next_index;
match start_source(&st, core, index, &label, ports.len()) {
Ok(mut rec) => {
rec.src_ports = ports.iter().map(|(_, g)| *g).collect();
eprintln!("track {index:02}: {label} ({} ch)", ports.len());
st.next_index += 1;
st.recs.insert(*node, rec);
}
Err(e) => eprintln!("cannot record {label}: {e}"),
}
}
// Link any capture stream whose own input ports have appeared.
let mut new_links: Vec<(u32, Vec<pw::link::Link>)> = Vec::new();
for (node, rec) in st.recs.iter() {
if !rec.links.is_empty() {
continue;
}
let my_node = rec.stream.node_id();
if my_node == u32::MAX {
continue;
}
let mut mine: Vec<(u32, u32)> = st
.ports
.iter()
.filter(|(_, p)| p.node == my_node && !p.output)
.map(|(g, p)| (p.port_id, *g))
.collect();
if mine.len() < rec.src_ports.len() {
continue;
}
mine.sort();
let mut links = Vec::new();
for (i, src) in rec.src_ports.iter().enumerate() {
let props = properties! {
"link.output.port" => src.to_string(),
"link.input.port" => mine[i].1.to_string(),
};
match core.create_object::<pw::link::Link>("link-factory", &props) {
Ok(l) => links.push(l),
Err(e) => eprintln!("link failed for {}: {e}", rec.track.label),
}
}
new_links.push((*node, links));
}
for (node, links) in new_links {
if let Some(rec) = st.recs.get_mut(&node) {
rec.links = links;
}
}
}
/// A capture stream that we link by hand to one source's output ports.
fn start_source(
st: &State,
core: &pw::core::CoreRc,
index: usize,
label: &str,
channels: usize,
) -> Result<Rec> {
let props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Production",
*pw::keys::NODE_NAME => format!("stems.{label}"),
*pw::keys::NODE_AUTOCONNECT => "false",
*pw::keys::NODE_DONT_RECONNECT => "true",
};
build_rec(
st,
core,
index,
label,
channels,
props,
pw::stream::StreamFlags::empty(),
)
}
/// The sink's own mix, via its monitor ports.
fn start_master(st: &State, core: &pw::core::CoreRc, sink: &str, channels: usize) -> Result<Rec> {
let props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Production",
*pw::keys::NODE_NAME => "stems.master",
*pw::keys::STREAM_CAPTURE_SINK => "true",
*pw::keys::TARGET_OBJECT => sink,
};
build_rec(
st,
core,
0,
"master",
channels,
props,
pw::stream::StreamFlags::AUTOCONNECT,
)
}
fn build_rec(
st: &State,
core: &pw::core::CoreRc,
index: usize,
label: &str,
channels: usize,
props: pw::properties::PropertiesBox,
extra: pw::stream::StreamFlags,
) -> Result<Rec> {
let (track, mut prod) = Track::start(index, label, &st.session_dir, channels)?;
let stream = pw::stream::StreamRc::new(core.clone(), &format!("stems.{label}"), props)
.map_err(|e| anyhow!("stream: {e}"))?;
let ctl = track.ctl.clone();
let ctl_fmt = track.ctl.clone();
let clock = st.clock.clone();
let origin = st.origin.clone();
// Per-stream bookkeeping for the RT callback: where in the session this
// track starts, and how many frames it has placed so far.
let mut pad: u64 = UNSET;
let mut placed: u64 = 0;
let listener = stream
.add_local_listener_with_user_data(())
.param_changed(move |_, _, id, param| {
let Some(param) = param else { return };
if id != spa::param::ParamType::Format.as_raw() {
return;
}
let mut info = AudioInfoRaw::new();
if info.parse(param).is_ok() && info.rate() != 0 {
ctl_fmt.rate.store(info.rate(), Ordering::Relaxed);
}
})
.process(move |stream, _| {
// RT path: copy whole frames into the ring, bump the session clock.
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
// The driver stamps every cycle with one timestamp shared by all
// streams it runs (pw_time.now). Its distance from the session
// origin, in frames, is where this buffer belongs in the file.
// (pw_time.ticks is per stream and restarts at 0, so it is useless here.)
let (now, num, denom) = match stream.time() {
Ok(t) => (
t.now().max(0) as u64,
t.rate().num as u64,
t.rate().denom as u64,
),
Err(_) => (0, 0, 0),
};
let o = origin.fetch_min(now, Ordering::AcqRel).min(now);
let pos = frames_since(now, o, num, denom);
if pad == UNSET {
pad = pos;
ctl.pad.store(pad, Ordering::Release);
} else if pos > pad + placed && pos - (pad + placed) < 1 << 24 {
// Missed cycles (xrun / suspend): keep the timeline by inserting silence.
let gap = (pos - (pad + placed)) as usize;
push_frames(&mut prod, &ctl, channels, std::iter::repeat(0.0f32), gap);
placed += gap as u64;
}
let datas = buffer.datas_mut();
let Some(data) = datas.first_mut() else {
return;
};
let off = data.chunk().offset() as usize;
let size = data.chunk().size() as usize;
let Some(bytes) = data.data() else { return };
let end = (off + size).min(bytes.len());
let n_frames = (end.saturating_sub(off)) / 4 / channels;
let samples = bytes[off..end]
.as_chunks::<4>()
.0
.iter()
.map(|b| f32::from_le_bytes(*b));
placed += n_frames as u64;
let end_pos = pad + push_frames(&mut prod, &ctl, channels, samples, n_frames);
clock.fetch_max(end_pos, Ordering::Relaxed);
})
.register()
.map_err(|e| anyhow!("listener: {e}"))?;
let mut info = AudioInfoRaw::new();
info.set_format(AudioFormat::F32LE);
info.set_channels(channels as u32);
info.set_flags(AudioInfoRawFlags::UNPOSITIONED);
let obj = spa::pod::Object {
type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: spa::param::ParamType::EnumFormat.as_raw(),
properties: info.into(),
};
let bytes = spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&spa::pod::Value::Object(obj),
)
.map_err(|e| anyhow!("pod: {e}"))?
.0
.into_inner();
let mut params = [Pod::from_bytes(&bytes).ok_or_else(|| anyhow!("bad pod"))?];
stream
.connect(
spa::utils::Direction::Input,
None,
extra | pw::stream::StreamFlags::MAP_BUFFERS | pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|e| anyhow!("connect: {e}"))?;
Ok(Rec {
track,
stream,
_listener: listener,
src_ports: Vec::new(),
links: Vec::new(),
})
}
/// Frames between two cycle timestamps at a stream rate given as a fraction
/// (`num/denom` seconds per tick, so frames per second is `denom/num`).
/// Rounded to nearest; cycle stamps are exact multiples of the quantum anyway.
#[inline]
fn frames_since(now: u64, origin: u64, num: u64, denom: u64) -> u64 {
if now <= origin || num == 0 {
return 0;
}
let ns = (now - origin) as u128;
let per_sec = num as u128 * 1_000_000_000;
((ns * denom as u128 + per_sec / 2) / per_sec) as u64
}
#[cfg(test)]
mod tests {
use super::frames_since;
#[test]
fn frames_since_converts_cycle_stamps() {
// one 256-frame cycle at 48 kHz is 5_333_333 ns
assert_eq!(frames_since(5_333_333, 0, 1, 48000), 256);
assert_eq!(frames_since(10_666_667, 0, 1, 48000), 512);
assert_eq!(frames_since(1_000_000_000, 0, 1, 48000), 48000);
assert_eq!(frames_since(5, 10, 1, 48000), 0); // never negative
assert_eq!(frames_since(100, 0, 0, 48000), 0); // unknown rate
}
}
fn print_report(dir: &std::path::Path, reports: &[Report]) {
println!("\nsession {}", dir.display());
println!(
"{:<3} {:<40} {:>3} {:>10} {:>10} {:>8}",
"#", "track", "ch", "length", "starts at", "dropped"
);
for r in reports {
let secs = if r.rate > 0 {
r.frames as f64 / r.rate as f64
} else {
0.0
};
let start = if r.rate > 0 {
r.pad as f64 / r.rate as f64
} else {
0.0
};
println!(
"{:<3} {:<40} {:>3} {:>7}.{:02}s {:>7}.{:02}s {:>8}",
format!("{:02}", r.index),
r.path
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default(),
r.channels,
secs as u64,
(secs.fract() * 100.0) as u64,
start as u64,
(start.fract() * 100.0) as u64,
r.dropped
);
}
}
+217
View File
@@ -0,0 +1,217 @@
//! One recorded track: a lock-free ring fed from the PipeWire process callback
//! and a writer thread that drains it into a 32-bit float WAV.
//!
//! Alignment model: every stream reports the driver's cycle timestamp
//! (`pw_time.now`, identical for all streams processed in the same graph
//! cycle), so frame N of every file is the same instant. A track's first
//! buffer fixes its `pad` (its cycle's distance from the session origin, in
//! frames): the writer puts that many frames of silence first. Cycles a stream
//! missed are filled with silence as they are detected, and at the end every
//! file is padded up to the same final length.
use std::fs::File;
use std::io::BufWriter;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::Duration;
use anyhow::{Context, Result};
use rtrb::{Consumer, Producer, RingBuffer};
/// ~10 s of stereo at 48 kHz; the writer drains every few ms so this is ample.
const RING_SAMPLES: usize = 1 << 20;
/// Shared between the RT process callback, the writer thread and the main thread.
pub struct Ctl {
/// Sample rate as negotiated on the stream; 0 until the format arrives.
pub rate: AtomicU32,
/// Frames the process callback has pushed into the ring.
pub received: AtomicU64,
/// Frames that did not fit in the ring (writer too slow); reported at exit.
pub dropped: AtomicU64,
/// Silence frames written before the first buffer; `UNSET` until the
/// process callback sees its first buffer.
pub pad: AtomicU64,
/// Set by the main thread once the final length is known.
pub stop: AtomicBool,
/// Final length in frames every file is padded to.
pub final_frames: AtomicU64,
}
pub const UNSET: u64 = u64::MAX;
pub struct Track {
pub index: usize,
pub label: String,
pub path: PathBuf,
pub channels: usize,
pub ctl: Arc<Ctl>,
writer: Option<JoinHandle<Result<u64>>>,
}
pub struct Report {
pub index: usize,
pub path: PathBuf,
pub channels: usize,
pub frames: u64,
pub rate: u32,
pub dropped: u64,
/// Frames of leading silence (when the track joined the session).
pub pad: u64,
}
impl Track {
/// Create the track and start its writer thread. Returns the track and the
/// ring producer that the process callback owns.
pub fn start(
index: usize,
label: &str,
dir: &Path,
channels: usize,
) -> Result<(Track, Producer<f32>)> {
let (prod, cons) = RingBuffer::<f32>::new(RING_SAMPLES);
let ctl = Arc::new(Ctl {
rate: AtomicU32::new(0),
received: AtomicU64::new(0),
dropped: AtomicU64::new(0),
pad: AtomicU64::new(UNSET),
stop: AtomicBool::new(false),
final_frames: AtomicU64::new(0),
});
let path = dir.join(format!("{index:02}-{label}.wav"));
let writer = {
let (path, ctl) = (path.clone(), ctl.clone());
thread::Builder::new()
.name(format!("stems-w{index}"))
.spawn(move || write_loop(&path, channels, cons, ctl))
.context("spawn writer thread")?
};
Ok((
Track {
index,
label: label.to_string(),
path,
channels,
ctl,
writer: Some(writer),
},
prod,
))
}
/// Tell the writer the final length, wait for it to pad and close the file.
pub fn finish(mut self, final_frames: u64) -> Result<Report> {
self.ctl.final_frames.store(final_frames, Ordering::SeqCst);
self.ctl.stop.store(true, Ordering::SeqCst);
let frames = match self.writer.take() {
Some(h) => h
.join()
.map_err(|_| anyhow::anyhow!("writer thread panicked"))??,
None => 0,
};
Ok(Report {
index: self.index,
path: self.path,
channels: self.channels,
frames,
rate: self.ctl.rate.load(Ordering::Relaxed),
dropped: self.ctl.dropped.load(Ordering::Relaxed),
pad: self.ctl.pad.load(Ordering::Relaxed),
})
}
}
/// Push whole frames from the process callback into the ring. Never blocks or
/// allocates; frames that do not fit are counted as dropped. Returns the
/// track's frame count after the push.
#[inline]
pub fn push_frames<'a>(
prod: &mut Producer<f32>,
ctl: &Ctl,
channels: usize,
samples: impl Iterator<Item = f32> + 'a,
n_frames: usize,
) -> u64 {
let room = prod.slots() / channels;
let take = n_frames.min(room);
if take > 0 {
if let Ok(chunk) = prod.write_chunk_uninit(take * channels) {
chunk.fill_from_iter(samples.take(take * channels));
}
}
if take < n_frames {
ctl.dropped
.fetch_add((n_frames - take) as u64, Ordering::Relaxed);
}
ctl.received.fetch_add(take as u64, Ordering::Relaxed) + take as u64
}
fn write_loop(path: &Path, channels: usize, mut cons: Consumer<f32>, ctl: Arc<Ctl>) -> Result<u64> {
// The WAV header needs the rate (from format negotiation) and the body
// starts with the pad (from the first buffer). Wait for both, or give up
// on stop with defaults so the file is still valid.
let (rate, pad) = loop {
let (r, p) = (
ctl.rate.load(Ordering::Relaxed),
ctl.pad.load(Ordering::Relaxed),
);
if r != 0 && p != UNSET {
break (r, p);
}
if ctl.stop.load(Ordering::Relaxed) {
break (
if r != 0 { r } else { 48000 },
if p != UNSET { p } else { 0 },
);
}
thread::sleep(Duration::from_millis(5));
};
let spec = hound::WavSpec {
channels: channels as u16,
sample_rate: rate,
bits_per_sample: 32,
sample_format: hound::SampleFormat::Float,
};
let file = BufWriter::with_capacity(
1 << 16,
File::create(path).with_context(|| format!("create {}", path.display()))?,
);
let mut wav = hound::WavWriter::new(file, spec).context("wav header")?;
let mut written: u64 = 0; // frames
for _ in 0..pad * channels as u64 {
wav.write_sample(0.0f32)?;
}
written += pad;
loop {
let n = cons.slots();
if n > 0 {
let whole = n - n % channels;
if whole > 0 {
let chunk = cons.read_chunk(whole).expect("slots just reported");
let (a, b) = chunk.as_slices();
for s in a.iter().chain(b.iter()) {
wav.write_sample(*s)?;
}
chunk.commit_all();
written += (whole / channels) as u64;
}
}
if ctl.stop.load(Ordering::SeqCst) && cons.slots() < channels {
let target = ctl.final_frames.load(Ordering::SeqCst);
for _ in 0..crate::util::pad_frames(written, target) * channels as u64 {
wav.write_sample(0.0f32)?;
}
written = written.max(target);
break;
}
if n == 0 {
thread::sleep(Duration::from_millis(3));
}
}
wav.finalize().context("finalize wav")?;
Ok(written)
}
+89
View File
@@ -0,0 +1,89 @@
//! Pure helpers: file naming, the default-sink metadata value, padding math.
/// Make a node name safe as a file-name component: keep `[A-Za-z0-9._-]`,
/// fold any other run of characters into a single `_`, trim, cap the length.
pub fn sanitize(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut pending_sep = false;
for c in name.chars() {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
if pending_sep && !out.is_empty() {
out.push('_');
}
pending_sep = false;
out.push(c);
} else {
pending_sep = true;
}
}
let out: String = out.chars().take(60).collect();
let trimmed = out.trim_matches(|c| c == '.' || c == '_' || c == '-');
if trimmed.is_empty() {
"track".to_string()
} else {
trimmed.to_string()
}
}
/// The `default.audio.sink` metadata value is JSON like `{"name":"alsa_output.x"}`.
/// Pull the name out without a JSON dependency.
pub fn parse_default_sink(value: &str) -> Option<String> {
let key = value.find("\"name\"")?;
let rest = &value[key + 6..];
let colon = rest.find(':')?;
let rest = rest[colon + 1..].trim_start();
let rest = rest.strip_prefix('"')?;
let end = rest.find('"')?;
let name = &rest[..end];
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
/// Frames of silence a track needs appended so that `written` reaches `target`.
pub fn pad_frames(written: u64, target: u64) -> u64 {
target.saturating_sub(written)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_keeps_safe_chars_and_folds_the_rest() {
assert_eq!(sanitize("fc.zoom.playback"), "fc.zoom.playback");
assert_eq!(sanitize("Firefox: Play (stereo)"), "Firefox_Play_stereo");
assert_eq!(sanitize(" /weird//name "), "weird_name");
}
#[test]
fn sanitize_never_returns_empty_and_caps_length() {
assert_eq!(sanitize("///"), "track");
assert_eq!(sanitize(""), "track");
let long = "a".repeat(200);
assert_eq!(sanitize(&long).len(), 60);
}
#[test]
fn parses_default_sink_json() {
assert_eq!(
parse_default_sink(r#"{"name":"alsa_output.platform-soc.stereo-fallback"}"#).as_deref(),
Some("alsa_output.platform-soc.stereo-fallback")
);
assert_eq!(
parse_default_sink(r#"{ "name" : "x" }"#).as_deref(),
Some("x")
);
assert_eq!(parse_default_sink(r#"{"name":""}"#), None);
assert_eq!(parse_default_sink("garbage"), None);
}
#[test]
fn padding_math() {
assert_eq!(pad_frames(0, 480), 480);
assert_eq!(pad_frames(480, 480), 0);
assert_eq!(pad_frames(500, 480), 0); // never negative
}
}