diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 88694ec..ce126e3 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -54,4 +54,14 @@ jobs: desktop-file-validate packaging/linux/ai.storyteller.soundcraft.desktop sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.soundcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml" appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml" - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.soundcraft.yml')); assert m['id'] == 'ai.storyteller.soundcraft' and m['command'] == 'soundcraft'" + python3 - <<'EOF' + import yaml + src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.yml")) + bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml")) + for m in (src, bundle): + assert m["id"] == "ai.storyteller.soundcraft" and m["command"] == "soundcraft", m["id"] + assert "--socket=pulseaudio" in m["finish-args"], "audio needs --socket=pulseaudio" + for key in ("runtime", "runtime-version", "sdk", "finish-args", "add-extensions"): + assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" + print("Flatpak manifests ok") + EOF diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c10b5e..b1c0a39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,11 @@ # Release pipeline: every push to `release` builds signed installers for every platform and # creates or updates the draft GitHub Release "SoundCraft v". See docs/releasing.md. # -# All jobs run in the `release` environment (restricted to the `release` branch), which holds -# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a -# warning, never a failed build. +# The jobs that sign (macOS, Windows) and the one that drafts the release run in the `release` +# environment (restricted to the `release` branch), which holds the signing secrets. Every secret +# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The +# other jobs sign nothing and get no secrets, so a dispatch on any branch builds and checks them +# (a dry run); the signed jobs are refused there, and so the draft release is skipped. name: Release on: @@ -35,7 +37,7 @@ jobs: version: name: version runs-on: ubuntu-latest - environment: release + # No `environment: release`: this job reads no secrets (and gates every other job). outputs: version: ${{ steps.v.outputs.version }} date: ${{ steps.v.outputs.date }} @@ -157,7 +159,7 @@ jobs: # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. runs-on: ${{ matrix.runner }} - environment: release + # No `environment: release`: this job signs nothing, so it gets no release secrets. strategy: fail-fast: false matrix: @@ -176,7 +178,7 @@ jobs: sudo apt-get install -y --no-install-recommends \ libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ libasound2-dev pkg-config \ - desktop-file-utils appstream python3-yaml + desktop-file-utils appstream python3-yaml zsync curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" @@ -200,6 +202,9 @@ jobs: for appimage in dist/release/*.AppImage; do chmod +x "$appimage" APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version + # Update information is embedded, and the .zsync for delta updates sits beside it. + "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|storytold|soundcraft|latest|soundcraft-*-linux-${{ matrix.arch }}.AppImage.zsync' + test -s "$appimage.zsync" done - uses: actions/upload-artifact@v4 with: @@ -207,13 +212,51 @@ jobs: path: dist/release/* if-no-files-found: error + flatpak: + name: Flatpak ${{ matrix.arch }} + needs: [version, linux] + # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust + # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. + runs-on: ${{ matrix.runner }} + # No `environment: release`: this job signs nothing, so it gets no release secrets. + strategy: + fail-fast: false + matrix: + include: + - { arch: x86_64, runner: ubuntu-24.04 } + - { arch: aarch64, runner: ubuntu-24.04-arm } + env: + SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} + SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: release-linux-${{ matrix.arch }} + path: dist/linux + - name: Install flatpak-builder + run: | + sudo apt-get update + # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's + # `appstreamcli compose`, which renders the scalable icon and fails without it. + sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common + # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - name: Build the Flatpak bundle + run: packaging/linux/flatpak-bundle.sh "dist/linux/soundcraft-$SOUNDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" + - uses: actions/upload-artifact@v4 + with: + name: release-flatpak-${{ matrix.arch }} + path: dist/release/*.flatpak + if-no-files-found: error + freebsd: name: FreeBSD x86_64 needs: version # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image as freebsd.yml). runs-on: ubuntu-latest timeout-minutes: 150 - environment: release + # No `environment: release`: this job signs nothing, so it gets no release secrets. env: SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} @@ -253,7 +296,7 @@ jobs: name: Web (wasm) needs: version runs-on: ubuntu-latest - environment: release + # No `environment: release`: this job signs nothing, so it gets no release secrets. env: SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} @@ -284,7 +327,8 @@ jobs: release: name: Draft GitHub Release - needs: [version, macos, windows, linux, freebsd, web] + # Needs the signed jobs, which only run on the `release` branch: a branch dispatch never drafts. + needs: [version, macos, windows, linux, flatpak, freebsd, web] runs-on: ubuntu-latest environment: release permissions: diff --git a/.gitignore b/.gitignore index 75bd19e..901797f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ plan/ /target-*/ /craft-fonts/ plans/ +.DS_Store diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 69f0f0b..084f424 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 | diff --git a/Cargo.lock b/Cargo.lock index 67d4a78..164e0ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index 35520ba..6f07dfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.1.0" +version = "0.3.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/README.md b/README.md index c0d4591..da490e2 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Agents & scripts · How it's built · Status · + Downloads · The Crafting Apps · License

