From 74926df5272d397f128b1e71bec0812942cea9eb Mon Sep 17 00:00:00 2001
From: Gert Pellin
Date: Thu, 8 Oct 2026 12:25:49 +0200
Subject: [PATCH 01/26] Desktop: write log records to standard error and a
rotating log file
The desktop app installed no logger, so every log::warn!/log::error! in
the workspace was discarded: the engine's panic report, audio devices
that failed to open or broke, plugins that refused their stored state or
failed to activate, CLAP plugins' own log messages, files that failed to
open. A launch from a desktop menu has no terminal either, so there was
nothing to attach to a bug report.
apps/soundcraft/src/logging.rs (no new dependency):
- standard error plus /logs/soundcraft.log (beside
ui.json, Autosave/ and Presets/); each launch rotates the previous log
to soundcraft.1.log and .2.log, so the log of a run that crashed
survives the next start; the file is capped at 16 MiB
- records logged before the settings directory is known are buffered
(bounded) and written once the file is attached
- default levels: info for soundcraft*, warn for everything else;
RUST_LOG replaces them with env_logger-style directives
- never panics: an unusable directory or a failed write leaves standard
error as the only sink; a poisoned lock is tolerated
- runs with SOUNDCRAFT_NO_PREFS (agents' test runs) log to standard
error only, so they don't rotate away the user's own logs
Realtime audio: a record takes a lock and does I/O, which the audio
callback must never do, yet code on the audio thread logs (the cpal
error callbacks, the synth's full event queue, a hosted plugin's failed
process, a CLAP plugin calling the host's log, the panic hook). The cpal
callbacks now mark their thread (soundcraft_mix::mark_audio_thread,
re-exported by soundcraft_playback), and the mix engine carries the mark
into the rayon workers that render strips for it. A record from a marked
thread is not written there: the first is kept in a fixed buffer
(try_lock, no allocation, never waits), the rest are counted, and the UI
thread writes them every frame (any other thread before its own next
record).
The logger is installed first in main (before the engine's panic hook)
and the file is opened after argument parsing (--version leaves no
file). The settings directory moves out of prefs_path into config_dir.
The control server's eprintln!s go through the logger (the
SOUNDCRAFT_CONTROL_PORT line on standard output stays). Documented in
README.md (Getting started > Logs) and docs/architecture.md (Audio).
---
README.md | 22 +
apps/soundcraft/src/control_server.rs | 6 +-
apps/soundcraft/src/logging.rs | 796 ++++++++++++++++++++++++++
apps/soundcraft/src/main.rs | 60 +-
crates/mix/src/lib.rs | 81 ++-
crates/playback/src/lib.rs | 16 +-
crates/playback/src/record.rs | 7 +-
docs/architecture.md | 6 +
8 files changed, 976 insertions(+), 18 deletions(-)
create mode 100644 apps/soundcraft/src/logging.rs
diff --git a/README.md b/README.md
index c0d4591..8ff13d8 100644
--- a/README.md
+++ b/README.md
@@ -157,6 +157,28 @@ Useful shortcuts: Space play/stop, ⌘= Mix/Edi
separate, ⌘D duplicate, Enter new marker, ⌘⇧N
new tracks. **Setup › Keyboard Shortcuts** lists them all.
+### Logs
+
+The desktop app writes its `log` records to standard error and to `logs/soundcraft.log` in the
+settings directory, beside `ui.json`, `Autosave/` and `Presets/` (Linux `~/.config/soundcraft/logs/`,
+or `$XDG_CONFIG_HOME/soundcraft/logs/`; macOS `~/Library/Application Support/SoundCraft/logs/`;
+Windows `%APPDATA%\SoundCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so
+this file is what to attach to a bug report: the engine's panic report, audio devices that failed to
+open or broke, plugins that refused their stored state, CLAP plugins' own messages and files that
+failed to open land there. Each launch moves the previous log to `soundcraft.1.log` (and that one to
+`soundcraft.2.log`), so the log of a run that crashed survives the next start. The file stops
+growing at 16 MiB. `--version` writes no file, and runs with `SOUNDCRAFT_NO_PREFS` log to standard
+error only.
+
+| Variable | Effect |
+|---|---|
+| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for SoundCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,soundcraft_mix=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`soundcraft*=debug`). |
+| `SOUNDCRAFT_NO_PREFS` | No preferences read or written and no log file (agents' test runs). |
+
+The realtime audio thread never writes a record itself: the logger keeps the first one it logs
+(without waiting or allocating), counts the rest, and the UI thread writes them. The logger is
+`apps/soundcraft/src/logging.rs`. The web app logs to the browser console instead.
+
## For agents and scripts
Everything you can click is also a command with an id and JSON parameters, and the same commands
diff --git a/apps/soundcraft/src/control_server.rs b/apps/soundcraft/src/control_server.rs
index 7c0c3e0..51758b9 100644
--- a/apps/soundcraft/src/control_server.rs
+++ b/apps/soundcraft/src/control_server.rs
@@ -13,12 +13,12 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
- eprintln!("soundcraft: control server failed to bind 127.0.0.1:{port}: {e}");
+ log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
let actual = listener.local_addr().map(|a| a.port()).unwrap_or(port);
- eprintln!("soundcraft: control server listening on 127.0.0.1:{actual}");
+ log::info!("control server listening on 127.0.0.1:{actual}");
println!("SOUNDCRAFT_CONTROL_PORT={actual}");
let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || {
for stream in listener.incoming().flatten() {
@@ -28,7 +28,7 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver {
}
});
if let Err(e) = spawned {
- eprintln!("soundcraft: control thread failed: {e}");
+ log::error!("control thread failed: {e}");
}
rx
}
diff --git a/apps/soundcraft/src/logging.rs b/apps/soundcraft/src/logging.rs
new file mode 100644
index 0000000..53be177
--- /dev/null
+++ b/apps/soundcraft/src/logging.rs
@@ -0,0 +1,796 @@
+//! The desktop app's logger: `log::` records go to standard error and to a log file.
+//!
+//! Without a logger every `log::warn!`/`log::error!` in the workspace (the engine's panic report,
+//! audio devices that failed to open or broke, plugins that refused their stored state or failed
+//! to activate, CLAP plugins' own log messages, decode errors on import) vanished. A launch from a
+//! desktop menu has no terminal, so the file is what a bug report can attach:
+//! `/logs/soundcraft.log`, beside `ui.json`, `Autosave/` and `Presets/`. Each start
+//! moves the previous log to `soundcraft.1.log` (and that one to `.2`), so the log of a run that
+//! crashed survives the next launch. Runs without preferences (`SOUNDCRAFT_NO_PREFS`, agents' test
+//! runs) log to standard error only, so they don't rotate away the user's own logs.
+//!
+//! Levels: `info` for SoundCraft's own crates, `warn` for everything else (wgpu and naga are
+//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
+//! `warn,soundcraft_mix=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
+//! that starts with it (`soundcraft*=debug`).
+//!
+//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
+//! written once the file is attached. Writing never panics: a file that can't be created or
+//! written leaves standard error as the only sink.
+//!
+//! Realtime audio: a record takes a lock and writes to standard error and a file, which the audio
+//! callback must never do. Code that runs on the audio thread logs anyway (the cpal error callback,
+//! the synth's full event queue, a hosted plugin's failed `process`, a CLAP plugin calling the
+//! host's `log` from its audio thread, the panic hook). So the cpal callbacks mark their thread
+//! (`soundcraft_playback::mark_audio_thread`; the mix engine carries the mark into the worker
+//! threads that render strips for it), and a record from a marked thread is not written
+//! there: the first one is kept in a fixed buffer (`try_lock`, no allocation, never waits) and the
+//! rest are only counted (an atomic). Another thread writes them out: the UI thread every frame
+//! ([`AppLogger::report_audio_thread`]) and any other thread before its own next record.
+
+use std::fmt::Write as _;
+use std::fs::File;
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::{Mutex, OnceLock, PoisonError};
+use std::time::SystemTime;
+
+use log::LevelFilter;
+
+/// The current log file's name inside the log directory.
+pub const LOG_FILE: &str = "soundcraft.log";
+/// The log directory inside the settings directory.
+pub const LOG_DIR: &str = "logs";
+/// How many previous logs are kept (`soundcraft.1.log` … `soundcraft..log`).
+pub const KEEP: usize = 2;
+/// The log file stops growing past this size (a runaway warning can't fill the disk).
+pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
+/// Records kept in memory until the log file is attached.
+pub const MAX_PENDING: usize = 512;
+/// The built-in filter when `RUST_LOG` is unset or empty.
+pub const DEFAULT_FILTER: &str = "warn,soundcraft*=info";
+/// The longest message kept from the audio thread (longer ones are cut, marked with `…`).
+pub const REALTIME_MESSAGE_BYTES: usize = 480;
+/// The longest target kept from the audio thread.
+const REALTIME_TARGET_BYTES: usize = 96;
+/// The thread name shown for records from the audio thread.
+const AUDIO_THREAD: &str = "audio";
+
+/// Per-target level filter parsed from env_logger-style directives.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Filter {
+ default: LevelFilter,
+ directives: Vec<(String, LevelFilter)>,
+}
+
+impl Filter {
+ /// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
+ /// directive names log errors only.
+ pub fn parse(spec: &str) -> Filter {
+ let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
+ for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
+ match part.split_once('=') {
+ Some((name, level)) => {
+ let name = name.trim();
+ if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
+ f.directives.push((name.to_owned(), level));
+ }
+ }
+ None => match parse_level(part) {
+ Some(level) => f.default = level,
+ // A bare target name: everything from it (env_logger does the same).
+ None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
+ },
+ }
+ }
+ f
+ }
+
+ /// The level that applies to `target` (the most specific matching directive wins).
+ pub fn level_for(&self, target: &str) -> LevelFilter {
+ self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
+ }
+
+ /// The most verbose level any target can reach (for `log::set_max_level`).
+ pub fn max(&self) -> LevelFilter {
+ self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
+ }
+}
+
+fn parse_level(s: &str) -> Option {
+ s.trim().parse().ok()
+}
+
+/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
+/// `name*` matches every target starting with `name`.
+fn matches(name: &str, target: &str) -> bool {
+ match name.strip_suffix('*') {
+ Some(prefix) => target.starts_with(prefix),
+ None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
+ }
+}
+
+/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
+pub fn timestamp(t: SystemTime) -> String {
+ let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
+ let secs = since.as_secs();
+ let (days, rem) = (secs / 86_400, secs % 86_400);
+ let (y, m, d) = civil_from_days(days);
+ format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
+}
+
+/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
+/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
+/// clock can't overflow).
+fn civil_from_days(days: u64) -> (u64, u64, u64) {
+ let z = days.saturating_add(719_468);
+ let era = z / 146_097;
+ let doe = z % 146_097;
+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ let mp = (5 * doy + 2) / 153;
+ let d = doy - (153 * mp + 2) / 5 + 1;
+ let m = if mp < 10 { mp + 3 } else { mp - 9 };
+ let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
+ (y, m, d)
+}
+
+fn numbered(dir: &Path, n: usize) -> PathBuf {
+ if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("soundcraft.{n}.log")) }
+}
+
+/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped)
+/// and create a fresh, empty log file in `dir` (created if missing).
+pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
+ std::fs::create_dir_all(dir)?;
+ for n in (1..=KEEP).rev() {
+ match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
+ Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
+ _ => {}
+ }
+ }
+ let path = numbered(dir, 0);
+ let file = File::create(&path)?;
+ Ok((path, file))
+}
+
+/// One formatted log line: ` [] : \n`.
+pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
+ let message = message.strip_suffix('\n').unwrap_or(message);
+ format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
+}
+
+/// Where formatted lines go besides standard error: kept in memory until a file is attached,
+/// then appended to it until it reaches its size cap.
+pub struct Sink {
+ file: Option,
+ pending: Vec,
+ dropped: usize,
+ written: u64,
+ max_bytes: u64,
+ capped: bool,
+}
+
+impl Sink {
+ pub fn new(max_bytes: u64) -> Sink {
+ Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
+ }
+
+ /// Append `line` to the file, or keep it until [`Sink::attach`].
+ pub fn write(&mut self, line: &str) {
+ if self.file.is_none() {
+ if self.capped {
+ return;
+ }
+ if self.pending.len() < MAX_PENDING {
+ self.pending.push(line.to_owned());
+ } else {
+ self.dropped = self.dropped.saturating_add(1);
+ }
+ return;
+ }
+ self.append(line);
+ }
+
+ /// Start writing to `file`, first the lines kept so far.
+ pub fn attach(&mut self, file: File) {
+ self.file = Some(file);
+ for line in std::mem::take(&mut self.pending) {
+ self.append(&line);
+ }
+ if self.dropped > 0 {
+ let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
+ self.dropped = 0;
+ self.append(¬e);
+ }
+ }
+
+ /// There will be no log file: forget the lines kept for it and keep no more.
+ pub fn no_file(&mut self) {
+ self.file = None;
+ self.pending = Vec::new();
+ self.dropped = 0;
+ self.capped = true;
+ }
+
+ fn append(&mut self, line: &str) {
+ if self.capped {
+ return;
+ }
+ let Some(file) = self.file.as_mut() else { return };
+ let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
+ let text = if self.written.saturating_add(len) > self.max_bytes {
+ self.capped = true;
+ format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
+ } else {
+ self.written = self.written.saturating_add(len);
+ line.to_owned()
+ };
+ // A full disk or a vanished file must not take the app down: drop the file sink (and
+ // stop buffering, since no file will come).
+ if file.write_all(text.as_bytes()).is_err() {
+ self.file = None;
+ self.capped = true;
+ }
+ }
+}
+
+/// Text formatted into a fixed buffer: no allocation, cut at a character boundary when full.
+struct Bounded {
+ buf: [u8; N],
+ len: usize,
+ cut: bool,
+}
+
+impl Bounded {
+ fn new() -> Self {
+ Bounded { buf: [0; N], len: 0, cut: false }
+ }
+
+ fn as_str(&self) -> &str {
+ // Only whole characters are ever copied in, so this never fails.
+ self.buf.get(..self.len).and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("")
+ }
+}
+
+impl std::fmt::Write for Bounded {
+ fn write_str(&mut self, s: &str) -> std::fmt::Result {
+ let room = N.saturating_sub(self.len);
+ let mut n = s.len().min(room);
+ while !s.is_char_boundary(n) {
+ n = n.saturating_sub(1);
+ }
+ if let (Some(dst), Some(src)) = (self.buf.get_mut(self.len..self.len.saturating_add(n)), s.as_bytes().get(..n)) {
+ dst.copy_from_slice(src);
+ self.len = self.len.saturating_add(n);
+ }
+ if n < s.len() {
+ self.cut = true;
+ }
+ Ok(())
+ }
+}
+
+/// The first record the audio thread logged since the last report.
+struct Held {
+ at: SystemTime,
+ level: log::Level,
+ target: Bounded,
+ message: Bounded,
+}
+
+/// Records from the realtime audio thread, waiting for another thread to write them.
+#[derive(Default)]
+struct Realtime {
+ /// Records logged on the audio thread since the last report (the held one included).
+ records: AtomicUsize,
+ /// The first of them; the audio thread only `try_lock`s it.
+ held: Mutex
@@ -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 1d89fcd..2f7894d 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 0000000..1b40026
--- /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 0000000..ff502f0
--- /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 c26df34..bec9ffb 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 ba0b6c7..65e9a60 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 06/26] 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 00bc5a0..398061e 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 07/26] 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 402aa74..e6719ef 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 fcfe030..6f07dfb 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 08/26] 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 cc35c90..2326b24 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 09/26] 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 bfbb6df..34fb355 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 6815d60..dc09ec7 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 17a54b0..24391ec 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 10/26] 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 6b6c023..7572ac3 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 bc8a2a2..1e7daa0 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 ecc15af..28e9d1d 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 6bc6b29..1e48e69 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 11/26] 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 dc09ec7..39f3cf8 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 12/26] 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 6bc6b29..45d5245 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 d9def4c..217c79b 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 13/26] 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 17a54b0..0338420 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::