Merge origin/main into fix/macos-native-menu-bar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-09 18:15:50 -04:00
commit acf3237bed
39 files changed
+2276 -130

No files matched your search

+11 -1
View File
@@ -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
+53 -9
View File
@@ -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<version>". 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:
+1
View File
@@ -9,3 +9,4 @@ plan/
/target-*/
/craft-fonts/
plans/
.DS_Store
+1
View File
@@ -38,6 +38,7 @@ and, at runtime, the operating system's own UI fonts when available (never redis
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `assets/app-icon/icon.svg` | SoundCraft contributors | original (hand-placed SVG paths; placeholder app icon) | MIT OR Apache-2.0 |
| `packaging/macos/dmg/` (all files) | @XusBadia | original (macOS DMG window background and layout, from the app icon; see `packaging/macos/dmg/README.md`) | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft-1024.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft-macos-512.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft.icns` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
Generated
+18 -18
View File
@@ -3403,7 +3403,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"eframe",
"egui",
@@ -3419,7 +3419,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"log",
"serde",
@@ -3429,7 +3429,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"log",
"proptest",
@@ -3441,7 +3441,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"log",
"serde_json",
@@ -3451,7 +3451,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"clap-sys",
"libloading",
@@ -3463,7 +3463,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"serde_json",
"soundcraft-audio-io",
@@ -3474,7 +3474,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"log",
"proptest",
@@ -3485,7 +3485,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"log",
"proptest",
@@ -3505,7 +3505,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"proptest",
"serde",
@@ -3515,7 +3515,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"log",
"rayon",
@@ -3531,7 +3531,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"proptest",
"serde",
@@ -3544,7 +3544,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"cpal",
"log",
@@ -3557,7 +3557,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"proptest",
"serde",
@@ -3567,7 +3567,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"egui",
"egui_kittest",
@@ -3592,7 +3592,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"image",
"proptest",
@@ -3603,7 +3603,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"libloading",
"log",
@@ -3618,7 +3618,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"eframe",
"egui",
@@ -5165,7 +5165,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.1.0"
version = "0.3.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+66
View File
@@ -54,6 +54,7 @@
<a href="#for-agents-and-scripts">Agents &amp; scripts</a> ·
<a href="#how-its-built">How it's built</a> ·
<a href="#status-and-roadmap">Status</a> ·
<a href="#downloads">Downloads</a> ·
<a href="#the-crafting-apps">The Crafting Apps</a> ·
<a href="#license-and-credits">License</a>
</p>
@@ -157,6 +158,28 @@ Useful shortcuts: <kbd>Space</kbd> play/stop, <kbd>⌘</kbd><kbd>=</kbd> Mix/Edi
separate, <kbd>⌘</kbd><kbd>D</kbd> duplicate, <kbd>Enter</kbd> new marker, <kbd>⌘</kbd><kbd>⇧</kbd><kbd>N</kbd>
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
@@ -213,6 +236,49 @@ 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
**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. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows
| Build | Installer | Portable |
|---|---|---|
| x64 (64-bit Intel/AMD) | `soundcraft-<ver>-windows-x64.msi` | `soundcraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `soundcraft-<ver>-windows-arm64.msi` | `soundcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `soundcraft-<ver>-windows-x86.msi` | `soundcraft-<ver>-windows-x86-portable.zip` |
Installers and executables are code-signed.
### macOS
| Build | File | Notes |
|---|---|---|
| App, universal (Apple silicon + Intel) | `soundcraft-<ver>-macos-universal.dmg` | Signed and notarized |
| Command-line tool, universal | `soundcraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
### Linux
| Format | x86_64 | aarch64 (ARM64) | Notes |
|---|---|---|---|
| AppImage | `soundcraft-<ver>-linux-x86_64.AppImage` | `soundcraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
| Flatpak | `soundcraft-<ver>-linux-x86_64.flatpak` | `soundcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
| Debian/Ubuntu | `soundcraft-<ver>-linux-x86_64.deb` | `soundcraft-<ver>-linux-aarch64.deb` | |
| Fedora/RHEL/openSUSE | `soundcraft-<ver>-linux-x86_64.rpm` | `soundcraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `soundcraft-<ver>-linux-x86_64.tar.gz` | `soundcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
### FreeBSD
| Build | File |
|---|---|
| x86_64 | `soundcraft-<ver>-freebsd-x86_64.tar.gz` |
### Web (WebAssembly)
| Build | File | Notes |
|---|---|---|
| Static site | `soundcraft-web-<ver>.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
+21
View File
@@ -15,6 +15,27 @@
</head>
<body>
<canvas id="soundcraft_canvas"></canvas>
<script>
// cpal (wasm) builds the output stream at startup, before any user gesture, so the
// WebAudio AudioContext stays 'suspended': no audio is heard and the transport clock
// (driven by the audio callback) never advances. Capture the AudioContext cpal creates
// and resume it on the first user gesture. Runs before the wasm module is instantiated.
(function () {
var Orig = window.AudioContext || window.webkitAudioContext;
if (!Orig) return;
var ctxs = [];
function Wrapped(opts) { var c = new Orig(opts); ctxs.push(c); return c; }
Wrapped.prototype = Orig.prototype;
window.AudioContext = Wrapped;
if (window.webkitAudioContext) window.webkitAudioContext = Wrapped;
function resumeAll() {
ctxs.forEach(function (c) { if (c.state === 'suspended') { c.resume().catch(function () {}); } });
}
['pointerdown', 'mousedown', 'touchstart', 'keydown'].forEach(function (ev) {
window.addEventListener(ev, resumeAll, { capture: true });
});
})();
</script>
<div id="soundcraft_loading">Loading SoundCraft&hellip;</div>
</body>
</html>
+3 -3
View File
@@ -13,12 +13,12 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
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<ControlRequest> {
}
});
if let Err(e) = spawned {
eprintln!("soundcraft: control thread failed: {e}");
log::error!("control thread failed: {e}");
}
rx
}
+796
View File
@@ -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:
//! `<settings dir>/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.<KEEP>.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<LevelFilter> {
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` → … → `.<KEEP>.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: `<timestamp> <LEVEL> [<thread>] <target>: <message>\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<File>,
pending: Vec<String>,
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(&note);
}
}
/// 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<const N: usize> {
buf: [u8; N],
len: usize,
cut: bool,
}
impl<const N: usize> Bounded<N> {
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<const N: usize> std::fmt::Write for Bounded<N> {
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<REALTIME_TARGET_BYTES>,
message: Bounded<REALTIME_MESSAGE_BYTES>,
}
/// 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<Option<Held>>,
}
impl Realtime {
/// Audio thread: count `record` and keep it if it is the first. Never waits, never allocates.
fn keep(&self, record: &log::Record<'_>) {
self.records.fetch_add(1, Ordering::Relaxed);
if let Ok(mut held) = self.held.try_lock()
&& held.is_none()
{
let mut target = Bounded::new();
let _ = target.write_str(record.target());
let mut message = Bounded::new();
let _ = write!(message, "{}", record.args());
*held = Some(Held { at: SystemTime::now(), level: record.level(), target, message });
}
}
/// Another thread: the lines to write for what the audio thread logged (empty when nothing).
fn take_lines(&self) -> String {
if self.records.load(Ordering::Relaxed) == 0 {
return String::new();
}
let held = self.held.lock().unwrap_or_else(PoisonError::into_inner).take();
let records = self.records.swap(0, Ordering::Relaxed);
let mut lines = String::new();
if let Some(h) = &held {
let mut message = h.message.as_str().to_owned();
if h.message.cut {
message.push('…');
}
lines.push_str(&format_line(&timestamp(h.at), h.level, AUDIO_THREAD, h.target.as_str(), &message));
}
let more = records.saturating_sub(usize::from(held.is_some()));
if more > 0 {
let thread = std::thread::current();
let note = format!("{more} more log records from the realtime audio thread were not written (only the first is kept)");
lines.push_str(&format_line(&timestamp(SystemTime::now()), log::Level::Warn, thread.name().unwrap_or("?"), module_path!(), &note));
}
lines
}
}
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
realtime: Realtime,
}
impl AppLogger {
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), realtime: Realtime::default() }
}
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
/// An unusable directory leaves standard error as the only sink ([`AppLogger::no_file`]).
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
match rotate(dir) {
Ok((path, file)) => {
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
Ok(path)
}
Err(e) => {
self.no_file();
Err(format!("{}: {e}", dir.display()))
}
}
}
/// No log file this run (no settings directory, `SOUNDCRAFT_NO_PREFS`, or the directory can't
/// be used): stop keeping records for one.
pub fn no_file(&self) {
self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file();
}
/// Write what the audio thread logged since the last report (never call it on the audio thread).
pub fn report_audio_thread(&self) {
let lines = self.realtime.take_lines();
if !lines.is_empty() {
self.emit(&lines);
}
}
/// Standard error, then the sink; the lock covers one write of already formatted text.
fn emit(&self, text: &str) {
if self.stderr {
// No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting.
let _ = std::io::stderr().write_all(text.as_bytes());
}
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(text);
}
}
impl log::Log for AppLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= self.filter.level_for(metadata.target())
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
if soundcraft_playback::on_audio_thread() {
self.realtime.keep(record);
return;
}
self.report_audio_thread();
// Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock.
let thread = std::thread::current();
let line =
format_line(&timestamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
self.emit(&line);
}
fn flush(&self) {}
}
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
/// logger was installed first. Call [`AppLogger::attach_dir`] or [`AppLogger::no_file`] once the
/// settings directory is known.
pub fn install() -> Option<&'static AppLogger> {
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
let max = filter.max();
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
log::set_logger(logger).ok()?;
log::set_max_level(max);
Some(logger)
}
#[cfg(test)]
mod tests {
use super::*;
use log::Log;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("soundcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &str) {
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
}
/// Run `f` on a thread the playback crate marks as the realtime audio thread.
fn on_audio_thread<T: Send>(f: impl FnOnce() -> T + Send) -> T {
std::thread::scope(|s| {
s.spawn(|| {
soundcraft_playback::mark_audio_thread();
f()
})
.join()
.expect("audio thread")
})
}
#[test]
fn the_default_filter_shows_soundcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("soundcraft"), LevelFilter::Info);
assert_eq!(f.level_for("soundcraft::control_server"), LevelFilter::Info);
assert_eq!(f.level_for("soundcraft_playback"), LevelFilter::Info);
assert_eq!(f.level_for("soundcraft_clap_host::ffi"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("cpal"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,soundcraft_mix=trace,soundcraft_mix::pdc=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("soundcraft_mix::render"), LevelFilter::Trace);
assert_eq!(f.level_for("soundcraft_mix::pdc"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A trailing `*` is a prefix.
assert_eq!(Filter::parse("warn,soundcraft_ui*=debug").level_for("soundcraft_ui_egui::mix_window"), LevelFilter::Debug);
assert_eq!(Filter::parse("warn,soundcraft_ui*=debug").level_for("soundcraft_engine"), LevelFilter::Warn);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
// Levels are case-insensitive; the last bare level wins.
assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
let long = "a".repeat(100_000);
let specs = [
"",
",,,",
"=",
"==",
"=debug",
"soundcraft=",
"nonsense=loud",
"🦀=info",
"*",
"*=debug",
"a=b=c",
"warn,,soundcraft=DEBUG",
" \t ",
"\u{0}",
"é*=trace",
&long,
];
for spec in specs {
let f = Filter::parse(spec);
for target in ["soundcraft", "", "🦀", "é", "a::b"] {
let _ = f.level_for(target);
}
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,soundcraft=DEBUG").level_for("soundcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped");
assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target");
assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day, and the day after it.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("soundcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("soundcraft.2.log")), "run 2");
assert!(!dir.join("soundcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
assert!(rotate(&dir.join("below")).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line(
"2026-10-08T07:59:17.728Z",
log::Level::Warn,
"main",
"soundcraft_mix",
"eq_7band: the plugin did not accept its stored state"
),
"2026-10-08T07:59:17.728Z WARN [main] soundcraft_mix: eq_7band: the plugin did not accept its stored state\n"
);
// A multi-line message keeps its lines; the record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exactly_max_pending_lines_drop_nothing() {
let dir = temp_dir("exact");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().count(), MAX_PENDING);
assert!(!text.contains("dropped"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
record(&logger, log::Level::Info, "soundcraft", "SoundCraft starting");
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(&logger, log::Level::Debug, "soundcraft", "below info");
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("soundcraft: SoundCraft starting")), "{text}");
assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("soundcraft_engine").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
// A second launch rotates: the first log becomes `.1`.
let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE));
assert_eq!(read(&dir.join("soundcraft.1.log")), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn records_from_many_threads_are_whole_lines() {
let dir = temp_dir("threads");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
std::thread::scope(|s| {
for _ in 0..4 {
s.spawn(|| {
for _ in 0..100 {
record(&logger, log::Level::Info, "soundcraft", "from a worker");
}
});
}
});
let text = read(&path);
assert_eq!(text.lines().count(), 400);
assert!(text.lines().all(|l| l.ends_with("soundcraft: from a worker")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
let dir = temp_dir("unusable");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
record(&logger, log::Level::Info, "soundcraft", "kept for a file");
let e = logger.attach_dir(&dir).expect_err("a file is not a log directory");
assert!(e.contains(&dir.display().to_string()), "{e}");
record(&logger, log::Level::Error, "x", "still fine");
let sink = logger.sink.lock().expect("sink");
assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come");
drop(sink);
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_run_without_a_log_file_keeps_nothing_for_one() {
let logger = AppLogger::new(Filter::parse("info"), false);
record(&logger, log::Level::Info, "soundcraft", "early");
logger.no_file();
record(&logger, log::Level::Info, "soundcraft", "late");
let sink = logger.sink.lock().expect("sink");
assert!(sink.file.is_none() && sink.pending.is_empty() && sink.dropped == 0);
}
#[test]
fn a_poisoned_lock_does_not_stop_logging() {
let dir = temp_dir("poison");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = logger.sink.lock();
panic!("poison the sink");
}));
assert!(logger.sink.is_poisoned());
record(&logger, log::Level::Info, "soundcraft", "after the panic");
assert!(read(&path).contains("after the panic"));
let _ = std::fs::remove_dir_all(&dir);
}
// Realtime audio: the audio thread never takes the sink lock or touches the file.
#[test]
fn an_audio_thread_record_never_waits_for_the_sink() {
let dir = temp_dir("rt-nowait");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
// Another thread holds the sink (a slow disk, say) while the audio thread logs.
let held = logger.sink.lock().expect("sink");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::scope(|s| {
s.spawn(|| {
soundcraft_playback::mark_audio_thread();
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: device unplugged");
let _ = tx.send(());
});
let done = rx.recv_timeout(Duration::from_secs(10)).is_ok();
drop(held);
assert!(done, "the audio thread blocked on the sink lock");
});
assert_eq!(read(&path), "", "nothing is written from the audio thread itself");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_first_audio_thread_record_is_written_later_with_a_count_of_the_rest() {
let dir = temp_dir("rt-report");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
let path = logger.attach_dir(&dir).expect("attach");
on_audio_thread(|| {
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: device unplugged");
for _ in 0..41 {
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: still unplugged");
}
// Filtered out: neither kept nor counted.
record(&logger, log::Level::Debug, "soundcraft_clap_host::plugin", "process failed");
});
assert_eq!(read(&path), "");
logger.report_audio_thread();
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines.first().is_some_and(|l| l.contains(" WARN [audio] soundcraft_playback: audio stream error: device unplugged")), "{text}");
assert!(lines.get(1).is_some_and(|l| l.contains("41 more log records from the realtime audio thread were not written")), "{text}");
// Reported once.
logger.report_audio_thread();
assert_eq!(read(&path), text);
// The next record from any other thread reports what the audio thread kept since, first.
on_audio_thread(|| record(&logger, log::Level::Error, "soundcraft_clap_host::ffi", "clap plugin: out of voices"));
record(&logger, log::Level::Info, "soundcraft", "UI thread");
let text = read(&path);
let tail: Vec<&str> = text.lines().skip(2).collect();
assert_eq!(tail.len(), 2, "{text}");
assert!(tail.first().is_some_and(|l| l.contains(" ERROR [audio] soundcraft_clap_host::ffi: clap plugin: out of voices")), "{text}");
assert!(tail.get(1).is_some_and(|l| l.ends_with("soundcraft: UI thread")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_long_audio_thread_record_is_cut_at_a_character_boundary() {
let dir = temp_dir("rt-long");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
let long = "é".repeat(REALTIME_MESSAGE_BYTES);
on_audio_thread(|| record(&logger, log::Level::Warn, "soundcraft", &long));
logger.report_audio_thread();
let text = read(&path);
let message = text.split("soundcraft: ").nth(1).expect("message");
assert!(message.ends_with("…\n"), "{text}");
assert!(message.len() <= REALTIME_MESSAGE_BYTES + "…\n".len(), "{}", message.len());
assert!(message.trim_end_matches(['…', '\n']).chars().all(|c| c == 'é'));
let _ = std::fs::remove_dir_all(&dir);
}
}
+51 -12
View File
@@ -5,10 +5,14 @@
//! `--control <port>` (or `SOUNDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server
//! (`0` picks a free port and prints it): `{"id":1,"method":"ui.inspect","params":{}}` →
//! `{"id":1,"ok":true,"result":…}`. See `soundcraft_ui_egui::control` for the methods.
//!
//! `log` records go to standard error and `<settings dir>/logs/soundcraft.log` (`RUST_LOG` sets the
//! levels); see [`logging`].
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod control_server;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
@@ -16,17 +20,21 @@ use soundcraft_engine::Engine;
use soundcraft_ui_egui::{Services, SoundApp, UiState};
use std::sync::Arc;
struct App(SoundApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// What the realtime audio thread logged is written here, off that thread.
if let Some(logger) = self.1 {
logger.report_audio_thread();
}
#[cfg(target_os = "macos")]
if let Some(menu) = &mut self.1 {
if let Some(menu) = &mut self.2 {
menu.process(&mut self.0);
}
self.0.logic(ctx);
#[cfg(target_os = "macos")]
if let Some(menu) = &mut self.1 {
if let Some(menu) = &mut self.2 {
menu.sync(&self.0);
}
// Files dropped on the window: sessions open, audio/MIDI import.
@@ -60,6 +68,9 @@ impl eframe::App for App {
}
fn on_exit(&mut self) {
save_prefs(&self.0.ui);
if let Some(logger) = self.1 {
logger.report_audio_thread();
}
}
}
@@ -78,8 +89,9 @@ fn open_path(app: &mut SoundApp, p: &str) {
}
}
fn prefs_path() -> Option<std::path::PathBuf> {
let base = if cfg!(target_os = "macos") {
/// The per-user settings directory: `ui.json`, `Autosave/`, `Presets/` and `logs/` live here.
fn config_dir() -> Option<std::path::PathBuf> {
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/SoundCraft"))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("SoundCraft"))
@@ -88,12 +100,20 @@ fn prefs_path() -> Option<std::path::PathBuf> {
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.map(|c| c.join("soundcraft"))
};
base.map(|b| b.join("ui.json"))
}
}
fn prefs_path() -> Option<std::path::PathBuf> {
config_dir().map(|b| b.join("ui.json"))
}
/// Runs without preferences (`SOUNDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
fn prefs_enabled() -> bool {
std::env::var_os("SOUNDCRAFT_NO_PREFS").is_none()
}
fn load_prefs() -> Option<UiState> {
if std::env::var_os("SOUNDCRAFT_NO_PREFS").is_some() {
if !prefs_enabled() {
return None;
}
let bytes = std::fs::read(prefs_path()?).ok()?;
@@ -101,7 +121,7 @@ fn load_prefs() -> Option<UiState> {
}
fn save_prefs(ui: &UiState) {
if std::env::var_os("SOUNDCRAFT_NO_PREFS").is_some() {
if !prefs_enabled() {
return;
}
if let Some(p) = prefs_path() {
@@ -153,14 +173,14 @@ fn app_icon() -> Option<egui::IconData> {
}
fn main() -> eframe::Result {
// First, so every start-up record (and the engine's panic hook, installed with the first
// Engine) is captured; see `logging`.
let logger = logging::install();
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("SoundCraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
if std::env::var_os("RUST_LOG").is_none() {
// Quiet by default; `RUST_LOG=info` for more.
}
let demo = args.iter().any(|a| a == "--demo" || a == "--sample");
let no_audio = args.iter().any(|a| a == "--no-audio");
let control_port: Option<u16> = args
@@ -175,6 +195,24 @@ fn main() -> eframe::Result {
.filter(|(i, a)| !a.starts_with("--") && !(i > &0 && args.get(i - 1).is_some_and(|p| p == "--control")))
.map(|(_, a)| a.clone())
.collect();
log::info!("SoundCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
// The log file lives in the settings directory; opened after the arguments, so `--version`
// leaves no file behind. Records logged until now are written to it first. Runs without
// preferences (agents' test runs) log to standard error only, so they don't rotate away the
// user's own logs.
if let Some(logger) = logger {
match config_dir() {
_ if !prefs_enabled() => logger.no_file(),
Some(dir) => match logger.attach_dir(&dir.join(logging::LOG_DIR)) {
Ok(path) => log::info!("log file {}", path.display()),
Err(e) => log::warn!("no log file: {e}"),
},
None => {
logger.no_file();
log::warn!("no log file: no settings directory (HOME, XDG_CONFIG_HOME or APPDATA is not set)");
}
}
}
let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
let mut options = eframe::NativeOptions {
@@ -221,6 +259,7 @@ fn main() -> eframe::Result {
};
Ok(Box::new(App(
app,
logger,
#[cfg(target_os = "macos")]
menu,
)))
+10
View File
@@ -21,3 +21,13 @@
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.hamada147]
real_name = "Ahmed Moussa"
display_name = "Ahmed Moussa"
sync_github_name = true
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
sync_github_name = false
+5
View File
@@ -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::*;
+18
View File
@@ -359,3 +359,21 @@ fn step_marker(e: &mut Engine, dir: i64) -> Result<Value> {
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);
}
}
+2 -6
View File
@@ -110,10 +110,6 @@ fn drums(sr: f64, frames: usize) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
(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<f32> {
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<f32>, Vec<f32>) {
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;
+222 -1
View File
@@ -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,13 @@ 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`.
// Not while monitoring only (transport stopped): the position does not advance there, so the
// start of the same beat would repeat every block as a buzz.
if self.metronome && !self.monitor_only && 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);
@@ -1000,12 +1011,47 @@ fn structure_fingerprint(s: &Session) -> usize {
h.finish() as usize
}
std::thread_local! {
static AUDIO_THREAD: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Mark the calling thread as the realtime audio thread (`soundcraft_playback`'s cpal callbacks
/// do, every call: one thread-local store). Strips [`MixEngine::render`] hands to worker threads
/// count as the audio thread while they render for it. Code that might run there asks
/// [`on_audio_thread`] before doing anything that blocks; the desktop app's logger does, so a
/// `log::` record from the audio thread never takes a lock or touches a file.
pub fn mark_audio_thread() {
AUDIO_THREAD.set(true);
}
/// True on the thread [`mark_audio_thread`] marked, and on a worker while it renders strips for
/// it. Lock-free and allocation-free.
pub fn on_audio_thread() -> bool {
AUDIO_THREAD.get()
}
/// Run `f` on this thread as (or as not) the audio thread, then restore the thread's own mark
/// (also when `f` unwinds): a pool worker renders for realtime playback and offline bounces alike.
#[cfg(not(target_arch = "wasm32"))]
fn as_audio_thread(audio: bool, f: impl FnOnce()) {
struct Restore(bool);
impl Drop for Restore {
fn drop(&mut self) {
AUDIO_THREAD.set(self.0);
}
}
let _restore = Restore(AUDIO_THREAD.replace(audio));
f();
}
/// Process strips, in parallel on native targets when there are enough of them.
#[cfg(not(target_arch = "wasm32"))]
fn run_strips<T: Send>(work: &mut [T], f: impl Fn(&mut T) + Sync + Send) {
use rayon::prelude::*;
if work.len() >= 4 {
work.par_iter_mut().for_each(f);
// The workers render on behalf of this thread: on the audio thread, they are it too.
let audio = on_audio_thread();
work.par_iter_mut().for_each(|w| as_audio_thread(audio, || f(w)));
} else {
work.iter_mut().for_each(f);
}
@@ -1075,6 +1121,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<f32>]) {
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<f32>], 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<f32>], _scratch: &mut [f32]) {
let ClipContent::Audio { source, offset } = clip.content else { return };
let block = Range { start: pos, end: pos.saturating_add(frames as i64) };
@@ -1642,6 +1749,50 @@ fn create_plugin(id: &str) -> Option<Box<dyn Plugin>> {
.or_else(|| soundcraft_au_host::create(id))
}
#[cfg(test)]
mod audio_thread_tests {
use super::*;
#[test]
fn only_a_marked_thread_is_the_audio_thread() {
assert!(!on_audio_thread());
let marked = std::thread::spawn(|| {
let before = on_audio_thread();
mark_audio_thread();
(before, on_audio_thread())
})
.join()
.unwrap();
assert_eq!(marked, (false, true));
assert!(!on_audio_thread(), "marking another thread leaves this one alone");
}
/// What `on_audio_thread` says inside each strip job, for strips run from a thread that is
/// (or is not) the audio thread.
fn seen_by_strips(audio: bool) -> Vec<bool> {
std::thread::spawn(move || {
if audio {
mark_audio_thread();
}
let mut work = vec![None; 64];
run_strips(&mut work, |w| {
// Long enough that the pool's workers take part.
std::thread::sleep(std::time::Duration::from_millis(1));
*w = Some(on_audio_thread());
});
work.into_iter().map(|w| w.unwrap()).collect()
})
.join()
.unwrap()
}
#[test]
fn strips_rendered_for_the_audio_thread_count_as_the_audio_thread_and_only_then() {
assert!(seen_by_strips(true).iter().all(|&rt| rt), "a worker rendering for the audio thread is on it");
assert!(seen_by_strips(false).iter().all(|&rt| !rt), "the workers don't stay marked afterwards");
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1934,6 +2085,76 @@ mod tests {
assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0)));
}
#[test]
fn stopped_monitoring_does_not_click() {
let mut s = Session::default();
s.edit.click = true;
let mut eng = MixEngine::new(48_000.0, 512);
eng.metronome = true;
eng.monitor_only = true;
let mut out = vec![vec![0.0f32; 512]; 2];
for _ in 0..4 {
eng.render(&s, 0, 512, &mut out);
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<Vec<f32>> {
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();
+16 -3
View File
@@ -35,6 +35,10 @@ use std::sync::{Arc, Mutex, PoisonError};
const BLOCK: usize = 512;
/// The realtime audio thread marker lives in the mix engine, which carries it into the strips it
/// renders on worker threads; the cpal callbacks here set it.
pub use soundcraft_mix::{mark_audio_thread, on_audio_thread};
/// Meter values for the UI.
#[derive(Debug, Clone, Default)]
pub struct MeterSnapshot {
@@ -99,6 +103,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
}
@@ -222,7 +227,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;
@@ -390,11 +395,19 @@ impl Player {
let rate = config.sample_rate.0;
let channels = usize::from(config.channels);
let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate), gtx);
let err_fn = |e: cpal::StreamError| log::warn!("audio stream error: {e}");
// cpal may call this on the audio thread (on ALSA in a loop while the device
// stays broken): marked, so the logger hands the record off instead of writing it.
let err_fn = |e: cpal::StreamError| {
mark_audio_thread();
log::warn!("audio stream error: {e}");
};
use cpal::traits::{DeviceTrait, StreamTrait};
match device.build_output_stream(
&config,
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| state.fill(data, channels),
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
mark_audio_thread();
state.fill(data, channels);
},
err_fn,
None,
) {
+110 -19
View File
@@ -50,13 +50,14 @@ 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<f32> = Vec::new();
let stream = device
.build_input_stream(
&config,
move |data: &[f32], _: &cpal::InputCallbackInfo| {
crate::mark_audio_thread();
mon.push(data);
if !sh.armed.load(Ordering::Relaxed) {
backlog.clear();
@@ -72,7 +73,11 @@ impl Recorder {
backlog.extend_from_slice(data);
}
},
|e| log::warn!("input stream error: {e}"),
|e| {
// Possibly the audio thread (see `crate::mark_audio_thread`).
crate::mark_audio_thread();
log::warn!("input stream error: {e}");
},
None,
)
.map_err(|e| e.to_string())?;
@@ -140,47 +145,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<f32>,
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<std::collections::VecDeque<f32>>,
state: Mutex<RingState>,
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<InputRing> {
pub fn new(channels: usize, rate: u32) -> Arc<InputRing> {
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<f32>], 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<f32>], 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<f32> = (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<f32> = (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<f32> = (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));
}
}
+27
View File
@@ -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,7 @@ 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 },
TempoChange { at: Samples, bpm: f64 }, // from double-clicking the tempo ruler
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 +40,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 +170,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 +319,16 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
action = Some((cmd.clone(), v));
}
}
Dialog::TempoChange { at, bpm } => {
ui.horizontal(|ui| {
// 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(tempo_change_action(*at, *bpm));
}
}
Dialog::Fades { shape } => {
for s in soundcraft_model::FadeShape::ALL {
ui.radio_value(shape, s.label().to_lowercase(), s.label());
@@ -422,6 +436,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;
@@ -495,4 +514,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);
}
}
+120 -12
View File
@@ -32,6 +32,18 @@ pub struct EditLayout {
pub rows: Vec<(u64, [f32; 4])>,
pub scroll_y: f32,
pub content_h: f32,
/// Follow-hold: the view was moved by something other than the playhead
/// follow (a manual pan, scrollbar, universe jump, or programmatic
/// scroll) during playback, so timeline auto-scroll stays paused until
/// the playhead is scrolled back into view (or the transport
/// stops/starts, or a scrolling mode is re-selected). Lets the timeline
/// be inspected anywhere while playing.
pub follow_hold: bool,
/// Session `zoom.scroll` seen by the last overlay frame, for telling our
/// own follow jumps apart from outside view moves.
pub last_scroll: Samples,
/// Follow target issued by the last overlay frame (`None` when none).
pub last_follow_to: Option<Samples>,
}
pub fn x_of(s: &Session, tl: Rect, at: Samples) -> f32 {
@@ -372,13 +384,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() {
@@ -1372,6 +1378,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::<Option<ClipId>>(menu_clip_id)).flatten().filter(|c| track.clips().iter().any(|x| x.id == *c));
resp.context_menu(|ui| clip_context_menu(app, ui, menu_clip));
// Audio files dropped from the Clip List.
if let Some(src) = resp.dnd_release_payload::<crate::DragSource>()
&& let Some(p) = ui.ctx().pointer_latest_pos()
@@ -1613,7 +1633,6 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
{
let _ = app.run("edit.select", json!({"clips": [c.id.0]}));
}
resp.context_menu(|ui| clip_context_menu(app, ui, hit.as_ref().map(|c| c.id)));
}
fn automation_id(p: &AutoParam) -> String {
@@ -1679,7 +1698,44 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
}
}
/// Next follow state after observing one frame: `(hold, follow_to)`.
///
/// The view is ours to move only while nothing else moved it: when the
/// session scroll differs from both the last seen value and our own last
/// follow target, something else (a manual pan, scrollbar, universe jump,
/// or programmatic scroll) took over, so the follow pauses (`hold`) and no
/// jump is issued. Stopping, or scrolling back to the playhead, clears the
/// hold. Only `page`/`continuous`/`center` ever issue follow jumps while
/// playing; `none`/`after_playback` leave the view alone.
fn follow_update(
playing: bool,
mode_follow: bool,
playhead_visible: bool,
cur: Samples,
last_scroll: Samples,
last_follow_to: Option<Samples>,
hold: bool,
pos: Samples,
) -> (bool, Option<Samples>) {
if !playing || playhead_visible {
return (false, None);
}
if cur != last_scroll && Some(cur) != last_follow_to {
return (mode_follow, None);
}
if hold || !mode_follow {
return (hold, None);
}
(false, Some(pos))
}
/// Selection overlay, insertion point and playhead across rulers and tracks.
/// While playing, auto-scroll keeps the playhead in view for the follow
/// modes (`page`/`continuous`/`center`) — but any outside view move (a manual
/// pan, scrollbar, universe jump, or programmatic scroll) pauses it, so the
/// timeline can be inspected anywhere during playback. Scrolling back to the
/// playhead, stopping/starting, or re-selecting a scrolling mode resumes the
/// follow. `none` and `after_playback` never scroll during playback.
fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
let t = Tokens::DARK;
let mut scroll_to: Option<Samples> = None;
@@ -1721,10 +1777,18 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
if app.is_playing() {
let x = x_of(s, tl, app.position());
painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.5, t.playhead));
// Page scrolling.
if s.edit.scrolling != "none" && (x > tl.max.x || x < tl.min.x) {
scroll_to = Some(app.position());
}
// Session reads first (owned copies), then the layout mutation: the
// two borrow disjoint fields.
let pos = app.position();
let cur = s.edit.zoom.scroll;
let mode_follow = matches!(s.edit.scrolling.as_str(), "page" | "continuous" | "center");
let visible = x >= tl.min.x && x <= tl.max.x;
let layout = &mut app.edit_layout;
let (hold, follow_to) = follow_update(true, mode_follow, visible, cur, layout.last_scroll, layout.last_follow_to, layout.follow_hold, pos);
layout.follow_hold = hold;
layout.last_scroll = cur;
layout.last_follow_to = follow_to;
scroll_to = follow_to;
}
// Ghost of a clip move.
if let Some(Gesture::MoveClips { clips, delta, to_track, .. }) = &app.gesture {
@@ -1763,3 +1827,47 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
let _ = app.engine.execute("view.scroll", &json!({"to": at}));
}
}
#[cfg(test)]
mod tests {
use super::follow_update;
#[test]
fn follow_jumps_to_offscreen_playhead_while_playing() {
// Steady view, follow mode, playhead left the screen: jump to it.
assert_eq!(follow_update(true, true, false, 1_000, 1_000, None, false, 96_000), (false, Some(96_000)));
}
#[test]
fn follow_leaves_onscreen_playhead_alone() {
assert_eq!(follow_update(true, true, true, 1_000, 1_000, None, false, 96_000), (false, None));
}
#[test]
fn follow_never_scrolls_when_stopped_nor_in_free_modes() {
assert_eq!(follow_update(false, true, false, 1_000, 1_000, None, false, 96_000), (false, None));
// `none`/`after_playback` never follow, even with a steady view.
assert_eq!(follow_update(true, false, false, 1_000, 1_000, None, false, 96_000), (false, None));
assert_eq!(follow_update(true, false, false, 9_000, 1_000, None, false, 96_000), (false, None));
}
#[test]
fn outside_view_move_pauses_the_follow() {
// The view moved and it was not our own jump: pause, no jump.
assert_eq!(follow_update(true, true, false, 9_000, 1_000, None, false, 96_000), (true, None));
// A held view stays held while it sits still elsewhere.
assert_eq!(follow_update(true, true, false, 9_000, 9_000, None, true, 96_000), (true, None));
}
#[test]
fn own_follow_jump_is_not_mistaken_for_a_takeover() {
// The scroll equals the target we issued last frame: keep following.
assert_eq!(follow_update(true, true, false, 96_000, 1_000, Some(96_000), false, 97_000), (false, Some(97_000)));
}
#[test]
fn hold_clears_when_playhead_returns_or_transport_stops() {
assert_eq!(follow_update(true, true, true, 9_000, 9_000, None, true, 96_000), (false, None));
assert_eq!(follow_update(false, true, false, 9_000, 9_000, None, true, 96_000), (false, None));
}
}
+34 -4
View File
@@ -26,11 +26,41 @@ const BOLD: &[(&str, u32)] = &[
("/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", 0),
];
/// Asks fontconfig for the desktop's UI font. Only TrueType/OpenType results are used: egui cannot
/// read Type 1 or bitmap fonts, which `fc-match` may return on minimal systems.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn load_from_fontconfig(bold: bool) -> Option<FontData> {
let pattern = if bold { "sans-serif:style=Bold" } else { "sans-serif" };
let output = std::process::Command::new("fc-match").args(["--format=%{file}\\n%{index}\\n", pattern]).output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
let mut lines = stdout.lines();
let path = lines.next()?.trim();
// The upper 16 bits carry a variable font's named instance; the face index is the lower 16.
let index = lines.next()?.trim().parse::<u32>().ok()? & 0xFFFF;
let bytes = std::fs::read(path).ok()?;
let sfnt = matches!(bytes.get(..4), Some(b"\0\x01\0\0" | b"OTTO" | b"true" | b"ttcf"));
if !sfnt {
return None;
}
let mut font = FontData::from_owned(bytes);
font.index = index;
Some(font)
}
#[cfg(not(target_arch = "wasm32"))]
fn load(cands: &[(&str, u32)]) -> Option<FontData> {
fn load(cands: &[(&str, u32)], bold: bool) -> Option<FontData> {
if std::env::var_os("SOUNDCRAFT_NO_SYSTEM_FONTS").is_some() {
return None;
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
if let Some(font) = load_from_fontconfig(bold) {
return Some(font);
}
#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
let _ = bold;
for (p, idx) in cands {
if let Ok(bytes) = std::fs::read(p) {
let mut fd = FontData::from_owned(bytes);
@@ -42,7 +72,7 @@ fn load(cands: &[(&str, u32)]) -> Option<FontData> {
}
#[cfg(target_arch = "wasm32")]
fn load(_: &[(&str, u32)]) -> Option<FontData> {
fn load(_: &[(&str, u32)], _: bool) -> Option<FontData> {
None
}
@@ -50,13 +80,13 @@ pub fn definitions() -> FontDefinitions {
let mut fonts = FontDefinitions::default();
let base: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
let mut bold_family = base.clone();
if let Some(reg) = load(REGULAR) {
if let Some(reg) = load(REGULAR, false) {
fonts.font_data.insert("system-regular".into(), Arc::new(reg));
if let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) {
f.insert(0, "system-regular".into());
}
}
if let Some(b) = load(BOLD) {
if let Some(b) = load(BOLD, true) {
fonts.font_data.insert("system-bold".into(), Arc::new(b));
bold_family.insert(0, "system-bold".into());
} else if fonts.font_data.contains_key("system-regular") {
+69 -3
View File
@@ -234,6 +234,16 @@ pub struct SoundApp {
pub video: video_track::VideoPool,
}
/// Whether another automation-write pass is due: the transport moved at least
/// `step` since `last` in either direction. `last == i64::MIN` marks "never
/// written", so the first pass after (re)start is always due. Saturating
/// arithmetic keeps restarts and loop wraps overflow-free: the previous
/// `(pos - last).abs()` panicked in debug builds on the first playing frame,
/// when `last` is still `i64::MIN`.
fn automation_step_due(pos: Samples, last: Samples, step: Samples) -> bool {
last == i64::MIN || pos.saturating_sub(last) >= step || last.saturating_sub(pos) >= step
}
impl SoundApp {
pub fn new(engine: Engine, player: Option<Player>, services: Services) -> Self {
SoundApp {
@@ -295,6 +305,10 @@ impl SoundApp {
if id == "app.quit" {
self.quit_requested = true;
}
if id == "options.scrolling" {
// Re-selecting a scrolling mode resumes the playhead follow.
self.edit_layout.follow_hold = false;
}
Ok(v)
}
Err(e) => {
@@ -323,6 +337,12 @@ impl SoundApp {
}
self.engine.transport.playing = true;
self.engine.transport.position = from;
// A fresh start resumes the playhead follow: clear a manual hold and
// re-sync the follow tracker so the current view is not mistaken for
// an outside move on the first frame.
self.edit_layout.follow_hold = false;
self.edit_layout.last_scroll = self.engine.session().edit.zoom.scroll;
self.edit_layout.last_follow_to = None;
}
fn stop_play(&mut self) {
@@ -335,11 +355,33 @@ impl SoundApp {
self.sim = None;
self.engine.transport.playing = false;
self.engine.transport.recording = false;
let s = self.engine.session();
if s.edit.insertion_follows_playback {
let stop_at = self.engine.transport.position;
// Snapshot the session flags first: `session_mut` below needs a
// mutable borrow, so no session borrow may span it.
let (follow_insertion, after_playback) = {
let s = self.engine.session();
(s.edit.insertion_follows_playback, s.edit.scrolling == "after_playback")
};
if follow_insertion {
let pos = self.engine.transport.position;
self.engine.session_mut().edit.selection = Range::point(pos);
}
if after_playback {
// After Playback never scrolls while playing, so bring the stopped
// position into view now (only if it is off-screen); every other
// mode keeps its view.
let at = stop_at.max(0);
let (scroll, spp) = {
let z = &self.engine.session().edit.zoom;
(z.scroll, z.samples_per_px.max(0.01))
};
let width = f64::from(self.edit_layout.timeline[2] - self.edit_layout.timeline[0]).max(0.0);
let end = scroll.saturating_add((width * spp) as Samples);
if at < scroll || at >= end {
let _ = self.engine.execute("view.scroll", &json!({ "to": at }));
}
}
self.edit_layout.follow_hold = false;
}
fn start_recording(&mut self) {
@@ -534,7 +576,7 @@ impl SoundApp {
}
let pos = self.position();
let step = self.engine.session().sample_rate.samples(0.02);
if (pos - self.last_write_at).abs() < step {
if !automation_step_due(pos, self.last_write_at, step) {
return;
}
self.last_write_at = pos;
@@ -829,3 +871,27 @@ fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) {
}
d.gr = if gr > d.gr { gr } else { d.gr * fall };
}
#[cfg(test)]
mod tests {
use super::automation_step_due;
#[test]
fn automation_first_pass_after_start_is_due_without_overflow() {
// `last_write_at` starts (and resets) at `i64::MIN`: the previous
// `(pos - last).abs()` panicked here in debug builds.
assert!(automation_step_due(0, i64::MIN, 960));
assert!(automation_step_due(48_000, i64::MIN, 960));
}
#[test]
fn automation_write_throttles_to_step() {
assert!(!automation_step_due(1_000, 900, 960));
assert!(automation_step_due(2_000, 900, 960));
}
#[test]
fn automation_write_fires_after_loop_wrap() {
assert!(automation_step_due(0, 1_000_000, 960));
}
}
+52 -6
View File
@@ -41,8 +41,17 @@ 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)
/// 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 } 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) {
@@ -58,12 +67,14 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
})
.collect()
});
// Cmd and Ctrl are separate keys only on macOS (asked at runtime so the web build in a Mac browser agrees).
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
for (key, mods) in events {
if fixed(app, key, mods) {
if fixed(app, key, mods, mac) {
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) {
@@ -75,7 +86,7 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
}
/// Keys with context-dependent meaning. Returns true when handled.
fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool {
fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let plain = !m.command && !m.alt && !m.ctrl && !m.shift;
match key {
Key::Space if !m.command && !m.alt => {
@@ -87,7 +98,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 +238,38 @@ 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!(!mods_match(want("Cmd+X"), Modifiers { ctrl: true, ..MAC_CMD }, true), "Cmd+Ctrl+X is not Cut");
assert!(!search_chord(MAC_CMD, true));
assert!(search_chord(Modifiers { ctrl: true, ..MAC_CMD }, true));
}
}
+66
View File
@@ -0,0 +1,66 @@
//! Regression: the clip context menu must stay open while the pointer moves onto it, so its
//! Delete item can be used. It used to vanish once the pointer left the lane, because the lane
//! returned early (no hover position) before the menu was drawn.
use egui::{Event, Modifiers, PointerButton, pos2, vec2};
use egui_kittest::Harness;
use soundcraft_ui_egui::{Services, SoundApp};
/// One UI frame, with queued synthetic pointer input applied first (as the control channel does).
fn step(h: &mut Harness<'_, SoundApp>) {
let mut raw = std::mem::take(h.input_mut());
h.state_mut().raw_input_hook(&mut raw);
*h.input_mut() = raw;
h.step();
}
fn press(h: &mut Harness<'_, SoundApp>, x: f32, y: f32, button: PointerButton) {
let pos = pos2(x, y);
let s = &mut h.state_mut().synthetic;
s.push(Event::PointerMoved(pos));
s.push(Event::PointerButton { pos, button, pressed: true, modifiers: Modifiers::NONE });
s.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE });
}
fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize {
let s = h.state().engine.session();
s.tracks.iter().find(|t| t.name == "Kick").map_or(0, |t| t.clips().len())
}
#[test]
fn clip_menu_stays_open_while_moving_onto_delete() {
let engine = soundcraft_engine::demo::demo_engine();
let app = SoundApp::new(engine, None, Services::default());
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
for _ in 0..6 {
step(&mut h);
}
let before = kick_clip_count(&h);
assert!(before > 0, "the demo session should have a recorded Kick clip");
// Right-click the Kick clip, then move the pointer into the menu and down to Delete.
press(&mut h, 880.0, 240.0, PointerButton::Secondary);
for _ in 0..10 {
step(&mut h);
}
// Move (no click) into the menu, as a real pointer would, so the lane loses its hover.
for (x, y) in [(900.0, 300.0), (907.0, 442.0)] {
h.state_mut().synthetic.push(Event::PointerMoved(pos2(x, y)));
for _ in 0..10 {
step(&mut h);
}
}
press(&mut h, 907.0, 442.0, PointerButton::Primary);
for _ in 0..15 {
step(&mut h);
}
assert_eq!(kick_clip_count(&h), before - 1, "clicking Delete in the clip menu should remove the clip");
}
+8 -5
View File
@@ -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<sv::SpeakerArrangement> {
fn arrangement(&self, dir: sv::BusDirections, index: i32) -> Option<sv::SpeakerArrangement> {
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<sv::SpeakerArrangement> {
let current = |s: &Self, dir: sv::BusDirections, n: i32| -> Vec<sv::SpeakerArrangement> {
(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)
}
+6 -1
View File
@@ -9,7 +9,12 @@ fn main() {
let dir = common::fixture_dir();
let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
let mut p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate");
assert_eq!(p.editor_size(), Ok((320, 240)));
// Editor windows are hosted on macOS only; elsewhere the probe reports "unsupported".
if cfg!(target_os = "macos") {
assert_eq!(p.editor_size(), Ok((320, 240)));
} else {
assert!(p.editor_size().is_err());
}
let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle");
assert!(!ed.is_open());
assert_eq!(ed.idle().len(), 1, "the fixture's setup edit");
+6 -1
View File
@@ -212,7 +212,12 @@ fn state_saves_component_and_controller_and_restores_them() {
fn editor_view_is_probed_and_edits_come_back_as_plain_values() {
let id = id_of("Fixture Gain");
let mut p = soundcraft_vst3_host::instantiate_plugin(&id).unwrap();
assert_eq!(p.editor_size(), Ok((320, 240)));
// Editor windows are hosted on macOS only; elsewhere the probe reports "unsupported".
if cfg!(target_os = "macos") {
assert_eq!(p.editor_size(), Ok((320, 240)));
} else {
assert!(p.editor_size().is_err());
}
let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle");
// The fixture "edits" Gain to 0.9 (normalized) when it gets the component handler.
assert_eq!(ed.idle(), vec![("7".to_string(), 1.8)]);
+6
View File
@@ -41,6 +41,12 @@ process in parallel; plugin delay compensation aligns every path. The same engin
bounces offline (`render_range`) and realtime playback (`soundcraft_playback::Player`, which owns
it on the audio thread and receives new session snapshots through a channel).
The cpal callbacks mark their thread (`soundcraft_playback::mark_audio_thread`). Code that may run
there can ask `on_audio_thread()` before doing anything that blocks; the desktop app's logger does,
so a `log::` record from the audio thread (a stream error, a full synth event queue, a hosted
plugin's failed `process`, a CLAP plugin's own log call) is kept without waiting and written later
by the UI thread (see README › Logs).
## Agent control
The desktop app's control channel (JSON lines over TCP) exposes engine commands, inspection,
+33 -15
View File
@@ -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-<v>-windows-arm64.msi`, `soundcraft-<v>-windows-arm64-portable.zip` | `windows-latest` (cross-compiled; `windows-arm64.yml` installs and runs it on ARM64) |
| Linux x86_64 | `soundcraft-<v>-linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` |
| Linux aarch64 | `soundcraft-<v>-linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` |
| AppImage updates | `soundcraft-<v>-linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImage |
| Flatpak x86_64, aarch64 | `soundcraft-<v>-linux-{x86_64,aarch64}.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackages the Linux tarball) |
| FreeBSD 14 x86_64 | `soundcraft-<v>-freebsd-x86_64.tar.gz` (a `/usr/local` tree) | FreeBSD 14.3 VM on `ubuntu-latest` |
| Web | `soundcraft-web-<v>.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.
@@ -89,6 +94,9 @@ plugins), and WebAudio in the browser.
- **Notarization:** the app is zipped and sent with `xcrun notarytool submit --wait`, then the
ticket is stapled to the app. The app goes on a DMG (`hdiutil`, with an `Applications` link).
The DMG is signed, notarized and stapled too.
Its Finder window (background, icon size and positions) comes from
[`packaging/macos/dmg/`](../packaging/macos/dmg/README.md), and its volume is named `SoundCraft`
without the version, which the window's background needs; the DMG file name keeps the version.
- **CLI:** the universal `soundcraft-cli` is signed with the hardened runtime, zipped and
notarized.
@@ -131,14 +139,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-<arch>.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-<v>-linux-<arch>.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 +182,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 +215,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.
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Repackage a SoundCraft Linux tarball as a single-file Flatpak bundle:
#
# $DIST/soundcraft-<version>-linux-<arch>.flatpak
#
# Usage: packaging/linux/flatpak-bundle.sh [--no-test] [TARBALL]
#
# TARBALL defaults to $DIST/soundcraft-<version>-linux-<arch>.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"
@@ -0,0 +1,73 @@
# Flatpak manifest for the single-file bundle attached to every GitHub release
# (soundcraft-<version>-linux-<arch>.flatpak). Build it with packaging/linux/flatpak-bundle.sh.
#
# It repackages the release's own Linux tarball (soundcraft-<version>-linux-<arch>.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
@@ -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: ../../..
+25 -5
View File
@@ -2,6 +2,7 @@
# Build and package SoundCraft for Linux (<arch> is x86_64 or aarch64):
#
# $DIST/soundcraft-<version>-linux-<arch>.AppImage any distro with glibc >= the build host's
# $DIST/soundcraft-<version>-linux-<arch>.AppImage.zsync delta updates (needs zsyncmake)
# $DIST/soundcraft-<version>-linux-<arch>.deb Debian, Ubuntu, Mint, Pop!_OS, ...
# $DIST/soundcraft-<version>-linux-<arch>.rpm Fedora, openSUSE, RHEL, ...
# $DIST/soundcraft-<version>-linux-<arch>.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
+44
View File
@@ -0,0 +1,44 @@
# macOS DMG window
What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the
`Applications` link side by side. `package.sh` copies these files into the image; nothing here is
generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no
Finder scripting on CI, nothing extra installed in the signing job).
| File | What |
|---|---|
| `background.svg` | Source of the background: the app icon (`assets/app-icon/icon.svg`, linked, not copied) cropped as a cover on the SoundCraft colour field (`#14a9c4`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. No paper grain: noise doesn't compress, and this keeps `background.tiff` small. |
| `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (16-colour palette, Deflate, sRGB). Goes to `.background/background.tiff`. |
| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, SoundCraft.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. |
| `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. |
## The volume name has no version
The mounted volume is called `SoundCraft`, not `SoundCraft <version>`. The layout points at the background
through an alias that includes the volume name, and Finder resolves it by that name: with a
versioned name the window keeps its size and icon positions but shows no background (tested).
The DMG file name (`soundcraft-<version>-macos-<arch>.dmg`) still carries the version.
## Rules
- **Finder draws the icon labels in black in light and dark mode** when a window has a background,
so the area under both icons stays light (Paper).
- **Nothing goes inside the icon boxes:** artwork keeps 10 pt clear of each 128 pt icon box and of
the label strip under it.
## Regenerate
Edit `background.svg` (or the layout constants in `generate.py`), then run, on any OS:
```sh
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
python3 packaging/macos/dmg/generate.py # needs resvg on PATH
```
Both outputs are byte-for-byte reproducible and carry nothing from the machine that ran it: the
SVG renders without any fonts, and `dmg-layout.DS_Store` is written from scratch, its background
alias holding only the volume name and `/.background/background.tiff`. If you add text to the SVG,
outline it (`usvg` from resvg converts text to paths). The window is 660 × 432 with Finder's 32 pt
title bar; the content area is 660 × 400.
This follows VectorCraft's `packaging/macos/dmg/` (storytold/vectorcraft#493).
+31
View File
@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="660" height="400" viewBox="0 0 660 400">
<defs><clipPath id="panel"><rect width="232" height="400"/></clipPath><pattern id="hatch" width="2.6" height="2.6" patternUnits="userSpaceOnUse" patternTransform="rotate(-38)">
<rect width="2.6" height="1.05" fill="#0b0b0c"/></pattern><filter id="grain" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="7"/>
<feColorMatrix values="0 0 0 0 0.04 0 0 0 0 0.04 0 0 0 0 0.05 0.5 0 0 0 -0.225"/>
</filter></defs>
<rect width="660" height="400" fill="#efe9dc"/>
<g clip-path="url(#panel)"><rect width="232" height="400" fill="#14a9c4"/><g transform="translate(260.0,29.6) scale(-0.8,0.8)"><image href="../../../assets/app-icon/icon.svg" width="512" height="512"/></g></g>
<line x1="232.5" y1="0" x2="232.5" y2="400" stroke="#0b0b0c" stroke-width="1.2"/>
<line x1="236.5" y1="0" x2="236.5" y2="400" stroke="#0b0b0c" stroke-width="0.5"/>
<line x1="267" y1="88.5" x2="624" y2="88.5" stroke="#0b0b0c" stroke-width="0.6"/>
<path d="M271.5,81.5 L276,88.5 L271.5,95.5 L267,88.5 Z" fill="#14a9c4"/>
<path d="M422,187 Q438,205 422,223" fill="none" stroke="#14a9c4" stroke-width="3.4" stroke-linecap="round"/><path d="M440,182 Q456,205 440,228" fill="none" stroke="#14a9c4" stroke-width="3.0" stroke-linecap="round"/><path d="M458,177 Q474,205 458,233" fill="none" stroke="#14a9c4" stroke-width="2.6" stroke-linecap="round"/><path d="M476,172 Q492,205 476,238" fill="none" stroke="#14a9c4" stroke-width="2.2" stroke-linecap="round"/>
<line x1="267" y1="335.5" x2="624" y2="335.5" stroke="#0b0b0c" stroke-width="0.6"/>
<g id="text">
<g>
<path fill="#0b0b0c" stroke="none" d="M 275.8584 72.336914 Q 272.0498 72.336914 269.78662 70.5791 Q 267.52344 68.82129 267.3916 65.583984 L 271.24414 65.583984 Q 271.37598 67.3125 272.6797 68.154785 Q 273.9834 68.99707 275.8291 68.99707 Q 277.7627 68.99707 278.98584 68.12549 Q 280.20898 67.25391 280.20898 65.84766 Q 280.20898 64.57324 279.125 63.921387 Q 278.04102 63.26953 276.26855 62.815434 L 273.79297 62.170902 Q 271.0537 61.46777 269.52295 60.046875 Q 267.9922 58.625977 267.9922 56.31152 Q 267.9922 54.377926 269.03223 52.93506 Q 270.07227 51.492188 271.87402 50.686527 Q 273.67578 49.88086 275.96094 49.88086 Q 278.29004 49.88086 280.04053 50.686527 Q 281.79102 51.492188 282.7871 52.913086 Q 283.7832 54.333984 283.82715 56.15039 L 280.0332 56.15039 Q 279.88672 54.75879 278.78076 53.98242 Q 277.6748 53.20606 275.90234 53.20606 Q 274.05664 53.20606 273.00195 54.019043 Q 271.94727 54.83203 271.94727 56.07715 Q 271.94727 57 272.51123 57.578613 Q 273.0752 58.157227 273.93213 58.50879 Q 274.78906 58.860348 275.66797 59.08008 L 277.7041 59.607426 Q 279.31543 60.00293 280.7876 60.742676 Q 282.25977 61.482426 283.18994 62.727535 Q 284.12012 63.972652 284.12012 65.87695 Q 284.12012 67.7959 283.13867 69.25342 Q 282.15723 70.71094 280.31152 71.523926 Q 278.46582 72.336914 275.8584 72.336914 Z M 294.1377 72.336914 Q 291.75 72.336914 289.9922 71.2749 Q 288.23438 70.21289 287.2749 68.31592 Q 286.31543 66.418945 286.31543 63.899414 Q 286.31543 61.350586 287.2749 59.438965 Q 288.23438 57.527344 289.9922 56.472656 Q 291.75 55.41797 294.1377 55.41797 Q 296.5254 55.41797 298.2832 56.472656 Q 300.04102 57.527344 301.0078 59.438965 Q 301.9746 61.350586 301.9746 63.899414 Q 301.9746 66.418945 301.0078 68.31592 Q 300.04102 70.21289 298.2832 71.2749 Q 296.5254 72.336914 294.1377 72.336914 Z M 294.1377 69.24609 Q 295.4707 69.24609 296.3496 68.52832 Q 297.22852 67.81055 297.66797 66.59473 Q 298.10742 65.37891 298.10742 63.884766 Q 298.10742 62.375977 297.66797 61.160156 Q 297.22852 59.944336 296.3496 59.226563 Q 295.4707 58.50879 294.1377 58.50879 Q 292.81934 58.50879 291.94043 59.226563 Q 291.06152 59.944336 290.6294 61.160156 Q 290.19727 62.375977 290.19727 63.884766 Q 290.19727 65.37891 290.6294 66.59473 Q 291.06152 67.81055 291.94043 68.52832 Q 292.81934 69.24609 294.1377 69.24609 Z M 310.41016 72.20508 Q 307.87598 72.20508 306.3379 70.59375 Q 304.7998 68.98242 304.7998 66.038086 L 304.7998 55.623047 L 308.6084 55.623047 L 308.6084 65.45215 Q 308.6084 67.09277 309.46533 68.03027 Q 310.32227 68.96777 311.8164 68.96777 Q 313.33984 68.96777 314.3213 67.98633 Q 315.30273 67.00488 315.30273 65.21777 L 315.30273 55.623047 L 319.12598 55.623047 L 319.12598 72 L 315.5078 72 L 315.47852 68.71875 Q 314.01367 72.20508 310.41016 72.20508 Z M 326.47754 62.405273 L 326.47754 72 L 322.66895 72 L 322.66895 55.623047 L 326.27246 55.623047 L 326.3164 58.889645 Q 327.78125 55.41797 331.38477 55.41797 Q 333.91895 55.41797 335.4497 57.0293 Q 336.98047 58.640625 336.98047 61.58496 L 336.98047 72 L 333.17188 72 L 333.17188 62.170902 Q 333.17188 60.530273 332.32227 59.592777 Q 331.47266 58.655273 329.97852 58.655273 Q 328.45508 58.655273 327.4663 59.63672 Q 326.47754 60.618164 326.47754 62.405273 Z M 346.5586 72.29297 Q 344.625 72.29297 343.09424 71.3042 Q 341.56348 70.31543 340.68457 68.42578 Q 339.80566 66.53613 339.80566 63.84082 Q 339.80566 61.11621 340.69922 59.233883 Q 341.59277 57.351563 343.12354 56.38477 Q 344.65433 55.41797 346.54398 55.41797 Q 348.0088 55.41797 348.93896 55.90869 Q 349.86914 56.399418 350.41113 57.080566 Q 350.95316 57.76172 351.2461 58.33301 L 351.40723 58.33301 L 351.40723 50.17383 L 355.21582 50.17383 L 355.21582 72 L 351.48047 72 L 351.48047 69.40723 L 351.2461 69.40723 Q 350.93848 69.993164 350.38184 70.66699 Q 349.8252 71.34082 348.89502 71.816895 Q 347.9648 72.29297 346.5586 72.29297 Z M 347.59863 69.1582 Q 349.45895 69.1582 350.46973 67.686035 Q 351.48047 66.21387 351.48047 63.82617 Q 351.48047 61.438473 350.47708 59.995605 Q 349.47363 58.552734 347.59863 58.552734 Q 345.66504 58.552734 344.6836 60.046875 Q 343.70215 61.54102 343.70215 63.82617 Q 343.70215 66.12598 344.69095 67.64209 Q 345.67966 69.1582 347.59863 69.1582 Z M 368.22168 72.29297 Q 365.3506 72.29297 363.09473 70.95996 Q 360.83887 69.62695 359.53516 67.12207 Q 358.23145 64.61719 358.23145 61.101563 Q 358.23145 57.571285 359.5425 55.059082 Q 360.85352 52.54688 363.10938 51.213863 Q 365.36523 49.88086 368.22168 49.88086 Q 370.60934 49.88086 372.56494 50.774414 Q 374.5205 51.66797 375.79492 53.359863 Q 377.06934 55.051754 377.42087 57.439453 L 373.4805 57.439453 Q 373.11426 55.50586 371.6787 54.443848 Q 370.24316 53.381836 368.2803 53.381836 Q 365.5996 53.381836 363.8784 55.381344 Q 362.15723 57.38086 362.15723 61.101563 Q 362.15723 64.86621 363.88574 66.8291 Q 365.61426 68.79199 368.26563 68.79199 Q 370.22852 68.79199 371.67136 67.72998 Q 373.11426 66.66797 373.4805 64.734375 L 377.43555 64.734375 Q 377.12793 66.8291 375.92676 68.54297 Q 374.7256Line truncated
</g>
<g>
<path fill="#0b0b0c" fill-opacity="0.7" stroke="none" d="M 270.6 112 L 270.6 110.56 L 267.63 110.56 L 267.63 109.291 L 270.024 105.42999 L 270.942 105.42999 L 268.44 109.47099 L 268.44 109.82201 L 270.6 109.82201 L 270.6 108.21999 L 271.41 108.21999 L 271.41 112 Z M 275.1 112.09 Q 274.479 112.09 274.00198 111.84701 Q 273.525 111.604 273.2595 111.167496 Q 272.994 110.730995 272.994 110.164 Q 272.994 109.54301 273.3495 109.09301 Q 273.705 108.643 274.29898 108.544 L 274.29898 108.517 Q 273.78598 108.4 273.47998 107.9995 Q 273.17398 107.599 273.17398 107.07701 Q 273.17398 106.564 273.417 106.1725 Q 273.66 105.781006 274.0965 105.5605 Q 274.533 105.34 275.1 105.34 Q 275.676 105.34 276.108 105.5605 Q 276.54 105.781006 276.783 106.1725 Q 277.026 106.564 277.026 107.07701 Q 277.026 107.599 276.72 107.9995 Q 276.414 108.4 275.901 108.517 L 275.901 108.544 Q 276.495 108.643 276.8505 109.09301 Q 277.206 109.54301 277.206 110.164 Q 277.206 110.730995 276.9405 111.167496 Q 276.675 111.604 276.20248 111.84701 Q 275.72998 112.09 275.1 112.09 Z M 275.1 108.202 Q 275.604 108.202 275.91 107.90501 Q 276.216 107.608 276.216 107.13099 Q 276.216 106.65401 275.91 106.357 Q 275.604 106.06 275.1 106.06 Q 274.60498 106.06 274.2945 106.357 Q 273.984 106.65401 273.984 107.13099 Q 273.984 107.608 274.2945 107.90501 Q 274.60498 108.202 275.1 108.202 Z M 275.1 111.361 Q 275.676 111.361 276.03598 111.023506 Q 276.396 110.68599 276.396 110.146 Q 276.396 109.597 276.03598 109.2595 Q 275.676 108.922 275.1 108.922 Q 274.524 108.922 274.164 109.2595 Q 273.804 109.597 273.804 110.146 Q 273.804 110.68599 274.164 111.023506 Q 274.524 111.361 275.1 111.361 Z M 284.073 112 L 284.073 105.42999 L 284.88303 105.42999 L 284.88303 109.09301 L 285.828 109.09301 L 287.21402 107.05 L 288.14102 107.05 L 286.548 109.408 L 288.23102 112 L 287.286 112 L 285.846 109.795 L 284.88303 109.795 L 284.88303 112 Z M 289.437 112 L 289.437 105.42999 L 290.247 105.42999 L 290.247 108.238 L 292.353 108.238 L 292.353 105.42999 L 293.163 105.42999 L 293.163 112 L 292.353 112 L 292.353 108.976 L 290.247 108.976 L 290.247 112 Z M 294.81 112 L 294.81 111.19 L 297.582 107.788 L 294.882 107.788 L 294.882 107.05 L 298.491 107.05 L 298.491 107.86 L 295.665 111.262 L 298.59 111.262 L 298.59 112 Z M 307.5 109.66 Q 307.167 109.66 306.96448 109.462 Q 306.762 109.26399 306.762 108.949005 Q 306.762 108.616 306.96448 108.409 Q 307.167 108.202 307.5 108.202 Q 307.83298 108.202 308.0355 108.409 Q 308.23798 108.616 308.23798 108.949005 Q 308.23798 109.26399 308.0355 109.462 Q 307.83298 109.66 307.5 109.66 Z M 316.374 112 L 316.374 111.253006 L 318.561 108.913 Q 319.038 108.409 319.236 108.01301 Q 319.43402 107.617 319.43402 107.23899 Q 319.43402 106.699 319.1235 106.3795 Q 318.81302 106.06 318.282 106.06 Q 317.733 106.06 317.4135 106.388504 Q 317.094 106.717 317.094 107.275 L 316.284 107.275 Q 316.311 106.384 316.851 105.862 Q 317.391 105.34 318.282 105.34 Q 319.191 105.34 319.7265 105.853 Q 320.262 106.36599 320.262 107.24801 Q 320.262 107.716 320.0055 108.238 Q 319.749 108.76 319.128 109.408 L 317.355 111.262 L 320.34302 111.262 L 320.34302 112 Z M 324.6 112 L 324.6 110.56 L 321.63 110.56 L 321.63 109.291 L 324.024 105.42999 L 324.942 105.42999 L 322.44 109.47099 L 322.44 109.82201 L 324.6 109.82201 L 324.6 108.21999 L 325.41 108.21999 L 325.41 112 Z M 327.66 109.39 L 327.66 108.670006 L 330.54 108.670006 L 330.54 109.39 Z M 334.788 112.09 Q 334.22998 112.09 333.87 111.8155 Q 333.50998 111.541 333.447 111.055 L 333.438 111.055 L 333.438 112 L 332.628 112 L 332.628 105.42999 L 333.438 105.42999 L 333.438 106.87 L 333.41998 107.995 L 333.429 107.995 Q 333.50098 107.51799 333.86548 107.23899 Q 334.22998 106.95999 334.788 106.95999 Q 335.53497 106.95999 335.97598 107.45501 Q 336.417 107.95 336.417 108.805 L 336.417 110.254 Q 336.417 111.1 335.97598 111.595 Q 335.53497 112.09 334.788 112.09 Z M 334.51797 111.38799 Q 335.03098 111.38799 335.319 111.118 Q 335.607 110.848 335.607 110.24501 L 335.607 108.805 Q 335.607 108.193 335.319 107.9275 Q 335.03098 107.661995 334.51797 107.661995 Q 334.02298 107.661995 333.7305 107.977 Q 333.438 108.29201 333.438 108.85 L 333.438 110.19999 Q 333.438 110.758 333.7305 111.07299 Q 334.02298 111.38799 334.51797 111.38799 Z M 337.96503 112 L 337.96503 111.262 L 339.72 111.262 L 339.72 107.788 L 338.14502 107.788 L 338.14502 107.05 L 340.53 107.05 L 340.53 111.262 L 342.195 111.262 L 342.195 112 Z M 340.035 106.159004 Q 339.738 106.159004 339.56702 106.006 Q 339.39603 105.853 339.39603 105.592 Q 339.39603 105.32199 339.56702 105.16449 Q 339.738 105.007 340.035 105.007 Q 340.332 105.007 340.50302 105.16449 Q 340.674 105.32199 340.674 105.592 Q 340.674 105.853 340.50302 106.006 Q 340.332 106.159004 340.035 106.159004 Z M 345.795 112 Q 345.183 112 344.81396 111.6445 Q 344.445 111.28901 344.445 110.69501 L 344.445 107.788 L 343.023 107.788 L 343.023 107.05 L 344.445 107.05 L 344.445 105.655 L 345.255 105.655 L 345.255 107.05 L 347.28003 107.05 L 347.28003 10Line truncated
</g>
<g>
<path fill="#0b0b0c" fill-opacity="0.65" stroke="none" d="M 270.4912 358 L 267.88623 358 L 267.88623 349.99707 L 270.5664 349.99707 Q 271.74805 349.99707 272.5913 350.4778 Q 273.43457 350.9585 273.88574 351.8501 Q 274.3369 352.7417 274.3369 353.9878 Q 274.3369 355.23923 273.88306 356.13623 Q 273.4292 357.0332 272.56714 357.5166 Q 271.70508 358 270.4912 358 Z M 269.11084 356.92578 L 270.4214 356.92578 Q 271.78027 356.92578 272.45703 356.16574 Q 273.1338 355.40573 273.1338 353.9878 Q 273.1338 352.5752 272.4651 351.82327 Q 271.7964 351.07126 270.48584 351.07126 L 269.11084 351.07126 Z M 277.3247 358 L 277.3247 349.99707 L 280.19287 349.99707 Q 281.1167 349.99707 281.73438 350.31934 Q 282.35205 350.6416 282.6582 351.20825 Q 282.96436 351.77487 282.96436 352.51074 Q 282.96436 353.3164 282.59912 353.90457 Q 282.2339 354.49268 281.50342 354.7666 L 283.26514 358 L 281.87402 358 L 280.25195 354.97607 Q 280.21973 354.97607 280.18213 354.97607 L 278.54932 354.97607 L 278.54932 358 Z M 278.54932 353.9287 L 280.06396 353.9287 Q 280.94482 353.9287 281.33423 353.55542 Q 281.72363 353.18216 281.72363 352.51074 Q 281.72363 351.83398 281.33154 351.4392 Q 280.93945 351.04443 280.05322 351.04443 L 278.54932 351.04443 Z M 285.33984 358 L 288.18652 349.99707 L 289.69043 349.99707 L 292.59082 358 L 291.26416 358 L 290.52832 355.88916 L 287.38086 355.88916 L 286.6665 358 Z M 287.72998 354.86862 L 290.16846 354.86862 L 289.70654 353.54733 Q 289.5454 353.06934 289.35742 352.43823 Q 289.16943 351.80713 288.9331 350.9585 Q 288.69678 351.81784 288.51147 352.4543 Q 288.32617 353.09082 288.17578 353.54733 Z M 298.21045 358.10742 Q 297.1255 358.10742 296.3037 357.6079 Q 295.48193 357.1084 295.0254 356.1899 Q 294.56885 355.27148 294.56885 354.00388 Q 294.56885 352.72562 295.03345 351.80444 Q 295.49805 350.8833 296.30908 350.38647 Q 297.12012 349.88965 298.15674 349.88965 Q 299.02148 349.88965 299.7251 350.2253 Q 300.4287 350.56104 300.88794 351.15454 Q 301.34717 351.74805 301.48145 352.52686 L 300.2246 352.52686 Q 300.0044 351.82327 299.4807 351.41504 Q 298.95703 351.00684 298.16748 351.00684 Q 297.49072 351.00684 296.95093 351.3506 Q 296.41113 351.6943 296.09692 352.36304 Q 295.7827 353.03174 295.7827 353.99854 Q 295.7827 354.96533 296.09692 355.63135 Q 296.41113 356.29736 296.95898 356.6438 Q 297.50684 356.99023 298.21582 356.99023 Q 299.1665 356.99023 299.74927 356.42896 Q 300.33203 355.86768 300.35352 354.90625 L 298.39307 354.90625 L 298.39307 353.89648 L 301.55127 353.89648 L 301.55127 354.81494 Q 301.55127 355.8247 301.1189 356.56323 Q 300.68652 357.30173 299.93457 357.7046 Q 299.18262 358.10742 298.21045 358.10742 Z M 312.33936 358.10742 Q 311.30273 358.10742 310.48364 357.61328 Q 309.66455 357.11914 309.19458 356.20068 Q 308.7246 355.2822 308.7246 354.00388 Q 308.7246 352.7202 309.19458 351.7991 Q 309.66455 350.87793 310.48364 350.3838 Q 311.30273 349.88965 312.33936 349.88965 Q 313.37598 349.88965 314.1897 350.3838 Q 315.00342 350.87793 315.4734 351.7991 Q 315.94336 352.7202 315.94336 354.00388 Q 315.94336 355.2822 315.4734 356.20337 Q 315.00342 357.1245 314.1897 357.616 Q 313.37598 358.10742 312.33936 358.10742 Z M 312.33936 356.99023 Q 313.02148 356.99023 313.56128 356.6465 Q 314.10107 356.30273 314.41528 355.63672 Q 314.7295 354.9707 314.7295 354.00388 Q 314.7295 353.03174 314.41528 352.36304 Q 314.10107 351.6943 313.56128 351.3506 Q 313.02148 351.00684 312.33936 351.00684 Q 311.65186 351.00684 311.10938 351.3506 Q 310.5669 351.6943 310.2527 352.36304 Q 309.93848 353.03174 309.93848 354.00388 Q 309.93848 354.9707 310.2527 355.63672 Q 310.5669 356.30273 311.10938 356.6465 Q 311.65186 356.99023 312.33936 356.99023 Z M 318.93652 358 L 318.93652 349.99707 L 320.34912 349.99707 L 323.3623 354.76123 Q 323.5503 355.062 323.80273 355.51053 Q 324.05518 355.95898 324.32373 356.5337 Q 324.28076 355.9536 324.26196 355.42453 Q 324.24316 354.8955 324.24316 354.55176 L 324.24316 349.99707 L 325.4839 349.99707 L 325.4839 358 L 324.06592 358 L 321.36426 353.74072 Q 321.16553 353.4238 320.9856 353.1123 Q 320.80566 352.80078 320.59082 352.41135 Q 320.37598 352.02194 320.08594 351.48483 Q 320.12354 352.23682 320.1504 352.82764 Q 320.17725 353.41846 320.17725 353.72998 L 320.17725 358 Z M 328.35352 351.0498 L 328.35352 349.99707 L 334.56787 349.99707 L 334.56787 351.0498 L 332.08105 351.0498 L 332.08105 358 L 330.85107 358 L 330.85107 351.0498 Z M 340.43994 358.10742 Q 339.40332 358.10742 338.58423 357.61328 Q 337.76514 357.11914 337.29517 356.20068 Q 336.8252 355.2822 336.8252 354.00388 Q 336.8252 352.7202 337.29517 351.7991 Q 337.76514 350.87793 338.58423 350.3838 Q 339.40332 349.88965 340.43994 349.88965 Q 341.47656 349.88965 342.29028 350.3838 Q 343.104 350.87793 343.57397 351.7991 Q 344.0439 352.7202 344.0439 354.00388 Q 344.0439 355.2822 343.57397 356.20337 Q 343.104 357.1245 342.29028 357.616 Q 341.47656 358.10742 340.43994 358.10742 Z M 340.43994 356.99023 Q 341.12207 356.99023 341.66187 356.6465 Q 342.20166 356.30273 342.51587 35Line truncated
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.
Binary file not shown.
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Regenerate background.tiff and dmg-layout.DS_Store for the macOS DMG window (see README.md here).
Needs resvg on PATH and:
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
Runs on any OS, no Mac needed. Both outputs come from this script alone, so they carry nothing
from the machine that made them: no local paths, user or disk names, dates or volume UUIDs.
python3 packaging/macos/dmg/generate.py
"""
import datetime
import os
import shutil
import subprocess
import sys
import tempfile
from ds_store import DSStore
from mac_alias import Alias, TargetInfo, VolumeInfo
from PIL import Image, ImageCms
HERE = os.path.dirname(os.path.abspath(__file__))
# Keep in sync with package.sh (volume name) and README.md (layout).
VOLUME = "SoundCraft"
WIDTH, HEIGHT = 660, 400 # window content, pt; the background's 1x size
TITLE_BAR = 32
ICON_SIZE = 128
PALETTE = 16 # colours in background.tiff
ICONS = {"SoundCraft.app": (326, 205), "Applications": (574, 205)}
# A fixed date for the alias and the colour profile. Finder never matches it against the image
# (each build is a new volume): it finds the background by volume name and path.
FIXED_DATE = datetime.datetime(2026, 10, 8, tzinfo=datetime.timezone.utc)
def render(svg, width, out):
# The SVG's text is already outlined and no fonts are loaded, so every machine draws the
# same pixels.
subprocess.run(
["resvg", "--skip-system-fonts", "-w", str(width), svg, out],
check=True,
)
with Image.open(out) as im:
return im.convert("RGB")
def quantize(im):
# No dithering: dither noise compresses badly and the artwork has flat fills.
return im.quantize(PALETTE, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE)
def srgb_profile():
# LittleCMS's built-in sRGB ("No copyright, use freely"), with its creation date fixed.
icc = bytearray(ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes())
d = FIXED_DATE
icc[24:36] = b"".join(v.to_bytes(2, "big") for v in (d.year, d.month, d.day, 0, 0, 0))
if icc[84:100] != bytes(16):
sys.exit("error: the sRGB profile has an ID; recompute it after fixing the date")
return bytes(icc)
def write_tiff(svg, out):
"""1x (72 dpi) and 2x (144 dpi) in one TIFF, as `tiffutil -cathidpicheck` writes them."""
with tempfile.TemporaryDirectory() as tmp:
one, two = (
quantize(render(svg, WIDTH * s, os.path.join(tmp, f"{s}x.png"))) for s in (1, 2)
)
two.encoderinfo = {"dpi": (144, 144)} # the rest as the first page
# Opaque 16-colour palette, Deflate: the artwork is three inks plus antialiasing, and a palette
# keeps the file under ~100 KB (repository assets must stay small). As PhotoCraft's.
one.save(
out,
"TIFF",
save_all=True,
append_images=[two],
compression="tiff_adobe_deflate", # brand-ok: Pillow's name for TIFF compression 8 (Deflate)
dpi=(72, 72),
icc_profile=srgb_profile(),
)
with Image.open(out) as im:
im.seek(1)
if im.info.get("dpi") != (144, 144):
sys.exit("error: the 2x page isn't 144 dpi (needs Pillow 12 or later)")
strips = []
for page in (0, 1):
im.seek(page)
strips += zip(im.tag_v2[273], im.tag_v2[279]) # StripOffsets, StripByteCounts
# TIFF keeps directories and tag data on even offsets. When a strip ends on an odd one, libtiff
# leaves the padding byte after it uninitialised, so the file changed from run to run with
# identical pixels. Zero it: nothing refers to that byte.
starts = {o for o, _ in strips}
with open(out, "r+b") as f:
for o, n in strips:
if (o + n) % 2 and o + n not in starts:
f.seek(o + n)
f.write(bytes(1))
def background_alias():
# What Finder stores for /.background/background.tiff on the volume, minus anything about the
# machine (dmgbuild writes the same kind of alias from a mounted image). Disk type 5 (ejectable),
# the volume flags and the folder/file ids (25, 26) are what Finder wrote on a test image; the ids
# are only hints, as the volume's creation date.
volume = VolumeInfo(VOLUME, FIXED_DATE, b"H+", 5, 0x0D02, b"\0\0")
volume.posix_path = f"/Volumes/{VOLUME}"
target = TargetInfo(0, "background.tiff", 25, 26, FIXED_DATE, b"\0\0\0\0", b"\0\0\0\0")
target.folder_name = ".background"
target.cnid_path = [25]
target.carbon_path = f"{VOLUME}:.background:\0background.tiff"
target.posix_path = "/.background/background.tiff"
return Alias(volume=volume, target=target).to_bytes()
def write_ds_store(out):
if os.path.exists(out):
os.remove(out)
with DSStore.open(out, "w+") as d:
d["."]["bwsp"] = {
"ShowStatusBar": False,
"ShowToolbar": False,
"ShowTabView": False,
"ContainerShowSidebar": False,
"ShowSidebar": False,
"WindowBounds": f"{{{{200, 528}}, {{{WIDTH}, {HEIGHT + TITLE_BAR}}}}}",
}
d["."]["icvp"] = {
"viewOptionsVersion": 1,
"backgroundType": 2,
"backgroundImageAlias": background_alias(),
"backgroundColorRed": 1.0,
"backgroundColorGreen": 1.0,
"backgroundColorBlue": 1.0,
"gridOffsetX": 0.0,
"gridOffsetY": 0.0,
"gridSpacing": 100.0,
"arrangeBy": "none",
"showIconPreview": True,
"showItemInfo": False,
"labelOnBottom": True,
"textSize": 16.0,
"iconSize": float(ICON_SIZE),
}
d["."]["vSrn"] = ("long", 1)
for name, pos in ICONS.items():
d[name]["Iloc"] = pos
def main():
if not shutil.which("resvg"):
sys.exit("error: resvg not found (brew install resvg / cargo install resvg --locked)")
write_tiff(os.path.join(HERE, "background.svg"), os.path.join(HERE, "background.tiff"))
write_ds_store(os.path.join(HERE, "dmg-layout.DS_Store"))
if __name__ == "__main__":
main()
+6 -1
View File
@@ -129,10 +129,15 @@ STAGE="$WORK/dmg"
mkdir -p "$STAGE"
ditto "$APP" "$STAGE/SoundCraft.app"
ln -s /Applications "$STAGE/Applications"
# Finder window layout: background, icon size and positions (packaging/macos/dmg/README.md).
mkdir -p "$STAGE/.background"
cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff"
cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store"
rm -f "$DMG" "$WORK/raw.dmg"
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
hdiutil makehybrid -hfs -hfs-volume-name "SoundCraft $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
# The volume name has no version: .DS_Store finds the background through an alias that includes it.
hdiutil makehybrid -hfs -hfs-volume-name "SoundCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
rm -f "$WORK/raw.dmg"
sign "$DMG"