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