From b9c70400f9d364785d46636e1d205e051d03c428 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Thu, 8 Oct 2026 08:13:25 -0400
Subject: [PATCH 01/24] Bump version to 0.2.0 (from 0.1.0) for release
Co-Authored-By: Claude Opus 5.5
---
Cargo.lock | 36 ++++++++++++++++++------------------
Cargo.toml | 2 +-
2 files changed, 19 insertions(+), 19 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 759f472a..0825401e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3361,7 +3361,7 @@ dependencies = [
[[package]]
name = "soundcraft"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"eframe",
"egui",
@@ -3376,7 +3376,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"log",
"serde",
@@ -3386,7 +3386,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"log",
"proptest",
@@ -3398,7 +3398,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"log",
"serde_json",
@@ -3408,7 +3408,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"clap-sys",
"libloading",
@@ -3420,7 +3420,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"serde_json",
"soundcraft-audio-io",
@@ -3431,7 +3431,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"log",
"proptest",
@@ -3442,7 +3442,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"log",
"proptest",
@@ -3462,7 +3462,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"proptest",
"serde",
@@ -3472,7 +3472,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"log",
"rayon",
@@ -3488,7 +3488,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"proptest",
"serde",
@@ -3501,7 +3501,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"cpal",
"log",
@@ -3514,7 +3514,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"proptest",
"serde",
@@ -3524,7 +3524,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"egui",
"egui_kittest",
@@ -3549,7 +3549,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"image",
"proptest",
@@ -3560,7 +3560,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"libloading",
"log",
@@ -3575,7 +3575,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"eframe",
"egui",
@@ -5122,7 +5122,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.1.0"
+version = "0.2.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index 35520baf..bb951183 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
-version = "0.1.0"
+version = "0.2.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
From ef47ab59d017df9d9820886805e89afdd2261788 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Thu, 8 Oct 2026 08:39:10 -0400
Subject: [PATCH 02/24] Fix the Windows build of vst3-host; bump version to
0.2.1
The vst3 bindings' enum constants use the C default enum type, which is i32 on
Windows and u32 elsewhere. The bus helpers took u32 and the process-context
flags were assigned to a u32 field directly, so the release build failed on all
three Windows targets. Type the helpers with the bindings' own aliases and cast
the flags once.
Co-Authored-By: Claude Opus 5.5
---
Cargo.lock | 36 ++++++++++++++++++------------------
Cargo.toml | 2 +-
crates/vst3-host/src/ffi.rs | 13 ++++++++-----
3 files changed, 27 insertions(+), 24 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 0825401e..402aa74e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3361,7 +3361,7 @@ dependencies = [
[[package]]
name = "soundcraft"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"eframe",
"egui",
@@ -3376,7 +3376,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"log",
"serde",
@@ -3386,7 +3386,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"log",
"proptest",
@@ -3398,7 +3398,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"log",
"serde_json",
@@ -3408,7 +3408,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"clap-sys",
"libloading",
@@ -3420,7 +3420,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"serde_json",
"soundcraft-audio-io",
@@ -3431,7 +3431,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"log",
"proptest",
@@ -3442,7 +3442,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"log",
"proptest",
@@ -3462,7 +3462,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"proptest",
"serde",
@@ -3472,7 +3472,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"log",
"rayon",
@@ -3488,7 +3488,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"proptest",
"serde",
@@ -3501,7 +3501,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"cpal",
"log",
@@ -3514,7 +3514,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"proptest",
"serde",
@@ -3524,7 +3524,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"egui",
"egui_kittest",
@@ -3549,7 +3549,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"image",
"proptest",
@@ -3560,7 +3560,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"libloading",
"log",
@@ -3575,7 +3575,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"eframe",
"egui",
@@ -5122,7 +5122,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.2.0"
+version = "0.2.1"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index bb951183..fcfe030b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
-version = "0.2.0"
+version = "0.2.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs
index b8ee3e30..c7c60c4c 100644
--- a/crates/vst3-host/src/ffi.rs
+++ b/crates/vst3-host/src/ffi.rs
@@ -1059,19 +1059,19 @@ impl Instance {
self.active
}
- fn bus_count(&self, media: u32, dir: u32) -> i32 {
+ fn bus_count(&self, media: sv::MediaTypes, dir: sv::BusDirections) -> i32 {
// SAFETY: valid component.
unsafe { self.component.getBusCount(media as sv::MediaType, dir as sv::BusDirection) }.clamp(0, MAX_BUSES)
}
- fn arrangement(&self, dir: u32, index: i32) -> Option {
+ fn arrangement(&self, dir: sv::BusDirections, index: i32) -> Option {
let p = self.processor()?;
let mut arr: sv::SpeakerArrangement = 0;
// SAFETY: valid processor; `arr` is writable.
ok(unsafe { p.getBusArrangement(dir as sv::BusDirection, index, &mut arr) }).then_some(arr)
}
- fn bus_channels(&self, dir: u32, index: i32) -> u32 {
+ fn bus_channels(&self, dir: sv::BusDirections, index: i32) -> u32 {
if let Some(arr) = self.arrangement(dir, index) {
return arr.count_ones().min(MAX_BUS_CHANNELS);
}
@@ -1095,7 +1095,7 @@ impl Instance {
let (din, dout) = (sv::BusDirections_::kInput, sv::BusDirections_::kOutput);
let nin = self.bus_count(audio, din);
let nout = self.bus_count(audio, dout);
- let current = |s: &Self, dir: u32, n: i32| -> Vec {
+ let current = |s: &Self, dir: sv::BusDirections, n: i32| -> Vec {
(0..n).map(|i| s.arrangement(dir, i).unwrap_or(sv::SpeakerArr::kStereo)).collect()
};
let want = if mono { sv::SpeakerArr::kMono } else { sv::SpeakerArr::kStereo };
@@ -1166,7 +1166,10 @@ impl Instance {
self.context.tempo = 120.0;
self.context.timeSigNumerator = 4;
self.context.timeSigDenominator = 4;
- self.context.state = sv::ProcessContext_::StatesAndFlags_::kTempoValid | sv::ProcessContext_::StatesAndFlags_::kTimeSigValid;
+ // The flag constants are the C default enum type: i32 on Windows, u32 elsewhere.
+ #[allow(clippy::unnecessary_cast)]
+ let flags = (sv::ProcessContext_::StatesAndFlags_::kTempoValid | sv::ProcessContext_::StatesAndFlags_::kTimeSigValid) as u32;
+ self.context.state = flags;
self.host.latency_changed.store(false, Ordering::Relaxed);
Ok(layout)
}
From 8115ba9ed5b1073ca6333e8cc3060829f45f19f0 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Thu, 8 Oct 2026 09:34:50 -0400
Subject: [PATCH 03/24] Release: Flatpak x86_64/aarch64, AppImage zsync
updates, README Downloads (#13)
- AppImages embed gh-releases-zsync update information and ship a .zsync beside them.
- New flatpak job repackages the Linux tarball into soundcraft--linux-.flatpak
(bundle manifest + flatpak-bundle.sh). Sandbox: pulseaudio socket (PipeWire's pulse server),
~/.vst3 and ~/.clap read-only, the LinuxAudio.Plugins extension point via VST3_PATH/CLAP_PATH;
no raw ALSA devices, no JACK. The from-source Flathub manifest gets the same finish-args.
- Only signing jobs (macOS, Windows) and the draft job keep `environment: release`, so a branch
dispatch dry-runs Linux, Flatpak, FreeBSD and web; the draft job still needs the signed jobs.
- README: Downloads section; docs/releasing.md updated.
Co-authored-by: Brandon Thomas
Co-authored-by: Claude Opus 5.5
---
.github/workflows/packaging-lint.yml | 12 ++-
.github/workflows/release.yml | 62 +++++++++++++---
README.md | 45 ++++++++++++
docs/releasing.md | 45 ++++++++----
packaging/linux/flatpak-bundle.sh | 72 ++++++++++++++++++
.../ai.storyteller.soundcraft.bundle.yml | 73 +++++++++++++++++++
.../flatpak/ai.storyteller.soundcraft.yml | 39 +++++++++-
packaging/linux/package.sh | 30 ++++++--
8 files changed, 344 insertions(+), 34 deletions(-)
create mode 100755 packaging/linux/flatpak-bundle.sh
create mode 100644 packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml
diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index 88694ec6..ce126e3a 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -54,4 +54,14 @@ jobs:
desktop-file-validate packaging/linux/ai.storyteller.soundcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.soundcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml"
- python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.soundcraft.yml')); assert m['id'] == 'ai.storyteller.soundcraft' and m['command'] == 'soundcraft'"
+ python3 - <<'EOF'
+ import yaml
+ src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.yml"))
+ bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml"))
+ for m in (src, bundle):
+ assert m["id"] == "ai.storyteller.soundcraft" and m["command"] == "soundcraft", m["id"]
+ assert "--socket=pulseaudio" in m["finish-args"], "audio needs --socket=pulseaudio"
+ for key in ("runtime", "runtime-version", "sdk", "finish-args", "add-extensions"):
+ assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
+ print("Flatpak manifests ok")
+ EOF
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1c10b5e5..b1c0a39a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,9 +1,11 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "SoundCraft v". See docs/releasing.md.
#
-# All jobs run in the `release` environment (restricted to the `release` branch), which holds
-# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
-# warning, never a failed build.
+# The jobs that sign (macOS, Windows) and the one that drafts the release run in the `release`
+# environment (restricted to the `release` branch), which holds the signing secrets. Every secret
+# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
+# other jobs sign nothing and get no secrets, so a dispatch on any branch builds and checks them
+# (a dry run); the signed jobs are refused there, and so the draft release is skipped.
name: Release
on:
@@ -35,7 +37,7 @@ jobs:
version:
name: version
runs-on: ubuntu-latest
- environment: release
+ # No `environment: release`: this job reads no secrets (and gates every other job).
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
@@ -157,7 +159,7 @@ jobs:
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
- environment: release
+ # No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
@@ -176,7 +178,7 @@ jobs:
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
libasound2-dev pkg-config \
- desktop-file-utils appstream python3-yaml
+ desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
@@ -200,6 +202,9 @@ jobs:
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
+ # Update information is embedded, and the .zsync for delta updates sits beside it.
+ "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|storytold|soundcraft|latest|soundcraft-*-linux-${{ matrix.arch }}.AppImage.zsync'
+ test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
with:
@@ -207,13 +212,51 @@ jobs:
path: dist/release/*
if-no-files-found: error
+ flatpak:
+ name: Flatpak ${{ matrix.arch }}
+ needs: [version, linux]
+ # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
+ # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
+ runs-on: ${{ matrix.runner }}
+ # No `environment: release`: this job signs nothing, so it gets no release secrets.
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { arch: x86_64, runner: ubuntu-24.04 }
+ - { arch: aarch64, runner: ubuntu-24.04-arm }
+ env:
+ SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
+ SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/download-artifact@v4
+ with:
+ name: release-linux-${{ matrix.arch }}
+ path: dist/linux
+ - name: Install flatpak-builder
+ run: |
+ sudo apt-get update
+ # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
+ # `appstreamcli compose`, which renders the scalable icon and fails without it.
+ sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
+ # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
+ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
+ - name: Build the Flatpak bundle
+ run: packaging/linux/flatpak-bundle.sh "dist/linux/soundcraft-$SOUNDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
+ - uses: actions/upload-artifact@v4
+ with:
+ name: release-flatpak-${{ matrix.arch }}
+ path: dist/release/*.flatpak
+ if-no-files-found: error
+
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image as freebsd.yml).
runs-on: ubuntu-latest
timeout-minutes: 150
- environment: release
+ # No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -253,7 +296,7 @@ jobs:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
- environment: release
+ # No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -284,7 +327,8 @@ jobs:
release:
name: Draft GitHub Release
- needs: [version, macos, windows, linux, freebsd, web]
+ # Needs the signed jobs, which only run on the `release` branch: a branch dispatch never drafts.
+ needs: [version, macos, windows, linux, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
diff --git a/README.md b/README.md
index c0d45916..00bc5a0e 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@
Agents & scripts ·
How it's built ·
Status ·
+ Downloads ·
The Crafting Apps ·
License
@@ -213,6 +214,50 @@ honest status, what's next and our effort estimates are in [`ROADMAP.md`](ROADMA
menu-by-menu comparison is in [`docs/parity.md`](docs/parity.md). Bug reports and wish lists are
very welcome, in the issues or on Discord.
+## Downloads
+
+Every [release](https://github.com/storytold/soundcraft/releases/latest) ships these builds. `` is the
+version number; `SHA256SUMS.txt` lists a checksum for every file.
+
+### macOS
+
+| Build | File | Notes |
+|---|---|---|
+| App, universal (Apple silicon + Intel) | `soundcraft--macos-universal.dmg` | Signed and notarized |
+| Command-line tool, universal | `soundcraft-cli--macos-universal.zip` | Signed and notarized |
+
+### Windows
+
+| Build | Installer | Portable |
+|---|---|---|
+| x64 (64-bit Intel/AMD) | `soundcraft--windows-x64.msi` | `soundcraft--windows-x64-portable.zip` |
+| arm64 (Snapdragon and other ARM PCs) | `soundcraft--windows-arm64.msi` | `soundcraft--windows-arm64-portable.zip` |
+| x86 (32-bit) | `soundcraft--windows-x86.msi` | `soundcraft--windows-x86-portable.zip` |
+
+Installers and executables are code-signed.
+
+### Linux
+
+| Format | x86_64 | aarch64 (ARM64) | Notes |
+|---|---|---|---|
+| AppImage | `soundcraft--linux-x86_64.AppImage` | `soundcraft--linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
+| Flatpak | `soundcraft--linux-x86_64.flatpak` | `soundcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` |
+| Debian/Ubuntu | `soundcraft--linux-x86_64.deb` | `soundcraft--linux-aarch64.deb` | |
+| Fedora/RHEL/openSUSE | `soundcraft--linux-x86_64.rpm` | `soundcraft--linux-aarch64.rpm` | |
+| Tarball | `soundcraft--linux-x86_64.tar.gz` | `soundcraft--linux-aarch64.tar.gz` | Unpack anywhere |
+
+### FreeBSD
+
+| Build | File |
+|---|---|
+| x86_64 | `soundcraft--freebsd-x86_64.tar.gz` |
+
+### Web (WebAssembly)
+
+| Build | File | Notes |
+|---|---|---|
+| Static site | `soundcraft-web-.zip` | Runs in a modern browser; host it on any static server |
+
## The Crafting Apps
SoundCraft is one of the **Crafting Apps**: free, open-source creative tools from the
diff --git a/docs/releasing.md b/docs/releasing.md
index 1d89fcd4..2f7894d5 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -35,9 +35,10 @@ After the draft is published, the workflow refuses to touch that version again,
**Test runs:** *Actions → Release → Run workflow* runs the whole pipeline by hand. The optional
`version` input (such as `0.2.0-rc.1`) overrides `Cargo.toml` for that run only. The jobs apply it
-with `cargo xtask version set` before building, so the binaries report it too. The run still needs
-the `release` environment, which only the `release` branch can use, so pick that branch in the
-dialog.
+with `cargo xtask version set` before building, so the binaries report it too. Only the signing
+jobs (macOS, Windows) and the draft-release job use the `release` environment, which only the
+`release` branch can use. On any other branch the run is a dry run: Linux, Flatpak, FreeBSD and
+web build and are checked, the signing jobs are refused, and no release is drafted.
## What gets built
@@ -49,11 +50,14 @@ dialog.
| Windows 11 ARM64 | `soundcraft--windows-arm64.msi`, `soundcraft--windows-arm64-portable.zip` | `windows-latest` (cross-compiled; `windows-arm64.yml` installs and runs it on ARM64) |
| Linux x86_64 | `soundcraft--linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` |
| Linux aarch64 | `soundcraft--linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` |
+| AppImage updates | `soundcraft--linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImage |
+| Flatpak x86_64, aarch64 | `soundcraft--linux-{x86_64,aarch64}.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackages the Linux tarball) |
| FreeBSD 14 x86_64 | `soundcraft--freebsd-x86_64.tar.gz` (a `/usr/local` tree) | FreeBSD 14.3 VM on `ubuntu-latest` |
| Web | `soundcraft-web-.zip` (static site; see [`packaging/web/README.md`](../packaging/web/README.md)) | `ubuntu-latest` |
-The Flatpak manifest (`packaging/linux/flatpak/ai.storyteller.soundcraft.yml`) is kept ready for a
-Flathub submission and validated in CI, but not built there (see Linux below).
+The from-source Flatpak manifest (`packaging/linux/flatpak/ai.storyteller.soundcraft.yml`) is
+kept ready for a Flathub submission; the release's `.flatpak` bundles come from
+`ai.storyteller.soundcraft.bundle.yml` (see Linux below).
### Audio libraries
@@ -68,7 +72,8 @@ plugins), and WebAudio in the browser.
routes ALSA to the OSS `sound(4)` devices. Without a device the player falls back to its
silent clock, so the transport still runs.
- **Flatpak**: alsa-lib and its PulseAudio plugin come with the freedesktop runtime; the
- manifest grants `--socket=pulseaudio`.
+ manifests grant `--socket=pulseaudio` (PipeWire's pulse server answers on it). No raw ALSA
+ devices and no JACK: SoundCraft doesn't use either.
- **Web**: browsers start audio only after a user gesture, so the first click in the page may be
needed before sound plays. The web build has no recording.
@@ -131,14 +136,24 @@ Locally on Windows: `dotnet tool install -g wix --version 5.0.2`, then
MIDI), hicolor icons from 16 px to 512 px plus a scalable SVG, AppStream metainfo, and a
shared-mime-info file declaring `application/x-soundcraft-session` (`*.scraft`).
-- **AppImage** (maintained `AppImage/appimagetool`, static runtime: no libfuse2 needed).
+- **AppImage** (maintained `AppImage/appimagetool`, static runtime: no libfuse2 needed). Each
+ embeds the update information
+ `gh-releases-zsync|storytold|soundcraft|latest|soundcraft-*-linux-.AppImage.zsync`, and
+ the `.zsync` next to it (written when `zsyncmake` from the `zsync` package is installed, as in
+ CI) lets AppImageUpdate fetch only the changed blocks of the newest non-pre-release.
- **.deb** and **.rpm** by [nfpm](https://nfpm.goreleaser.com/) from one `nfpm.yaml`; the
`postinst.sh` hook refreshes the desktop, MIME and icon caches.
- **.tar.gz** for people who manage their own `/opt` or `~/.local`.
-- **Flatpak**: `packaging/linux/flatpak/ai.storyteller.soundcraft.yml` builds from source on the
- freedesktop 25.08 runtime with Wayland/X11, `dri`, PulseAudio and `xdg-music`; other files go
- through the portals. A real build needs vendored crates (`cargo-sources.json` from
- `flatpak-cargo-generator.py`); the commands are in the manifest's header.
+- **Flatpak**: the `flatpak` job runs `packaging/linux/flatpak-bundle.sh` on the Linux job's
+ tarball (no Rust build) with `flatpak/ai.storyteller.soundcraft.bundle.yml`, then installs the
+ bundle and runs `soundcraft-cli --version` in the sandbox. Install with
+ `flatpak install --user soundcraft--linux-.flatpak`. Both manifests use the freedesktop
+ 25.08 runtime with Wayland/X11, `dri`, PulseAudio and `xdg-music`; other files go through the
+ portals. For the VST3 and CLAP hosts they grant `~/.vst3` and `~/.clap` read-only and mount
+ Flathub's `org.freedesktop.LinuxAudio.Plugins` extensions (pointed to by `VST3_PATH` and
+ `CLAP_PATH`); system plugin folders aren't visible in the sandbox.
+ `ai.storyteller.soundcraft.yml` builds from source for Flathub and needs vendored crates
+ (`cargo-sources.json` from `flatpak-cargo-generator.py`); the commands are in its header.
Binaries are built on Ubuntu 22.04 and need **glibc ≥ 2.35**. X11, Wayland, xkbcommon, Vulkan
and EGL are loaded at runtime, so the packages declare them as dependencies or recommendations.
@@ -164,8 +179,8 @@ through WebAudio.
## Secrets
All secrets live in the repository's **`release` environment** (*Settings → Environments →
-release*), restricted to the `release` branch. Every job in `release.yml` declares
-`environment: release`. Each secret is optional: if one is missing, that platform's artifacts
+release*), restricted to the `release` branch. Only the jobs that sign (macOS, Windows) and the
+draft-release job declare `environment: release`; the others get no secrets. Each secret is optional: if one is missing, that platform's artifacts
are unsigned and the run shows a `::warning::`. Scripts never echo secret values; certificates
are written with `umask 077` and deleted in `always()`/`finally` steps.
@@ -197,5 +212,5 @@ those tools. `apps/soundcraft/src/main.rs` sets the runtime window/Dock/taskbar
`.github/workflows/packaging-lint.yml` runs in seconds on any change to `packaging/`, the
workflows or the icons: actionlint, shellcheck, a PowerShell parse, xmllint (WiX, plists,
-AppStream, MIME), `desktop-file-validate`, `appstreamcli validate`, and a YAML check of the
-Flatpak manifest.
+AppStream, MIME), `desktop-file-validate`, `appstreamcli validate`, and a check that the two
+Flatpak manifests agree on runtime, finish-args and plugin extensions.
diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh
new file mode 100755
index 00000000..1b400266
--- /dev/null
+++ b/packaging/linux/flatpak-bundle.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+# Repackage a SoundCraft Linux tarball as a single-file Flatpak bundle:
+#
+# $DIST/soundcraft--linux-.flatpak
+#
+# Usage: packaging/linux/flatpak-bundle.sh [--no-test] [TARBALL]
+#
+# TARBALL defaults to $DIST/soundcraft--linux-.tar.gz from package.sh. The bundle
+# is built for the host architecture (x86_64 or aarch64), which must match the tarball's.
+# Needs flatpak, flatpak-builder and the SVG pixbuf loader (librsvg2-common) for the host's
+# `appstreamcli compose`; the freedesktop runtime and SDK named in the manifest are
+# installed per-user from Flathub. Unless --no-test, the bundle is then installed per-user and
+# `soundcraft-cli --version` is run inside the sandbox as a smoke test.
+# Manifest: packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml.
+set -euo pipefail
+# shellcheck source=../env.sh
+. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
+HERE="$ROOT/packaging/linux"
+APP_ID=ai.storyteller.soundcraft
+FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
+
+TEST=1
+TARBALL=""
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --no-test) TEST=0; shift ;;
+ -h | --help) sed -n '2,14p' "$0"; exit 0 ;;
+ -*) echo "unknown argument: $1" >&2; exit 2 ;;
+ *) TARBALL="$1"; shift ;;
+ esac
+done
+
+ARCH="$(uname -m)"
+case "$ARCH" in
+ x86_64) ;;
+ aarch64 | arm64) ARCH=aarch64 ;;
+ *) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
+esac
+BASENAME="soundcraft-$VERSION-linux-$ARCH"
+TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}"
+[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
+for tool in flatpak flatpak-builder; do
+ command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; }
+done
+
+echo "==> SoundCraft $VERSION Flatpak bundle for $ARCH from $(basename "$TARBALL")"
+
+WORK="$CARGO_TARGET_DIR/flatpak-bundle"
+rm -rf "$WORK"
+mkdir -p "$WORK/stage"
+tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1
+[ -x "$WORK/stage/bin/soundcraft" ] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; }
+cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml"
+
+flatpak remote-add --user --if-not-exists flathub "$FLATHUB"
+# --disable-rofiles-fuse: no FUSE in containers and CI runners.
+flatpak-builder --user --install-deps-from=flathub --disable-rofiles-fuse --force-clean \
+ --default-branch=stable --repo="$WORK/repo" --state-dir="$WORK/state" "$WORK/build" "$WORK/$APP_ID.yml"
+
+OUT="$DIST/$BASENAME.flatpak"
+# --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing.
+flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable
+echo "wrote $OUT"
+
+if [ "$TEST" = 1 ]; then
+ flatpak install --user -y --noninteractive --reinstall "$OUT"
+ flatpak info --user "$APP_ID"
+ flatpak run --command=soundcraft-cli "$APP_ID" --version
+ flatpak run --command=sh "$APP_ID" -c 'ls /app/share/applications /app/share/metainfo /app/share/mime/packages /app/share/icons/hicolor/scalable/apps'
+fi
+echo "==> done"
+ls -lh "$OUT"
diff --git a/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml
new file mode 100644
index 00000000..ff502f02
--- /dev/null
+++ b/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml
@@ -0,0 +1,73 @@
+# Flatpak manifest for the single-file bundle attached to every GitHub release
+# (soundcraft--linux-.flatpak). Build it with packaging/linux/flatpak-bundle.sh.
+#
+# It repackages the release's own Linux tarball (soundcraft--linux-.tar.gz,
+# built on Ubuntu 22.04 by package.sh) instead of compiling Rust inside flatpak-builder: no
+# network and no vendored crate sources during the build, a few seconds instead of 20+ minutes,
+# and byte-for-byte the same binaries as the AppImage/deb/rpm. The script unpacks the tarball
+# into `stage/` next to a copy of this file. The binaries need glibc >= 2.35 and load X11,
+# Wayland, xkbcommon, Vulkan and libasound at runtime; the freedesktop runtime has all of them
+# (plus alsa-lib's PulseAudio plugin), and the GPU driver comes from its
+# org.freedesktop.Platform.GL extension.
+#
+# ai.storyteller.soundcraft.yml (same directory) is the from-source manifest for Flathub. Keep
+# runtime-version, finish-args and add-extensions identical in both (packaging-lint checks this).
+id: ai.storyteller.soundcraft
+runtime: org.freedesktop.Platform
+runtime-version: '25.08'
+sdk: org.freedesktop.Sdk
+command: soundcraft
+finish-args:
+ # Display: Wayland first; X11 only when no Wayland compositor is running.
+ - --socket=wayland
+ - --socket=fallback-x11
+ # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails.
+ - --share=ipc
+ # GPU access for the wgpu UI (Vulkan, or GL as a fallback).
+ - --device=dri
+ # Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its
+ # PulseAudio plugin to this socket, which PipeWire's pulse server provides on modern desktops
+ # (and PulseAudio itself on older ones). Deliberately not granted: --device=all (raw /dev/snd
+ # for direct ALSA hardware access, which would bypass the sound server) and JACK, which
+ # SoundCraft doesn't use (cpal's jack feature is off; there is no live MIDI I/O either).
+ - --socket=pulseaudio
+ # Open/Save dialogs use the file-chooser portal (rfd) and drag-and-drop the document portal,
+ # so broad filesystem access isn't needed. Music is granted for sessions and their
+ # "Audio Files" folders opened by path from the command line.
+ - --filesystem=xdg-music
+ # VST3 and CLAP plugin hosts. The per-user plugin folders the hosts scan, read-only.
+ # System folders (/usr/lib/vst3, /usr/lib/clap) aren't reachable from the sandbox, and plugins
+ # there are linked against the host's libraries rather than the runtime's; sandboxed plugins
+ # come from Flatpak extensions instead (add-extensions below), found through these paths.
+ - --filesystem=~/.vst3:ro
+ - --filesystem=~/.clap:ro
+ - --env=VST3_PATH=/app/extensions/Plugins/vst3
+ - --env=CLAP_PATH=/app/extensions/Plugins/clap
+# Flathub's shared audio-plugin extension point (org.freedesktop.LinuxAudio.Plugins.*, the one
+# Ardour, Bitwig and friends use). Optional: nothing is downloaded unless the user installs a
+# plugin extension; merge-dirs lets every installed one appear under .../Plugins/{vst3,clap}.
+add-extensions:
+ org.freedesktop.LinuxAudio.Plugins:
+ directory: extensions/Plugins
+ version: '25.08'
+ add-ld-path: lib
+ merge-dirs: vst3;clap
+ subdirectories: true
+ no-autodownload: true
+modules:
+ - name: soundcraft
+ buildsystem: simple
+ build-commands:
+ - install -Dm755 bin/soundcraft /app/bin/soundcraft
+ - install -Dm755 bin/soundcraft-cli /app/bin/soundcraft-cli
+ - install -Dm644 share/applications/ai.storyteller.soundcraft.desktop /app/share/applications/ai.storyteller.soundcraft.desktop
+ # Session files; the runtime's shared-mime-info already knows WAV, AIFF, FLAC, MP3 and MIDI.
+ - install -Dm644 share/mime/packages/ai.storyteller.soundcraft.xml /app/share/mime/packages/ai.storyteller.soundcraft.xml
+ # Already has the release's version and date substituted by package.sh.
+ - install -Dm644 share/metainfo/ai.storyteller.soundcraft.metainfo.xml /app/share/metainfo/ai.storyteller.soundcraft.metainfo.xml
+ - mkdir -p /app/share/icons /app/share/doc && cp -R share/icons/hicolor /app/share/icons/ && cp -R share/doc/soundcraft /app/share/doc/
+ # Mount point for the plugin extensions (add-extensions).
+ - install -d /app/extensions/Plugins
+ sources:
+ - type: dir
+ path: stage
diff --git a/packaging/linux/flatpak/ai.storyteller.soundcraft.yml b/packaging/linux/flatpak/ai.storyteller.soundcraft.yml
index c26df340..bec9ffb5 100644
--- a/packaging/linux/flatpak/ai.storyteller.soundcraft.yml
+++ b/packaging/linux/flatpak/ai.storyteller.soundcraft.yml
@@ -8,6 +8,9 @@
# flatpak run ai.storyteller.soundcraft
#
# For Flathub, replace the `dir` source with a `git` source pinned to a release tag and commit.
+# ai.storyteller.soundcraft.bundle.yml repackages the release tarball into the .flatpak attached
+# to each GitHub release; keep runtime-version, finish-args and add-extensions identical in both
+# (packaging-lint checks this).
#
# Audio: cpal talks to ALSA. alsa-lib (headers in the Sdk, the library in the Platform) and its
# PulseAudio plugin ship with the freedesktop runtime, which routes ALSA through the PulseAudio
@@ -20,16 +23,42 @@ sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: soundcraft
finish-args:
- - --share=ipc
+ # Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
- --socket=fallback-x11
+ # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails.
+ - --share=ipc
+ # GPU access for the wgpu UI (Vulkan, or GL as a fallback).
- --device=dri
- # Playback and recording (ALSA through the PulseAudio/PipeWire socket).
+ # Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its
+ # PulseAudio plugin to this socket, which PipeWire's pulse server provides on modern desktops
+ # (and PulseAudio itself on older ones). Deliberately not granted: --device=all (raw /dev/snd
+ # for direct ALSA hardware access, which would bypass the sound server) and JACK, which
+ # SoundCraft doesn't use (cpal's jack feature is off; there is no live MIDI I/O either).
- --socket=pulseaudio
- # Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document
- # portal, so broad filesystem access isn't needed. Music is granted for sessions and their
+ # Open/Save dialogs use the file-chooser portal (rfd) and drag-and-drop the document portal,
+ # so broad filesystem access isn't needed. Music is granted for sessions and their
# "Audio Files" folders opened by path from the command line.
- --filesystem=xdg-music
+ # VST3 and CLAP plugin hosts. The per-user plugin folders the hosts scan, read-only.
+ # System folders (/usr/lib/vst3, /usr/lib/clap) aren't reachable from the sandbox, and plugins
+ # there are linked against the host's libraries rather than the runtime's; sandboxed plugins
+ # come from Flatpak extensions instead (add-extensions below), found through these paths.
+ - --filesystem=~/.vst3:ro
+ - --filesystem=~/.clap:ro
+ - --env=VST3_PATH=/app/extensions/Plugins/vst3
+ - --env=CLAP_PATH=/app/extensions/Plugins/clap
+# Flathub's shared audio-plugin extension point (org.freedesktop.LinuxAudio.Plugins.*, the one
+# Ardour, Bitwig and friends use). Optional: nothing is downloaded unless the user installs a
+# plugin extension; merge-dirs lets every installed one appear under .../Plugins/{vst3,clap}.
+add-extensions:
+ org.freedesktop.LinuxAudio.Plugins:
+ directory: extensions/Plugins
+ version: '25.08'
+ add-ld-path: lib
+ merge-dirs: vst3;clap
+ subdirectories: true
+ no-autodownload: true
build-options:
append-path: /usr/lib/sdk/rust-stable/bin
modules:
@@ -51,6 +80,8 @@ modules:
install -d /app/share/metainfo;
sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/"
packaging/linux/ai.storyteller.soundcraft.metainfo.xml.in > /app/share/metainfo/ai.storyteller.soundcraft.metainfo.xml
+ # Mount point for the plugin extensions (add-extensions).
+ - install -d /app/extensions/Plugins
sources:
- type: dir
path: ../../..
diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh
index ba0b6c73..65e9a600 100755
--- a/packaging/linux/package.sh
+++ b/packaging/linux/package.sh
@@ -2,6 +2,7 @@
# Build and package SoundCraft for Linux ( is x86_64 or aarch64):
#
# $DIST/soundcraft--linux-.AppImage any distro with glibc >= the build host's
+# $DIST/soundcraft--linux-.AppImage.zsync delta updates (needs zsyncmake)
# $DIST/soundcraft--linux-.deb Debian, Ubuntu, Mint, Pop!_OS, ...
# $DIST/soundcraft--linux-.rpm Fedora, openSUSE, RHEL, ...
# $DIST/soundcraft--linux-.tar.gz plain FHS-style tree (bin/, share/)
@@ -10,7 +11,8 @@
#
# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage
# (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04,
-# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli.
+# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli,
+# zsyncmake (the zsync package) for the AppImage's .zsync.
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
@@ -23,7 +25,7 @@ while [ $# -gt 0 ]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
--formats) FORMATS="$2"; shift 2 ;;
- -h | --help) sed -n '2,15p' "$0"; exit 0 ;;
+ -h | --help) sed -n '2,17p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
@@ -108,11 +110,29 @@ if has appimage; then
chmod +x "$TOOL"
fi
fi
- OUT="$DIST/$BASENAME.AppImage"
+ # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be
+ # relative).
+ OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage"
+ APPDIR="$(cd "$APPDIR" && pwd)"
+ TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")"
+ # A .zsync left by an earlier run would hide a missing zsyncmake and describe another file.
+ rm -f "$OUT.zsync"
+ # Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and
+ # fetch only the blocks that changed in a newer release, through the .zsync published next to
+ # each AppImage on GitHub Releases. `latest` is the newest published release that is not a
+ # pre-release. A fork's builds point at its own releases through GITHUB_REPOSITORY.
+ REPO="${GITHUB_REPOSITORY:-storytold/soundcraft}"
+ UPDATE_INFO="gh-releases-zsync|${REPO%%/*}|${REPO#*/}|latest|soundcraft-*-linux-$ARCH.AppImage.zsync"
# Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime,
- # so users don't need libfuse2 either.
- ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream "$APPDIR" "$OUT"
+ # so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync
+ # package) appimagetool also writes the .zsync, into its working directory, hence the cd.
+ (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT")
echo "wrote $OUT"
+ if [ -s "$OUT.zsync" ]; then
+ echo "wrote $OUT.zsync"
+ else
+ warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it"
+ fi
fi
"$STAGE/usr/bin/soundcraft-cli" --version
From 684243c7bcb85bf54e8e0300bab2f69b790d8e79 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Thu, 8 Oct 2026 09:50:23 -0400
Subject: [PATCH 04/24] README Downloads: where to get it first, then builds
with Windows before macOS (#14)
Co-authored-by: Brandon Thomas
Co-authored-by: Claude Opus 5.5
---
README.md | 17 ++++++++---------
1 file changed, 8 insertions(+), 9 deletions(-)
diff --git a/README.md b/README.md
index 00bc5a0e..398061e3 100644
--- a/README.md
+++ b/README.md
@@ -216,15 +216,7 @@ very welcome, in the issues or on Discord.
## Downloads
-Every [release](https://github.com/storytold/soundcraft/releases/latest) ships these builds. `` is the
-version number; `SHA256SUMS.txt` lists a checksum for every file.
-
-### macOS
-
-| Build | File | Notes |
-|---|---|---|
-| App, universal (Apple silicon + Intel) | `soundcraft--macos-universal.dmg` | Signed and notarized |
-| Command-line tool, universal | `soundcraft-cli--macos-universal.zip` | Signed and notarized |
+**Download SoundCraft** from GitHub: the [latest release](https://github.com/storytold/soundcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/soundcraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows
@@ -236,6 +228,13 @@ version number; `SHA256SUMS.txt` lists a checksum for every file.
Installers and executables are code-signed.
+### macOS
+
+| Build | File | Notes |
+|---|---|---|
+| App, universal (Apple silicon + Intel) | `soundcraft--macos-universal.dmg` | Signed and notarized |
+| Command-line tool, universal | `soundcraft-cli--macos-universal.zip` | Signed and notarized |
+
### Linux
| Format | x86_64 | aarch64 (ARM64) | Notes |
From c51e5d5ec52f11c6264b72e26661fa712feb5345 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Thu, 8 Oct 2026 10:02:25 -0400
Subject: [PATCH 05/24] Bump version to 0.3.0 (from 0.2.1) for release
Co-Authored-By: Claude Opus 5.5
---
Cargo.lock | 36 ++++++++++++++++++------------------
Cargo.toml | 2 +-
2 files changed, 19 insertions(+), 19 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 402aa74e..e6719efc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3361,7 +3361,7 @@ dependencies = [
[[package]]
name = "soundcraft"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"eframe",
"egui",
@@ -3376,7 +3376,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"log",
"serde",
@@ -3386,7 +3386,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"log",
"proptest",
@@ -3398,7 +3398,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"log",
"serde_json",
@@ -3408,7 +3408,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"clap-sys",
"libloading",
@@ -3420,7 +3420,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"serde_json",
"soundcraft-audio-io",
@@ -3431,7 +3431,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"log",
"proptest",
@@ -3442,7 +3442,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"log",
"proptest",
@@ -3462,7 +3462,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"proptest",
"serde",
@@ -3472,7 +3472,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"log",
"rayon",
@@ -3488,7 +3488,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"proptest",
"serde",
@@ -3501,7 +3501,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"cpal",
"log",
@@ -3514,7 +3514,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"proptest",
"serde",
@@ -3524,7 +3524,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"egui",
"egui_kittest",
@@ -3549,7 +3549,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"image",
"proptest",
@@ -3560,7 +3560,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"libloading",
"log",
@@ -3575,7 +3575,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"eframe",
"egui",
@@ -5122,7 +5122,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.2.1"
+version = "0.3.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index fcfe030b..6f07dfbe 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
-version = "0.2.1"
+version = "0.3.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
From b919713ea7602d7d1e1b79c847bb8c7195e4598a Mon Sep 17 00:00:00 2001
From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com>
Date: Thu, 8 Oct 2026 20:06:42 +0200
Subject: [PATCH 06/24] Fix keyboard shortcuts on Windows and Linux: Ctrl is
the Cmd key (#15)
egui reports a held Ctrl as both ctrl and command off macOS, so no Cmd+ shortcut matched and Ctrl+S opened Search (Cmd+Ctrl+S) instead of saving. Off macOS a held Ctrl now stands for the shortcut's Cmd, shortcuts that also need the Mac Control key stay off the keyboard there (Ctrl+X is Cut, not Cmd+Ctrl+X), and the Search chord is macOS only.
---
crates/ui-egui/src/shortcuts.rs | 54 ++++++++++++++++++++++++++++++---
1 file changed, 50 insertions(+), 4 deletions(-)
diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs
index cc35c907..2326b247 100644
--- a/crates/ui-egui/src/shortcuts.rs
+++ b/crates/ui-egui/src/shortcuts.rs
@@ -41,8 +41,20 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
key.map(|k| (m, k))
}
-fn mods_match(want: Modifiers, got: Modifiers) -> bool {
- want.command == got.command && want.shift == got.shift && want.alt == got.alt && (want.ctrl == got.ctrl || got.mac_cmd)
+/// Cmd and Ctrl are separate keys only on macOS.
+const MAC: bool = cfg!(target_os = "macos");
+
+/// Whether the held modifiers `got` fire a shortcut that asks for `want`.
+fn mods_match(want: Modifiers, got: Modifiers, mac: bool) -> bool {
+ // Off macOS egui reports Ctrl as both `ctrl` and `command`: Ctrl is the shortcut's Cmd, and a shortcut's
+ // Ctrl (the Mac Control key) has no key of its own there, so Ctrl+X is Cut, not Cmd+Ctrl+X.
+ let ctrl = if mac { want.ctrl == got.ctrl || got.mac_cmd } else { !want.ctrl };
+ want.command == got.command && want.shift == got.shift && want.alt == got.alt && ctrl
+}
+
+/// Cmd+Ctrl+S opens Search. Off macOS Ctrl+S is Save (Cmd+S), so Search is reached from the Window menu.
+fn search_chord(m: Modifiers, mac: bool) -> bool {
+ mac && m.command && m.ctrl
}
pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
@@ -63,7 +75,7 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
continue;
}
let hit =
- soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods)));
+ soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, MAC)));
if let Some(c) = hit {
let id = c.id;
if crate::menus::invoke_shortcut_dialog(app, id) {
@@ -87,7 +99,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool {
let _ = app.run("transport.record", json!({}));
true
}
- Key::S if m.command && m.ctrl => {
+ Key::S if search_chord(m, MAC) => {
let _ = app.run("window.search", json!({}));
true
}
@@ -227,3 +239,37 @@ fn tab_clip(app: &mut SoundApp, back: bool) {
let _ = app.run("transport.locate", json!({"at": n}));
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::{mods_match, parse, search_chord};
+ use egui::Modifiers;
+
+ /// Ctrl held on Windows or Linux, as egui reports it.
+ const PC_CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
+ /// Cmd held on macOS.
+ const MAC_CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
+
+ fn want(s: &str) -> Modifiers {
+ parse(s).map(|(m, _)| m).unwrap()
+ }
+
+ #[test]
+ fn ctrl_is_cmd_off_macos() {
+ assert!(mods_match(want("Cmd+S"), PC_CTRL, false));
+ assert!(mods_match(want("Cmd+Shift+N"), Modifiers { shift: true, ..PC_CTRL }, false));
+ assert!(mods_match(want("Cmd+X"), PC_CTRL, false));
+ assert!(!mods_match(want("Cmd+Ctrl+X"), PC_CTRL, false));
+ assert!(!mods_match(want("Cmd+S"), Modifiers::NONE, false));
+ assert!(!search_chord(PC_CTRL, false));
+ }
+
+ #[test]
+ fn cmd_and_ctrl_stay_apart_on_macos() {
+ assert!(mods_match(want("Cmd+S"), MAC_CMD, true));
+ assert!(!mods_match(want("Cmd+S"), Modifiers::CTRL, true));
+ assert!(mods_match(want("Cmd+Ctrl+X"), Modifiers { ctrl: true, ..MAC_CMD }, true));
+ assert!(!search_chord(MAC_CMD, true));
+ assert!(search_chord(Modifiers { ctrl: true, ..MAC_CMD }, true));
+ }
+}
From c8437c5c61ec1c63cd8b357f5c85e5860ca3c2de Mon Sep 17 00:00:00 2001
From: Gary Gunn
Date: Thu, 8 Oct 2026 21:24:47 +0100
Subject: [PATCH 07/24] Fix tempo change from the tempo ruler not saving
Double-clicking the tempo ruler opened a number dialog whose command was
built as "event.tempo@". No such command exists, so OK failed
and nothing changed. The position was also never passed, so a change
would have landed at the session start.
Add a TempoChange dialog that carries the position and runs
event.tempo with bpm and at. Add an engine test that a tempo change at a
position is applied there.
Co-Authored-By: Claude Haiku 5.5
---
crates/engine/src/cmd/event.rs | 18 ++++++++++++++++++
crates/ui-egui/src/dialogs.rs | 14 ++++++++++++++
crates/ui-egui/src/edit_window.rs | 8 +-------
3 files changed, 33 insertions(+), 7 deletions(-)
diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs
index bfbb6df8..34fb355d 100644
--- a/crates/engine/src/cmd/event.rs
+++ b/crates/engine/src/cmd/event.rs
@@ -359,3 +359,21 @@ fn step_marker(e: &mut Engine, dir: i64) -> Result {
e.transport_requests.push(crate::TransportRequest::Locate(t));
Ok(json!({"at": t}))
}
+
+#[cfg(test)]
+mod tests {
+ use crate::demo::demo_engine;
+ use serde_json::json;
+
+ /// The tempo-ruler dialog runs `event.tempo` with `bpm` and `at`; the change must land at that position.
+ #[test]
+ fn tempo_change_at_position_is_applied() {
+ let mut e = demo_engine();
+ let sr = e.session().sample_rate;
+ let at = sr.samples(4.0);
+ e.execute("event.tempo", &json!({"bpm": 133.5, "at": at})).unwrap();
+ let s = e.session();
+ let tick = s.tempo.samples_to_ticks(at, s.sample_rate);
+ assert!((s.tempo.tempo_at_tick(tick) - 133.5).abs() < 1e-9);
+ }
+}
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 6815d60d..dc09ec74 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -6,6 +6,7 @@ use egui::{Align2, vec2};
use serde_json::{Value, json};
use soundcraft_engine::Engine;
use soundcraft_model::{ClipId, TrackId};
+use soundcraft_time::Samples;
#[derive(Debug, Clone)]
pub enum Dialog {
@@ -16,6 +17,8 @@ pub enum Dialog {
RenameClip { id: ClipId, name: String },
PathPrompt { cmd: String, title: String, path: String, key: String },
Number { cmd: String, title: String, key: String, value: f64, suffix: String },
+ /// Tempo change at a position, from double-clicking the tempo ruler.
+ TempoChange { at: Samples, bpm: f64 },
Fades { shape: String },
StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 },
Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> },
@@ -38,6 +41,7 @@ impl Dialogs {
Dialog::RenameClip { .. } => "rename_clip",
Dialog::PathPrompt { .. } => "path",
Dialog::Number { .. } => "number",
+ Dialog::TempoChange { .. } => "tempo_change",
Dialog::Fades { .. } => "fades",
Dialog::StripSilence { .. } => "strip_silence",
Dialog::Group { .. } => "group",
@@ -167,6 +171,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::RenameTrack { .. } => "Rename Track",
Dialog::RenameClip { .. } => "Rename Clip",
Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(),
+ Dialog::TempoChange { .. } => "Tempo Change (BPM)",
Dialog::Fades { .. } => "Fades",
Dialog::StripSilence { .. } => "Strip Silence",
Dialog::Group { .. } => "Create Group",
@@ -315,6 +320,15 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
action = Some((cmd.clone(), v));
}
}
+ Dialog::TempoChange { at, bpm } => {
+ ui.horizontal(|ui| {
+ ui.add(egui::DragValue::new(bpm).speed(0.1).range(1.0..=999.0));
+ ui.label("bpm");
+ });
+ if buttons(ui, "OK", enter) {
+ action = Some(("event.tempo".into(), json!({"bpm": bpm, "at": at})));
+ }
+ }
Dialog::Fades { shape } => {
for s in soundcraft_model::FadeShape::ALL {
ui.radio_value(shape, s.label().to_lowercase(), s.label());
diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs
index 17a54b08..24391ec1 100644
--- a/crates/ui-egui/src/edit_window.rs
+++ b/crates/ui-egui/src/edit_window.rs
@@ -372,13 +372,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[
{
let at = snap(&s, sample_at(&s, tl, p.x).max(0));
let bpm = s.tempo.tempo_at_tick(s.tempo.samples_to_ticks(at, sr));
- app.dialogs.open = Some(crate::dialogs::Dialog::Number {
- cmd: format!("event.tempo@{at}"),
- title: "Tempo Change (BPM)".into(),
- key: "bpm".into(),
- value: bpm,
- suffix: "bpm".into(),
- });
+ app.dialogs.open = Some(crate::dialogs::Dialog::TempoChange { at, bpm });
}
let mut last_label = f32::MIN;
for ev in s.tempo.tempos() {
From 368c3df2766cea3c6305a7fe93691544295d02ab Mon Sep 17 00:00:00 2001
From: Gary Gunn
Date: Thu, 8 Oct 2026 21:26:40 +0100
Subject: [PATCH 08/24] Make the Click option audible: metronome in live
playback
Options > Click set a flag but nothing rendered sound, so a click track
was silent. The mix engine now synthesizes a short blip on every beat of
the tempo map (accent on beat 1), using the click.* settings from
Setup > Click/Countoff, and sums it after the master fader.
The metronome is opt-in per engine: only live playback enables it.
Offline renders (bounces, stems, exports) leave it off so the click is
never printed into a file.
Also: midi_to_hz moves from the engine demo into soundcraft-dsp so the
mix and demo share one tuning formula.
Co-Authored-By: Claude Haiku 5.5
---
crates/dsp/src/lib.rs | 5 ++
crates/engine/src/demo.rs | 8 +--
crates/mix/src/lib.rs | 126 +++++++++++++++++++++++++++++++++++++
crates/playback/src/lib.rs | 1 +
4 files changed, 134 insertions(+), 6 deletions(-)
diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs
index 6b6c0235..7572ac3c 100644
--- a/crates/dsp/src/lib.rs
+++ b/crates/dsp/src/lib.rs
@@ -278,6 +278,11 @@ pub fn gain_to_db(g: f32) -> f32 {
(20.0 * a.log10()).max(MIN_DB)
}
+/// MIDI note number to frequency in Hz (equal temperament, A4 = note 69 = 440 Hz).
+pub fn midi_to_hz(note: f64) -> f64 {
+ 440.0 * 2f64.powf((note - 69.0) / 12.0)
+}
+
#[cfg(test)]
mod trait_default_tests {
use super::*;
diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs
index bc8a2a2e..1e7daa00 100644
--- a/crates/engine/src/demo.rs
+++ b/crates/engine/src/demo.rs
@@ -110,10 +110,6 @@ fn drums(sr: f64, frames: usize) -> (Vec, Vec, Vec) {
(k, s, h)
}
-fn midi_hz(n: f64) -> f64 {
- 440.0 * 2f64.powf((n - 69.0) / 12.0)
-}
-
/// Chord roots per bar (A minor, F, C, G progression), as MIDI notes.
const PROG: [[u8; 4]; 4] = [[57, 60, 64, 69], [53, 57, 60, 65], [48, 52, 55, 60], [55, 59, 62, 67]];
@@ -127,7 +123,7 @@ fn bass(sr: f64, frames: usize) -> Vec {
for (eighth, semi, beats) in pattern {
let at = bar * beat * 4 + eighth * beat / 2;
let len = (beats * beat as f64) as usize;
- let f = midi_hz(root + semi);
+ let f = soundcraft_dsp::midi_to_hz(root + semi);
let mut ph = 0.0f64;
for i in 0..len {
let Some(o) = out.get_mut(at + i) else { break };
@@ -152,7 +148,7 @@ fn pad(sr: f64, frames: usize) -> (Vec, Vec) {
let chord = PROG[bar % 4];
let at = bar * bar_len;
for (vi, n) in chord.iter().enumerate() {
- let f = midi_hz(f64::from(*n) + 12.0);
+ let f = soundcraft_dsp::midi_to_hz(f64::from(*n) + 12.0);
for (side, buf) in [(-1.0f64, &mut l), (1.0, &mut r)] {
let det = 1.0 + side * 0.0035 * (vi as f64 + 1.0);
let mut ph = vi as f64 * 0.17;
diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs
index ecc15af9..28e9d1d1 100644
--- a/crates/mix/src/lib.rs
+++ b/crates/mix/src/lib.rs
@@ -299,6 +299,9 @@ pub struct MixEngine {
pub monitor_only: bool,
/// Recording: record-armed tracks hear their input (auto input).
pub recording: bool,
+ /// Mix the metronome (Options > Click) into the output. Playback turns this on; offline renders
+ /// (bounces, stems, exports) leave it off so the click is never printed into a file.
+ pub metronome: bool,
/// Third-party instances come from [`MixEngine::adopt`] instead of being created in `sync`.
external: bool,
/// Instances handed over by the host, waiting for `sync` to place them.
@@ -363,6 +366,7 @@ impl MixEngine {
input: Vec::new(),
monitor_only: false,
recording: false,
+ metronome: false,
external: false,
adopt: HashMap::new(),
retired: Vec::new(),
@@ -655,6 +659,11 @@ impl MixEngine {
self.meters.entry(t.id).or_default().measure(&self.main, frames, 0.0);
}
}
+ // Metronome: summed after the master fader, so it reaches the speakers without passing
+ // through any track, send or insert. Only the playback engine sets `metronome`.
+ if self.metronome && s.edit.click && !(s.edit.flag("click.only_during_record") && !self.recording) {
+ click::render(s, pos, frames, &mut self.main);
+ }
// Output: min(out.len(), main channels) channels; any further output channels are silent.
for (c, o) in out.iter_mut().enumerate() {
let src = self.main.get(c);
@@ -1075,6 +1084,67 @@ fn receives_solo(s: &Session, t: &Track) -> bool {
.any(|o| o.mixer.solo && (o.mixer.output == Route::Bus(*b) || o.mixer.sends.iter().flatten().any(|sn| sn.target == Route::Bus(*b))))
}
+/// Metronome: a short synthesized blip on every beat of the tempo map, accented on beat 1 of
+/// each bar. Settings come from `setup.click_countoff` (`click.*` values).
+mod click {
+ use super::*;
+
+ /// Blip length in seconds.
+ const LEN_S: f64 = 0.03;
+
+ pub fn render(s: &Session, pos: Samples, frames: usize, main: &mut [Vec]) {
+ let sr = s.sample_rate;
+ let rate = sr.as_f64();
+ let len = (LEN_S * rate) as i64;
+ let end = pos.saturating_add(frames as i64);
+ let gain = f64::from(db_to_gain(s.edit.value("click.volume_db", -6.0).clamp(-60.0, 12.0) as f32));
+ let accent = (note_hz(s.edit.value("click.accent_note", 84.0)), s.edit.value("click.accent_velocity", 127.0));
+ let normal = (note_hz(s.edit.value("click.normal_note", 72.0)), s.edit.value("click.normal_velocity", 100.0));
+ // Start one blip length early so a click that began in an earlier block still rings into this one.
+ let t0 = s.tempo.samples_to_ticks(pos.saturating_sub(len), sr).max(0).saturating_sub(1);
+ let mut tick = t0.saturating_sub(s.tempo.bar_beat_at_tick(t0).tick);
+ // Blocks hold a few beats at most; the guard only protects against a malformed tempo map.
+ for _ in 0..4096 {
+ let at = s.tempo.tick_to_samples(tick, sr);
+ if at >= end {
+ break;
+ }
+ if at.saturating_add(len) > pos {
+ let (hz, vel) = if s.tempo.bar_beat_at_tick(tick).beat == 1 { accent } else { normal };
+ let amp = gain * (vel.clamp(1.0, 127.0) / 127.0);
+ blip(main, at - pos, frames, hz, amp as f32, rate);
+ }
+ tick = tick.saturating_add(s.tempo.meter_at_tick(tick).ticks_per_beat());
+ }
+ }
+
+ /// Adds one decaying sine blip whose first sample sits `rel` frames into the block (negative
+ /// when it began before the block) to every channel.
+ fn blip(main: &mut [Vec], rel: i64, frames: usize, hz: f64, amp: f32, rate: f64) {
+ let len = (LEN_S * rate) as usize;
+ let w = std::f64::consts::TAU * hz / rate;
+ let first = usize::try_from(-rel).unwrap_or(0);
+ for i in first..len {
+ let Ok(at) = usize::try_from(rel + i as i64) else { continue };
+ if at >= frames {
+ break;
+ }
+ let env = (-6.0 * i as f64 / len as f64).exp() as f32;
+ let v = ((w * i as f64).sin() as f32) * env * amp;
+ for ch in main.iter_mut() {
+ if let Some(d) = ch.get_mut(at) {
+ *d += v;
+ }
+ }
+ }
+ }
+
+ /// Click pitch for a MIDI note setting (clamped to the MIDI range).
+ fn note_hz(note: f64) -> f64 {
+ soundcraft_dsp::midi_to_hz(note.clamp(0.0, 127.0))
+ }
+}
+
fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut [Vec], _scratch: &mut [f32]) {
let ClipContent::Audio { source, offset } = clip.content else { return };
let block = Range { start: pos, end: pos.saturating_add(frames as i64) };
@@ -1934,6 +2004,62 @@ mod tests {
assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0)));
}
+ /// Renders `len` frames from zero the way playback does (metronome on), in blocks of `block`.
+ fn render_playback(s: &Session, len: usize, block: usize, recording: bool) -> Vec> {
+ let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
+ eng.metronome = true;
+ eng.recording = recording;
+ let mut out = vec![vec![0.0f32; len]; 2];
+ let mut tmp = vec![vec![0.0f32; block]; 2];
+ let mut done = 0usize;
+ while done < len {
+ let n = (len - done).min(block);
+ eng.render(s, done as i64, n, &mut tmp);
+ for (o, t) in out.iter_mut().zip(tmp.iter()) {
+ if let (Some(d), Some(src)) = (o.get_mut(done..done + n), t.get(..n)) {
+ d.copy_from_slice(src);
+ }
+ }
+ done += n;
+ }
+ out
+ }
+
+ #[test]
+ fn click_sounds_on_every_beat_only_in_playback() {
+ let mut s = Session::default();
+ let beat = s.sample_rate.as_f64() as usize * 60 / 120; // 120 BPM quarter notes
+ let len = beat * 8;
+ s.edit.click = true;
+ // Offline renders (bounces, stems) never print the metronome.
+ let offline = render_range(&s, Range::new(0, len as i64), 512);
+ assert!(offline.iter().all(|c| c.iter().all(|x| *x == 0.0)));
+
+ let out = render_playback(&s, len, 512, false);
+ for b in 0..8usize {
+ let at = b * beat;
+ let on: f32 = out[0][at..at + 200].iter().map(|x| x.abs()).fold(0.0, f32::max);
+ let off: f32 = out[0][at + beat / 2..at + beat / 2 + 200].iter().map(|x| x.abs()).fold(0.0, f32::max);
+ assert!(on > 0.1, "beat {b} should click, peak {on}");
+ assert_eq!(off, 0.0, "between beats must stay silent");
+ assert_eq!(out[0][at], out[1][at], "both channels get the click");
+ }
+ // Blocks that start mid-beat render the same clicks as a single pass.
+ let split = render_playback(&s, len, 97, false);
+ assert_eq!(out, split);
+ }
+
+ #[test]
+ fn click_only_during_record_follows_recording() {
+ let mut s = Session::default();
+ s.edit.click = true;
+ s.edit.set_flag("click.only_during_record", true);
+ let idle = render_playback(&s, 48_000, 512, false);
+ assert!(idle.iter().all(|c| c.iter().all(|x| *x == 0.0)));
+ let rec = render_playback(&s, 48_000, 512, true);
+ assert!(rec.iter().any(|c| c.iter().any(|x| x.abs() > 0.1)));
+ }
+
/// A session with one track of `fmt` playing a distinct DC level per channel (0.1, 0.2, …).
fn multichannel_session(fmt: ChannelFormat, main: ChannelFormat, frames: usize) -> (Session, TrackId) {
let mut s = Session::default();
diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs
index 6bc6b29e..1e48e69d 100644
--- a/crates/playback/src/lib.rs
+++ b/crates/playback/src/lib.rs
@@ -99,6 +99,7 @@ struct AudioState {
fn new_engine(sr: f32) -> MixEngine {
let mut m = MixEngine::new(sr, BLOCK);
m.set_external_instances(true);
+ m.metronome = true;
m
}
From 9701f8e9a5cb2678a2dea35452c1585abea6e314 Mon Sep 17 00:00:00 2001
From: Gary Gunn
Date: Thu, 8 Oct 2026 21:42:02 +0100
Subject: [PATCH 09/24] Match tempo dialog limits to the engine and test its
action
The tempo-change dialog clamped BPM to 1..=999, but the engine accepts
5..=1000. A 1000 BPM tempo was silently changed to 999 on OK, and values
below 5 were rejected after the dialog had closed.
Use the engine's limits, and move the OK action into tempo_change_action
so a test can check it runs a real command with the position. The test
would have caught the original bad command id.
Co-Authored-By: Claude Haiku 5.5
---
crates/ui-egui/src/dialogs.rs | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index dc09ec74..39f3cf8a 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -322,11 +322,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
}
Dialog::TempoChange { at, bpm } => {
ui.horizontal(|ui| {
- ui.add(egui::DragValue::new(bpm).speed(0.1).range(1.0..=999.0));
+ // Same limits the engine enforces (time crate `valid_bpm`).
+ ui.add(egui::DragValue::new(bpm).speed(0.1).range(5.0..=1000.0));
ui.label("bpm");
});
if buttons(ui, "OK", enter) {
- action = Some(("event.tempo".into(), json!({"bpm": bpm, "at": at})));
+ action = Some(tempo_change_action(*at, *bpm));
}
}
Dialog::Fades { shape } => {
@@ -436,6 +437,11 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
pressed
}
+/// The command the tempo-change dialog runs on OK.
+fn tempo_change_action(at: Samples, bpm: f64) -> (String, Value) {
+ ("event.tempo".into(), json!({"bpm": bpm, "at": at}))
+}
+
fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) {
let Some(process) = app.ui.audiosuite.clone() else { return };
let mut open = true;
@@ -509,4 +515,12 @@ mod tests {
assert!(d.open_for_command(&e, "file.score_setup"));
assert!(matches!(d.open, Some(Dialog::ScoreSetup { bars_per_system: 4, .. })));
}
+
+ #[test]
+ fn tempo_change_dialog_runs_a_real_command_at_its_position() {
+ let (cmd, params) = tempo_change_action(96_000, 133.5);
+ assert!(soundcraft_engine::command_specs().iter().any(|c| c.id == cmd), "{cmd} is not a command");
+ assert_eq!(params["at"], 96_000);
+ assert_eq!(params["bpm"], 133.5);
+ }
}
From b3669c767eab76fa0a6a06eeefdf67b5e477d6eb Mon Sep 17 00:00:00 2001
From: Gary Gunn
Date: Thu, 8 Oct 2026 22:18:32 +0100
Subject: [PATCH 10/24] Fix input monitoring pitch and dropouts when input and
session rates differ
The monitor ring fed raw input-device samples to the output callback as if
they were at the session rate. A 16 kHz or 44.1 kHz input played back too
fast (higher pitch) and ran dry between bursts, so it sounded quiet.
InputRing now knows the input rate and converts to the session rate with
linear interpolation in pop_into, allocation-free and non-blocking. The
buffer cap is computed once in new(). Tests cover rate up-conversion,
down-conversion, equal rates and underrun.
Co-Authored-By: Claude Haiku 5.5
---
crates/playback/src/lib.rs | 2 +-
crates/playback/src/record.rs | 122 +++++++++++++++++++++++++++++-----
2 files changed, 105 insertions(+), 19 deletions(-)
diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs
index 6bc6b29e..45d52453 100644
--- a/crates/playback/src/lib.rs
+++ b/crates/playback/src/lib.rs
@@ -222,7 +222,7 @@ impl AudioState {
if self.mix.input.len() != ch {
self.mix.input = vec![vec![0.0; BLOCK]; ch];
}
- ring.pop_into(&mut self.mix.input, n);
+ ring.pop_into(&mut self.mix.input, n, self.session.sample_rate.hz());
}
self.mix.monitor_only = !self.playing;
self.mix.recording = self.recording;
diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs
index d9def4c0..217c79be 100644
--- a/crates/playback/src/record.rs
+++ b/crates/playback/src/record.rs
@@ -50,7 +50,7 @@ impl Recorder {
let config = cfg.config();
let shared = Arc::new(Shared { armed: AtomicBool::new(false), data: Mutex::new(Vec::new()) });
let sh = Arc::clone(&shared);
- let monitor = InputRing::new(usize::from(config.channels));
+ let monitor = InputRing::new(usize::from(config.channels), config.sample_rate.0);
let mon = Arc::clone(&monitor);
let mut backlog: Vec = Vec::new();
let stream = device
@@ -140,47 +140,133 @@ mod tests {
}
}
+/// Ring state behind the lock: interleaved input frames at the input device's rate, plus the
+/// fractional read position (in input frames) of the next output sample.
+struct RingState {
+ data: std::collections::VecDeque,
+ phase: f64,
+}
+
/// A small interleaved FIFO from the input callback to the output callback, for input
/// monitoring. Both sides only `try_lock`; it holds at most ~0.5 s and drops the oldest frames.
-#[derive(Default)]
+/// The input device may run at a different rate than the session, so the reader converts
+/// rates with linear interpolation (cheap, allocation-free, fine for monitoring).
pub struct InputRing {
- data: Mutex>,
+ state: Mutex,
pub channels: std::sync::atomic::AtomicUsize,
+ /// Input device sample rate in Hz (0 when unknown, which reads as "same as the session").
+ rate: u32,
+ /// Most samples kept: half a second of input, or 24 000 frames when the rate is unknown.
+ cap: usize,
}
impl InputRing {
- pub fn new(channels: usize) -> Arc {
+ pub fn new(channels: usize, rate: u32) -> Arc {
+ let ch = channels.max(1);
+ let frames = if rate == 0 { 24_000 } else { rate as usize / 2 };
Arc::new(InputRing {
- data: Mutex::new(std::collections::VecDeque::with_capacity(48_000 * channels.max(1))),
- channels: std::sync::atomic::AtomicUsize::new(channels.max(1)),
+ state: Mutex::new(RingState { data: std::collections::VecDeque::with_capacity(rate.max(48_000) as usize * ch), phase: 0.0 }),
+ channels: std::sync::atomic::AtomicUsize::new(ch),
+ rate,
+ cap: frames * ch,
})
}
pub fn push(&self, samples: &[f32]) {
- if let Ok(mut d) = self.data.try_lock() {
- let cap = 24_000 * self.channels.load(Ordering::Relaxed).max(1);
- d.extend(samples.iter().copied());
- while d.len() > cap {
- d.pop_front();
+ if let Ok(mut s) = self.state.try_lock() {
+ s.data.extend(samples.iter().copied());
+ while s.data.len() > self.cap {
+ s.data.pop_front();
}
}
}
- /// Fill `out` (planar) with up to `frames` frames; missing frames are silence.
- pub fn pop_into(&self, out: &mut [Vec], frames: usize) {
+ /// Fill `out` (planar, at `session_rate`) with up to `frames` frames, converting from the
+ /// input rate. Missing frames are silence.
+ pub fn pop_into(&self, out: &mut [Vec], frames: usize, session_rate: u32) {
let ch = self.channels.load(Ordering::Relaxed).max(1);
for c in out.iter_mut() {
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
}
- let Ok(mut d) = self.data.try_lock() else { return };
- let avail = (d.len() / ch).min(frames);
- for f in 0..avail {
+ let Ok(mut s) = self.state.try_lock() else { return };
+ // Input frames consumed per output frame.
+ let step = if self.rate == 0 || session_rate == 0 { 1.0 } else { f64::from(self.rate) / f64::from(session_rate) };
+ for f in 0..frames {
+ // Drop input frames we have moved past; keep the one at `phase` and the next one.
+ while s.phase >= 1.0 && s.data.len() >= 2 * ch {
+ s.data.drain(..ch);
+ s.phase -= 1.0;
+ }
+ if s.phase >= 1.0 || s.data.len() < 2 * ch {
+ break;
+ }
+ let fr = s.phase as f32;
for c in 0..ch {
- let v = d.pop_front().unwrap_or(0.0);
+ let a = s.data.get(c).copied().unwrap_or(0.0);
+ let b = s.data.get(ch + c).copied().unwrap_or(a);
if let Some(x) = out.get_mut(c).and_then(|o| o.get_mut(f)) {
- *x = v;
+ *x = a + (b - a) * fr;
}
}
+ s.phase += step;
}
}
}
+
+#[cfg(test)]
+mod ring_tests {
+ use super::InputRing;
+
+ /// A 1 kHz sine recorded at 16 kHz must come out as a 1 kHz sine at 48 kHz, across calls.
+ #[test]
+ fn monitor_converts_input_rate_to_session_rate() {
+ let ring = InputRing::new(1, 16_000);
+ let input: Vec = (0..4_000).map(|i| (2.0 * std::f64::consts::PI * 1_000.0 * f64::from(i) / 16_000.0).sin() as f32).collect();
+ ring.push(&input);
+ let mut out = vec![vec![0.0f32; 48]];
+ let mut k = 0usize;
+ for _ in 0..10 {
+ ring.pop_into(&mut out, 48, 48_000);
+ for v in &out[0] {
+ let want = (2.0 * std::f64::consts::PI * 1_000.0 * k as f64 / 48_000.0).sin() as f32;
+ assert!((v - want).abs() < 0.05, "sample {k}: got {v}, want {want}");
+ k += 1;
+ }
+ }
+ }
+
+ /// Input faster than the session (96 kHz into 48 kHz): every other frame is read, in order.
+ #[test]
+ fn monitor_decimates_when_input_is_faster() {
+ let ring = InputRing::new(1, 96_000);
+ let input: Vec = (0..1_000).map(|i| i as f32).collect();
+ ring.push(&input);
+ let mut out = vec![vec![0.0f32; 100]];
+ ring.pop_into(&mut out, 100, 48_000);
+ for (k, v) in out[0].iter().enumerate() {
+ assert_eq!(*v, (2 * k) as f32, "output {k}");
+ }
+ }
+
+ /// Same rate: samples pass through unchanged.
+ #[test]
+ fn monitor_passes_through_at_equal_rates() {
+ let ring = InputRing::new(2, 48_000);
+ let input: Vec = (0..200).map(|i| i as f32).collect();
+ ring.push(&input);
+ let mut out = vec![vec![0.0f32; 50], vec![0.0f32; 50]];
+ ring.pop_into(&mut out, 50, 48_000);
+ assert_eq!(out[0][0], 0.0);
+ assert_eq!(out[1][0], 1.0);
+ assert_eq!(out[0][49], 98.0);
+ }
+
+ /// Nothing buffered: silence, not stale data.
+ #[test]
+ fn monitor_underrun_is_silence() {
+ let ring = InputRing::new(1, 16_000);
+ let mut out = vec![vec![1.0f32; 16]];
+ ring.pop_into(&mut out, 16, 48_000);
+ assert!(out[0].iter().all(|v| *v == 0.0));
+ }
+}
From 4c7ffc5f03daf43283f55d4ec1800ed626f709d7 Mon Sep 17 00:00:00 2001
From: Gary Gunn
Date: Thu, 8 Oct 2026 22:25:48 +0100
Subject: [PATCH 11/24] Keep the clip context menu open while moving onto it
The clip menu was attached at the end of lane_interaction, which returns
early when the lane has no pointer position. Once the menu opened, the
pointer sat over the popup, the lane lost its hover, and the menu was
never drawn again, so it vanished before Delete could be clicked.
Attach the menu before the early return and remember the right-clicked
clip so it keeps its target. Stale clip ids are dropped if the clip no
longer exists. Adds a kittest regression test covering right-click,
move onto the menu, and Delete.
Co-Authored-By: Claude Haiku 5.5
---
crates/ui-egui/src/edit_window.rs | 15 ++++++-
crates/ui-egui/tests/clip_menu.rs | 66 +++++++++++++++++++++++++++++++
2 files changed, 80 insertions(+), 1 deletion(-)
create mode 100644 crates/ui-egui/tests/clip_menu.rs
diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs
index 17a54b08..0338420a 100644
--- a/crates/ui-egui/src/edit_window.rs
+++ b/crates/ui-egui/src/edit_window.rs
@@ -1372,6 +1372,20 @@ fn clip_at(track: &Track, at: Samples) -> Option<&Clip> {
fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) {
let id = ui.id().with(("lane", track.id.0));
let resp = ui.interact(lane, id, Sense::click_and_drag());
+ // The clip menu is attached before the early return below: once the menu is open the pointer
+ // is over the popup, the lane has no hover position, and the rest of this function bails out.
+ // The clip is remembered at the right-click so the menu keeps its target while it is used.
+ let menu_clip_id = id.with("menu_clip");
+ if resp.secondary_clicked() {
+ let clip = resp.interact_pointer_pos().and_then(|p| {
+ let s = app.engine.session();
+ clip_at(track, sample_at(s, tl, p.x).max(0)).map(|c| c.id)
+ });
+ ui.ctx().data_mut(|d| d.insert_temp(menu_clip_id, clip));
+ }
+ // The clip may have been removed or moved since the right-click; only use it if it still exists.
+ let menu_clip = ui.ctx().data(|d| d.get_temp::