From 9484d0d578efe36a3fb40693ddb49883141179ac Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Thu, 8 Oct 2026 17:11:47 +0200 Subject: [PATCH] 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 --- .gitea/workflows/release.yml | 158 +++++++ .gitignore | 1 + Cargo.lock | 776 +++++++++++++++++++++++++++++++++++ Cargo.toml | 18 + LICENSE | 21 + README.md | 64 +++ aur/PKGBUILD | 21 + release.toml | 12 + src/main.rs | 714 ++++++++++++++++++++++++++++++++ src/track.rs | 217 ++++++++++ src/util.rs | 89 ++++ 11 files changed, 2091 insertions(+) create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 aur/PKGBUILD create mode 100644 release.toml create mode 100644 src/main.rs create mode 100644 src/track.rs create mode 100644 src/util.rs diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..4d379f1 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d460362 --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d78942e --- /dev/null +++ b/Cargo.toml @@ -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"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..add8a78 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e871723 --- /dev/null +++ b/README.md @@ -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 ] [--sink ] [--no-master] [--list] +``` + +- `--dir` session root, default `~/Recordings/stems`. Each run writes + `//NN-.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`. diff --git a/aur/PKGBUILD b/aur/PKGBUILD new file mode 100644 index 0000000..2251936 --- /dev/null +++ b/aur/PKGBUILD @@ -0,0 +1,21 @@ +# Maintainer: Bendik Aagaard Lynghaug +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" +} diff --git a/release.toml b/release.toml new file mode 100644 index 0000000..1c30fae --- /dev/null +++ b/release.toml @@ -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" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0126cd5 --- /dev/null +++ b/src/main.rs @@ -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 Session root (default: ~/Recordings/stems); each run + writes //NN-.wav + --sink 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, + 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, + links: Vec, +} + +struct State { + opts: Opts, + nodes: HashMap, + ports: HashMap, + links: HashMap, + sink_name: Option, + started: bool, + session_dir: PathBuf, + /// source node id -> recording + recs: HashMap, + master: Option, + /// Tracks whose source went away; finished with the rest at exit. + orphans: Vec, + next_index: usize, + /// Session clock: max frames any track has accounted for. + clock: Arc, + /// Cycle timestamp (ns) of the first buffer any track received. + origin: Arc, + pending: Option, + _metadata: Option<(pw::metadata::Metadata, pw::metadata::MetadataListener)>, +} + +fn parse_args() -> Result { + 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, ®, 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 = Vec::new(); + if let Some(m) = st.master.take() { + tracks.push(stop_rec(&core, m)); + } + let ids: Vec = 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 = tracks + .into_iter() + .map(|t| t.finish(final_frames)) + .collect::>()?; + reports.sort_by_key(|r| r.index); + print_report(&dir, &reports); + Ok(()) +} + +fn request_sync(state: &Rc>, 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>, + 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::().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::(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>, 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>, + 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> = 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)> = 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::("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 { + 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 { + 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 { + 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 + ); + } +} diff --git a/src/track.rs b/src/track.rs new file mode 100644 index 0000000..c0e490a --- /dev/null +++ b/src/track.rs @@ -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, + writer: Option>>, +} + +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)> { + let (prod, cons) = RingBuffer::::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 { + 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, + ctl: &Ctl, + channels: usize, + samples: impl Iterator + '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, ctl: Arc) -> Result { + // 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) +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..90a33bb --- /dev/null +++ b/src/util.rs @@ -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 { + 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 + } +}