@@ -157,6 +158,28 @@ Useful shortcuts: Space play/stop, ⌘= Mix/Edi separate, ⌘D duplicate, Enter new marker, ⌘⇧N new tracks. **Setup › Keyboard Shortcuts** lists them all. +### Logs + +The desktop app writes its `log` records to standard error and to `logs/soundcraft.log` in the +settings directory, beside `ui.json`, `Autosave/` and `Presets/` (Linux `~/.config/soundcraft/logs/`, +or `$XDG_CONFIG_HOME/soundcraft/logs/`; macOS `~/Library/Application Support/SoundCraft/logs/`; +Windows `%APPDATA%\SoundCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so +this file is what to attach to a bug report: the engine's panic report, audio devices that failed to +open or broke, plugins that refused their stored state, CLAP plugins' own messages and files that +failed to open land there. Each launch moves the previous log to `soundcraft.1.log` (and that one to +`soundcraft.2.log`), so the log of a run that crashed survives the next start. The file stops +growing at 16 MiB. `--version` writes no file, and runs with `SOUNDCRAFT_NO_PREFS` log to standard +error only. + +| Variable | Effect | +|---|---| +| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for SoundCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,soundcraft_mix=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`soundcraft*=debug`). | +| `SOUNDCRAFT_NO_PREFS` | No preferences read or written and no log file (agents' test runs). | + +The realtime audio thread never writes a record itself: the logger keeps the first one it logs +(without waiting or allocating), counts the rest, and the UI thread writes them. The logger is +`apps/soundcraft/src/logging.rs`. The web app logs to the browser console instead. + ## For agents and scripts Everything you can click is also a command with an id and JSON parameters, and the same commands @@ -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. `` 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--windows-x64.msi` | `soundcraft--windows-x64-portable.zip` | +| arm64 (Snapdragon and other ARM PCs) | `soundcraft--windows-arm64.msi` | `soundcraft--windows-arm64-portable.zip` | +| x86 (32-bit) | `soundcraft--windows-x86.msi` | `soundcraft--windows-x86-portable.zip` | + +Installers and executables are code-signed. + +### macOS + +| Build | File | Notes | +|---|---|---| +| App, universal (Apple silicon + Intel) | `soundcraft--macos-universal.dmg` | Signed and notarized | +| Command-line tool, universal | `soundcraft-cli--macos-universal.zip` | Signed and notarized | + +### Linux + +| Format | x86_64 | aarch64 (ARM64) | Notes | +|---|---|---|---| +| AppImage | `soundcraft--linux-x86_64.AppImage` | `soundcraft--linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) | +| Flatpak | `soundcraft--linux-x86_64.flatpak` | `soundcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` | +| Debian/Ubuntu | `soundcraft--linux-x86_64.deb` | `soundcraft--linux-aarch64.deb` | | +| Fedora/RHEL/openSUSE | `soundcraft--linux-x86_64.rpm` | `soundcraft--linux-aarch64.rpm` | | +| Tarball | `soundcraft--linux-x86_64.tar.gz` | `soundcraft--linux-aarch64.tar.gz` | Unpack anywhere | + +### FreeBSD + +| Build | File | +|---|---| +| x86_64 | `soundcraft--freebsd-x86_64.tar.gz` | + +### Web (WebAssembly) + +| Build | File | Notes | +|---|---|---| +| Static site | `soundcraft-web-.zip` | Runs in a modern browser; host it on any static server | + ## The Crafting Apps SoundCraft is one of the **Crafting Apps**: free, open-source creative tools from the diff --git a/apps/soundcraft-web/index.html b/apps/soundcraft-web/index.html index b68f2d7..af72b21 100644 --- a/apps/soundcraft-web/index.html +++ b/apps/soundcraft-web/index.html @@ -15,6 +15,27 @@ +
Loading SoundCraft…
diff --git a/apps/soundcraft/src/control_server.rs b/apps/soundcraft/src/control_server.rs index 7c0c3e0..51758b9 100644 --- a/apps/soundcraft/src/control_server.rs +++ b/apps/soundcraft/src/control_server.rs @@ -13,12 +13,12 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { let listener = match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { - eprintln!("soundcraft: control server failed to bind 127.0.0.1:{port}: {e}"); + log::error!("control server failed to bind 127.0.0.1:{port}: {e}"); return rx; } }; let actual = listener.local_addr().map(|a| a.port()).unwrap_or(port); - eprintln!("soundcraft: control server listening on 127.0.0.1:{actual}"); + log::info!("control server listening on 127.0.0.1:{actual}"); println!("SOUNDCRAFT_CONTROL_PORT={actual}"); let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || { for stream in listener.incoming().flatten() { @@ -28,7 +28,7 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { } }); if let Err(e) = spawned { - eprintln!("soundcraft: control thread failed: {e}"); + log::error!("control thread failed: {e}"); } rx } diff --git a/apps/soundcraft/src/logging.rs b/apps/soundcraft/src/logging.rs new file mode 100644 index 0000000..53be177 --- /dev/null +++ b/apps/soundcraft/src/logging.rs @@ -0,0 +1,796 @@ +//! The desktop app's logger: `log::` records go to standard error and to a log file. +//! +//! Without a logger every `log::warn!`/`log::error!` in the workspace (the engine's panic report, +//! audio devices that failed to open or broke, plugins that refused their stored state or failed +//! to activate, CLAP plugins' own log messages, decode errors on import) vanished. A launch from a +//! desktop menu has no terminal, so the file is what a bug report can attach: +//! `/logs/soundcraft.log`, beside `ui.json`, `Autosave/` and `Presets/`. Each start +//! moves the previous log to `soundcraft.1.log` (and that one to `.2`), so the log of a run that +//! crashed survives the next launch. Runs without preferences (`SOUNDCRAFT_NO_PREFS`, agents' test +//! runs) log to standard error only, so they don't rotate away the user's own logs. +//! +//! Levels: `info` for SoundCraft's own crates, `warn` for everything else (wgpu and naga are +//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`, +//! `warn,soundcraft_mix=trace`, `wgpu_core=info`. A directive ending in `*` matches every target +//! that starts with it (`soundcraft*=debug`). +//! +//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and +//! written once the file is attached. Writing never panics: a file that can't be created or +//! written leaves standard error as the only sink. +//! +//! Realtime audio: a record takes a lock and writes to standard error and a file, which the audio +//! callback must never do. Code that runs on the audio thread logs anyway (the cpal error callback, +//! the synth's full event queue, a hosted plugin's failed `process`, a CLAP plugin calling the +//! host's `log` from its audio thread, the panic hook). So the cpal callbacks mark their thread +//! (`soundcraft_playback::mark_audio_thread`; the mix engine carries the mark into the worker +//! threads that render strips for it), and a record from a marked thread is not written +//! there: the first one is kept in a fixed buffer (`try_lock`, no allocation, never waits) and the +//! rest are only counted (an atomic). Another thread writes them out: the UI thread every frame +//! ([`AppLogger::report_audio_thread`]) and any other thread before its own next record. + +use std::fmt::Write as _; +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Mutex, OnceLock, PoisonError}; +use std::time::SystemTime; + +use log::LevelFilter; + +/// The current log file's name inside the log directory. +pub const LOG_FILE: &str = "soundcraft.log"; +/// The log directory inside the settings directory. +pub const LOG_DIR: &str = "logs"; +/// How many previous logs are kept (`soundcraft.1.log` … `soundcraft..log`). +pub const KEEP: usize = 2; +/// The log file stops growing past this size (a runaway warning can't fill the disk). +pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; +/// Records kept in memory until the log file is attached. +pub const MAX_PENDING: usize = 512; +/// The built-in filter when `RUST_LOG` is unset or empty. +pub const DEFAULT_FILTER: &str = "warn,soundcraft*=info"; +/// The longest message kept from the audio thread (longer ones are cut, marked with `…`). +pub const REALTIME_MESSAGE_BYTES: usize = 480; +/// The longest target kept from the audio thread. +const REALTIME_TARGET_BYTES: usize = 96; +/// The thread name shown for records from the audio thread. +const AUDIO_THREAD: &str = "audio"; + +/// Per-target level filter parsed from env_logger-style directives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Filter { + default: LevelFilter, + directives: Vec<(String, LevelFilter)>, +} + +impl Filter { + /// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no + /// directive names log errors only. + pub fn parse(spec: &str) -> Filter { + let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() }; + for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) { + match part.split_once('=') { + Some((name, level)) => { + let name = name.trim(); + if let (false, Some(level)) = (name.is_empty(), parse_level(level)) { + f.directives.push((name.to_owned(), level)); + } + } + None => match parse_level(part) { + Some(level) => f.default = level, + // A bare target name: everything from it (env_logger does the same). + None => f.directives.push((part.to_owned(), LevelFilter::Trace)), + }, + } + } + f + } + + /// The level that applies to `target` (the most specific matching directive wins). + pub fn level_for(&self, target: &str) -> LevelFilter { + self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level) + } + + /// The most verbose level any target can reach (for `log::set_max_level`). + pub fn max(&self) -> LevelFilter { + self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max) + } +} + +fn parse_level(s: &str) -> Option { + s.trim().parse().ok() +} + +/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`); +/// `name*` matches every target starting with `name`. +fn matches(name: &str, target: &str) -> bool { + match name.strip_suffix('*') { + Some(prefix) => target.starts_with(prefix), + None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")), + } +} + +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +pub fn timestamp(t: SystemTime) -> String { + let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + let secs = since.as_secs(); + let (days, rem) = (secs / 86_400, secs % 86_400); + let (y, m, d) = civil_from_days(days); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) +} + +/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's +/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd +/// clock can't overflow). +fn civil_from_days(days: u64) -> (u64, u64, u64) { + let z = days.saturating_add(719_468); + let era = z / 146_097; + let doe = z % 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); + (y, m, d) +} + +fn numbered(dir: &Path, n: usize) -> PathBuf { + if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("soundcraft.{n}.log")) } +} + +/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped) +/// and create a fresh, empty log file in `dir` (created if missing). +pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { + std::fs::create_dir_all(dir)?; + for n in (1..=KEEP).rev() { + match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), + _ => {} + } + } + let path = numbered(dir, 0); + let file = File::create(&path)?; + Ok((path, file)) +} + +/// One formatted log line: ` [] : \n`. +pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String { + let message = message.strip_suffix('\n').unwrap_or(message); + format!("{ts} {level:<5} [{thread}] {target}: {message}\n") +} + +/// Where formatted lines go besides standard error: kept in memory until a file is attached, +/// then appended to it until it reaches its size cap. +pub struct Sink { + file: Option, + pending: Vec, + dropped: usize, + written: u64, + max_bytes: u64, + capped: bool, +} + +impl Sink { + pub fn new(max_bytes: u64) -> Sink { + Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false } + } + + /// Append `line` to the file, or keep it until [`Sink::attach`]. + pub fn write(&mut self, line: &str) { + if self.file.is_none() { + if self.capped { + return; + } + if self.pending.len() < MAX_PENDING { + self.pending.push(line.to_owned()); + } else { + self.dropped = self.dropped.saturating_add(1); + } + return; + } + self.append(line); + } + + /// Start writing to `file`, first the lines kept so far. + pub fn attach(&mut self, file: File) { + self.file = Some(file); + for line in std::mem::take(&mut self.pending) { + self.append(&line); + } + if self.dropped > 0 { + let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped); + self.dropped = 0; + self.append(¬e); + } + } + + /// There will be no log file: forget the lines kept for it and keep no more. + pub fn no_file(&mut self) { + self.file = None; + self.pending = Vec::new(); + self.dropped = 0; + self.capped = true; + } + + fn append(&mut self, line: &str) { + if self.capped { + return; + } + let Some(file) = self.file.as_mut() else { return }; + let len = u64::try_from(line.len()).unwrap_or(u64::MAX); + let text = if self.written.saturating_add(len) > self.max_bytes { + self.capped = true; + format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes) + } else { + self.written = self.written.saturating_add(len); + line.to_owned() + }; + // A full disk or a vanished file must not take the app down: drop the file sink (and + // stop buffering, since no file will come). + if file.write_all(text.as_bytes()).is_err() { + self.file = None; + self.capped = true; + } + } +} + +/// Text formatted into a fixed buffer: no allocation, cut at a character boundary when full. +struct Bounded { + buf: [u8; N], + len: usize, + cut: bool, +} + +impl Bounded { + fn new() -> Self { + Bounded { buf: [0; N], len: 0, cut: false } + } + + fn as_str(&self) -> &str { + // Only whole characters are ever copied in, so this never fails. + self.buf.get(..self.len).and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("") + } +} + +impl std::fmt::Write for Bounded { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + let room = N.saturating_sub(self.len); + let mut n = s.len().min(room); + while !s.is_char_boundary(n) { + n = n.saturating_sub(1); + } + if let (Some(dst), Some(src)) = (self.buf.get_mut(self.len..self.len.saturating_add(n)), s.as_bytes().get(..n)) { + dst.copy_from_slice(src); + self.len = self.len.saturating_add(n); + } + if n < s.len() { + self.cut = true; + } + Ok(()) + } +} + +/// The first record the audio thread logged since the last report. +struct Held { + at: SystemTime, + level: log::Level, + target: Bounded, + message: Bounded, +} + +/// Records from the realtime audio thread, waiting for another thread to write them. +#[derive(Default)] +struct Realtime { + /// Records logged on the audio thread since the last report (the held one included). + records: AtomicUsize, + /// The first of them; the audio thread only `try_lock`s it. + held: Mutex>, +} + +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(×tamp(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(×tamp(SystemTime::now()), log::Level::Warn, thread.name().unwrap_or("?"), module_path!(), ¬e)); + } + lines + } +} + +/// The installed logger: a [`Filter`], standard error and a [`Sink`]. +pub struct AppLogger { + filter: Filter, + stderr: bool, + sink: Mutex, + 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 { + 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(×tamp(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 = 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(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); + } +} diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 5928d6f..0111b81 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -5,10 +5,14 @@ //! `--control ` (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 `/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); +struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option); 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 { - let base = if cfg!(target_os = "macos") { +/// The per-user settings directory: `ui.json`, `Autosave/`, `Presets/` and `logs/` live here. +fn config_dir() -> Option { + 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 { .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 { + 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 { - 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 { } 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 { } 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 = 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 = 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, ))) diff --git a/contributors/people.toml b/contributors/people.toml index 89c6db2..78cc705 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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 diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs index 6b6c023..7572ac3 100644 --- a/crates/dsp/src/lib.rs +++ b/crates/dsp/src/lib.rs @@ -278,6 +278,11 @@ pub fn gain_to_db(g: f32) -> f32 { (20.0 * a.log10()).max(MIN_DB) } +/// MIDI note number to frequency in Hz (equal temperament, A4 = note 69 = 440 Hz). +pub fn midi_to_hz(note: f64) -> f64 { + 440.0 * 2f64.powf((note - 69.0) / 12.0) +} + #[cfg(test)] mod trait_default_tests { use super::*; diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index bfbb6df..34fb355 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -359,3 +359,21 @@ fn step_marker(e: &mut Engine, dir: i64) -> Result { e.transport_requests.push(crate::TransportRequest::Locate(t)); Ok(json!({"at": t})) } + +#[cfg(test)] +mod tests { + use crate::demo::demo_engine; + use serde_json::json; + + /// The tempo-ruler dialog runs `event.tempo` with `bpm` and `at`; the change must land at that position. + #[test] + fn tempo_change_at_position_is_applied() { + let mut e = demo_engine(); + let sr = e.session().sample_rate; + let at = sr.samples(4.0); + e.execute("event.tempo", &json!({"bpm": 133.5, "at": at})).unwrap(); + let s = e.session(); + let tick = s.tempo.samples_to_ticks(at, s.sample_rate); + assert!((s.tempo.tempo_at_tick(tick) - 133.5).abs() < 1e-9); + } +} diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index bc8a2a2..1e7daa0 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -110,10 +110,6 @@ fn drums(sr: f64, frames: usize) -> (Vec, Vec, Vec) { (k, s, h) } -fn midi_hz(n: f64) -> f64 { - 440.0 * 2f64.powf((n - 69.0) / 12.0) -} - /// Chord roots per bar (A minor, F, C, G progression), as MIDI notes. const PROG: [[u8; 4]; 4] = [[57, 60, 64, 69], [53, 57, 60, 65], [48, 52, 55, 60], [55, 59, 62, 67]]; @@ -127,7 +123,7 @@ fn bass(sr: f64, frames: usize) -> Vec { for (eighth, semi, beats) in pattern { let at = bar * beat * 4 + eighth * beat / 2; let len = (beats * beat as f64) as usize; - let f = midi_hz(root + semi); + let f = soundcraft_dsp::midi_to_hz(root + semi); let mut ph = 0.0f64; for i in 0..len { let Some(o) = out.get_mut(at + i) else { break }; @@ -152,7 +148,7 @@ fn pad(sr: f64, frames: usize) -> (Vec, Vec) { let chord = PROG[bar % 4]; let at = bar * bar_len; for (vi, n) in chord.iter().enumerate() { - let f = midi_hz(f64::from(*n) + 12.0); + let f = soundcraft_dsp::midi_to_hz(f64::from(*n) + 12.0); for (side, buf) in [(-1.0f64, &mut l), (1.0, &mut r)] { let det = 1.0 + side * 0.0035 * (vi as f64 + 1.0); let mut ph = vi as f64 * 0.17; diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index ecc15af..70cdd1c 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -299,6 +299,9 @@ pub struct MixEngine { pub monitor_only: bool, /// Recording: record-armed tracks hear their input (auto input). pub recording: bool, + /// Mix the metronome (Options > Click) into the output. Playback turns this on; offline renders + /// (bounces, stems, exports) leave it off so the click is never printed into a file. + pub metronome: bool, /// Third-party instances come from [`MixEngine::adopt`] instead of being created in `sync`. external: bool, /// Instances handed over by the host, waiting for `sync` to place them. @@ -363,6 +366,7 @@ impl MixEngine { input: Vec::new(), monitor_only: false, recording: false, + metronome: false, external: false, adopt: HashMap::new(), retired: Vec::new(), @@ -655,6 +659,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 = 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(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]) { + let sr = s.sample_rate; + let rate = sr.as_f64(); + let len = (LEN_S * rate) as i64; + let end = pos.saturating_add(frames as i64); + let gain = f64::from(db_to_gain(s.edit.value("click.volume_db", -6.0).clamp(-60.0, 12.0) as f32)); + let accent = (note_hz(s.edit.value("click.accent_note", 84.0)), s.edit.value("click.accent_velocity", 127.0)); + let normal = (note_hz(s.edit.value("click.normal_note", 72.0)), s.edit.value("click.normal_velocity", 100.0)); + // Start one blip length early so a click that began in an earlier block still rings into this one. + let t0 = s.tempo.samples_to_ticks(pos.saturating_sub(len), sr).max(0).saturating_sub(1); + let mut tick = t0.saturating_sub(s.tempo.bar_beat_at_tick(t0).tick); + // Blocks hold a few beats at most; the guard only protects against a malformed tempo map. + for _ in 0..4096 { + let at = s.tempo.tick_to_samples(tick, sr); + if at >= end { + break; + } + if at.saturating_add(len) > pos { + let (hz, vel) = if s.tempo.bar_beat_at_tick(tick).beat == 1 { accent } else { normal }; + let amp = gain * (vel.clamp(1.0, 127.0) / 127.0); + blip(main, at - pos, frames, hz, amp as f32, rate); + } + tick = tick.saturating_add(s.tempo.meter_at_tick(tick).ticks_per_beat()); + } + } + + /// Adds one decaying sine blip whose first sample sits `rel` frames into the block (negative + /// when it began before the block) to every channel. + fn blip(main: &mut [Vec], rel: i64, frames: usize, hz: f64, amp: f32, rate: f64) { + let len = (LEN_S * rate) as usize; + let w = std::f64::consts::TAU * hz / rate; + let first = usize::try_from(-rel).unwrap_or(0); + for i in first..len { + let Ok(at) = usize::try_from(rel + i as i64) else { continue }; + if at >= frames { + break; + } + let env = (-6.0 * i as f64 / len as f64).exp() as f32; + let v = ((w * i as f64).sin() as f32) * env * amp; + for ch in main.iter_mut() { + if let Some(d) = ch.get_mut(at) { + *d += v; + } + } + } + } + + /// Click pitch for a MIDI note setting (clamped to the MIDI range). + fn note_hz(note: f64) -> f64 { + soundcraft_dsp::midi_to_hz(note.clamp(0.0, 127.0)) + } +} + fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut [Vec], _scratch: &mut [f32]) { let ClipContent::Audio { source, offset } = clip.content else { return }; let block = Range { start: pos, end: pos.saturating_add(frames as i64) }; @@ -1642,6 +1749,50 @@ fn create_plugin(id: &str) -> Option> { .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 { + 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> { + let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); + eng.metronome = true; + eng.recording = recording; + let mut out = vec![vec![0.0f32; len]; 2]; + let mut tmp = vec![vec![0.0f32; block]; 2]; + let mut done = 0usize; + while done < len { + let n = (len - done).min(block); + eng.render(s, done as i64, n, &mut tmp); + for (o, t) in out.iter_mut().zip(tmp.iter()) { + if let (Some(d), Some(src)) = (o.get_mut(done..done + n), t.get(..n)) { + d.copy_from_slice(src); + } + } + done += n; + } + out + } + + #[test] + fn click_sounds_on_every_beat_only_in_playback() { + let mut s = Session::default(); + let beat = s.sample_rate.as_f64() as usize * 60 / 120; // 120 BPM quarter notes + let len = beat * 8; + s.edit.click = true; + // Offline renders (bounces, stems) never print the metronome. + let offline = render_range(&s, Range::new(0, len as i64), 512); + assert!(offline.iter().all(|c| c.iter().all(|x| *x == 0.0))); + + let out = render_playback(&s, len, 512, false); + for b in 0..8usize { + let at = b * beat; + let on: f32 = out[0][at..at + 200].iter().map(|x| x.abs()).fold(0.0, f32::max); + let off: f32 = out[0][at + beat / 2..at + beat / 2 + 200].iter().map(|x| x.abs()).fold(0.0, f32::max); + assert!(on > 0.1, "beat {b} should click, peak {on}"); + assert_eq!(off, 0.0, "between beats must stay silent"); + assert_eq!(out[0][at], out[1][at], "both channels get the click"); + } + // Blocks that start mid-beat render the same clicks as a single pass. + let split = render_playback(&s, len, 97, false); + assert_eq!(out, split); + } + + #[test] + fn click_only_during_record_follows_recording() { + let mut s = Session::default(); + s.edit.click = true; + s.edit.set_flag("click.only_during_record", true); + let idle = render_playback(&s, 48_000, 512, false); + assert!(idle.iter().all(|c| c.iter().all(|x| *x == 0.0))); + let rec = render_playback(&s, 48_000, 512, true); + assert!(rec.iter().any(|c| c.iter().any(|x| x.abs() > 0.1))); + } + /// A session with one track of `fmt` playing a distinct DC level per channel (0.1, 0.2, …). fn multichannel_session(fmt: ChannelFormat, main: ChannelFormat, frames: usize) -> (Session, TrackId) { let mut s = Session::default(); diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index 6bc6b29..eb1db9b 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -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, ) { diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index d9def4c..09eb684 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -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 = 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, + phase: f64, +} + /// A small interleaved FIFO from the input callback to the output callback, for input /// monitoring. Both sides only `try_lock`; it holds at most ~0.5 s and drops the oldest frames. -#[derive(Default)] +/// The input device may run at a different rate than the session, so the reader converts +/// rates with linear interpolation (cheap, allocation-free, fine for monitoring). pub struct InputRing { - data: Mutex>, + state: Mutex, pub channels: std::sync::atomic::AtomicUsize, + /// Input device sample rate in Hz (0 when unknown, which reads as "same as the session"). + rate: u32, + /// Most samples kept: half a second of input, or 24 000 frames when the rate is unknown. + cap: usize, } impl InputRing { - pub fn new(channels: usize) -> Arc { + pub fn new(channels: usize, rate: u32) -> Arc { + let ch = channels.max(1); + let frames = if rate == 0 { 24_000 } else { rate as usize / 2 }; Arc::new(InputRing { - data: Mutex::new(std::collections::VecDeque::with_capacity(48_000 * channels.max(1))), - channels: std::sync::atomic::AtomicUsize::new(channels.max(1)), + state: Mutex::new(RingState { data: std::collections::VecDeque::with_capacity(rate.max(48_000) as usize * ch), phase: 0.0 }), + channels: std::sync::atomic::AtomicUsize::new(ch), + rate, + cap: frames * ch, }) } pub fn push(&self, samples: &[f32]) { - if let Ok(mut d) = self.data.try_lock() { - let cap = 24_000 * self.channels.load(Ordering::Relaxed).max(1); - d.extend(samples.iter().copied()); - while d.len() > cap { - d.pop_front(); + if let Ok(mut s) = self.state.try_lock() { + s.data.extend(samples.iter().copied()); + while s.data.len() > self.cap { + s.data.pop_front(); } } } - /// Fill `out` (planar) with up to `frames` frames; missing frames are silence. - pub fn pop_into(&self, out: &mut [Vec], frames: usize) { + /// Fill `out` (planar, at `session_rate`) with up to `frames` frames, converting from the + /// input rate. Missing frames are silence. + pub fn pop_into(&self, out: &mut [Vec], frames: usize, session_rate: u32) { let ch = self.channels.load(Ordering::Relaxed).max(1); for c in out.iter_mut() { c.iter_mut().take(frames).for_each(|x| *x = 0.0); } - let Ok(mut d) = self.data.try_lock() else { return }; - let avail = (d.len() / ch).min(frames); - for f in 0..avail { + let Ok(mut s) = self.state.try_lock() else { return }; + // Input frames consumed per output frame. + let step = if self.rate == 0 || session_rate == 0 { 1.0 } else { f64::from(self.rate) / f64::from(session_rate) }; + for f in 0..frames { + // Drop input frames we have moved past; keep the one at `phase` and the next one. + while s.phase >= 1.0 && s.data.len() >= 2 * ch { + s.data.drain(..ch); + s.phase -= 1.0; + } + if s.phase >= 1.0 || s.data.len() < 2 * ch { + break; + } + let fr = s.phase as f32; for c in 0..ch { - let v = d.pop_front().unwrap_or(0.0); + let a = s.data.get(c).copied().unwrap_or(0.0); + let b = s.data.get(ch + c).copied().unwrap_or(a); if let Some(x) = out.get_mut(c).and_then(|o| o.get_mut(f)) { - *x = v; + *x = a + (b - a) * fr; } } + s.phase += step; } } } + +#[cfg(test)] +mod ring_tests { + use super::InputRing; + + /// A 1 kHz sine recorded at 16 kHz must come out as a 1 kHz sine at 48 kHz, across calls. + #[test] + fn monitor_converts_input_rate_to_session_rate() { + let ring = InputRing::new(1, 16_000); + let input: Vec = (0..4_000).map(|i| (2.0 * std::f64::consts::PI * 1_000.0 * f64::from(i) / 16_000.0).sin() as f32).collect(); + ring.push(&input); + let mut out = vec![vec![0.0f32; 48]]; + let mut k = 0usize; + for _ in 0..10 { + ring.pop_into(&mut out, 48, 48_000); + for v in &out[0] { + let want = (2.0 * std::f64::consts::PI * 1_000.0 * k as f64 / 48_000.0).sin() as f32; + assert!((v - want).abs() < 0.05, "sample {k}: got {v}, want {want}"); + k += 1; + } + } + } + + /// Input faster than the session (96 kHz into 48 kHz): every other frame is read, in order. + #[test] + fn monitor_decimates_when_input_is_faster() { + let ring = InputRing::new(1, 96_000); + let input: Vec = (0..1_000).map(|i| i as f32).collect(); + ring.push(&input); + let mut out = vec![vec![0.0f32; 100]]; + ring.pop_into(&mut out, 100, 48_000); + for (k, v) in out[0].iter().enumerate() { + assert_eq!(*v, (2 * k) as f32, "output {k}"); + } + } + + /// Same rate: samples pass through unchanged. + #[test] + fn monitor_passes_through_at_equal_rates() { + let ring = InputRing::new(2, 48_000); + let input: Vec = (0..200).map(|i| i as f32).collect(); + ring.push(&input); + let mut out = vec![vec![0.0f32; 50], vec![0.0f32; 50]]; + ring.pop_into(&mut out, 50, 48_000); + assert_eq!(out[0][0], 0.0); + assert_eq!(out[1][0], 1.0); + assert_eq!(out[0][49], 98.0); + } + + /// Nothing buffered: silence, not stale data. + #[test] + fn monitor_underrun_is_silence() { + let ring = InputRing::new(1, 16_000); + let mut out = vec![vec![1.0f32; 16]]; + ring.pop_into(&mut out, 16, 48_000); + assert!(out[0].iter().all(|v| *v == 0.0)); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6815d60..8bfe6f0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6,6 +6,7 @@ use egui::{Align2, vec2}; use serde_json::{Value, json}; use soundcraft_engine::Engine; use soundcraft_model::{ClipId, TrackId}; +use soundcraft_time::Samples; #[derive(Debug, Clone)] pub enum Dialog { @@ -16,6 +17,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); + } } diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 17a54b0..6ba40c7 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -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, } 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::>(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::() && 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) { } } +/// 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, + hold: bool, + pos: Samples, +) -> (bool, Option) { + 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 = 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)); + } +} diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs index 6905934..bcaed2d 100644 --- a/crates/ui-egui/src/fonts.rs +++ b/crates/ui-egui/src/fonts.rs @@ -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 { + 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::().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 { +fn load(cands: &[(&str, u32)], bold: bool) -> Option { 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 { } #[cfg(target_arch = "wasm32")] -fn load(_: &[(&str, u32)]) -> Option { +fn load(_: &[(&str, u32)], _: bool) -> Option { None } @@ -50,13 +80,13 @@ pub fn definitions() -> FontDefinitions { let mut fonts = FontDefinitions::default(); let base: Vec = 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") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7ed4d74..b8aae83 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, 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)); + } +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index cc35c90..15141c8 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -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)); + } +} diff --git a/crates/ui-egui/tests/clip_menu.rs b/crates/ui-egui/tests/clip_menu.rs new file mode 100644 index 0000000..71684a9 --- /dev/null +++ b/crates/ui-egui/tests/clip_menu.rs @@ -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"); +} diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index b8ee3e3..c7c60c4 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -1059,19 +1059,19 @@ impl Instance { self.active } - fn bus_count(&self, media: u32, dir: u32) -> i32 { + fn bus_count(&self, media: sv::MediaTypes, dir: sv::BusDirections) -> i32 { // SAFETY: valid component. unsafe { self.component.getBusCount(media as sv::MediaType, dir as sv::BusDirection) }.clamp(0, MAX_BUSES) } - fn arrangement(&self, dir: u32, index: i32) -> Option { + fn arrangement(&self, dir: sv::BusDirections, index: i32) -> Option { let p = self.processor()?; let mut arr: sv::SpeakerArrangement = 0; // SAFETY: valid processor; `arr` is writable. ok(unsafe { p.getBusArrangement(dir as sv::BusDirection, index, &mut arr) }).then_some(arr) } - fn bus_channels(&self, dir: u32, index: i32) -> u32 { + fn bus_channels(&self, dir: sv::BusDirections, index: i32) -> u32 { if let Some(arr) = self.arrangement(dir, index) { return arr.count_ones().min(MAX_BUS_CHANNELS); } @@ -1095,7 +1095,7 @@ impl Instance { let (din, dout) = (sv::BusDirections_::kInput, sv::BusDirections_::kOutput); let nin = self.bus_count(audio, din); let nout = self.bus_count(audio, dout); - let current = |s: &Self, dir: u32, n: i32| -> Vec { + let current = |s: &Self, dir: sv::BusDirections, n: i32| -> Vec { (0..n).map(|i| s.arrangement(dir, i).unwrap_or(sv::SpeakerArr::kStereo)).collect() }; let want = if mono { sv::SpeakerArr::kMono } else { sv::SpeakerArr::kStereo }; @@ -1166,7 +1166,10 @@ impl Instance { self.context.tempo = 120.0; self.context.timeSigNumerator = 4; self.context.timeSigDenominator = 4; - self.context.state = sv::ProcessContext_::StatesAndFlags_::kTempoValid | sv::ProcessContext_::StatesAndFlags_::kTimeSigValid; + // The flag constants are the C default enum type: i32 on Windows, u32 elsewhere. + #[allow(clippy::unnecessary_cast)] + let flags = (sv::ProcessContext_::StatesAndFlags_::kTempoValid | sv::ProcessContext_::StatesAndFlags_::kTimeSigValid) as u32; + self.context.state = flags; self.host.latency_changed.store(false, Ordering::Relaxed); Ok(layout) } diff --git a/crates/vst3-host/tests/editor_main_thread.rs b/crates/vst3-host/tests/editor_main_thread.rs index ab5d28c..b6fbaa5 100644 --- a/crates/vst3-host/tests/editor_main_thread.rs +++ b/crates/vst3-host/tests/editor_main_thread.rs @@ -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"); diff --git a/crates/vst3-host/tests/fixture_e2e.rs b/crates/vst3-host/tests/fixture_e2e.rs index 23970d2..e3603b7 100644 --- a/crates/vst3-host/tests/fixture_e2e.rs +++ b/crates/vst3-host/tests/fixture_e2e.rs @@ -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)]); diff --git a/docs/architecture.md b/docs/architecture.md index 527711b..3432699 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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, diff --git a/docs/releasing.md b/docs/releasing.md index 1d89fcd..e68b9a5 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -35,9 +35,10 @@ After the draft is published, the workflow refuses to touch that version again, **Test runs:** *Actions → Release → Run workflow* runs the whole pipeline by hand. The optional `version` input (such as `0.2.0-rc.1`) overrides `Cargo.toml` for that run only. The jobs apply it -with `cargo xtask version set` before building, so the binaries report it too. The run still needs -the `release` environment, which only the `release` branch can use, so pick that branch in the -dialog. +with `cargo xtask version set` before building, so the binaries report it too. Only the signing +jobs (macOS, Windows) and the draft-release job use the `release` environment, which only the +`release` branch can use. On any other branch the run is a dry run: Linux, Flatpak, FreeBSD and +web build and are checked, the signing jobs are refused, and no release is drafted. ## What gets built @@ -49,11 +50,14 @@ dialog. | Windows 11 ARM64 | `soundcraft--windows-arm64.msi`, `soundcraft--windows-arm64-portable.zip` | `windows-latest` (cross-compiled; `windows-arm64.yml` installs and runs it on ARM64) | | Linux x86_64 | `soundcraft--linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` | | Linux aarch64 | `soundcraft--linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | +| AppImage updates | `soundcraft--linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImage | +| Flatpak x86_64, aarch64 | `soundcraft--linux-{x86_64,aarch64}.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackages the Linux tarball) | | FreeBSD 14 x86_64 | `soundcraft--freebsd-x86_64.tar.gz` (a `/usr/local` tree) | FreeBSD 14.3 VM on `ubuntu-latest` | | Web | `soundcraft-web-.zip` (static site; see [`packaging/web/README.md`](../packaging/web/README.md)) | `ubuntu-latest` | -The Flatpak manifest (`packaging/linux/flatpak/ai.storyteller.soundcraft.yml`) is kept ready for a -Flathub submission and validated in CI, but not built there (see Linux below). +The from-source Flatpak manifest (`packaging/linux/flatpak/ai.storyteller.soundcraft.yml`) is +kept ready for a Flathub submission; the release's `.flatpak` bundles come from +`ai.storyteller.soundcraft.bundle.yml` (see Linux below). ### Audio libraries @@ -68,7 +72,8 @@ plugins), and WebAudio in the browser. routes ALSA to the OSS `sound(4)` devices. Without a device the player falls back to its silent clock, so the transport still runs. - **Flatpak**: alsa-lib and its PulseAudio plugin come with the freedesktop runtime; the - manifest grants `--socket=pulseaudio`. + manifests grant `--socket=pulseaudio` (PipeWire's pulse server answers on it). No raw ALSA + devices and no JACK: SoundCraft doesn't use either. - **Web**: browsers start audio only after a user gesture, so the first click in the page may be needed before sound plays. The web build has no recording. @@ -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-.AppImage.zsync`, and + the `.zsync` next to it (written when `zsyncmake` from the `zsync` package is installed, as in + CI) lets AppImageUpdate fetch only the changed blocks of the newest non-pre-release. - **.deb** and **.rpm** by [nfpm](https://nfpm.goreleaser.com/) from one `nfpm.yaml`; the `postinst.sh` hook refreshes the desktop, MIME and icon caches. - **.tar.gz** for people who manage their own `/opt` or `~/.local`. -- **Flatpak**: `packaging/linux/flatpak/ai.storyteller.soundcraft.yml` builds from source on the - freedesktop 25.08 runtime with Wayland/X11, `dri`, PulseAudio and `xdg-music`; other files go - through the portals. A real build needs vendored crates (`cargo-sources.json` from - `flatpak-cargo-generator.py`); the commands are in the manifest's header. +- **Flatpak**: the `flatpak` job runs `packaging/linux/flatpak-bundle.sh` on the Linux job's + tarball (no Rust build) with `flatpak/ai.storyteller.soundcraft.bundle.yml`, then installs the + bundle and runs `soundcraft-cli --version` in the sandbox. Install with + `flatpak install --user soundcraft--linux-.flatpak`. Both manifests use the freedesktop + 25.08 runtime with Wayland/X11, `dri`, PulseAudio and `xdg-music`; other files go through the + portals. For the VST3 and CLAP hosts they grant `~/.vst3` and `~/.clap` read-only and mount + Flathub's `org.freedesktop.LinuxAudio.Plugins` extensions (pointed to by `VST3_PATH` and + `CLAP_PATH`); system plugin folders aren't visible in the sandbox. + `ai.storyteller.soundcraft.yml` builds from source for Flathub and needs vendored crates + (`cargo-sources.json` from `flatpak-cargo-generator.py`); the commands are in its header. Binaries are built on Ubuntu 22.04 and need **glibc ≥ 2.35**. X11, Wayland, xkbcommon, Vulkan and EGL are loaded at runtime, so the packages declare them as dependencies or recommendations. @@ -164,8 +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. diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh new file mode 100755 index 0000000..1b40026 --- /dev/null +++ b/packaging/linux/flatpak-bundle.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Repackage a SoundCraft Linux tarball as a single-file Flatpak bundle: +# +# $DIST/soundcraft--linux-.flatpak +# +# Usage: packaging/linux/flatpak-bundle.sh [--no-test] [TARBALL] +# +# TARBALL defaults to $DIST/soundcraft--linux-.tar.gz from package.sh. The bundle +# is built for the host architecture (x86_64 or aarch64), which must match the tarball's. +# Needs flatpak, flatpak-builder and the SVG pixbuf loader (librsvg2-common) for the host's +# `appstreamcli compose`; the freedesktop runtime and SDK named in the manifest are +# installed per-user from Flathub. Unless --no-test, the bundle is then installed per-user and +# `soundcraft-cli --version` is run inside the sandbox as a smoke test. +# Manifest: packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml. +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" +APP_ID=ai.storyteller.soundcraft +FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo + +TEST=1 +TARBALL="" +while [ $# -gt 0 ]; do + case "$1" in + --no-test) TEST=0; shift ;; + -h | --help) sed -n '2,14p' "$0"; exit 0 ;; + -*) echo "unknown argument: $1" >&2; exit 2 ;; + *) TARBALL="$1"; shift ;; + esac +done + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ;; + aarch64 | arm64) ARCH=aarch64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +esac +BASENAME="soundcraft-$VERSION-linux-$ARCH" +TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}" +[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } +for tool in flatpak flatpak-builder; do + command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } +done + +echo "==> SoundCraft $VERSION Flatpak bundle for $ARCH from $(basename "$TARBALL")" + +WORK="$CARGO_TARGET_DIR/flatpak-bundle" +rm -rf "$WORK" +mkdir -p "$WORK/stage" +tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 +[ -x "$WORK/stage/bin/soundcraft" ] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; } +cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" + +flatpak remote-add --user --if-not-exists flathub "$FLATHUB" +# --disable-rofiles-fuse: no FUSE in containers and CI runners. +flatpak-builder --user --install-deps-from=flathub --disable-rofiles-fuse --force-clean \ + --default-branch=stable --repo="$WORK/repo" --state-dir="$WORK/state" "$WORK/build" "$WORK/$APP_ID.yml" + +OUT="$DIST/$BASENAME.flatpak" +# --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing. +flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable +echo "wrote $OUT" + +if [ "$TEST" = 1 ]; then + flatpak install --user -y --noninteractive --reinstall "$OUT" + flatpak info --user "$APP_ID" + flatpak run --command=soundcraft-cli "$APP_ID" --version + flatpak run --command=sh "$APP_ID" -c 'ls /app/share/applications /app/share/metainfo /app/share/mime/packages /app/share/icons/hicolor/scalable/apps' +fi +echo "==> done" +ls -lh "$OUT" diff --git a/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml new file mode 100644 index 0000000..ff502f0 --- /dev/null +++ b/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml @@ -0,0 +1,73 @@ +# Flatpak manifest for the single-file bundle attached to every GitHub release +# (soundcraft--linux-.flatpak). Build it with packaging/linux/flatpak-bundle.sh. +# +# It repackages the release's own Linux tarball (soundcraft--linux-.tar.gz, +# built on Ubuntu 22.04 by package.sh) instead of compiling Rust inside flatpak-builder: no +# network and no vendored crate sources during the build, a few seconds instead of 20+ minutes, +# and byte-for-byte the same binaries as the AppImage/deb/rpm. The script unpacks the tarball +# into `stage/` next to a copy of this file. The binaries need glibc >= 2.35 and load X11, +# Wayland, xkbcommon, Vulkan and libasound at runtime; the freedesktop runtime has all of them +# (plus alsa-lib's PulseAudio plugin), and the GPU driver comes from its +# org.freedesktop.Platform.GL extension. +# +# ai.storyteller.soundcraft.yml (same directory) is the from-source manifest for Flathub. Keep +# runtime-version, finish-args and add-extensions identical in both (packaging-lint checks this). +id: ai.storyteller.soundcraft +runtime: org.freedesktop.Platform +runtime-version: '25.08' +sdk: org.freedesktop.Sdk +command: soundcraft +finish-args: + # Display: Wayland first; X11 only when no Wayland compositor is running. + - --socket=wayland + - --socket=fallback-x11 + # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. + - --share=ipc + # GPU access for the wgpu UI (Vulkan, or GL as a fallback). + - --device=dri + # Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its + # PulseAudio plugin to this socket, which PipeWire's pulse server provides on modern desktops + # (and PulseAudio itself on older ones). Deliberately not granted: --device=all (raw /dev/snd + # for direct ALSA hardware access, which would bypass the sound server) and JACK, which + # SoundCraft doesn't use (cpal's jack feature is off; there is no live MIDI I/O either). + - --socket=pulseaudio + # Open/Save dialogs use the file-chooser portal (rfd) and drag-and-drop the document portal, + # so broad filesystem access isn't needed. Music is granted for sessions and their + # "Audio Files" folders opened by path from the command line. + - --filesystem=xdg-music + # VST3 and CLAP plugin hosts. The per-user plugin folders the hosts scan, read-only. + # System folders (/usr/lib/vst3, /usr/lib/clap) aren't reachable from the sandbox, and plugins + # there are linked against the host's libraries rather than the runtime's; sandboxed plugins + # come from Flatpak extensions instead (add-extensions below), found through these paths. + - --filesystem=~/.vst3:ro + - --filesystem=~/.clap:ro + - --env=VST3_PATH=/app/extensions/Plugins/vst3 + - --env=CLAP_PATH=/app/extensions/Plugins/clap +# Flathub's shared audio-plugin extension point (org.freedesktop.LinuxAudio.Plugins.*, the one +# Ardour, Bitwig and friends use). Optional: nothing is downloaded unless the user installs a +# plugin extension; merge-dirs lets every installed one appear under .../Plugins/{vst3,clap}. +add-extensions: + org.freedesktop.LinuxAudio.Plugins: + directory: extensions/Plugins + version: '25.08' + add-ld-path: lib + merge-dirs: vst3;clap + subdirectories: true + no-autodownload: true +modules: + - name: soundcraft + buildsystem: simple + build-commands: + - install -Dm755 bin/soundcraft /app/bin/soundcraft + - install -Dm755 bin/soundcraft-cli /app/bin/soundcraft-cli + - install -Dm644 share/applications/ai.storyteller.soundcraft.desktop /app/share/applications/ai.storyteller.soundcraft.desktop + # Session files; the runtime's shared-mime-info already knows WAV, AIFF, FLAC, MP3 and MIDI. + - install -Dm644 share/mime/packages/ai.storyteller.soundcraft.xml /app/share/mime/packages/ai.storyteller.soundcraft.xml + # Already has the release's version and date substituted by package.sh. + - install -Dm644 share/metainfo/ai.storyteller.soundcraft.metainfo.xml /app/share/metainfo/ai.storyteller.soundcraft.metainfo.xml + - mkdir -p /app/share/icons /app/share/doc && cp -R share/icons/hicolor /app/share/icons/ && cp -R share/doc/soundcraft /app/share/doc/ + # Mount point for the plugin extensions (add-extensions). + - install -d /app/extensions/Plugins + sources: + - type: dir + path: stage diff --git a/packaging/linux/flatpak/ai.storyteller.soundcraft.yml b/packaging/linux/flatpak/ai.storyteller.soundcraft.yml index c26df34..bec9ffb 100644 --- a/packaging/linux/flatpak/ai.storyteller.soundcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.soundcraft.yml @@ -8,6 +8,9 @@ # flatpak run ai.storyteller.soundcraft # # For Flathub, replace the `dir` source with a `git` source pinned to a release tag and commit. +# ai.storyteller.soundcraft.bundle.yml repackages the release tarball into the .flatpak attached +# to each GitHub release; keep runtime-version, finish-args and add-extensions identical in both +# (packaging-lint checks this). # # Audio: cpal talks to ALSA. alsa-lib (headers in the Sdk, the library in the Platform) and its # PulseAudio plugin ship with the freedesktop runtime, which routes ALSA through the PulseAudio @@ -20,16 +23,42 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: soundcraft finish-args: - - --share=ipc + # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 + # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. + - --share=ipc + # GPU access for the wgpu UI (Vulkan, or GL as a fallback). - --device=dri - # Playback and recording (ALSA through the PulseAudio/PipeWire socket). + # Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its + # PulseAudio plugin to this socket, which PipeWire's pulse server provides on modern desktops + # (and PulseAudio itself on older ones). Deliberately not granted: --device=all (raw /dev/snd + # for direct ALSA hardware access, which would bypass the sound server) and JACK, which + # SoundCraft doesn't use (cpal's jack feature is off; there is no live MIDI I/O either). - --socket=pulseaudio - # Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document - # portal, so broad filesystem access isn't needed. Music is granted for sessions and their + # Open/Save dialogs use the file-chooser portal (rfd) and drag-and-drop the document portal, + # so broad filesystem access isn't needed. Music is granted for sessions and their # "Audio Files" folders opened by path from the command line. - --filesystem=xdg-music + # VST3 and CLAP plugin hosts. The per-user plugin folders the hosts scan, read-only. + # System folders (/usr/lib/vst3, /usr/lib/clap) aren't reachable from the sandbox, and plugins + # there are linked against the host's libraries rather than the runtime's; sandboxed plugins + # come from Flatpak extensions instead (add-extensions below), found through these paths. + - --filesystem=~/.vst3:ro + - --filesystem=~/.clap:ro + - --env=VST3_PATH=/app/extensions/Plugins/vst3 + - --env=CLAP_PATH=/app/extensions/Plugins/clap +# Flathub's shared audio-plugin extension point (org.freedesktop.LinuxAudio.Plugins.*, the one +# Ardour, Bitwig and friends use). Optional: nothing is downloaded unless the user installs a +# plugin extension; merge-dirs lets every installed one appear under .../Plugins/{vst3,clap}. +add-extensions: + org.freedesktop.LinuxAudio.Plugins: + directory: extensions/Plugins + version: '25.08' + add-ld-path: lib + merge-dirs: vst3;clap + subdirectories: true + no-autodownload: true build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: @@ -51,6 +80,8 @@ modules: install -d /app/share/metainfo; sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/" packaging/linux/ai.storyteller.soundcraft.metainfo.xml.in > /app/share/metainfo/ai.storyteller.soundcraft.metainfo.xml + # Mount point for the plugin extensions (add-extensions). + - install -d /app/extensions/Plugins sources: - type: dir path: ../../.. diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index ba0b6c7..65e9a60 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -2,6 +2,7 @@ # Build and package SoundCraft for Linux ( is x86_64 or aarch64): # # $DIST/soundcraft--linux-.AppImage any distro with glibc >= the build host's +# $DIST/soundcraft--linux-.AppImage.zsync delta updates (needs zsyncmake) # $DIST/soundcraft--linux-.deb Debian, Ubuntu, Mint, Pop!_OS, ... # $DIST/soundcraft--linux-.rpm Fedora, openSUSE, RHEL, ... # $DIST/soundcraft--linux-.tar.gz plain FHS-style tree (bin/, share/) @@ -10,7 +11,8 @@ # # Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage # (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, -# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli. +# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, +# zsyncmake (the zsync package) for the AppImage's .zsync. set -euo pipefail # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" @@ -23,7 +25,7 @@ while [ $# -gt 0 ]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; - -h | --help) sed -n '2,15p' "$0"; exit 0 ;; + -h | --help) sed -n '2,17p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done @@ -108,11 +110,29 @@ if has appimage; then chmod +x "$TOOL" fi fi - OUT="$DIST/$BASENAME.AppImage" + # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be + # relative). + OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" + APPDIR="$(cd "$APPDIR" && pwd)" + TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")" + # A .zsync left by an earlier run would hide a missing zsyncmake and describe another file. + rm -f "$OUT.zsync" + # Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and + # fetch only the blocks that changed in a newer release, through the .zsync published next to + # each AppImage on GitHub Releases. `latest` is the newest published release that is not a + # pre-release. A fork's builds point at its own releases through GITHUB_REPOSITORY. + REPO="${GITHUB_REPOSITORY:-storytold/soundcraft}" + UPDATE_INFO="gh-releases-zsync|${REPO%%/*}|${REPO#*/}|latest|soundcraft-*-linux-$ARCH.AppImage.zsync" # Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime, - # so users don't need libfuse2 either. - ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream "$APPDIR" "$OUT" + # so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync + # package) appimagetool also writes the .zsync, into its working directory, hence the cd. + (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" + if [ -s "$OUT.zsync" ]; then + echo "wrote $OUT.zsync" + else + warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it" + fi fi "$STAGE/usr/bin/soundcraft-cli" --version diff --git a/packaging/macos/dmg/README.md b/packaging/macos/dmg/README.md new file mode 100644 index 0000000..5bbc169 --- /dev/null +++ b/packaging/macos/dmg/README.md @@ -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 `. 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--macos-.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). diff --git a/packaging/macos/dmg/background.svg b/packaging/macos/dmg/background.svg new file mode 100644 index 0000000..2f07687 --- /dev/null +++ b/packaging/macos/dmg/background.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/macos/dmg/background.tiff b/packaging/macos/dmg/background.tiff new file mode 100644 index 0000000..feeb2f9 Binary files /dev/null and b/packaging/macos/dmg/background.tiff differ diff --git a/packaging/macos/dmg/dmg-layout.DS_Store b/packaging/macos/dmg/dmg-layout.DS_Store new file mode 100644 index 0000000..95d4596 Binary files /dev/null and b/packaging/macos/dmg/dmg-layout.DS_Store differ diff --git a/packaging/macos/dmg/generate.py b/packaging/macos/dmg/generate.py new file mode 100644 index 0000000..f731e49 --- /dev/null +++ b/packaging/macos/dmg/generate.py @@ -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() diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 60b7ac0..a898e3d 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -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"