stems: multitrack PipeWire recorder
Records every source feeding the default sink into sample-aligned float WAV stems plus the sink mix, so sessions can be mixed and mastered later. Release flow mirrors galdr: tag -> build tarballs on both arches -> Gitea release -> publish to the uhhm Arch registry (no AUR package). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
commit
9484d0d578
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+776
@@ -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
@@ -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"] }
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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, ®, 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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user