From 74926df5272d397f128b1e71bec0812942cea9eb Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 8 Oct 2026 12:25:49 +0200 Subject: [PATCH 01/26] Desktop: write log records to standard error and a rotating log file The desktop app installed no logger, so every log::warn!/log::error! in the workspace was discarded: the engine's panic report, audio devices that failed to open or broke, plugins that refused their stored state or failed to activate, CLAP plugins' own log messages, files that failed to open. A launch from a desktop menu has no terminal either, so there was nothing to attach to a bug report. apps/soundcraft/src/logging.rs (no new dependency): - standard error plus /logs/soundcraft.log (beside ui.json, Autosave/ and Presets/); each launch rotates the previous log to soundcraft.1.log and .2.log, so the log of a run that crashed survives the next start; the file is capped at 16 MiB - records logged before the settings directory is known are buffered (bounded) and written once the file is attached - default levels: info for soundcraft*, warn for everything else; RUST_LOG replaces them with env_logger-style directives - never panics: an unusable directory or a failed write leaves standard error as the only sink; a poisoned lock is tolerated - runs with SOUNDCRAFT_NO_PREFS (agents' test runs) log to standard error only, so they don't rotate away the user's own logs Realtime audio: a record takes a lock and does I/O, which the audio callback must never do, yet code on the audio thread logs (the cpal error callbacks, the synth's full event queue, a hosted plugin's failed process, a CLAP plugin calling the host's log, the panic hook). The cpal callbacks now mark their thread (soundcraft_mix::mark_audio_thread, re-exported by soundcraft_playback), and the mix engine carries the mark into the rayon workers that render strips for it. A record from a marked thread is not written there: the first is kept in a fixed buffer (try_lock, no allocation, never waits), the rest are counted, and the UI thread writes them every frame (any other thread before its own next record). The logger is installed first in main (before the engine's panic hook) and the file is opened after argument parsing (--version leaves no file). The settings directory moves out of prefs_path into config_dir. The control server's eprintln!s go through the logger (the SOUNDCRAFT_CONTROL_PORT line on standard output stays). Documented in README.md (Getting started > Logs) and docs/architecture.md (Audio). --- README.md | 22 + apps/soundcraft/src/control_server.rs | 6 +- apps/soundcraft/src/logging.rs | 796 ++++++++++++++++++++++++++ apps/soundcraft/src/main.rs | 60 +- crates/mix/src/lib.rs | 81 ++- crates/playback/src/lib.rs | 16 +- crates/playback/src/record.rs | 7 +- docs/architecture.md | 6 + 8 files changed, 976 insertions(+), 18 deletions(-) create mode 100644 apps/soundcraft/src/logging.rs diff --git a/README.md b/README.md index c0d4591..8ff13d8 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,28 @@ Useful shortcuts: Space play/stop, ⌘= Mix/Edi separate, ⌘D duplicate, Enter new marker, ⌘⇧N new tracks. **Setup › Keyboard Shortcuts** lists them all. +### Logs + +The desktop app writes its `log` records to standard error and to `logs/soundcraft.log` in the +settings directory, beside `ui.json`, `Autosave/` and `Presets/` (Linux `~/.config/soundcraft/logs/`, +or `$XDG_CONFIG_HOME/soundcraft/logs/`; macOS `~/Library/Application Support/SoundCraft/logs/`; +Windows `%APPDATA%\SoundCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so +this file is what to attach to a bug report: the engine's panic report, audio devices that failed to +open or broke, plugins that refused their stored state, CLAP plugins' own messages and files that +failed to open land there. Each launch moves the previous log to `soundcraft.1.log` (and that one to +`soundcraft.2.log`), so the log of a run that crashed survives the next start. The file stops +growing at 16 MiB. `--version` writes no file, and runs with `SOUNDCRAFT_NO_PREFS` log to standard +error only. + +| Variable | Effect | +|---|---| +| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for SoundCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,soundcraft_mix=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`soundcraft*=debug`). | +| `SOUNDCRAFT_NO_PREFS` | No preferences read or written and no log file (agents' test runs). | + +The realtime audio thread never writes a record itself: the logger keeps the first one it logs +(without waiting or allocating), counts the rest, and the UI thread writes them. The logger is +`apps/soundcraft/src/logging.rs`. The web app logs to the browser console instead. + ## For agents and scripts Everything you can click is also a command with an id and JSON parameters, and the same commands diff --git a/apps/soundcraft/src/control_server.rs b/apps/soundcraft/src/control_server.rs index 7c0c3e0..51758b9 100644 --- a/apps/soundcraft/src/control_server.rs +++ b/apps/soundcraft/src/control_server.rs @@ -13,12 +13,12 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { let listener = match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { - eprintln!("soundcraft: control server failed to bind 127.0.0.1:{port}: {e}"); + log::error!("control server failed to bind 127.0.0.1:{port}: {e}"); return rx; } }; let actual = listener.local_addr().map(|a| a.port()).unwrap_or(port); - eprintln!("soundcraft: control server listening on 127.0.0.1:{actual}"); + log::info!("control server listening on 127.0.0.1:{actual}"); println!("SOUNDCRAFT_CONTROL_PORT={actual}"); let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || { for stream in listener.incoming().flatten() { @@ -28,7 +28,7 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { } }); if let Err(e) = spawned { - eprintln!("soundcraft: control thread failed: {e}"); + log::error!("control thread failed: {e}"); } rx } diff --git a/apps/soundcraft/src/logging.rs b/apps/soundcraft/src/logging.rs new file mode 100644 index 0000000..53be177 --- /dev/null +++ b/apps/soundcraft/src/logging.rs @@ -0,0 +1,796 @@ +//! The desktop app's logger: `log::` records go to standard error and to a log file. +//! +//! Without a logger every `log::warn!`/`log::error!` in the workspace (the engine's panic report, +//! audio devices that failed to open or broke, plugins that refused their stored state or failed +//! to activate, CLAP plugins' own log messages, decode errors on import) vanished. A launch from a +//! desktop menu has no terminal, so the file is what a bug report can attach: +//! `/logs/soundcraft.log`, beside `ui.json`, `Autosave/` and `Presets/`. Each start +//! moves the previous log to `soundcraft.1.log` (and that one to `.2`), so the log of a run that +//! crashed survives the next launch. Runs without preferences (`SOUNDCRAFT_NO_PREFS`, agents' test +//! runs) log to standard error only, so they don't rotate away the user's own logs. +//! +//! Levels: `info` for SoundCraft's own crates, `warn` for everything else (wgpu and naga are +//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`, +//! `warn,soundcraft_mix=trace`, `wgpu_core=info`. A directive ending in `*` matches every target +//! that starts with it (`soundcraft*=debug`). +//! +//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and +//! written once the file is attached. Writing never panics: a file that can't be created or +//! written leaves standard error as the only sink. +//! +//! Realtime audio: a record takes a lock and writes to standard error and a file, which the audio +//! callback must never do. Code that runs on the audio thread logs anyway (the cpal error callback, +//! the synth's full event queue, a hosted plugin's failed `process`, a CLAP plugin calling the +//! host's `log` from its audio thread, the panic hook). So the cpal callbacks mark their thread +//! (`soundcraft_playback::mark_audio_thread`; the mix engine carries the mark into the worker +//! threads that render strips for it), and a record from a marked thread is not written +//! there: the first one is kept in a fixed buffer (`try_lock`, no allocation, never waits) and the +//! rest are only counted (an atomic). Another thread writes them out: the UI thread every frame +//! ([`AppLogger::report_audio_thread`]) and any other thread before its own next record. + +use std::fmt::Write as _; +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Mutex, OnceLock, PoisonError}; +use std::time::SystemTime; + +use log::LevelFilter; + +/// The current log file's name inside the log directory. +pub const LOG_FILE: &str = "soundcraft.log"; +/// The log directory inside the settings directory. +pub const LOG_DIR: &str = "logs"; +/// How many previous logs are kept (`soundcraft.1.log` … `soundcraft..log`). +pub const KEEP: usize = 2; +/// The log file stops growing past this size (a runaway warning can't fill the disk). +pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; +/// Records kept in memory until the log file is attached. +pub const MAX_PENDING: usize = 512; +/// The built-in filter when `RUST_LOG` is unset or empty. +pub const DEFAULT_FILTER: &str = "warn,soundcraft*=info"; +/// The longest message kept from the audio thread (longer ones are cut, marked with `…`). +pub const REALTIME_MESSAGE_BYTES: usize = 480; +/// The longest target kept from the audio thread. +const REALTIME_TARGET_BYTES: usize = 96; +/// The thread name shown for records from the audio thread. +const AUDIO_THREAD: &str = "audio"; + +/// Per-target level filter parsed from env_logger-style directives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Filter { + default: LevelFilter, + directives: Vec<(String, LevelFilter)>, +} + +impl Filter { + /// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no + /// directive names log errors only. + pub fn parse(spec: &str) -> Filter { + let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() }; + for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) { + match part.split_once('=') { + Some((name, level)) => { + let name = name.trim(); + if let (false, Some(level)) = (name.is_empty(), parse_level(level)) { + f.directives.push((name.to_owned(), level)); + } + } + None => match parse_level(part) { + Some(level) => f.default = level, + // A bare target name: everything from it (env_logger does the same). + None => f.directives.push((part.to_owned(), LevelFilter::Trace)), + }, + } + } + f + } + + /// The level that applies to `target` (the most specific matching directive wins). + pub fn level_for(&self, target: &str) -> LevelFilter { + self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level) + } + + /// The most verbose level any target can reach (for `log::set_max_level`). + pub fn max(&self) -> LevelFilter { + self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max) + } +} + +fn parse_level(s: &str) -> Option { + s.trim().parse().ok() +} + +/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`); +/// `name*` matches every target starting with `name`. +fn matches(name: &str, target: &str) -> bool { + match name.strip_suffix('*') { + Some(prefix) => target.starts_with(prefix), + None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")), + } +} + +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +pub fn timestamp(t: SystemTime) -> String { + let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + let secs = since.as_secs(); + let (days, rem) = (secs / 86_400, secs % 86_400); + let (y, m, d) = civil_from_days(days); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) +} + +/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's +/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd +/// clock can't overflow). +fn civil_from_days(days: u64) -> (u64, u64, u64) { + let z = days.saturating_add(719_468); + let era = z / 146_097; + let doe = z % 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); + (y, m, d) +} + +fn numbered(dir: &Path, n: usize) -> PathBuf { + if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("soundcraft.{n}.log")) } +} + +/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped) +/// and create a fresh, empty log file in `dir` (created if missing). +pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { + std::fs::create_dir_all(dir)?; + for n in (1..=KEEP).rev() { + match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), + _ => {} + } + } + let path = numbered(dir, 0); + let file = File::create(&path)?; + Ok((path, file)) +} + +/// One formatted log line: ` [] : \n`. +pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String { + let message = message.strip_suffix('\n').unwrap_or(message); + format!("{ts} {level:<5} [{thread}] {target}: {message}\n") +} + +/// Where formatted lines go besides standard error: kept in memory until a file is attached, +/// then appended to it until it reaches its size cap. +pub struct Sink { + file: Option, + pending: Vec, + dropped: usize, + written: u64, + max_bytes: u64, + capped: bool, +} + +impl Sink { + pub fn new(max_bytes: u64) -> Sink { + Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false } + } + + /// Append `line` to the file, or keep it until [`Sink::attach`]. + pub fn write(&mut self, line: &str) { + if self.file.is_none() { + if self.capped { + return; + } + if self.pending.len() < MAX_PENDING { + self.pending.push(line.to_owned()); + } else { + self.dropped = self.dropped.saturating_add(1); + } + return; + } + self.append(line); + } + + /// Start writing to `file`, first the lines kept so far. + pub fn attach(&mut self, file: File) { + self.file = Some(file); + for line in std::mem::take(&mut self.pending) { + self.append(&line); + } + if self.dropped > 0 { + let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped); + self.dropped = 0; + self.append(¬e); + } + } + + /// There will be no log file: forget the lines kept for it and keep no more. + pub fn no_file(&mut self) { + self.file = None; + self.pending = Vec::new(); + self.dropped = 0; + self.capped = true; + } + + fn append(&mut self, line: &str) { + if self.capped { + return; + } + let Some(file) = self.file.as_mut() else { return }; + let len = u64::try_from(line.len()).unwrap_or(u64::MAX); + let text = if self.written.saturating_add(len) > self.max_bytes { + self.capped = true; + format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes) + } else { + self.written = self.written.saturating_add(len); + line.to_owned() + }; + // A full disk or a vanished file must not take the app down: drop the file sink (and + // stop buffering, since no file will come). + if file.write_all(text.as_bytes()).is_err() { + self.file = None; + self.capped = true; + } + } +} + +/// Text formatted into a fixed buffer: no allocation, cut at a character boundary when full. +struct Bounded { + buf: [u8; N], + len: usize, + cut: bool, +} + +impl Bounded { + fn new() -> Self { + Bounded { buf: [0; N], len: 0, cut: false } + } + + fn as_str(&self) -> &str { + // Only whole characters are ever copied in, so this never fails. + self.buf.get(..self.len).and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("") + } +} + +impl std::fmt::Write for Bounded { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + let room = N.saturating_sub(self.len); + let mut n = s.len().min(room); + while !s.is_char_boundary(n) { + n = n.saturating_sub(1); + } + if let (Some(dst), Some(src)) = (self.buf.get_mut(self.len..self.len.saturating_add(n)), s.as_bytes().get(..n)) { + dst.copy_from_slice(src); + self.len = self.len.saturating_add(n); + } + if n < s.len() { + self.cut = true; + } + Ok(()) + } +} + +/// The first record the audio thread logged since the last report. +struct Held { + at: SystemTime, + level: log::Level, + target: Bounded, + message: Bounded, +} + +/// Records from the realtime audio thread, waiting for another thread to write them. +#[derive(Default)] +struct Realtime { + /// Records logged on the audio thread since the last report (the held one included). + records: AtomicUsize, + /// The first of them; the audio thread only `try_lock`s it. + held: Mutex>, +} + +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 a6cafa1..47c4da3 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -5,19 +5,27 @@ //! `--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; use soundcraft_engine::Engine; use soundcraft_ui_egui::{Services, SoundApp, UiState}; use std::sync::Arc; -struct App(SoundApp); +struct App(SoundApp, Option<&'static logging::AppLogger>); 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(); + } self.0.logic(ctx); // Files dropped on the window: sessions open, audio/MIDI import. let dropped: Vec = @@ -39,6 +47,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(); + } } } @@ -57,8 +68,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")) @@ -67,12 +79,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()?; @@ -80,7 +100,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() { @@ -132,14 +152,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 @@ -154,6 +174,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 { @@ -186,7 +224,7 @@ fn main() -> eframe::Result { for f in &files { open_path(&mut app, f); } - Ok(Box::new(App(app))) + Ok(Box::new(App(app, logger))) }), ) } diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index ecc15af..e604bff 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -1000,12 +1000,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); } @@ -1642,6 +1677,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::*; diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index 6bc6b29..0621503 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 { @@ -390,11 +394,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..a4a4abd 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -57,6 +57,7 @@ impl Recorder { .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())?; 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, From 5b95e859297811766a02f1ddfc8147b16f050830 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 8 Oct 2026 12:27:07 +0200 Subject: [PATCH 02/26] Contributors: credit switch87 as Gert Pellin --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 89c6db2..fd3d031 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -21,3 +21,8 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false + +[people.switch87] +real_name = "Gert Pellin" +display_name = "Gert Pellin" +sync_github_name = false From b9c70400f9d364785d46636e1d205e051d03c428 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 8 Oct 2026 08:13:25 -0400 Subject: [PATCH 03/26] Bump version to 0.2.0 (from 0.1.0) for release Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 36 ++++++++++++++++++------------------ Cargo.toml | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 759f472..0825401 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3361,7 +3361,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.1.0" +version = "0.2.0" dependencies = [ "eframe", "egui", @@ -3376,7 +3376,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.1.0" +version = "0.2.0" dependencies = [ "log", "serde", @@ -3386,7 +3386,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.1.0" +version = "0.2.0" dependencies = [ "log", "proptest", @@ -3398,7 +3398,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.1.0" +version = "0.2.0" dependencies = [ "log", "serde_json", @@ -3408,7 +3408,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.1.0" +version = "0.2.0" dependencies = [ "clap-sys", "libloading", @@ -3420,7 +3420,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde_json", "soundcraft-audio-io", @@ -3431,7 +3431,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.1.0" +version = "0.2.0" dependencies = [ "log", "proptest", @@ -3442,7 +3442,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.1.0" +version = "0.2.0" dependencies = [ "log", "proptest", @@ -3462,7 +3462,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.1.0" +version = "0.2.0" dependencies = [ "proptest", "serde", @@ -3472,7 +3472,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.1.0" +version = "0.2.0" dependencies = [ "log", "rayon", @@ -3488,7 +3488,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.1.0" +version = "0.2.0" dependencies = [ "proptest", "serde", @@ -3501,7 +3501,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.1.0" +version = "0.2.0" dependencies = [ "cpal", "log", @@ -3514,7 +3514,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.1.0" +version = "0.2.0" dependencies = [ "proptest", "serde", @@ -3524,7 +3524,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.1.0" +version = "0.2.0" dependencies = [ "egui", "egui_kittest", @@ -3549,7 +3549,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.1.0" +version = "0.2.0" dependencies = [ "image", "proptest", @@ -3560,7 +3560,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.1.0" +version = "0.2.0" dependencies = [ "libloading", "log", @@ -3575,7 +3575,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.1.0" +version = "0.2.0" dependencies = [ "eframe", "egui", @@ -5122,7 +5122,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 35520ba..bb95118 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From ef47ab59d017df9d9820886805e89afdd2261788 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 8 Oct 2026 08:39:10 -0400 Subject: [PATCH 04/26] Fix the Windows build of vst3-host; bump version to 0.2.1 The vst3 bindings' enum constants use the C default enum type, which is i32 on Windows and u32 elsewhere. The bus helpers took u32 and the process-context flags were assigned to a u32 field directly, so the release build failed on all three Windows targets. Type the helpers with the bindings' own aliases and cast the flags once. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 36 ++++++++++++++++++------------------ Cargo.toml | 2 +- crates/vst3-host/src/ffi.rs | 13 ++++++++----- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0825401..402aa74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3361,7 +3361,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.2.0" +version = "0.2.1" dependencies = [ "eframe", "egui", @@ -3376,7 +3376,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.2.0" +version = "0.2.1" dependencies = [ "log", "serde", @@ -3386,7 +3386,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.2.0" +version = "0.2.1" dependencies = [ "log", "proptest", @@ -3398,7 +3398,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.2.0" +version = "0.2.1" dependencies = [ "log", "serde_json", @@ -3408,7 +3408,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.2.0" +version = "0.2.1" dependencies = [ "clap-sys", "libloading", @@ -3420,7 +3420,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.2.0" +version = "0.2.1" dependencies = [ "serde_json", "soundcraft-audio-io", @@ -3431,7 +3431,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.2.0" +version = "0.2.1" dependencies = [ "log", "proptest", @@ -3442,7 +3442,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.2.0" +version = "0.2.1" dependencies = [ "log", "proptest", @@ -3462,7 +3462,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.2.0" +version = "0.2.1" dependencies = [ "proptest", "serde", @@ -3472,7 +3472,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.2.0" +version = "0.2.1" dependencies = [ "log", "rayon", @@ -3488,7 +3488,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.2.0" +version = "0.2.1" dependencies = [ "proptest", "serde", @@ -3501,7 +3501,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.2.0" +version = "0.2.1" dependencies = [ "cpal", "log", @@ -3514,7 +3514,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.2.0" +version = "0.2.1" dependencies = [ "proptest", "serde", @@ -3524,7 +3524,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.2.0" +version = "0.2.1" dependencies = [ "egui", "egui_kittest", @@ -3549,7 +3549,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.2.0" +version = "0.2.1" dependencies = [ "image", "proptest", @@ -3560,7 +3560,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.2.0" +version = "0.2.1" dependencies = [ "libloading", "log", @@ -3575,7 +3575,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.2.0" +version = "0.2.1" dependencies = [ "eframe", "egui", @@ -5122,7 +5122,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.2.0" +version = "0.2.1" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index bb95118..fcfe030 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.2.0" +version = "0.2.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index 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) } From 8115ba9ed5b1073ca6333e8cc3060829f45f19f0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 8 Oct 2026 09:34:50 -0400 Subject: [PATCH 05/26] Release: Flatpak x86_64/aarch64, AppImage zsync updates, README Downloads (#13) - AppImages embed gh-releases-zsync update information and ship a .zsync beside them. - New flatpak job repackages the Linux tarball into soundcraft--linux-.flatpak (bundle manifest + flatpak-bundle.sh). Sandbox: pulseaudio socket (PipeWire's pulse server), ~/.vst3 and ~/.clap read-only, the LinuxAudio.Plugins extension point via VST3_PATH/CLAP_PATH; no raw ALSA devices, no JACK. The from-source Flathub manifest gets the same finish-args. - Only signing jobs (macOS, Windows) and the draft job keep `environment: release`, so a branch dispatch dry-runs Linux, Flatpak, FreeBSD and web; the draft job still needs the signed jobs. - README: Downloads section; docs/releasing.md updated. Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- .github/workflows/packaging-lint.yml | 12 ++- .github/workflows/release.yml | 62 +++++++++++++--- README.md | 45 ++++++++++++ docs/releasing.md | 45 ++++++++---- packaging/linux/flatpak-bundle.sh | 72 ++++++++++++++++++ .../ai.storyteller.soundcraft.bundle.yml | 73 +++++++++++++++++++ .../flatpak/ai.storyteller.soundcraft.yml | 39 +++++++++- packaging/linux/package.sh | 30 ++++++-- 8 files changed, 344 insertions(+), 34 deletions(-) create mode 100755 packaging/linux/flatpak-bundle.sh create mode 100644 packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 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/README.md b/README.md index c0d4591..00bc5a0 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Agents & scripts · How it's built · Status · + Downloads · The Crafting Apps · License

@@ -213,6 +214,50 @@ honest status, what's next and our effort estimates are in [`ROADMAP.md`](ROADMA menu-by-menu comparison is in [`docs/parity.md`](docs/parity.md). Bug reports and wish lists are very welcome, in the issues or on Discord. +## Downloads + +Every [release](https://github.com/storytold/soundcraft/releases/latest) ships these builds. `` is the +version number; `SHA256SUMS.txt` lists a checksum for every file. + +### macOS + +| Build | File | Notes | +|---|---|---| +| App, universal (Apple silicon + Intel) | `soundcraft--macos-universal.dmg` | Signed and notarized | +| Command-line tool, universal | `soundcraft-cli--macos-universal.zip` | Signed and notarized | + +### Windows + +| Build | Installer | Portable | +|---|---|---| +| x64 (64-bit Intel/AMD) | `soundcraft--windows-x64.msi` | `soundcraft--windows-x64-portable.zip` | +| arm64 (Snapdragon and other ARM PCs) | `soundcraft--windows-arm64.msi` | `soundcraft--windows-arm64-portable.zip` | +| x86 (32-bit) | `soundcraft--windows-x86.msi` | `soundcraft--windows-x86-portable.zip` | + +Installers and executables are code-signed. + +### Linux + +| Format | x86_64 | aarch64 (ARM64) | Notes | +|---|---|---|---| +| AppImage | `soundcraft--linux-x86_64.AppImage` | `soundcraft--linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) | +| Flatpak | `soundcraft--linux-x86_64.flatpak` | `soundcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` | +| Debian/Ubuntu | `soundcraft--linux-x86_64.deb` | `soundcraft--linux-aarch64.deb` | | +| Fedora/RHEL/openSUSE | `soundcraft--linux-x86_64.rpm` | `soundcraft--linux-aarch64.rpm` | | +| Tarball | `soundcraft--linux-x86_64.tar.gz` | `soundcraft--linux-aarch64.tar.gz` | Unpack anywhere | + +### FreeBSD + +| Build | File | +|---|---| +| x86_64 | `soundcraft--freebsd-x86_64.tar.gz` | + +### Web (WebAssembly) + +| Build | File | Notes | +|---|---|---| +| Static site | `soundcraft-web-.zip` | Runs in a modern browser; host it on any static server | + ## The Crafting Apps SoundCraft is one of the **Crafting Apps**: free, open-source creative tools from the diff --git a/docs/releasing.md b/docs/releasing.md index 1d89fcd..2f7894d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -35,9 +35,10 @@ After the draft is published, the workflow refuses to touch that version again, **Test runs:** *Actions → Release → Run workflow* runs the whole pipeline by hand. The optional `version` input (such as `0.2.0-rc.1`) overrides `Cargo.toml` for that run only. The jobs apply it -with `cargo xtask version set` before building, so the binaries report it too. The run still needs -the `release` environment, which only the `release` branch can use, so pick that branch in the -dialog. +with `cargo xtask version set` before building, so the binaries report it too. Only the signing +jobs (macOS, Windows) and the draft-release job use the `release` environment, which only the +`release` branch can use. On any other branch the run is a dry run: Linux, Flatpak, FreeBSD and +web build and are checked, the signing jobs are refused, and no release is drafted. ## What gets built @@ -49,11 +50,14 @@ dialog. | Windows 11 ARM64 | `soundcraft--windows-arm64.msi`, `soundcraft--windows-arm64-portable.zip` | `windows-latest` (cross-compiled; `windows-arm64.yml` installs and runs it on ARM64) | | Linux x86_64 | `soundcraft--linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` | | Linux aarch64 | `soundcraft--linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | +| AppImage updates | `soundcraft--linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImage | +| Flatpak x86_64, aarch64 | `soundcraft--linux-{x86_64,aarch64}.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackages the Linux tarball) | | FreeBSD 14 x86_64 | `soundcraft--freebsd-x86_64.tar.gz` (a `/usr/local` tree) | FreeBSD 14.3 VM on `ubuntu-latest` | | Web | `soundcraft-web-.zip` (static site; see [`packaging/web/README.md`](../packaging/web/README.md)) | `ubuntu-latest` | -The Flatpak manifest (`packaging/linux/flatpak/ai.storyteller.soundcraft.yml`) is kept ready for a -Flathub submission and validated in CI, but not built there (see Linux below). +The from-source Flatpak manifest (`packaging/linux/flatpak/ai.storyteller.soundcraft.yml`) is +kept ready for a Flathub submission; the release's `.flatpak` bundles come from +`ai.storyteller.soundcraft.bundle.yml` (see Linux below). ### Audio libraries @@ -68,7 +72,8 @@ plugins), and WebAudio in the browser. routes ALSA to the OSS `sound(4)` devices. Without a device the player falls back to its silent clock, so the transport still runs. - **Flatpak**: alsa-lib and its PulseAudio plugin come with the freedesktop runtime; the - manifest grants `--socket=pulseaudio`. + manifests grant `--socket=pulseaudio` (PipeWire's pulse server answers on it). No raw ALSA + devices and no JACK: SoundCraft doesn't use either. - **Web**: browsers start audio only after a user gesture, so the first click in the page may be needed before sound plays. The web build has no recording. @@ -131,14 +136,24 @@ Locally on Windows: `dotnet tool install -g wix --version 5.0.2`, then MIDI), hicolor icons from 16 px to 512 px plus a scalable SVG, AppStream metainfo, and a shared-mime-info file declaring `application/x-soundcraft-session` (`*.scraft`). -- **AppImage** (maintained `AppImage/appimagetool`, static runtime: no libfuse2 needed). +- **AppImage** (maintained `AppImage/appimagetool`, static runtime: no libfuse2 needed). Each + embeds the update information + `gh-releases-zsync|storytold|soundcraft|latest|soundcraft-*-linux-.AppImage.zsync`, and + the `.zsync` next to it (written when `zsyncmake` from the `zsync` package is installed, as in + CI) lets AppImageUpdate fetch only the changed blocks of the newest non-pre-release. - **.deb** and **.rpm** by [nfpm](https://nfpm.goreleaser.com/) from one `nfpm.yaml`; the `postinst.sh` hook refreshes the desktop, MIME and icon caches. - **.tar.gz** for people who manage their own `/opt` or `~/.local`. -- **Flatpak**: `packaging/linux/flatpak/ai.storyteller.soundcraft.yml` builds from source on the - freedesktop 25.08 runtime with Wayland/X11, `dri`, PulseAudio and `xdg-music`; other files go - through the portals. A real build needs vendored crates (`cargo-sources.json` from - `flatpak-cargo-generator.py`); the commands are in the manifest's header. +- **Flatpak**: the `flatpak` job runs `packaging/linux/flatpak-bundle.sh` on the Linux job's + tarball (no Rust build) with `flatpak/ai.storyteller.soundcraft.bundle.yml`, then installs the + bundle and runs `soundcraft-cli --version` in the sandbox. Install with + `flatpak install --user soundcraft--linux-.flatpak`. Both manifests use the freedesktop + 25.08 runtime with Wayland/X11, `dri`, PulseAudio and `xdg-music`; other files go through the + portals. For the VST3 and CLAP hosts they grant `~/.vst3` and `~/.clap` read-only and mount + Flathub's `org.freedesktop.LinuxAudio.Plugins` extensions (pointed to by `VST3_PATH` and + `CLAP_PATH`); system plugin folders aren't visible in the sandbox. + `ai.storyteller.soundcraft.yml` builds from source for Flathub and needs vendored crates + (`cargo-sources.json` from `flatpak-cargo-generator.py`); the commands are in its header. Binaries are built on Ubuntu 22.04 and need **glibc ≥ 2.35**. X11, Wayland, xkbcommon, Vulkan and EGL are loaded at runtime, so the packages declare them as dependencies or recommendations. @@ -164,8 +179,8 @@ through WebAudio. ## Secrets All secrets live in the repository's **`release` environment** (*Settings → Environments → -release*), restricted to the `release` branch. Every job in `release.yml` declares -`environment: release`. Each secret is optional: if one is missing, that platform's artifacts +release*), restricted to the `release` branch. Only the jobs that sign (macOS, Windows) and the +draft-release job declare `environment: release`; the others get no secrets. Each secret is optional: if one is missing, that platform's artifacts are unsigned and the run shows a `::warning::`. Scripts never echo secret values; certificates are written with `umask 077` and deleted in `always()`/`finally` steps. @@ -197,5 +212,5 @@ those tools. `apps/soundcraft/src/main.rs` sets the runtime window/Dock/taskbar `.github/workflows/packaging-lint.yml` runs in seconds on any change to `packaging/`, the workflows or the icons: actionlint, shellcheck, a PowerShell parse, xmllint (WiX, plists, -AppStream, MIME), `desktop-file-validate`, `appstreamcli validate`, and a YAML check of the -Flatpak manifest. +AppStream, MIME), `desktop-file-validate`, `appstreamcli validate`, and a check that the two +Flatpak manifests agree on runtime, finish-args and plugin extensions. diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh new file mode 100755 index 0000000..1b40026 --- /dev/null +++ b/packaging/linux/flatpak-bundle.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Repackage a SoundCraft Linux tarball as a single-file Flatpak bundle: +# +# $DIST/soundcraft--linux-.flatpak +# +# Usage: packaging/linux/flatpak-bundle.sh [--no-test] [TARBALL] +# +# TARBALL defaults to $DIST/soundcraft--linux-.tar.gz from package.sh. The bundle +# is built for the host architecture (x86_64 or aarch64), which must match the tarball's. +# Needs flatpak, flatpak-builder and the SVG pixbuf loader (librsvg2-common) for the host's +# `appstreamcli compose`; the freedesktop runtime and SDK named in the manifest are +# installed per-user from Flathub. Unless --no-test, the bundle is then installed per-user and +# `soundcraft-cli --version` is run inside the sandbox as a smoke test. +# Manifest: packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml. +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" +APP_ID=ai.storyteller.soundcraft +FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo + +TEST=1 +TARBALL="" +while [ $# -gt 0 ]; do + case "$1" in + --no-test) TEST=0; shift ;; + -h | --help) sed -n '2,14p' "$0"; exit 0 ;; + -*) echo "unknown argument: $1" >&2; exit 2 ;; + *) TARBALL="$1"; shift ;; + esac +done + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ;; + aarch64 | arm64) ARCH=aarch64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +esac +BASENAME="soundcraft-$VERSION-linux-$ARCH" +TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}" +[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } +for tool in flatpak flatpak-builder; do + command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } +done + +echo "==> SoundCraft $VERSION Flatpak bundle for $ARCH from $(basename "$TARBALL")" + +WORK="$CARGO_TARGET_DIR/flatpak-bundle" +rm -rf "$WORK" +mkdir -p "$WORK/stage" +tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 +[ -x "$WORK/stage/bin/soundcraft" ] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; } +cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" + +flatpak remote-add --user --if-not-exists flathub "$FLATHUB" +# --disable-rofiles-fuse: no FUSE in containers and CI runners. +flatpak-builder --user --install-deps-from=flathub --disable-rofiles-fuse --force-clean \ + --default-branch=stable --repo="$WORK/repo" --state-dir="$WORK/state" "$WORK/build" "$WORK/$APP_ID.yml" + +OUT="$DIST/$BASENAME.flatpak" +# --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing. +flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable +echo "wrote $OUT" + +if [ "$TEST" = 1 ]; then + flatpak install --user -y --noninteractive --reinstall "$OUT" + flatpak info --user "$APP_ID" + flatpak run --command=soundcraft-cli "$APP_ID" --version + flatpak run --command=sh "$APP_ID" -c 'ls /app/share/applications /app/share/metainfo /app/share/mime/packages /app/share/icons/hicolor/scalable/apps' +fi +echo "==> done" +ls -lh "$OUT" diff --git a/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml new file mode 100644 index 0000000..ff502f0 --- /dev/null +++ b/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml @@ -0,0 +1,73 @@ +# Flatpak manifest for the single-file bundle attached to every GitHub release +# (soundcraft--linux-.flatpak). Build it with packaging/linux/flatpak-bundle.sh. +# +# It repackages the release's own Linux tarball (soundcraft--linux-.tar.gz, +# built on Ubuntu 22.04 by package.sh) instead of compiling Rust inside flatpak-builder: no +# network and no vendored crate sources during the build, a few seconds instead of 20+ minutes, +# and byte-for-byte the same binaries as the AppImage/deb/rpm. The script unpacks the tarball +# into `stage/` next to a copy of this file. The binaries need glibc >= 2.35 and load X11, +# Wayland, xkbcommon, Vulkan and libasound at runtime; the freedesktop runtime has all of them +# (plus alsa-lib's PulseAudio plugin), and the GPU driver comes from its +# org.freedesktop.Platform.GL extension. +# +# ai.storyteller.soundcraft.yml (same directory) is the from-source manifest for Flathub. Keep +# runtime-version, finish-args and add-extensions identical in both (packaging-lint checks this). +id: ai.storyteller.soundcraft +runtime: org.freedesktop.Platform +runtime-version: '25.08' +sdk: org.freedesktop.Sdk +command: soundcraft +finish-args: + # Display: Wayland first; X11 only when no Wayland compositor is running. + - --socket=wayland + - --socket=fallback-x11 + # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. + - --share=ipc + # GPU access for the wgpu UI (Vulkan, or GL as a fallback). + - --device=dri + # Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its + # PulseAudio plugin to this socket, which PipeWire's pulse server provides on modern desktops + # (and PulseAudio itself on older ones). Deliberately not granted: --device=all (raw /dev/snd + # for direct ALSA hardware access, which would bypass the sound server) and JACK, which + # SoundCraft doesn't use (cpal's jack feature is off; there is no live MIDI I/O either). + - --socket=pulseaudio + # Open/Save dialogs use the file-chooser portal (rfd) and drag-and-drop the document portal, + # so broad filesystem access isn't needed. Music is granted for sessions and their + # "Audio Files" folders opened by path from the command line. + - --filesystem=xdg-music + # VST3 and CLAP plugin hosts. The per-user plugin folders the hosts scan, read-only. + # System folders (/usr/lib/vst3, /usr/lib/clap) aren't reachable from the sandbox, and plugins + # there are linked against the host's libraries rather than the runtime's; sandboxed plugins + # come from Flatpak extensions instead (add-extensions below), found through these paths. + - --filesystem=~/.vst3:ro + - --filesystem=~/.clap:ro + - --env=VST3_PATH=/app/extensions/Plugins/vst3 + - --env=CLAP_PATH=/app/extensions/Plugins/clap +# Flathub's shared audio-plugin extension point (org.freedesktop.LinuxAudio.Plugins.*, the one +# Ardour, Bitwig and friends use). Optional: nothing is downloaded unless the user installs a +# plugin extension; merge-dirs lets every installed one appear under .../Plugins/{vst3,clap}. +add-extensions: + org.freedesktop.LinuxAudio.Plugins: + directory: extensions/Plugins + version: '25.08' + add-ld-path: lib + merge-dirs: vst3;clap + subdirectories: true + no-autodownload: true +modules: + - name: soundcraft + buildsystem: simple + build-commands: + - install -Dm755 bin/soundcraft /app/bin/soundcraft + - install -Dm755 bin/soundcraft-cli /app/bin/soundcraft-cli + - install -Dm644 share/applications/ai.storyteller.soundcraft.desktop /app/share/applications/ai.storyteller.soundcraft.desktop + # Session files; the runtime's shared-mime-info already knows WAV, AIFF, FLAC, MP3 and MIDI. + - install -Dm644 share/mime/packages/ai.storyteller.soundcraft.xml /app/share/mime/packages/ai.storyteller.soundcraft.xml + # Already has the release's version and date substituted by package.sh. + - install -Dm644 share/metainfo/ai.storyteller.soundcraft.metainfo.xml /app/share/metainfo/ai.storyteller.soundcraft.metainfo.xml + - mkdir -p /app/share/icons /app/share/doc && cp -R share/icons/hicolor /app/share/icons/ && cp -R share/doc/soundcraft /app/share/doc/ + # Mount point for the plugin extensions (add-extensions). + - install -d /app/extensions/Plugins + sources: + - type: dir + path: stage diff --git a/packaging/linux/flatpak/ai.storyteller.soundcraft.yml b/packaging/linux/flatpak/ai.storyteller.soundcraft.yml index c26df34..bec9ffb 100644 --- a/packaging/linux/flatpak/ai.storyteller.soundcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.soundcraft.yml @@ -8,6 +8,9 @@ # flatpak run ai.storyteller.soundcraft # # For Flathub, replace the `dir` source with a `git` source pinned to a release tag and commit. +# ai.storyteller.soundcraft.bundle.yml repackages the release tarball into the .flatpak attached +# to each GitHub release; keep runtime-version, finish-args and add-extensions identical in both +# (packaging-lint checks this). # # Audio: cpal talks to ALSA. alsa-lib (headers in the Sdk, the library in the Platform) and its # PulseAudio plugin ship with the freedesktop runtime, which routes ALSA through the PulseAudio @@ -20,16 +23,42 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: soundcraft finish-args: - - --share=ipc + # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 + # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. + - --share=ipc + # GPU access for the wgpu UI (Vulkan, or GL as a fallback). - --device=dri - # Playback and recording (ALSA through the PulseAudio/PipeWire socket). + # Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its + # PulseAudio plugin to this socket, which PipeWire's pulse server provides on modern desktops + # (and PulseAudio itself on older ones). Deliberately not granted: --device=all (raw /dev/snd + # for direct ALSA hardware access, which would bypass the sound server) and JACK, which + # SoundCraft doesn't use (cpal's jack feature is off; there is no live MIDI I/O either). - --socket=pulseaudio - # Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document - # portal, so broad filesystem access isn't needed. Music is granted for sessions and their + # Open/Save dialogs use the file-chooser portal (rfd) and drag-and-drop the document portal, + # so broad filesystem access isn't needed. Music is granted for sessions and their # "Audio Files" folders opened by path from the command line. - --filesystem=xdg-music + # VST3 and CLAP plugin hosts. The per-user plugin folders the hosts scan, read-only. + # System folders (/usr/lib/vst3, /usr/lib/clap) aren't reachable from the sandbox, and plugins + # there are linked against the host's libraries rather than the runtime's; sandboxed plugins + # come from Flatpak extensions instead (add-extensions below), found through these paths. + - --filesystem=~/.vst3:ro + - --filesystem=~/.clap:ro + - --env=VST3_PATH=/app/extensions/Plugins/vst3 + - --env=CLAP_PATH=/app/extensions/Plugins/clap +# Flathub's shared audio-plugin extension point (org.freedesktop.LinuxAudio.Plugins.*, the one +# Ardour, Bitwig and friends use). Optional: nothing is downloaded unless the user installs a +# plugin extension; merge-dirs lets every installed one appear under .../Plugins/{vst3,clap}. +add-extensions: + org.freedesktop.LinuxAudio.Plugins: + directory: extensions/Plugins + version: '25.08' + add-ld-path: lib + merge-dirs: vst3;clap + subdirectories: true + no-autodownload: true build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: @@ -51,6 +80,8 @@ modules: install -d /app/share/metainfo; sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/" packaging/linux/ai.storyteller.soundcraft.metainfo.xml.in > /app/share/metainfo/ai.storyteller.soundcraft.metainfo.xml + # Mount point for the plugin extensions (add-extensions). + - install -d /app/extensions/Plugins sources: - type: dir path: ../../.. diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index ba0b6c7..65e9a60 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -2,6 +2,7 @@ # Build and package SoundCraft for Linux ( is x86_64 or aarch64): # # $DIST/soundcraft--linux-.AppImage any distro with glibc >= the build host's +# $DIST/soundcraft--linux-.AppImage.zsync delta updates (needs zsyncmake) # $DIST/soundcraft--linux-.deb Debian, Ubuntu, Mint, Pop!_OS, ... # $DIST/soundcraft--linux-.rpm Fedora, openSUSE, RHEL, ... # $DIST/soundcraft--linux-.tar.gz plain FHS-style tree (bin/, share/) @@ -10,7 +11,8 @@ # # Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage # (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, -# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli. +# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, +# zsyncmake (the zsync package) for the AppImage's .zsync. set -euo pipefail # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" @@ -23,7 +25,7 @@ while [ $# -gt 0 ]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; - -h | --help) sed -n '2,15p' "$0"; exit 0 ;; + -h | --help) sed -n '2,17p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done @@ -108,11 +110,29 @@ if has appimage; then chmod +x "$TOOL" fi fi - OUT="$DIST/$BASENAME.AppImage" + # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be + # relative). + OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" + APPDIR="$(cd "$APPDIR" && pwd)" + TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")" + # A .zsync left by an earlier run would hide a missing zsyncmake and describe another file. + rm -f "$OUT.zsync" + # Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and + # fetch only the blocks that changed in a newer release, through the .zsync published next to + # each AppImage on GitHub Releases. `latest` is the newest published release that is not a + # pre-release. A fork's builds point at its own releases through GITHUB_REPOSITORY. + REPO="${GITHUB_REPOSITORY:-storytold/soundcraft}" + UPDATE_INFO="gh-releases-zsync|${REPO%%/*}|${REPO#*/}|latest|soundcraft-*-linux-$ARCH.AppImage.zsync" # Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime, - # so users don't need libfuse2 either. - ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream "$APPDIR" "$OUT" + # so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync + # package) appimagetool also writes the .zsync, into its working directory, hence the cd. + (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" + if [ -s "$OUT.zsync" ]; then + echo "wrote $OUT.zsync" + else + warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it" + fi fi "$STAGE/usr/bin/soundcraft-cli" --version From 684243c7bcb85bf54e8e0300bab2f69b790d8e79 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 8 Oct 2026 09:50:23 -0400 Subject: [PATCH 06/26] README Downloads: where to get it first, then builds with Windows before macOS (#14) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- README.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 00bc5a0..398061e 100644 --- a/README.md +++ b/README.md @@ -216,15 +216,7 @@ very welcome, in the issues or on Discord. ## Downloads -Every [release](https://github.com/storytold/soundcraft/releases/latest) ships these builds. `` is the -version number; `SHA256SUMS.txt` lists a checksum for every file. - -### macOS - -| Build | File | Notes | -|---|---|---| -| App, universal (Apple silicon + Intel) | `soundcraft--macos-universal.dmg` | Signed and notarized | -| Command-line tool, universal | `soundcraft-cli--macos-universal.zip` | Signed and notarized | +**Download SoundCraft** from GitHub: the [latest release](https://github.com/storytold/soundcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/soundcraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. ### Windows @@ -236,6 +228,13 @@ version number; `SHA256SUMS.txt` lists a checksum for every file. Installers and executables are code-signed. +### macOS + +| Build | File | Notes | +|---|---|---| +| App, universal (Apple silicon + Intel) | `soundcraft--macos-universal.dmg` | Signed and notarized | +| Command-line tool, universal | `soundcraft-cli--macos-universal.zip` | Signed and notarized | + ### Linux | Format | x86_64 | aarch64 (ARM64) | Notes | From c51e5d5ec52f11c6264b72e26661fa712feb5345 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 8 Oct 2026 10:02:25 -0400 Subject: [PATCH 07/26] Bump version to 0.3.0 (from 0.2.1) for release Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 36 ++++++++++++++++++------------------ Cargo.toml | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 402aa74..e6719ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3361,7 +3361,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.2.1" +version = "0.3.0" dependencies = [ "eframe", "egui", @@ -3376,7 +3376,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.2.1" +version = "0.3.0" dependencies = [ "log", "serde", @@ -3386,7 +3386,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.2.1" +version = "0.3.0" dependencies = [ "log", "proptest", @@ -3398,7 +3398,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.2.1" +version = "0.3.0" dependencies = [ "log", "serde_json", @@ -3408,7 +3408,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.2.1" +version = "0.3.0" dependencies = [ "clap-sys", "libloading", @@ -3420,7 +3420,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.2.1" +version = "0.3.0" dependencies = [ "serde_json", "soundcraft-audio-io", @@ -3431,7 +3431,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.2.1" +version = "0.3.0" dependencies = [ "log", "proptest", @@ -3442,7 +3442,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.2.1" +version = "0.3.0" dependencies = [ "log", "proptest", @@ -3462,7 +3462,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.2.1" +version = "0.3.0" dependencies = [ "proptest", "serde", @@ -3472,7 +3472,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.2.1" +version = "0.3.0" dependencies = [ "log", "rayon", @@ -3488,7 +3488,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.2.1" +version = "0.3.0" dependencies = [ "proptest", "serde", @@ -3501,7 +3501,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.2.1" +version = "0.3.0" dependencies = [ "cpal", "log", @@ -3514,7 +3514,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.2.1" +version = "0.3.0" dependencies = [ "proptest", "serde", @@ -3524,7 +3524,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.2.1" +version = "0.3.0" dependencies = [ "egui", "egui_kittest", @@ -3549,7 +3549,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.2.1" +version = "0.3.0" dependencies = [ "image", "proptest", @@ -3560,7 +3560,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.2.1" +version = "0.3.0" dependencies = [ "libloading", "log", @@ -3575,7 +3575,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.2.1" +version = "0.3.0" dependencies = [ "eframe", "egui", @@ -5122,7 +5122,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.2.1" +version = "0.3.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index fcfe030..6f07dfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.2.1" +version = "0.3.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From b919713ea7602d7d1e1b79c847bb8c7195e4598a Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:06:42 +0200 Subject: [PATCH 08/26] Fix keyboard shortcuts on Windows and Linux: Ctrl is the Cmd key (#15) egui reports a held Ctrl as both ctrl and command off macOS, so no Cmd+ shortcut matched and Ctrl+S opened Search (Cmd+Ctrl+S) instead of saving. Off macOS a held Ctrl now stands for the shortcut's Cmd, shortcuts that also need the Mac Control key stay off the keyboard there (Ctrl+X is Cut, not Cmd+Ctrl+X), and the Search chord is macOS only. --- crates/ui-egui/src/shortcuts.rs | 54 ++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index cc35c90..2326b24 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -41,8 +41,20 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> { key.map(|k| (m, k)) } -fn mods_match(want: Modifiers, got: Modifiers) -> bool { - want.command == got.command && want.shift == got.shift && want.alt == got.alt && (want.ctrl == got.ctrl || got.mac_cmd) +/// Cmd and Ctrl are separate keys only on macOS. +const MAC: bool = cfg!(target_os = "macos"); + +/// Whether the held modifiers `got` fire a shortcut that asks for `want`. +fn mods_match(want: Modifiers, got: Modifiers, mac: bool) -> bool { + // Off macOS egui reports Ctrl as both `ctrl` and `command`: Ctrl is the shortcut's Cmd, and a shortcut's + // Ctrl (the Mac Control key) has no key of its own there, so Ctrl+X is Cut, not Cmd+Ctrl+X. + let ctrl = if mac { want.ctrl == got.ctrl || got.mac_cmd } else { !want.ctrl }; + want.command == got.command && want.shift == got.shift && want.alt == got.alt && ctrl +} + +/// Cmd+Ctrl+S opens Search. Off macOS Ctrl+S is Save (Cmd+S), so Search is reached from the Window menu. +fn search_chord(m: Modifiers, mac: bool) -> bool { + mac && m.command && m.ctrl } pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { @@ -63,7 +75,7 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { continue; } let hit = - soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods))); + soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, MAC))); if let Some(c) = hit { let id = c.id; if crate::menus::invoke_shortcut_dialog(app, id) { @@ -87,7 +99,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool { let _ = app.run("transport.record", json!({})); true } - Key::S if m.command && m.ctrl => { + Key::S if search_chord(m, MAC) => { let _ = app.run("window.search", json!({})); true } @@ -227,3 +239,37 @@ fn tab_clip(app: &mut SoundApp, back: bool) { let _ = app.run("transport.locate", json!({"at": n})); } } + +#[cfg(test)] +mod tests { + use super::{mods_match, parse, search_chord}; + use egui::Modifiers; + + /// Ctrl held on Windows or Linux, as egui reports it. + const PC_CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + /// Cmd held on macOS. + const MAC_CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + + fn want(s: &str) -> Modifiers { + parse(s).map(|(m, _)| m).unwrap() + } + + #[test] + fn ctrl_is_cmd_off_macos() { + assert!(mods_match(want("Cmd+S"), PC_CTRL, false)); + assert!(mods_match(want("Cmd+Shift+N"), Modifiers { shift: true, ..PC_CTRL }, false)); + assert!(mods_match(want("Cmd+X"), PC_CTRL, false)); + assert!(!mods_match(want("Cmd+Ctrl+X"), PC_CTRL, false)); + assert!(!mods_match(want("Cmd+S"), Modifiers::NONE, false)); + assert!(!search_chord(PC_CTRL, false)); + } + + #[test] + fn cmd_and_ctrl_stay_apart_on_macos() { + assert!(mods_match(want("Cmd+S"), MAC_CMD, true)); + assert!(!mods_match(want("Cmd+S"), Modifiers::CTRL, true)); + assert!(mods_match(want("Cmd+Ctrl+X"), Modifiers { ctrl: true, ..MAC_CMD }, true)); + assert!(!search_chord(MAC_CMD, true)); + assert!(search_chord(Modifiers { ctrl: true, ..MAC_CMD }, true)); + } +} From c8437c5c61ec1c63cd8b357f5c85e5860ca3c2de Mon Sep 17 00:00:00 2001 From: Gary Gunn Date: Thu, 8 Oct 2026 21:24:47 +0100 Subject: [PATCH 09/26] Fix tempo change from the tempo ruler not saving Double-clicking the tempo ruler opened a number dialog whose command was built as "event.tempo@". No such command exists, so OK failed and nothing changed. The position was also never passed, so a change would have landed at the session start. Add a TempoChange dialog that carries the position and runs event.tempo with bpm and at. Add an engine test that a tempo change at a position is applied there. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/cmd/event.rs | 18 ++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 14 ++++++++++++++ crates/ui-egui/src/edit_window.rs | 8 +------- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index bfbb6df..34fb355 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -359,3 +359,21 @@ fn step_marker(e: &mut Engine, dir: i64) -> Result { e.transport_requests.push(crate::TransportRequest::Locate(t)); Ok(json!({"at": t})) } + +#[cfg(test)] +mod tests { + use crate::demo::demo_engine; + use serde_json::json; + + /// The tempo-ruler dialog runs `event.tempo` with `bpm` and `at`; the change must land at that position. + #[test] + fn tempo_change_at_position_is_applied() { + let mut e = demo_engine(); + let sr = e.session().sample_rate; + let at = sr.samples(4.0); + e.execute("event.tempo", &json!({"bpm": 133.5, "at": at})).unwrap(); + let s = e.session(); + let tick = s.tempo.samples_to_ticks(at, s.sample_rate); + assert!((s.tempo.tempo_at_tick(tick) - 133.5).abs() < 1e-9); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6815d60..dc09ec7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6,6 +6,7 @@ use egui::{Align2, vec2}; use serde_json::{Value, json}; use soundcraft_engine::Engine; use soundcraft_model::{ClipId, TrackId}; +use soundcraft_time::Samples; #[derive(Debug, Clone)] pub enum Dialog { @@ -16,6 +17,8 @@ pub enum Dialog { RenameClip { id: ClipId, name: String }, PathPrompt { cmd: String, title: String, path: String, key: String }, Number { cmd: String, title: String, key: String, value: f64, suffix: String }, + /// Tempo change at a position, from double-clicking the tempo ruler. + TempoChange { at: Samples, bpm: f64 }, Fades { shape: String }, StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 }, Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> }, @@ -38,6 +41,7 @@ impl Dialogs { Dialog::RenameClip { .. } => "rename_clip", Dialog::PathPrompt { .. } => "path", Dialog::Number { .. } => "number", + Dialog::TempoChange { .. } => "tempo_change", Dialog::Fades { .. } => "fades", Dialog::StripSilence { .. } => "strip_silence", Dialog::Group { .. } => "group", @@ -167,6 +171,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::RenameTrack { .. } => "Rename Track", Dialog::RenameClip { .. } => "Rename Clip", Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(), + Dialog::TempoChange { .. } => "Tempo Change (BPM)", Dialog::Fades { .. } => "Fades", Dialog::StripSilence { .. } => "Strip Silence", Dialog::Group { .. } => "Create Group", @@ -315,6 +320,15 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { action = Some((cmd.clone(), v)); } } + Dialog::TempoChange { at, bpm } => { + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(bpm).speed(0.1).range(1.0..=999.0)); + ui.label("bpm"); + }); + if buttons(ui, "OK", enter) { + action = Some(("event.tempo".into(), json!({"bpm": bpm, "at": at}))); + } + } Dialog::Fades { shape } => { for s in soundcraft_model::FadeShape::ALL { ui.radio_value(shape, s.label().to_lowercase(), s.label()); diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 17a54b0..24391ec 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -372,13 +372,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ { let at = snap(&s, sample_at(&s, tl, p.x).max(0)); let bpm = s.tempo.tempo_at_tick(s.tempo.samples_to_ticks(at, sr)); - app.dialogs.open = Some(crate::dialogs::Dialog::Number { - cmd: format!("event.tempo@{at}"), - title: "Tempo Change (BPM)".into(), - key: "bpm".into(), - value: bpm, - suffix: "bpm".into(), - }); + app.dialogs.open = Some(crate::dialogs::Dialog::TempoChange { at, bpm }); } let mut last_label = f32::MIN; for ev in s.tempo.tempos() { From 368c3df2766cea3c6305a7fe93691544295d02ab Mon Sep 17 00:00:00 2001 From: Gary Gunn Date: Thu, 8 Oct 2026 21:26:40 +0100 Subject: [PATCH 10/26] Make the Click option audible: metronome in live playback Options > Click set a flag but nothing rendered sound, so a click track was silent. The mix engine now synthesizes a short blip on every beat of the tempo map (accent on beat 1), using the click.* settings from Setup > Click/Countoff, and sums it after the master fader. The metronome is opt-in per engine: only live playback enables it. Offline renders (bounces, stems, exports) leave it off so the click is never printed into a file. Also: midi_to_hz moves from the engine demo into soundcraft-dsp so the mix and demo share one tuning formula. Co-Authored-By: Claude Haiku 5.5 --- crates/dsp/src/lib.rs | 5 ++ crates/engine/src/demo.rs | 8 +-- crates/mix/src/lib.rs | 126 +++++++++++++++++++++++++++++++++++++ crates/playback/src/lib.rs | 1 + 4 files changed, 134 insertions(+), 6 deletions(-) diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs index 6b6c023..7572ac3 100644 --- a/crates/dsp/src/lib.rs +++ b/crates/dsp/src/lib.rs @@ -278,6 +278,11 @@ pub fn gain_to_db(g: f32) -> f32 { (20.0 * a.log10()).max(MIN_DB) } +/// MIDI note number to frequency in Hz (equal temperament, A4 = note 69 = 440 Hz). +pub fn midi_to_hz(note: f64) -> f64 { + 440.0 * 2f64.powf((note - 69.0) / 12.0) +} + #[cfg(test)] mod trait_default_tests { use super::*; diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index bc8a2a2..1e7daa0 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -110,10 +110,6 @@ fn drums(sr: f64, frames: usize) -> (Vec, Vec, Vec) { (k, s, h) } -fn midi_hz(n: f64) -> f64 { - 440.0 * 2f64.powf((n - 69.0) / 12.0) -} - /// Chord roots per bar (A minor, F, C, G progression), as MIDI notes. const PROG: [[u8; 4]; 4] = [[57, 60, 64, 69], [53, 57, 60, 65], [48, 52, 55, 60], [55, 59, 62, 67]]; @@ -127,7 +123,7 @@ fn bass(sr: f64, frames: usize) -> Vec { for (eighth, semi, beats) in pattern { let at = bar * beat * 4 + eighth * beat / 2; let len = (beats * beat as f64) as usize; - let f = midi_hz(root + semi); + let f = soundcraft_dsp::midi_to_hz(root + semi); let mut ph = 0.0f64; for i in 0..len { let Some(o) = out.get_mut(at + i) else { break }; @@ -152,7 +148,7 @@ fn pad(sr: f64, frames: usize) -> (Vec, Vec) { let chord = PROG[bar % 4]; let at = bar * bar_len; for (vi, n) in chord.iter().enumerate() { - let f = midi_hz(f64::from(*n) + 12.0); + let f = soundcraft_dsp::midi_to_hz(f64::from(*n) + 12.0); for (side, buf) in [(-1.0f64, &mut l), (1.0, &mut r)] { let det = 1.0 + side * 0.0035 * (vi as f64 + 1.0); let mut ph = vi as f64 * 0.17; diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index ecc15af..28e9d1d 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -299,6 +299,9 @@ pub struct MixEngine { pub monitor_only: bool, /// Recording: record-armed tracks hear their input (auto input). pub recording: bool, + /// Mix the metronome (Options > Click) into the output. Playback turns this on; offline renders + /// (bounces, stems, exports) leave it off so the click is never printed into a file. + pub metronome: bool, /// Third-party instances come from [`MixEngine::adopt`] instead of being created in `sync`. external: bool, /// Instances handed over by the host, waiting for `sync` to place them. @@ -363,6 +366,7 @@ impl MixEngine { input: Vec::new(), monitor_only: false, recording: false, + metronome: false, external: false, adopt: HashMap::new(), retired: Vec::new(), @@ -655,6 +659,11 @@ impl MixEngine { self.meters.entry(t.id).or_default().measure(&self.main, frames, 0.0); } } + // Metronome: summed after the master fader, so it reaches the speakers without passing + // through any track, send or insert. Only the playback engine sets `metronome`. + if self.metronome && s.edit.click && !(s.edit.flag("click.only_during_record") && !self.recording) { + click::render(s, pos, frames, &mut self.main); + } // Output: min(out.len(), main channels) channels; any further output channels are silent. for (c, o) in out.iter_mut().enumerate() { let src = self.main.get(c); @@ -1075,6 +1084,67 @@ fn receives_solo(s: &Session, t: &Track) -> bool { .any(|o| o.mixer.solo && (o.mixer.output == Route::Bus(*b) || o.mixer.sends.iter().flatten().any(|sn| sn.target == Route::Bus(*b)))) } +/// Metronome: a short synthesized blip on every beat of the tempo map, accented on beat 1 of +/// each bar. Settings come from `setup.click_countoff` (`click.*` values). +mod click { + use super::*; + + /// Blip length in seconds. + const LEN_S: f64 = 0.03; + + pub fn render(s: &Session, pos: Samples, frames: usize, main: &mut [Vec]) { + let sr = s.sample_rate; + let rate = sr.as_f64(); + let len = (LEN_S * rate) as i64; + let end = pos.saturating_add(frames as i64); + let gain = f64::from(db_to_gain(s.edit.value("click.volume_db", -6.0).clamp(-60.0, 12.0) as f32)); + let accent = (note_hz(s.edit.value("click.accent_note", 84.0)), s.edit.value("click.accent_velocity", 127.0)); + let normal = (note_hz(s.edit.value("click.normal_note", 72.0)), s.edit.value("click.normal_velocity", 100.0)); + // Start one blip length early so a click that began in an earlier block still rings into this one. + let t0 = s.tempo.samples_to_ticks(pos.saturating_sub(len), sr).max(0).saturating_sub(1); + let mut tick = t0.saturating_sub(s.tempo.bar_beat_at_tick(t0).tick); + // Blocks hold a few beats at most; the guard only protects against a malformed tempo map. + for _ in 0..4096 { + let at = s.tempo.tick_to_samples(tick, sr); + if at >= end { + break; + } + if at.saturating_add(len) > pos { + let (hz, vel) = if s.tempo.bar_beat_at_tick(tick).beat == 1 { accent } else { normal }; + let amp = gain * (vel.clamp(1.0, 127.0) / 127.0); + blip(main, at - pos, frames, hz, amp as f32, rate); + } + tick = tick.saturating_add(s.tempo.meter_at_tick(tick).ticks_per_beat()); + } + } + + /// Adds one decaying sine blip whose first sample sits `rel` frames into the block (negative + /// when it began before the block) to every channel. + fn blip(main: &mut [Vec], rel: i64, frames: usize, hz: f64, amp: f32, rate: f64) { + let len = (LEN_S * rate) as usize; + let w = std::f64::consts::TAU * hz / rate; + let first = usize::try_from(-rel).unwrap_or(0); + for i in first..len { + let Ok(at) = usize::try_from(rel + i as i64) else { continue }; + if at >= frames { + break; + } + let env = (-6.0 * i as f64 / len as f64).exp() as f32; + let v = ((w * i as f64).sin() as f32) * env * amp; + for ch in main.iter_mut() { + if let Some(d) = ch.get_mut(at) { + *d += v; + } + } + } + } + + /// Click pitch for a MIDI note setting (clamped to the MIDI range). + fn note_hz(note: f64) -> f64 { + soundcraft_dsp::midi_to_hz(note.clamp(0.0, 127.0)) + } +} + fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut [Vec], _scratch: &mut [f32]) { let ClipContent::Audio { source, offset } = clip.content else { return }; let block = Range { start: pos, end: pos.saturating_add(frames as i64) }; @@ -1934,6 +2004,62 @@ mod tests { assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0))); } + /// Renders `len` frames from zero the way playback does (metronome on), in blocks of `block`. + fn render_playback(s: &Session, len: usize, block: usize, recording: bool) -> Vec> { + let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); + eng.metronome = true; + eng.recording = recording; + let mut out = vec![vec![0.0f32; len]; 2]; + let mut tmp = vec![vec![0.0f32; block]; 2]; + let mut done = 0usize; + while done < len { + let n = (len - done).min(block); + eng.render(s, done as i64, n, &mut tmp); + for (o, t) in out.iter_mut().zip(tmp.iter()) { + if let (Some(d), Some(src)) = (o.get_mut(done..done + n), t.get(..n)) { + d.copy_from_slice(src); + } + } + done += n; + } + out + } + + #[test] + fn click_sounds_on_every_beat_only_in_playback() { + let mut s = Session::default(); + let beat = s.sample_rate.as_f64() as usize * 60 / 120; // 120 BPM quarter notes + let len = beat * 8; + s.edit.click = true; + // Offline renders (bounces, stems) never print the metronome. + let offline = render_range(&s, Range::new(0, len as i64), 512); + assert!(offline.iter().all(|c| c.iter().all(|x| *x == 0.0))); + + let out = render_playback(&s, len, 512, false); + for b in 0..8usize { + let at = b * beat; + let on: f32 = out[0][at..at + 200].iter().map(|x| x.abs()).fold(0.0, f32::max); + let off: f32 = out[0][at + beat / 2..at + beat / 2 + 200].iter().map(|x| x.abs()).fold(0.0, f32::max); + assert!(on > 0.1, "beat {b} should click, peak {on}"); + assert_eq!(off, 0.0, "between beats must stay silent"); + assert_eq!(out[0][at], out[1][at], "both channels get the click"); + } + // Blocks that start mid-beat render the same clicks as a single pass. + let split = render_playback(&s, len, 97, false); + assert_eq!(out, split); + } + + #[test] + fn click_only_during_record_follows_recording() { + let mut s = Session::default(); + s.edit.click = true; + s.edit.set_flag("click.only_during_record", true); + let idle = render_playback(&s, 48_000, 512, false); + assert!(idle.iter().all(|c| c.iter().all(|x| *x == 0.0))); + let rec = render_playback(&s, 48_000, 512, true); + assert!(rec.iter().any(|c| c.iter().any(|x| x.abs() > 0.1))); + } + /// A session with one track of `fmt` playing a distinct DC level per channel (0.1, 0.2, …). fn multichannel_session(fmt: ChannelFormat, main: ChannelFormat, frames: usize) -> (Session, TrackId) { let mut s = Session::default(); diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index 6bc6b29..1e48e69 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -99,6 +99,7 @@ struct AudioState { fn new_engine(sr: f32) -> MixEngine { let mut m = MixEngine::new(sr, BLOCK); m.set_external_instances(true); + m.metronome = true; m } From 9701f8e9a5cb2678a2dea35452c1585abea6e314 Mon Sep 17 00:00:00 2001 From: Gary Gunn Date: Thu, 8 Oct 2026 21:42:02 +0100 Subject: [PATCH 11/26] Match tempo dialog limits to the engine and test its action The tempo-change dialog clamped BPM to 1..=999, but the engine accepts 5..=1000. A 1000 BPM tempo was silently changed to 999 on OK, and values below 5 were rejected after the dialog had closed. Use the engine's limits, and move the OK action into tempo_change_action so a test can check it runs a real command with the position. The test would have caught the original bad command id. Co-Authored-By: Claude Haiku 5.5 --- crates/ui-egui/src/dialogs.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dc09ec7..39f3cf8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -322,11 +322,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } Dialog::TempoChange { at, bpm } => { ui.horizontal(|ui| { - ui.add(egui::DragValue::new(bpm).speed(0.1).range(1.0..=999.0)); + // Same limits the engine enforces (time crate `valid_bpm`). + ui.add(egui::DragValue::new(bpm).speed(0.1).range(5.0..=1000.0)); ui.label("bpm"); }); if buttons(ui, "OK", enter) { - action = Some(("event.tempo".into(), json!({"bpm": bpm, "at": at}))); + action = Some(tempo_change_action(*at, *bpm)); } } Dialog::Fades { shape } => { @@ -436,6 +437,11 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool { pressed } +/// The command the tempo-change dialog runs on OK. +fn tempo_change_action(at: Samples, bpm: f64) -> (String, Value) { + ("event.tempo".into(), json!({"bpm": bpm, "at": at})) +} + fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { let Some(process) = app.ui.audiosuite.clone() else { return }; let mut open = true; @@ -509,4 +515,12 @@ mod tests { assert!(d.open_for_command(&e, "file.score_setup")); assert!(matches!(d.open, Some(Dialog::ScoreSetup { bars_per_system: 4, .. }))); } + + #[test] + fn tempo_change_dialog_runs_a_real_command_at_its_position() { + let (cmd, params) = tempo_change_action(96_000, 133.5); + assert!(soundcraft_engine::command_specs().iter().any(|c| c.id == cmd), "{cmd} is not a command"); + assert_eq!(params["at"], 96_000); + assert_eq!(params["bpm"], 133.5); + } } From b3669c767eab76fa0a6a06eeefdf67b5e477d6eb Mon Sep 17 00:00:00 2001 From: Gary Gunn Date: Thu, 8 Oct 2026 22:18:32 +0100 Subject: [PATCH 12/26] Fix input monitoring pitch and dropouts when input and session rates differ The monitor ring fed raw input-device samples to the output callback as if they were at the session rate. A 16 kHz or 44.1 kHz input played back too fast (higher pitch) and ran dry between bursts, so it sounded quiet. InputRing now knows the input rate and converts to the session rate with linear interpolation in pop_into, allocation-free and non-blocking. The buffer cap is computed once in new(). Tests cover rate up-conversion, down-conversion, equal rates and underrun. Co-Authored-By: Claude Haiku 5.5 --- crates/playback/src/lib.rs | 2 +- crates/playback/src/record.rs | 122 +++++++++++++++++++++++++++++----- 2 files changed, 105 insertions(+), 19 deletions(-) diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index 6bc6b29..45d5245 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -222,7 +222,7 @@ impl AudioState { if self.mix.input.len() != ch { self.mix.input = vec![vec![0.0; BLOCK]; ch]; } - ring.pop_into(&mut self.mix.input, n); + ring.pop_into(&mut self.mix.input, n, self.session.sample_rate.hz()); } self.mix.monitor_only = !self.playing; self.mix.recording = self.recording; diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index d9def4c..217c79b 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -50,7 +50,7 @@ impl Recorder { let config = cfg.config(); let shared = Arc::new(Shared { armed: AtomicBool::new(false), data: Mutex::new(Vec::new()) }); let sh = Arc::clone(&shared); - let monitor = InputRing::new(usize::from(config.channels)); + let monitor = InputRing::new(usize::from(config.channels), config.sample_rate.0); let mon = Arc::clone(&monitor); let mut backlog: Vec = Vec::new(); let stream = device @@ -140,47 +140,133 @@ mod tests { } } +/// Ring state behind the lock: interleaved input frames at the input device's rate, plus the +/// fractional read position (in input frames) of the next output sample. +struct RingState { + data: std::collections::VecDeque, + phase: f64, +} + /// A small interleaved FIFO from the input callback to the output callback, for input /// monitoring. Both sides only `try_lock`; it holds at most ~0.5 s and drops the oldest frames. -#[derive(Default)] +/// The input device may run at a different rate than the session, so the reader converts +/// rates with linear interpolation (cheap, allocation-free, fine for monitoring). pub struct InputRing { - data: Mutex>, + state: Mutex, pub channels: std::sync::atomic::AtomicUsize, + /// Input device sample rate in Hz (0 when unknown, which reads as "same as the session"). + rate: u32, + /// Most samples kept: half a second of input, or 24 000 frames when the rate is unknown. + cap: usize, } impl InputRing { - pub fn new(channels: usize) -> Arc { + pub fn new(channels: usize, rate: u32) -> Arc { + let ch = channels.max(1); + let frames = if rate == 0 { 24_000 } else { rate as usize / 2 }; Arc::new(InputRing { - data: Mutex::new(std::collections::VecDeque::with_capacity(48_000 * channels.max(1))), - channels: std::sync::atomic::AtomicUsize::new(channels.max(1)), + state: Mutex::new(RingState { data: std::collections::VecDeque::with_capacity(rate.max(48_000) as usize * ch), phase: 0.0 }), + channels: std::sync::atomic::AtomicUsize::new(ch), + rate, + cap: frames * ch, }) } pub fn push(&self, samples: &[f32]) { - if let Ok(mut d) = self.data.try_lock() { - let cap = 24_000 * self.channels.load(Ordering::Relaxed).max(1); - d.extend(samples.iter().copied()); - while d.len() > cap { - d.pop_front(); + if let Ok(mut s) = self.state.try_lock() { + s.data.extend(samples.iter().copied()); + while s.data.len() > self.cap { + s.data.pop_front(); } } } - /// Fill `out` (planar) with up to `frames` frames; missing frames are silence. - pub fn pop_into(&self, out: &mut [Vec], frames: usize) { + /// Fill `out` (planar, at `session_rate`) with up to `frames` frames, converting from the + /// input rate. Missing frames are silence. + pub fn pop_into(&self, out: &mut [Vec], frames: usize, session_rate: u32) { let ch = self.channels.load(Ordering::Relaxed).max(1); for c in out.iter_mut() { c.iter_mut().take(frames).for_each(|x| *x = 0.0); } - let Ok(mut d) = self.data.try_lock() else { return }; - let avail = (d.len() / ch).min(frames); - for f in 0..avail { + let Ok(mut s) = self.state.try_lock() else { return }; + // Input frames consumed per output frame. + let step = if self.rate == 0 || session_rate == 0 { 1.0 } else { f64::from(self.rate) / f64::from(session_rate) }; + for f in 0..frames { + // Drop input frames we have moved past; keep the one at `phase` and the next one. + while s.phase >= 1.0 && s.data.len() >= 2 * ch { + s.data.drain(..ch); + s.phase -= 1.0; + } + if s.phase >= 1.0 || s.data.len() < 2 * ch { + break; + } + let fr = s.phase as f32; for c in 0..ch { - let v = d.pop_front().unwrap_or(0.0); + let a = s.data.get(c).copied().unwrap_or(0.0); + let b = s.data.get(ch + c).copied().unwrap_or(a); if let Some(x) = out.get_mut(c).and_then(|o| o.get_mut(f)) { - *x = v; + *x = a + (b - a) * fr; } } + s.phase += step; } } } + +#[cfg(test)] +mod ring_tests { + use super::InputRing; + + /// A 1 kHz sine recorded at 16 kHz must come out as a 1 kHz sine at 48 kHz, across calls. + #[test] + fn monitor_converts_input_rate_to_session_rate() { + let ring = InputRing::new(1, 16_000); + let input: Vec = (0..4_000).map(|i| (2.0 * std::f64::consts::PI * 1_000.0 * f64::from(i) / 16_000.0).sin() as f32).collect(); + ring.push(&input); + let mut out = vec![vec![0.0f32; 48]]; + let mut k = 0usize; + for _ in 0..10 { + ring.pop_into(&mut out, 48, 48_000); + for v in &out[0] { + let want = (2.0 * std::f64::consts::PI * 1_000.0 * k as f64 / 48_000.0).sin() as f32; + assert!((v - want).abs() < 0.05, "sample {k}: got {v}, want {want}"); + k += 1; + } + } + } + + /// Input faster than the session (96 kHz into 48 kHz): every other frame is read, in order. + #[test] + fn monitor_decimates_when_input_is_faster() { + let ring = InputRing::new(1, 96_000); + let input: Vec = (0..1_000).map(|i| i as f32).collect(); + ring.push(&input); + let mut out = vec![vec![0.0f32; 100]]; + ring.pop_into(&mut out, 100, 48_000); + for (k, v) in out[0].iter().enumerate() { + assert_eq!(*v, (2 * k) as f32, "output {k}"); + } + } + + /// Same rate: samples pass through unchanged. + #[test] + fn monitor_passes_through_at_equal_rates() { + let ring = InputRing::new(2, 48_000); + let input: Vec = (0..200).map(|i| i as f32).collect(); + ring.push(&input); + let mut out = vec![vec![0.0f32; 50], vec![0.0f32; 50]]; + ring.pop_into(&mut out, 50, 48_000); + assert_eq!(out[0][0], 0.0); + assert_eq!(out[1][0], 1.0); + assert_eq!(out[0][49], 98.0); + } + + /// Nothing buffered: silence, not stale data. + #[test] + fn monitor_underrun_is_silence() { + let ring = InputRing::new(1, 16_000); + let mut out = vec![vec![1.0f32; 16]]; + ring.pop_into(&mut out, 16, 48_000); + assert!(out[0].iter().all(|v| *v == 0.0)); + } +} From 4c7ffc5f03daf43283f55d4ec1800ed626f709d7 Mon Sep 17 00:00:00 2001 From: Gary Gunn Date: Thu, 8 Oct 2026 22:25:48 +0100 Subject: [PATCH 13/26] Keep the clip context menu open while moving onto it The clip menu was attached at the end of lane_interaction, which returns early when the lane has no pointer position. Once the menu opened, the pointer sat over the popup, the lane lost its hover, and the menu was never drawn again, so it vanished before Delete could be clicked. Attach the menu before the early return and remember the right-clicked clip so it keeps its target. Stale clip ids are dropped if the clip no longer exists. Adds a kittest regression test covering right-click, move onto the menu, and Delete. Co-Authored-By: Claude Haiku 5.5 --- crates/ui-egui/src/edit_window.rs | 15 ++++++- crates/ui-egui/tests/clip_menu.rs | 66 +++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/clip_menu.rs diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 17a54b0..0338420 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1372,6 +1372,20 @@ fn clip_at(track: &Track, at: Samples) -> Option<&Clip> { fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) { let id = ui.id().with(("lane", track.id.0)); let resp = ui.interact(lane, id, Sense::click_and_drag()); + // The clip menu is attached before the early return below: once the menu is open the pointer + // is over the popup, the lane has no hover position, and the rest of this function bails out. + // The clip is remembered at the right-click so the menu keeps its target while it is used. + let menu_clip_id = id.with("menu_clip"); + if resp.secondary_clicked() { + let clip = resp.interact_pointer_pos().and_then(|p| { + let s = app.engine.session(); + clip_at(track, sample_at(s, tl, p.x).max(0)).map(|c| c.id) + }); + ui.ctx().data_mut(|d| d.insert_temp(menu_clip_id, clip)); + } + // The clip may have been removed or moved since the right-click; only use it if it still exists. + let menu_clip = ui.ctx().data(|d| d.get_temp::>(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 +1627,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 { 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"); +} From 983846a054a556ed88adadcaf94bf882c5ea8493 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Fri, 9 Oct 2026 11:12:26 +1100 Subject: [PATCH 14/26] Use fontconfig for Linux system fonts --- crates/ui-egui/src/fonts.rs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs index 6905934..46b99e1 100644 --- a/crates/ui-egui/src/fonts.rs +++ b/crates/ui-egui/src/fonts.rs @@ -27,10 +27,32 @@ const BOLD: &[(&str, u32)] = &[ ]; #[cfg(not(target_arch = "wasm32"))] -fn load(cands: &[(&str, u32)]) -> Option { +#[cfg(target_os = "linux")] +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(); + let index = lines.next()?.trim().parse().ok()?; + let bytes = std::fs::read(path).ok()?; + let mut font = FontData::from_owned(bytes); + font.index = index; + Some(font) +} + +#[cfg(not(target_arch = "wasm32"))] +fn load(cands: &[(&str, u32)], bold: bool) -> Option { if std::env::var_os("SOUNDCRAFT_NO_SYSTEM_FONTS").is_some() { return None; } + #[cfg(target_os = "linux")] + if let Some(font) = load_from_fontconfig(bold) { + return Some(font); + } for (p, idx) in cands { if let Ok(bytes) = std::fs::read(p) { let mut fd = FontData::from_owned(bytes); @@ -42,7 +64,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 +72,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") { From 5b7864c8f7fbc73224a8bab1b3ce8c18e69c5b0a Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:22:39 +0800 Subject: [PATCH 15/26] Pause playhead follow when the timeline is moved away during playback --- crates/ui-egui/src/edit_window.rs | 109 ++++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 27 +++++++- 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 17a54b0..5c13332 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 { @@ -1679,7 +1691,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 +1770,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 +1820,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/lib.rs b/crates/ui-egui/src/lib.rs index 18982a0..4fc47a9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -292,6 +292,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) => { @@ -320,6 +324,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) { @@ -332,11 +342,24 @@ 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 jump the view to + // the stopped position now; every other mode keeps its view. + let at = stop_at.max(0); + self.engine.session_mut().edit.zoom.scroll = at; + } + self.edit_layout.follow_hold = false; } fn start_recording(&mut self) { From da970b24906b6fe313040fd89aacd1b0bcd3b64a Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:24:37 +0800 Subject: [PATCH 16/26] Fix debug overflow panic on the first automation-write pass --- crates/ui-egui/src/lib.rs | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 18982a0..da07f92 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -232,6 +232,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 { @@ -531,7 +541,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; @@ -824,3 +834,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)); + } +} From 0ffd2a14418e5b191689781477c2ce0997d600da Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:28:58 +0800 Subject: [PATCH 17/26] Do not yank the view to the playhead on play start --- crates/ui-egui/src/edit_window.rs | 8 +++++--- crates/ui-egui/src/lib.rs | 11 +++++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 5c13332..5e0f6f2 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1726,9 +1726,11 @@ fn follow_update( /// 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. +/// timeline can be inspected anywhere during playback. Pressing play never +/// yanks the view: when the playhead starts off-screen the view stays put +/// until the playhead walks back into it. Scrolling back to the playhead, +/// stopping, 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; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4fc47a9..7a1a69e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -324,10 +324,13 @@ 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; + // A fresh start does not yank the view: hold the follow until the + // first overlay frame decides. If the playhead is already on-screen + // the hold clears at once and the follow runs as usual; if it is + // off-screen the view stays where it is until the playhead walks + // back into view. The tracker is re-synced so the current view is + // not mistaken for an outside move on that first frame. + self.edit_layout.follow_hold = true; self.edit_layout.last_scroll = self.engine.session().edit.zoom.scroll; self.edit_layout.last_follow_to = None; } From 617888baebd0fd54cb2822df24da8f207498f0c5 Mon Sep 17 00:00:00 2001 From: Xus Badia Date: Thu, 8 Oct 2026 15:12:32 +0200 Subject: [PATCH 18/26] macOS DMG: branded window background and icon layout The DMG opened as a plain Finder window. It now shows the app icon's figure on the SoundCraft colour, with SoundCraft.app and the Applications link side by side. Same approach as PhotoCraft's and VectorCraft's (storytold/photocraft#1201, storytold/vectorcraft#493). - packaging/macos/dmg/: background.svg (source; text outlined, no paper grain, the figure an of assets/app-icon), background.tiff (1x + 2x HiDPI, 16-colour palette, Deflate, sRGB; ~28 KB) and dmg-layout.DS_Store (Finder view settings: window, icon size 128, positions, background). - generate.py writes the TIFF and the layout on any OS, byte-for-byte reproducible, with nothing from the machine that ran it (no paths, users, disk names or volume UUIDs). - package.sh copies them into the staging folder; the image is still built with hdiutil makehybrid. Nothing extra runs in the signing job. - The volume name drops the version: the layout finds the background through an alias that includes the volume name. The DMG file name keeps the version. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 1 + docs/releasing.md | 3 + packaging/macos/dmg/README.md | 44 +++++++ packaging/macos/dmg/background.svg | 31 +++++ packaging/macos/dmg/background.tiff | Bin 0 -> 29216 bytes packaging/macos/dmg/dmg-layout.DS_Store | Bin 0 -> 16388 bytes packaging/macos/dmg/generate.py | 158 ++++++++++++++++++++++++ packaging/macos/package.sh | 7 +- 8 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 packaging/macos/dmg/README.md create mode 100644 packaging/macos/dmg/background.svg create mode 100644 packaging/macos/dmg/background.tiff create mode 100644 packaging/macos/dmg/dmg-layout.DS_Store create mode 100644 packaging/macos/dmg/generate.py 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/docs/releasing.md b/docs/releasing.md index 2f7894d..e68b9a5 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -94,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. 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 0000000000000000000000000000000000000000..feeb2f90402a225dc3bdc20b39f37cf2131fa35e GIT binary patch literal 29216 zcmeFZXCR#4w=X_=3lfotkZ5BD(R+{R!!V+Y=!59N=%Nck2!p7jWDo?2=$&YzM~yBq zdhgNyR5-d3W!dbI*g>pS|{4Yp?y;>)CrhW`IDr0K|*{fOp1L#+KVOGp%Cd z+nzKGaOb+_5gFlXZ)eGf?{~idOhOB472DQmPVn$0-$(VD2gJ-FnOHp5Uy#TD-2nb4 zq(Ip1KT1@QLmTj#fp$83kx@GL`J;A|w-W(0tjzLR$jR=B+S~T9Z^yIuzLrJxvUmQn z-Gkw=A|xg`PFXpNk;P2~u!A7+UWP^g7SWBrQq1yk$Y^4L@QRO;sdX8k$V~0GRe71Z z9&W?Tn^b0tSZ>RQAyx*oRG#|l5y(~hU@m`aJ-7@_cLU)YE{Rl`BYjD6-Fi4>WV_66KD+Lzw;4w`qyEE}pGSS(;uM3p?EEivU}l z^eli3d{?o@juprO*MZ6z!;#^s*v!xYSP#XGk_#Pz1^KpsarWNaVCqaqwH;eO-w6lN4fei`VB}nAPMTLWQEJtC zX7^d*8~GJY(<(oHQSDMBdQ7UT5qUbLL>xI(AN&2z*E(H>l}nlxA1^!IZKD&~!40Hq zmKWayq6tZG=WGM1!1Z`eX5?F|=dUUV;u6coU57g8WvQ=7n8cd+DM*fR@TS{=dY{$6qDAr8 z>TuVWHfF#YS`6$=?ijyl9<2!odeyb~C zniMZEUCpNaan5}*A1}o)i&NXraL~pbd~8%bi{aIs8%+dzyxOO@rRJu`AZtHs*ve?M zQlL_FbVuTdbtXgDz2uT{OBv;+m)FLB27NzB2l1XJk)EKQy7NGr=#E^T6g0id3uWJT zC)ZOP*M9M3eon0zY`BK3Y{{;VT$=UxO1dhim~{j+aY(qswIZ34=!i(5zPx-FYpLxS zne%kDeEh`*L6Bo}q-Js#D!=evw0{l-cbhqv)NJyn<^xYeEhLwx*KD&SqhO5#*;AjK zDf>Z$FlfKDvHT6gqIdMWt_}4u{9lvhmbErCx~p?3x_?7H$kqac*7zZis&M8NEN@aX zK^|j_d_0=Jwn2om6a3rBvRZHAY$UuS*&e8zQXzkKraeyXuPx7WO?dxLS+zw{`<4hR zQ_O-m7oEMSiVE9c&R_Hg3p(Au`uLN&FH_qOk}F#aLn?K{AnNNpLwp%8*7f_)G&0T( z)4joe3#(wFEg`M&z_m@(E@u%5pUuinnp{d`rPBtl+%P4`8naPEae!Zf|PpPv!L8;ie$kVhO3tF4=A z8wHfvq*=0~&$BbYUay3Ix8o-NUD#rhNb&7=aKUV;!5X}t*P=Q_t}W1UHsQ^X=ohyz z-g0rY4OD85#BSw{JvcG9)ZxtFwJgKXVzqx`0B_$niVtv5adO<${cQK8NG(kRg#3Lr z40QYppz_FAJRB>cm9PH_yWR7x-jwL`Gr`{=N>#~wRHB@hwDq?kHsz+_+YxC%k;NwB z9`BZXKfZH#3HdDb?k*$Q>Z6iR<)o@@56$G=gHpffvMN%N@X-tvYD$=Nv+a5I!Sl%D zX*s2Wfk#e?69C(4w z`_RKspeBgiDrCg?FY@p9gsN)i35?U9M9)DE?~hl7^h&r{#NYNbKJBF}B92iQ}Hd-Im6> ziM;7&8OuJ;NW)%EXtFqq{C#dbfE>2uDEU@0)KsgNvHv)OO4i)nq~ zjIuSY{v6NnG;^E@jyQ{G2}ziuM_{=-M|~OZ4*8gB$#C83*#0f?KreY~{Ygk4_8XhS zy}-H7h6d+vmUq~y!zc1NY|eR(h&HSfx)n&q_s_N?)FLc^f;!1B1+1iXo?Obu~a!dbugy3?LxprRm-$IZme%iik0#sRD!CClP(WQ(4Y`RLS2 z;(#&Qgo4O+0X^z!UVfz0H2fCCqPZm6K@mCb3v$+R-y;#6OrPYWwEv;@gyt=tS*p4~ zk6()aS-|q8tW=SNL=&AxP9`;@=d^I;@9NY|rU7iV#izsjfhxb;HVi-ZO_O$c37+~$ zB29iMuL;uG(fMXo-)l73RU{Qk@tbW0X0eUG8TI_eqgDg)Y6lVNV|?ik(|akz1lLZe zMRt&HUcFQJ-pY^>ylr^4IZ@s>LQ6WnKGOxyCgDHz(cJY+jke~Bdg13c%ASzgpq)iR zpZC$D`CSUF^X`d|_c3NeSh|~Ib{Sr=!r%le{TwS5wZN{xSWj*LKvnrvN!%$G^9szC z?B#N7SIsBN0 zxnQz@ObIa-@0qx+jqL47-E2-2UOE4;@a7)!!ENKjtQ9-8nMHDL2b=N@wtR7EVQ;e* z?2|6^vU0A(Y?$>J)N>!4{ea!KuyKxAXlye8^xj5k1;L)|AtYh0#i|@dxC0A?N-m0b zoNwiygQFH_>#|l`p`K>a+JXD6^&P*JA~1h0Jx`1hp4|TQt71NVrRdJ64x31b-9*|K zdRmeJ+DTgiiQB9RtuH*)=SE#jYhj)Q34Vb;)LHU*1lI<#xSh1NJ=B~$*%MP|fFJ2R zCb?J6TYz06k}PhNP@}FuJuZ==gUyEeAMa^85Z(afw&F{sno?%ON87E^fd{rnP|wKc ztWhxUypDFMhYbcB(2y#+2XT)s@9S}G2af) zdp5GNdU*bHJENvNF{G1Kd*<9heiQ*~YN`ZG2|r)Us_r{%i{bkczh?5{>`lwzV0V1M z(aG6KvF57_ytLfB#D_ES`kYITMvLbd>_YG0rSZRP(s~nu*?O}zyE``kZ5R3P*wxBiN>gIU^C-PAbiI3-W^f{MLe# zBy2pb=%=jmvClz2tVIpB7z3?`)y0vNDKjEHiggtZNX9jEUb6yg2fqaHFML$Ox;vNq z9v=ndNu&^`={l)JIgELT!7fbDPE(=qa^!Ccw5YKaEq+9$S-8Dc zv5%d_ON`HOJ3C&Xd&GpqY(Y4oRoki^9@Rv@@t0Vv-ij*(-chB8R+$eY_i|?5&rkVC zWn5#Z(S{(sN*Q;$qOElH!i@Rj_=Y6Rcex((@~#<6{6vkF_(g-{J#zQjIU_iP5B>X?_GXUY^EK8||a|D3NCsq6Y}(zYn@6nxmz0 z&cF&@znD){&de2)p%3z?TRiCha_2NSe$Y;F{4u1FD2^45bp&NL0fVh=ZQsvLFG~?~ zhGS5KLrf3bY|!4I#@k=s8x?r=B`d|QQw>5__7p;C+u-WIfT^0}BZ5zlrC7P|&NrXl zkE2P^WpN@5(oikD4I+q;qNKar%4*#kxp@8nN$`B2Lihb6MiElZ`lA3MA*R>C-MXI| z9;x6#t9Hy;$FZA?c1@jwbYR%D;ZKW>}5GCfxd}VhQXzbzYfnlen#ICaF zLn8h{QgyUr0_kjm{^2^XQ}X-3pI6`e7dN?L=D34j+;rxS6orI2v#`fjN3w=k*NvY( zvYqvL3{i*Bi%>?RCh8QAz{=#4Wkn^?GF<5A#9YZn3Rs5p5&E;!K;oFdBc3WQ{Qls@3 zWCK;2V@9D~m{T}MQ45Rcit0LHhu=G$9F2DO1Jub}y>)cX{usl3!_jWIdd&0jNmbn* zo9*uRq065dC~P%2tOX`3oDwwDQVnl;^j3x6j{GvqYyo|n>!|%|wJA!CJOWqWgB~Nq zT?ie#0?xVOQ$wr0L3-Ar5^JnUdnPpI`^1Ioj{D_Ck1MC#iCy+&A*z<{`#Rj8+@I2i zdcB8o?dACCeQdKv80{L<7r1f3-9ED*rddID+>Ccun1zUU$WwKXexZ3BPO;^Y`+aLB zO$#RMNJ@mMIgM}HzDFq6A!IdSpFI&*eX&c792yA`O9|5?cHt+JQ8FnFqmd<(;mIMy z$!XMZq7nu|P!8OBijx?WQ}8b%d9wcnarRnuEkIpcd9ti9RI*kGWYw;f0h;`2di&NJ z6C+Q)5R6;JwJ?Jb8G8!1%HpyC+f$tDrB{>4Suz+$D_mk~o7TMPQG?N4HGZSmO$NgU ztqja9@Kk6(@f_wG9zHag?Yi`-qwY4ASa}74Gl1;xBy=j~<&<7rZEjH$K;?_ewdiHn zJue*ieIyfiF@LfibnekR1V6QJGRPhnz3D9CHFE9uoe^Vmn%oWzcD$tvmJ1~+e{I5~ zG^U<#zpF4BD0$BCuFTz;M%lm#M(8^qd$E--4rLVb5IEScl`d@csS?uDa}V)S8A=JQ^z1Apn1F#Nv_*EC4ZLhN?h4{)!NpyLJH zo?HN&XkIS-$gMX8z(C6A%xwLUioS9%dOyy}vbvGVzX;o29Gu%yb zz_Zs52LH3v3g41MvX*LrBw6^ z9;q--zNyA#fTi`U)>1oavZB^)oRtJBTKQ2?Hj$GI-vLKTy5%7gxK6L(X0LHV7IWMV zcUSmW|Dsat`g-1Zk4te#+3s$)1XF$|hk2*%!?U0>Z{0OJ^aGV5Ti~BjjqQcNYtq$1 zSmAcP{F}%@Y$$%x%lj=rU@y8=n^Qzj|LTIom4?QuT$}f62;{T!y&pe(PX`{m(wyA~ zy-6Sd<+xb5(Lmikcu_xj4q`_#@=Fw{CPP|*B+%HRUg+>P)xY2dR93I_Yq=gdNW$Dr z*JP(&r~~}y@!~pr!i@!^@PvaFycH-$u=diMpKNvzUMI??D!r$i!Kg=>(kBo36D5|i zX`=Q4!gPx*d>KpQ!arH9>lz!T5D3P@y@`#$(99vDF zLR*2lfAS7LLK5IS=?YyD55B}+l_%1xzdcjzV{XR;Sbx@W1T|KE>Q;54;=uUHx!jo) zIAux+OOfciUn~B{sVm~VC+2oLjY~U5b19M|qPFiUrc%g6HniECMs1htTEeZ*IlH0U zUbXL*e8#5st^9Hlt?wM1Dc+$NswFy~9S{f6t4~^mO^8B?luek!S1H-g8R;*ZbM8Rp z8GvhlKl|@Uo2*wfqU58*{=i*cNX^fxPQdHZJwWFB6ZhVeNOaA*az!GWd3Y1dY3d;H zaqh2ye#^V(eatanzr&yhUuRp;iS&e^^XG7Hrth8vNNtQVt8i3~qd@3waF=elOlly$x{@0;h$YsZ z`+z%fWaWk{0X=INiV|r~Q>2x2PdHOiyBkLjqjnOt;;bBon*_vCRb-F~woAvy8QHY%xT&Pq=KWp63CGf&Wd# zWUkY^%+ptsYxNrTR;d|V_sqI46SnFLUXQ5d?3hlgGf%D6uaFd05|%)l{EcS?C&$Tq z<{9=9g*b}c8PjvqUYCDd?0Y!l7Hie=rsGrXy||aL3Z6rMJFyPakH5KRRD`t-yjMST zH4&N=-x^?rFxd!tKij6MGl%eghSKxB8mE)A5v;Vzc!sndPLnUBL(6BYYh%YK(k`zx z>?N!jsG(CrF&Ch$zWRz@9|cyi`=0GsPx>=S{UrYW->KA}q3?8Fgxm**DOsD!-Crj{ z4naVi^|~KZS`h?GKszl~%~aBn775+`)vj028^=HEex<;f`yRFOO{fLTTQ3HNOKt zx4P$ZS5CW4m;{+wbP?H|nc9JXPQtfw>&5fBXGhU}#iq7x2|;$T;*(ia`kUK>+*7uw zs`Mwg0Ka|no@R9~jACrZlwv;(KX%oqUsedlWF=F_leCCH&Bejt;-|gjYnb*Rc-`rj zhnm>zoo$tdu+ysZSD7tXzGyl?D-e<8UD(^m85?F7+f>iAryK=N;chTUGz`_|Kkc;) z_FP7F%>0>y1a|Yph@Tc0`MlRQ>F2GYnFL+DGt>V{g#18=Oige3l~j@SvfbN_KU3Yc zJbK6{DG0JE2ex(9jDQ5+#P0;3J2`cyvepDadJz!h-j(T6KaM(oguww#cCJ&1ahRhLOmj zkCfnPn75U*L_!Pj(CMuGb@?Th^$+$F!zFc@A`IU=6Ep*NTOxjQ`fZ>6_!<7#9LS4u=7_?@gSJdjtspiW6?K0sxV(00327 z`;GqpUk(rg0QzJAfFm&g03-nbAh@=hc*k*aZrspPXaRr*2Ap6UKnu79um$7+J^_9J zr~x#9Za@$~6X1YDRDejF#{XRZof%jK6yoL|4#)wF0Ga@XfcLm8cmt>dv;n*T(tz;) z&O`s7vEdk)2DAex0p@^I-27JntO0014Zsu71Lz0511JFk|A(Xh?hN2V9j%<)3FQC) zCufA4wu(Hnq0tLwl63(2KN|*a_!d^~ub*jYLH}zej>rGmd;lXnH@^OBz5i_s1>D*l zw>9G3Y)_(AuiX$hEQiBTFU0E`9D~EOR5Sv-e{aYCkDVsK1!rT0dwmV?0l48TYyq!uw%j+_uJ>&sl+ zpP?N|L`9&U={#C9R>hY{rC@=zpogbjv@fi%C}03URE=fi*V4vm%GTN2f+%mWfSv@E0XtE04; zVIdnsm5RMvI(dUw6M3N#(;GF*#4SvU;5S4enRQMlC z|Bt1`f1)&VI^hfREhWE)1EJT~KMF?mA?2YTTZ20rP4hb`hV;LYhlgb`4d1z*+xh#D z%;HSJneA=7`0kyj!BdaY{lmeFFV|08b?O)W7EupXb%u0Ip3fl_NTYAdyO%TO3z!5^ zMvIC1^Z0N#yq-iW*?Tz-SAca`pcQ#N)`oPR45qqCIkRB-?O@dUyK)qCo;RIQ)(3T~ zTJ&YI=@-Lc{z@rPy9s2ss1s%OS8&8hBjv}uSQXi_>+l}%)-#xg5(Flh<_Jj~LcXGX zS^nGjlqIZi{ntz=OL*bzH%KuGAu{=a`U6;Ki+5z#oGTApAA#$~4Fy%PJ}}Pnop2mQ z+6&Y7L=Vdn`j-3M1os)-nwvH>6GY=JnDnQCSo*%{=4yRyQkVQY1zc|euu|tcOBJHo z6)3230>&f#H$`9N5Rw}0*#&a)9S#Uff8{7=CtXYC`Z~WQ(2P^&RBpAflw%v?z4}@R zg%D1S6lA3CL&#UJVMw&=t&pNJYqy$-T`W#ivPHvQYtr3m`yj!QOTDN~i`b)iFP=4q zdf9ycm*0d=7E=PR&#f~Q&+_vM`2pKEYO1z1Gt6rHOE|t=tHEZj%8!xA9*aQ2A}U^+ z(UDCXxq?A^*fQ~ZH+1bx)Qiu{-9Nhq8DBcjvET?j<%QvIhIDvOBJK6!4#t}w4j#Zu zQ=j<=e(q@zJj$a##9HMP)aBNIPogL3wKMn8uCxsfN`AwzXbV~TFR0Pz%mu+Cn9wSw z>t+m9E3U^jjv0pQultcz%%kXbv+}y7-QbzpVsO%}?*d!@_I7cG$-I+S5bNE6l+zX! z*UIQXN$;H}aD9rBSG&So8J1zt3^x<-N?LPj?^3{8aEze~g4(*KKwbRe;iq&{H zcVgjkPRI@7927z+N{A&oyDHDUmi$c909)akA;)Qv%8cC$Ke-cH*t)eMT}!31z8(Ya z5YdKL?H{gsz_w*U#vHhOx#8frsY1cIgz7O8LX^3O-YvP{k1TEz5M`GkW9`Icjyx_C zPcQ2={#LzT9MLm5#yU(OS#0Z{%!A()bP53Xx07Y0DGl_jB<}G+t2(Csv zI|QcQu*3_q*BelIcz{jvSWYE$JOmoyv@zA*!_FTTeY78J>%+K&$fL~;(Dj8p^Yu)W zxWG?*wM(w%;wPBA-8i-&#gGB7yT0XO&c|3#4`{yrfFcw=j_@uVg_F9fyJGgRpKcr) z_-Ip2UJW1z662A9G*ldW8Puk668RNc)jyGOZ*e!GJnE%q+#Xw9fl%HAtSxQ%`n8^< znr$WHUyXgnL*SHY1PXWvcS(E*t0NGyC~Igye`quFrok?~4xl*%zSz-((cHvvf))>M zCb4A6`L%TyB~3G*Ir~OG6XPRTfG8CT1l=qk!$>XlWs2;SvDS|*E^qf5z`qMr(&H%M zuhREuUJfI%q=&i&iTh}zCzMHlKe$T{vU7k^?6_51VzswjV^gpr0~2gYj~U(mh2FZn z-vOHPb&;=_{^c9xPzvAcCxcBDz0EX1Y?|6I??_(AMxhk88>cB2fU|P{%+vjX_vXa4 zC-3_o!1b4Zy>DOc5Oq69zl(~Qnz7V*^lR5{nt{IP5w(vS&j8n>vbW`Q@lBs`b9a6o zX|#}{$wAg=4L->_K~FhCK}B){^1oaQz6ZFylqE|-onUF3emu91xLI>?EO*Yl;N@7V z)kZbKXFs3Efa3wb)x63eBvn`YF8zMmRF-^C-G{kv_z-#AMaS-MkQ5nHzp!}^Hbtz& zcn3R4)j!zYIR7PWU6BQ2tm<{+RoMCGUcd7?sTH}0r?<&I`kUb|_4#zWlb_NL_ENw~ zBe72{_IYl2`Tf@!oBp*~B&-p*`fZsV7II=-ru^Y#McY*56QWGBfk}Lg=;mHpY1Q)S zd)87dkcVV>iE3KKpfh~Nr$xRy);d*Z6&UOsz3xN4F3LhE;O?j<^S}(4cFfD`EKg*r z!R=auc1N;^bfIxt*vm9K9bSs=@J_15xp`X$ws`<9L#tQx*sxJI!EhV`S<3b=NJi3< z*l|aO*`Ll+a<}E4-!ie_B)h(@O;Si39!7hlV!lkQG++Rv)qyST{SREuqLGvnC033r3!lua>c!!^Q*^LsK#JsMt-D@`3AzC<6EXbDt{%& z%6y}UBt1aN$5az>K%B{_{t8^C@{s29W5>EJ?1rRW4&gc0oK@;{5o*=8Jr*l+>AAm> zQ_n=>2`hX{Gl8i#K4~+S*fd$v8gT`?z$kt4T0QN?wAv(35q|4BeGZgWMb7B88l+hD zc6hqjdA`0v)H-%&g}8CbJl(!CwMlAndqAd@cnv5?mNf#|mt32ptRA)DnCl8A#D&X| zMG-x|);>7dk2wj|>;I7OI$-ELUpmg~NgLVAoUQfU&9Bu9T>=13yi^ z_UN?1V^@Z!f`fFex0d~wm|K5@`lOoaAMf#gJnrRhQn^JV&7V+A*FETNSG!q)Z2EY= z)K?W-|8Y{Op+NJoGzaNbF>>pt-|>8(<)j99@Ub-66RFfn<+<;M$3G$F8sKRI_WjIC zZEz0t^=NnRN0t4a7j{`;REQeYtDYCNZ~er8t5OLD+J(s19EXnj~0oKreiUGWd%j(ztmf(1_b z0CGgR3H~0PD-Y{WZU4{PH0midr$7MRvr71uu%Mi?C7Dj{Ht_<@)%(-N z^y7EHBAP~*K3phxrwNX__F3-i&?s3dKtJw-iRfaQ=&+$3;%otlWJYITi5gVL(&RS4 z3F5Gy|I#gjEw91$50PCJE(%{a7KWdef+#q0j5 z+PROyZXlu?dJng*ln(#OJA)qRaqQ3OiRp6$U7CeSFn3mk3R)Uj^ea3k*kP)j>6?FW zq2vn#2_e;_5hYFF2^K;tdGE(kTaeBgEiQ5<=S{+L8O|f#8N+CTldqEFZg1V-r+bb5 zx@Ij%^WS(F_#Bk|Od?4JV}RKm8<^Rda##1i{Tl9`w5`h{`ZmkCq+$?p`wXr zqjojgC(^Z>-SoCU+-^(Q5(H!r?7eI>-~Yn-^Ka=IL?$S~kUuMtGc6Uy8#neT#kwD? z;MX0YRoLdkq!aKb!*;n;Kdo*VNrIXgbf1J8(eMocD4uT|o;5!|CUMwtO)EV{m%n3- z7FssQ;yQg%CnYcAp_)TmCU{ zKhHS=9E=qZ@k-_l{C0e$8Fo;#3@Ijke^@^@X#9%tFpe`&#lP>27ZYI8)&4x z&$U4bTZZpLtO6|jfY;@PEzwVc(RPBhvZWbjc0Kcjja^$F&V;8E``B`6WzvH07A{}) zimJeCNTdyp&JN`7rG+ba%g+n+MgPlThz!eaeTBoq=7x9CExJdSVjs2bA;WH1U)1PP z0axv8a)pr>1B!bHO+m-oY?+|0wuzzt{S$(89wAOSYYj_IZ1Q*Yn zWJBCrluu9hX~8R^z31mCwd~I#HRW6sAmoNd0t>X#5fA#J=?TOmu0$WdSl~ zOg0~|^+jmKbK^X+M7?Pxs;NHXaRq9i&)Bw!dynh8=Iw+4@?9W+l4BJ9wb&>d z;y@296J|fls5;qu5=;T5hT#tdZKd_iTj``jB5@F@i^PhY3F-aO)G` z`SZ7v4O!i3h)sRBzdsi;qXkE&OZRvwylH}nvGh($pH+{cNokG253eyydt|!itFC7P+C2P2L07_)R-Ofrk?3XeNt@+&%~pW=De} z%qB|JK^C%3o~BkB${jjBw*e)r2>)Xut&#r@zH%~}eK_zp%6Z@~T5%r@`V9eWvr3$F z`*nl-?yxh?glG}yC~3EWd|S)Y94qZC}A3Ocgl9fE&NJye_u_Nkx z*Ge@}r|fQmN8FuDXFzFZZEPqAC{bodvSOGx-}K1H&KZCrH2*vpX!F34r;rxmJ zww%SdD8p^1xocMgGRmA*VunsDKaZwnjWX?6o0`gvwlAk0+YO!P-!AzcoN$6AzW=<& zlbi3b-2Y+i8aRF;O&#ujve&y}Afhj1Uyj=3Ye53>}@nk$!7z zKQf&ZI5qj!?KbE0Smhp#dIXKuy-MJ880NJ|mOXd)Je}9M%vVA z`lUkRUb7EzV3gF;(GIKx57tK=D&PsD{f%i*Alx%u|2f*z7p@c8rea>S^i>Tu1g&<%&5u$+_ z?Ds72CCmPjR2M-^O#sc%K9kXAMiy@8vmN?1r=;FCo*Oce&`-uJEYGjlpa%y^lX3aB5M0z)z?Q^QF`Vz zuVscN7AMkHfdI~Y<&T<(c~mo9CLweC=<3vwoIBX%H{Dk+f>)Od*bcwf%bMGcbGY8| zb#jWUxvmg`4L&0ffKwduL4Ep zn3}CqdhEZq4L853S*A{POFQn*Vp`;Ik)V*5ah#-RH24Wdk#x!4MPB~YKnp{S3YO@oj5DHfEx*Lqtu$MMD7HtJD0-U zB)yx$%aBc9af;6mStWSqg=*YS_Nbxtc^<xvu*%knPQx(9zH!|I9n{qM+hx)DgWs%u@9VWZkxWQM=O$$GISS>_Bh z3m3_qjO0>M-)?=3KvVb0C{)&^%O?jZ$7cFn+aa~D(z8CSR^*b!zFAU&y$_l7F--`D z@9X#}wXednKzF}SVU%iTn#yDUpb~fM2@;=cHMSMFXv#Y5?>22|fuEYGgM&5ao{A!# z|NQ5K={zldTbT!GV(`jq{@RtU)Jg@dx+nUB!gru7mtM;?gwg*;x^uPA@l|*uPnQX5 z)-SA<>+0Ls?(4_9Qs1|u40abU)rFLgx^C@OAMCY+1lluoL|dqFpMKXwHeHiTpHjf^ z4{hTfS`x*SvTq&zeY(Lt`3J)1mjK4ABQC!u!np-87WTGInW^eOr;YQ6`Q~}k$Yq6+ zJ3nEyhvQ(PePEC%U39|>+_`Gk zcYR}ieBf)$!4=uB6EQD{0?w;h)EK=q8Nbzojc;tLFXTFZ<%}Q)e_vtT;Xm*-+mUe> z(ra8v(quy#$SDVLKV>%KMKuXn0IM^VhKv}B(C}?Q+Dx>gK3Z+f>6wr4(M$;np^iZU z-iZtO%{~T}^qd18`fX=}ttcdLQ8LFi9*)1>pV|VXlASb}r}Rn0MuPD`Bj~Aznj`jz zWX_#NyGh|FFrI$fos@Ab`~dYxXaP~ZkFUA?ZOfbG010^W`e=BOUM+uf6BDS^2E+xh zeY;mSAnF`nDjrO9|Mf1gG#1ukV1ZpBvp%L+Gt7!^w-A5=ReUu=sRD% zIYKVqLgM22md-|tdY#j_3UT(U4epLTL7U?k**aN6!YPtG7y)ktDS?5ZFy903nIxNC zSH{#?x%sbyjOO;`7dmlH(iV3Gvgp&m;XIgHr67zeA+xNnmb>*wW5wj26AM%#6>=sN zk6#LMqIxoOZz7LK!;Zv9doCeZn%-}b_2dvghS}n=orb>WG026Z-ZI7iPv8f`M<~Jv z&%U>*2~X~lrVFK+U^7^8V|?dALQ2i}THn}Q#P0c&(g0_^H0Jz1UcS7lWpzb4G=k;h zEu<~1Gau~3$J27Vojchrt^Me@YSdeGoiZ;8~Cr1L&J+7F|@!a*8Yu1CU!c98kN zxHF13d%z%kE|U|8t=kW?=;1GweUJ8Ve?(Y;BHEtWKC;C!ly-+9b5*Q-3UT%CU#FQ;@`U zAXmN7Gz_Kd+Pn{X1aoFbN#9$*8Hd$754!HP(d10}KPT7J@~ptNBKI1R zFkIGKQ7HN10{@KK|Q zV!O9;CKe#nn>%(B_)qb*9?)4gmq%*5c>MyQwTpTHVT#dx>MR_UZZlzYWJO0pkp+0O zeAQG>i-+_mWNXqYMh-bBa!miX;sxP zrcuhcvTNhgrGWT6$OJOScOTd?8iro_+4L|||GUx81Q^V8hm_n~P&WvSm*06ucVKy! ztAS39$Eb3zyz^*OF)lI3dCz(0t*_sbUU_!5!M9R6imYO@Y0>qXSwxB`)UHiu&K(RYpT##AF@_tp*{b&<$xqKfCMTZ5H5d&_hs zv-d=+l>xIWg8_8}&Ku2|g-`+tE;!@*@EZWH;A~qpe&0N2Z-(RCdmzjc1@PXd ziBXVA+$QYK(o|mZM(L{7DmjW}VlhqeScn-w+J`LPJ{JL&lMY4*@^(;&?K4m77o1=h zD1yLzt-6}bIka7&<1=j0APu&(EUqW_Wa`0h^u(ln?&Y`g&XV1Z&K<#USk%rrzpz^~ zuyekeTBk}^js;)hb%V>EoyZiEz|B+Fqs5)}rhcOHB(_#HsvqJZMqi$gO8eZ&|9Rsf zA|GtwmWMQ>^y%^K5dK~s@{kjCXfV5rwN9DSV#|@%nat$NJmjG&4`ixBtHNZv0(5ZB zmqxxFicy|iW#ZAZ$k2&RJTiRjRMUUCR@ayI^Q{&v0dE)W-Dv?2v#|RhtvXofZ+-F+ zaZk_tF4@wGm!J%Uqy;&=ChI}KMx`DKw6KYxUhn;a627Zu9NJ_G|kDgw!(**l7XAyP{EApI@mE|b@=7}T`n0-_R z*{-!WF4XRLx?`8pg(vkLTD7Fk@!Y$&)$gulE|{!;a;qWMH7uK$uIB6ubZbK%`Pkfze7%2YyL$-sW$HA}M zDe1ZYd}Gv_1?pwt%a6No&YI2_#9R*|+RovdSj?WQnnTGwy+*KKLV#mrQ<-iuPGkA{ zsUKHF?J_vU%Thg-_V@r8f%V`2*4TVbTOI~3&cZBfF72Ws+KkENWl|gL*P;!qOT)kv zd$qbUj6JvDrintt7S2pBtqJNu%YR~5D?4zSlu-L(@W=H?I zI~Ja_3x78L%v(@rkhhnn2fS0~qLrfKH_Sh@)h}#=LDQgU_ujct`PpzbxXP5=zBn(R zo^>^mPhZ85(w9uPyh%DOPT~9wxf2~qY-;$Uu<%=mHfaFtsxCpQAL;7?oAOLE(i}?B z{P}m%J4(jr+P_1Vk`QZUq%hD&dq6w4i@trqzH@~d)$=ZYynreHvUpo?fu?hqvp@ri z9fe_t63-&6_!l5w(>V@+i5J=3&LN8eZ`>=uCOoN+w1lRJQG`-WxmfnZ9N`c5!aniC zwcOg$2kaK5olt|*q(mNGcH{92r@~#yjGNu@!MtL>HDF#VsGWN9uLyz zx@*?$6TOlw@4KmPnWrit9qn^m+pL$MvRb_R4qzI8<~x)RXCj=9Mptl ztue_NQ#>136ya6&){VR6i*F?xnoyRIF)-sX&JqU!rEk&Xd20OJns5;vm|&OT40aVj z1>v*6WP_i@3n&6gr6&mAES`FWM&V&46Ik7~H<&chvt z(DdMb*Vd1p2IAjHO_H8ECNv&KF44|?0Q&)-uv$d|mStZB$<^+n2aG&>qmk-V1IdSI z(#|n&d@&9`x!QPvLpY~6P3Fc2zdrpX@xiYyJ%4s^9#|h3d>TZACZHN9+$jtr4xgbA zoe(x@%>i#F7Iok9nl>qg9}KRX@j;rm62C11=E=qbX@71up{WYM)*lui=qHaQrnOo} zkn9Ve-~5zI71He4`V;28vavga6!d1M>RX36XLE;~UilhK%6exBkf zF2h;0yB(}d^#_9HY+4eGI+QD{n9LJi%(yrJ>v5fj;STfG3D>`b!0ux)bPWtLe=yf~oodwFWGU9@;onamH|a=Sy?{?MKd$Ghn@AATT% zp|hCD-5;3f-HY_8vo&&LD7c)x$DRr%a?Vy1O^X2I#?Yl5t`a5aS%+J*R#F+~vS{&T zAPuVvJUzTF#nVM)GIY<0Cm+8=gTq_u(*N#p@?>!=YLAPIPamTBe|510d`YXu@b+|m z`e77rmI6L#TFSr$y6F4*8t7^P;qsKe(@gn2^UurjK=KgWt%US{T%a6Fo3}?iQK|@g zyw}L+d!tR*HH5rpg)#T=_miqS&@5klkn|f9D$At|>j~OvWwvmnE>&^7n|eI>btV|S zu|6||r1|YPrb;-iqYRR5mSdI_Z2@5d9ky9J3V*B3OGk=|^l;6ze@$M5 z0HVs*{^V$PVb{Bhiu57FNbLs(1gD3T`lPXqxr+F@ zK+JaUGy7`uE>1}Yt1$R6rw${%tS`fIrL?IYG>WEI^=u8w^)=P$Lj+;>4L#Yiv}3H5 z;J4zVH>pgz;m;CK+$Edat}x|+uEE8`_Z!N;K{}F8?8-oV zybWb!xUtpP-ZxWS^=IjE3VMe52x(u~{0vMVE@>R%(|z;IEzINODCXH6wRZxIG9Ns> zsAE_X_JC4I{?K~AN6961Va1OmYkY;Ajv!>&%}kYACHPmfN+z0RM= z;rbxV&kNE919hA!iU8NnonN15m{a&3qSv7>$Z%cwlz$M9+9Ge87N3zdl5Bk~=J>H9 zaEQ{FQ=vI3OJ^dP4ZMmePSwxFJoh}=)r~TM5aLUBUaBBIHxJ4v=&g3+`D&|XDB4@| zBbsiff=og2q`Petv|owPE^`yd&tRr~6}M9~S<>Z0Y50)cKpk76c^!07&7;~7R70|g zc1FA7yA0%(Gs}Z73U6Musn-&k=*3*f!`}UpoLq@Q4)MBIxhno-ck+{#Gx_x%!^g$v zbj~&#PjHA1OGo}BC6)P3I){QYsLT})d%o>?H0HIhBV89OKOKTf%*L#OG80G1!UrlS z*t?o_+$Pim+jjoHcD^&J$);;JU_eno!~!A+N+2N8dq)v4D4~c5(xrC@y#`RQAT>d1 zD4GBk9wC4MLf3~by?5yy=}qCA_`UD>zJKTZIKSTYWUYJMv*+G>&)ze$XOh`>u9@%l zO$uQff`tT*XBic74(9@Xs$Di}c2z1FNUv4ycprZ6zfVlSk*T_B*7afsaA5L?Dpjk@ zpZ!XKa^|MzQfSGW7=?_>L1hCAe<^x<(q)$g==r);+NG}q291X(eW2ejHGAIsekn_h z8l4ek!M<<9=0kdoVhmUH{~(}~SYn#6R^4=z@W_=Edy_#n6s|pWM5fJmvUa-CKa^q1 z25s25UQlpSvc%XbOsdN7K-B40%tTWW>x`X8pG=43a*M$x>9!4%FH z#lhCCsfQ7}I#VjK1!+@F(3EM*w|S_OH_znL8L`jU8WaI60l1~m~4Z?7vayu#9) ztVB|5lhcq%{+q4wg8rZ&Jo2dZzU%v|S}oyBXrFW86P;{QwsWSd z1+M+Ho_xJz%|4J?+_G+6_bEv^W3oBAYkSa(7ybEJ4aUJrYm&*QrJxthn)|EsE@^a3 zyj@f)@>pMP0uleIRKpATP-jv`&M_vAP?VnwUi4ZBW!C2XUaFa7Xv7`^FS0OrSIXBC z?7W|+t=NV%v?61*f-6r1U&(Ch)rhWe z7u@8tQKO0VhZ)(z>!Yb#+2qcnFVQ=mqs?3jDPmvQN;ZS@2FULC;}n13eh3}^9xZlYx-Ri87kA)@2+ z&LAdq`r+jen^J4Q&=T6`ikQsto*aQ+8%%f#Z^<%WE`n19T9oD1PBt2HswYvz)Kqli zWUdik2MZ5QuP0Lb5l`)@xY7BIjd!3Me4n>8UO3CQZhcPSO{y?WV%U%Jw5&s~SD)zX zFls*y7mMZ~4I8B==KLAjHYd|Le&>yVW}>o6MmedNsA_AjXj7}`Lr=IV&_Q(1Rb*TO zq{$XFh4koj0!>4hSSsJJ12R5dJ_{TuFkLP?q4|gW2paf5WTEb#rv;u9xZhMf8PL*i zbDU>4bW_1{GCGW$61foB31eg~vj7un!MJ(Oc`?h3xWJRjJmu*0IV;R(s}^AHrIJo6?=dw$>S(cY zbnL=^h;`!s5GrALPf18>8ZTyxEGoipm=2dC;8%&jQ)=N}#HBxXdcNaU4sb|)y9?|P zVa&HqXyTxn&`Pnp6vFK&3#0N9NF%8wQ54NRjhA&6VLIG%6ZsgG=yYjx2RPntrAS^E z1(MIGUGn$dSH7e$T`+t@aOs|ZLJ^}8vSbZ;kRq1VgW&tA8(G^v&i{w|k%xVEsoozB z*A0=MIbzd5ug>^$OJQ#>=7HfRUFpPYZbuf%bh%!r)}^&LU`U-2j(r^op8|~TX7C09 zU|OsXs`!>oy#U_|{Kp6~=3(<|3f~}Zc|&uq^JVS9ku%ded`yn+Vjua$+^*J4Pts0@ z>JjNzi-vDY1{sghfqlZBy-zQs{m1)fxh^-za>1lZZ?yR~n*=a9uXq-_;mKLC8sNHj zd<-fk$oYhk;5EM%`1R^hS=X*rtsss2Ha9^%sd|^;rwTgxDJajsp;y8Q#C)|IaVg! z4Ii=BeZw8|O?7SC(EP^Rd0ImJ3fA?a@z70vzAFOuf6dORqKmSUY}QF7$@SIevz$L- z*N)GfoI8};pOWmgGGf9(Y556>5(vJQz65jPRXIBP2W)BF*sA?k%0_3wL(OPy_vkK? zxeA<$U4T&cW~1U^ zy>&aK?K08BL`vnqBP0Wa<;f_E@k$vImnHv z@G0w;i)rx@)+BNGKc!HUN$1L9Np{mrtK&!B5s4?)r)Q$MsWmR;z?Oh){~3= z*q^Dw4&OG56EItC4y5giDN9=bLhi98|xdIs@F zwqqq-UIZJZ=yTU~2mh2^jt+WCMtf&9J1AHqaDH&igUXB94`te3_VxCDh;`MmFuG5B zx*!uuz4du|t=f0~D5YTS{#LC`2LkdPwsjDbxtcxV{lXR3r^^x0R~y27ib=O=TW3c; zQMy{OYvW?m6#2<$m^2Pb{Y)PviS66pL45aF2lv=P!j?i!|+vlUx?Wv67hl?CV^g9@p#WiKA z9+enM7}cVlazWH|>AZ4;hK`R%kPn=~LT^V*i0?XI;a-?zyeqNU(;jrD^r@Sjo0{{} zc#T{!v9XZj?_d6|rcyzBb|WuE692AwCti8^q8D?VRhkcx59=A_ArDfsMVRgoUej0s z+BJ2WO@s;=^L@O)n0JZ`%}QbIuE4CnDs6ac?9UA*1Zv0^C4glh`7OX8qsZC*vHZS~ z<}0$Ta4K0-IoUFO{^1bwj+d^}9S9X*Xz7ONd zwSuF)`0!imAeg4T)~u!9`U-)hb&M(UhZWJhuuU zp(p~-_bGV$KaIq=!zI^D@R-o_B`-beKNkM#%KL3`1_%8~-@kxwRA&S(#h~Ck(iICZ zuDRQ|o`J?}OekCK2%;$(Opd(EV~9tGjgrR@Il2iNIoV#^ZeT@iA&P}BMVs2O-giSO zybdwb?o=WTCSI0p5Xf|3gsr@Xu7GK(;~9=TVz6}5ll`o9|EeaOYKytGab!;v&oXh; zE`1=JOe2j-23p!Wn*8MJACqFfmF>Y@rDH(C_^+X+p4i~IXZ^HqmD#93_eti?TWj%2 zpOv5n&CIgnUe87Nd-|~hH*VlwOIZ^qIs9uG+-5N~_C9|F1&3$_*69qL8Tp|F4=Z%` zUTW)VD)n=Secp+Hl)Nw0l3#F4n%wCV$G$8>?S-H93~7ajg)O6nL|3)@Z;W+bEhpGX zW8cTmyMS@BNG6Cq7)I8g%^2VaTk?$-^PfpXQuCE;y$z6?d(P+mbAzS-WA}dN9kI;& zYY0}LNwkm0X*)iU|M8sP7kh|CCO+OYaPD^g?%yEoOkarnu$)bB-I#AZ&lfbtG0>|z zE;qhvy^%J;@)Rt&q5X3GKMEZ!3ns-|Yprgi2I>CJ#-?q9AaK-sRJ|xTKmB#})wVt~ z-&TL@aMhG8n$`{(yAS*6)sW-K6T2XIK)W}(u04g@c0Kk+firMsKcYLae_>~}D|es1 zOwq*kD~P%FnHZlB%c86{+3deRp7UZ(HtS;v^^(6x38PV09N3L4^Elp{NC3c6-8A)? z1_`NFr3}#}@?Tt<_}=xxANRXcwyXrTR-_&^CQ8eRddHKLzqJpNNuSNV$~R7)=&*{Hx zKn-}L(}F?|jVdZMZ5GQXkD`#&c>{iCH~;Dt zxjd_!{>vfUWm-g=+EmQH%V#9gX9J;{4(;>y@3a+ppE zI}x*1FT;~yz_|27+)ksfV{6N-TY-V?R z#6+MQyjJpBHKA#z;gac-3@BI2G*d(CI}Z9MqYU=gaeJRbq8Hc=-|1AgImaREhkn{- zKrg)didXB4l(9v^sj%V>dL^Rr`%XU5{>)5F444z%Vm*;yJ%!%+IxP}A1(xBrmZ)Sa zutS7?NSN{x>!H%@8w~~JcM37*m%lrDF*C)LIAf3zeFHUG;&cowZ9Xyl$0v zOwHF|+xza$8XZkvIJb5mjVP^BI**I%Fx9zsy{Ua#XglSL(JFW~bH5r z4;l{x&I(#N);sX9yHb+fRsO%94Vr>wlBQF1bA#O9(z>(m+mGY0%79YqD#mnMroS$x zgs+OeTjou4r{Bv_MGEtvae$NZ+g2hnV2sjL)Puz~@uvIOnZjfhiV;jN?CDH6G+VB$ z_8ov8*+up}>)MI*VAK)QrVh*NtMSg3Fr5ePoSr0YS^QZH^iPdy>gE1 z)|j=sRqVby=g~D#ndKutuOH}?4Ju6&HtA zIHe0G9u}o`Um`ByMY6q%3mlJc^F)jl5Peq(+p|`zXNFw?%H%RhgfBLk6uk7NJKVLG zk7}(*7Lnr|6xGHR@Z2<|?drW%Q_1-2+e&c7gVPU^`Y*Ze_yVXvcfC(9vF3Al8S89x zRf3)y3~3O6ludbUxs%?AP;#D_7M^$bu^Ky#m#UwS5Uo2(o^6zszs7R#F|*hwW7aC! zRNP~1aRcA0DToO~QWFdiQ6}Q5f>op0AH?7YEd&du@LIe0&%3|R<_qB6m4Tyuib>}g& zB-zWxIz+t^Ucu8e=d5l7s?Op~FnX!-*)&L7ENWaT8$BFIRepVYI1nk55^X6(dz-`} z?_8+wz7e7*Jc8q;tyGG{c(l=sbu19{H$G-2?YGAG;VP)Cu^Tt}JQEB(uRl)Ueb#>! zojqEWbscV{^`OE=vsoA=CKbbIv^<{P!&`4;IOv%_haYpMw+cjXAV@2A5aiJZxtZxL zs+Y5Ko?@hyQ<8m2)Le@^VlA$LIjxBB7ZK`Lr+M{d-tWpFo+|V5qlc#OTi&$?(rdB0 zWpfgJ-*iw`4EdTZpZimyM(ZjW*uZXL**&%7V|JUMhjhW}u6=}uJjTv1Al?XP=W zr^M4{jHY41f}*xA)P)r<+RN}Oyd}(qeT_5Sgh*OsfO7ECc26vjo(lCzd+d{0cQ~VE;L11mm$z63FkW3(ten|3&SzrVzPK;lFuT4RO_#EK-3*6$ za=DOBs4IT#-Am84@x0I4U8xB-1axy6n~ZorJDn;si5eyCrtcpPvbOniI21`5L8#zp z)yf>2_dY1Hanm@j4F5EZ%X#sNA`cX~C?2!gOisU_R**x}Ry6aC`Kj7!bgAP&Ta(Lo zB0JOI3|`!4VWx?&a(~W4j8kX=EJy74)Gm&rZ6K?On&|B)i^E@J+S7(eOGoN&Hv4KK0$P3QG`Hf z@0I1E;@lf0eKbZgtCFVo3eZ3JlpVJZdSqfYfBw?p@$U>jymya2R7=5P>8SE@LsM!& zdH-V!$4zy=cd{{^^k6lP*5TMG#_! ze=et6Bn{@g)}FCWWx2yUHL38}Nr6@ft2(|s^v$u5rXq<2D|rj*_1^8!|JDtGV+*s6 zfKK&?w=0bM!_C}gX!4UTiADY}4(pN?)ea_2E<$DeKg-Qp-zIZB%m@lT4Yzvra*pA9 ze2>wp7wnF4hJTH2JVt)7kN+yV29eE6+{^ zL|sv#gg)#ggZtTogCy2NmS}^(B-8Z^#JJSJ49|qu*~gks{IPiZ4M77W z^}O?}lD48JDxf=^ohNMLm#0Yht%R9t%*7G@zI*KSL_}HDMWa^2IfZUsByr=#i3*q` z!t^FePvIo@PVBVy8}X;a?-73odZIIfauMEfZ<|>6?KW;9l+p9niI&}A%wrF~9ZM)< zF~3YoT=?of7+2BNt7;tuTxjFqu4nx?I^90VeT|t;YwsL9SnKw_$2zgGN%Pnl#aw-F zY?Es@XwF2^^7FibhNZFGf@KKuyo^7leAarC*`_%~l=(YZrhTNse}ztLG(9`YGvLsm z!7}|}bowV{TI6jeul?24siil$`Cd|%Vw((N<=JD>kM?1Cn*3Ij4hglc6PEh1iS=|}aB??KC{*xNTCpGDsCScH8zMfr0j{SKf z5bwV@{aG=Ts@>=0cVPy%2vs*MD-*85`k`Py$vQkMZs*S2=9dHCP0xHGd-1N3;|A-? z*%wnP9YsEZv&L4F4%$skib>1pY4)8`S7$-WzQ#c&$EJ^YHo+4Th~);&Zodx~^$OtE z?4cal#`3(v^2o28az-Nytv0JQ7w~ak^WQezq*+!f?kTXAqe5rQ0`seIM@2d#C zS|_iR_*U5Qv1#P(Y!9}@D176NSvwY*EZg`-IH@I!wa;yRULYgHF_;OZ?2Ya*+aYz3 z_B;EwUDoGUj&@JSgm~6u9VIM1-YM`IROLNAz1q>vN%3iH9{mc-r3!Zx-RP<)$D$zg-L;-bS)1~g}-Q89Bb5{5a7H1 z!b3Xi#tH!|>7F_y6U+qLm@(ll*+Nb(3!K~jqk3%9&)E&Z~9m}^8>m>u%S_Hxf= zdy4J55&ghlg0i?wqq`+6u9io4{~gJWV%90pZi)=LQXZ{}q%KMj;MH)O!dD9{A}lVU z{x@(ru5e^bsM3Jwx^u`rb~+ zuXVq(g^#YmsU(Og8Q{-Pw_k`JMOwZd_g&^|g?PD(3vRhLj`}T~lI3Lj0PmYqu8@dgT5^hXCI?gShhqj=U`NJf z&P&+KSmG2K#jJ8>iR?iPkgZ$01s0sef~?nB*cc~D{UO=I>?C31JPSHG6RPllA+J2^ z0y^vXsYoNDrtraP(4I2iOy(T=skAJejO}Q>|KN6?-$@|&+*gS`5i2K-@4-M8`D6Xg zVDvs_nO3?rT8=R6+4vcDEa7_u?7+krH5=vly3*6cbXxebz#Swt>#FAC-sWv9kLMoJ z6G@$=yUwdDsTfSCxgAoQ?K=2zE~n@a0i5f<;Tbp$3w?|!_Gm;vdU;baw$Mzp!R$33 zA3RDiM*Zm+p$#Rf#Vkot5CbDEj8C~P(5PHZ?KwXF!Mmb9By`h*Y9ytS&QYN`KCX-5 z3rerMJoUVwP5ZmSdxK`&`2yp#$-0Wq8xCbnyRX5+lz23eAjkrlOFz-YUVy_n{Uv5c zMe;!iao*TURV0OSg4OU%MHRbuanh zEQ)!)=RP^MMEIS*bjLM4MSghL)i8q;w9B)2jVZJ=y+djMiu~h6r!;*6Mg%XWg*=k2 zIC!TPhl!i?r{=^nKKWk=WPCIL@8n-dU|ax!>NPjc;)av2xOvwrXY|}>eWI0 zzY)l!K>6PYWJ;j?e?uT^13dpmAZv32%D^WMTvY$-{s0h^YXTsUaZ>{n8E^+pLwJBM ztf0IN?%IHcfoB@PC(6YJt_uLO1q$x%K-XO!2qcXcfRw=@kTxL*WF6qD=t%>;@&H{# z1+=LSfkXgYnjRepWKRzQQ8s`;A^~Pe7QiAYwuV4T?SK*r!r2*!3&6ReV4J-G7-wH0 zp%5*AaK;E~UI31NKL5!*KtVV=14f$w(9FJqX=Ma(&i)(WOaWk=Mf@kb{*P~<2Pg<< t^Z;iG2T6sLLAn42Q}MqL&eQpyjt)%D*0J8e@#4R8T@VXek8k2}21&_}y)%b;GjL>~29MB;35{ z!FbfG@uno6j0dk?ymIwsJb3WpReiI&rKM_2j2GgYrt{v+yf^#ax9QKk764efoWBeZ zg6dOk!@+%kSSw1|6kk9SN(P_?LlQ)Q2oM1xKm>@u2`13s#~zZO_vi6*zx@Q~LZK4@ zBJg(vSmRW7DviGQCI7cguBGrP<-RXHbL_lx0?J05_8RI(hXvGAauVgfFHytBg5w1X z+?aP3k@LPQgIHjf3go^oeRp8qRnb1UDw9VUY%I7ki^&m=Q=XuX2oM1xKm>>Y5g-CY zfCvx)B0vO)01+SpM1TnVj|BYR_7nQHQ_@YLBZ33HWEzf}Odc|&p|R=IY1u++YS*N& zT+Oh!oxLNrvqqlln!Qw`uXwtvxn(CEtjuZpyur6Ut2rS|^jN-aSb4FX7G*2%;I@6c zyTi%kVC?Gf*xq1lcXSl>DZvFX{>4b#6ZEU5cIbKPyie=K0pFk$4jf(#lW zl?*HLJ+eJ8NoG*K!+0TgEjhOktTWe}(-SjNaAG6JDYDGR_8k)0OwVc~2@ui||Khw8VFLUd?enB`6G z3{^t{nWL)2E9u2ki@q99t@}!ID4t6Csw%7C7hwc4u#S;EfG6-AUcp;<2Z!(pzQQ*a zWqs_N@<@5CJXM}4FO-+cf%4k-Rw^%%Gyb^JD{oN4m&jJuwoG9ay%9hgOh5@ACT5<) z93=sPG@Ohna>1Jc7Sok$X#ZZ`E13zlj$hJA;10OkabGrF=j&}i9T6Y`M1Tko0U|&I mhyW2F0z`la5CI}U1c(3;AOb{y2oM1xKm>>Y5g-Epn7|JJ=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" From b63a13925b20e6947bce27f246acaf96abae3188 Mon Sep 17 00:00:00 2001 From: Ahmed Moussa Date: Fri, 9 Oct 2026 11:49:05 +0300 Subject: [PATCH 19/26] fix(Play Audio): Resume the web AudioContext on the first user gesture cpal (wasm) creates its output stream at startup, before any user gesture, so the browser leaves the WebAudio AudioContext 'suspended'. As a result no audio plays, and the transport clock never advances because the audio callback drives it. Wrap the AudioContext constructor before the wasm module loads so that every context cpal creates is captured. On the first pointer, mouse, touch or key event, resume any context that is still suspended. Signed-off-by: Ahmed Moussa --- .gitignore | 1 + apps/soundcraft-web/index.html | 21 +++++++++++++++++++++ contributors/people.toml | 5 +++++ 3 files changed, 27 insertions(+) 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/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/contributors/people.toml b/contributors/people.toml index 89c6db2..9d108ba 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -21,3 +21,8 @@ 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 From f0a0ac8318e794a65efe696d51689238e82f26f7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 10:39:25 -0400 Subject: [PATCH 20/26] vst3-host tests: expect editor 'unsupported' off macOS VST3 editor windows are hosted on macOS only, but editor_main_thread and fixture_e2e asserted a probed view size on every platform, failing the FreeBSD job on every commit to main (and Linux locally). Gate only the size assertion; the controller round trips still run everywhere. Co-Authored-By: Claude Opus 5.5 --- crates/vst3-host/tests/editor_main_thread.rs | 7 ++++++- crates/vst3-host/tests/fixture_e2e.rs | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) 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)]); From 667e32152eced3e802fae5eff0e422e2f7b106d3 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 9 Oct 2026 10:55:42 -0400 Subject: [PATCH 21/26] shortcuts: keep Cmd+Ctrl apart from Cmd on macOS; detect the Mac at runtime On macOS a held Cmd made the Ctrl state ignored, so Cmd+Ctrl+X ran Cut instead of Cut Clip Gain. Ask egui for the OS so the web build in a Mac browser uses Mac modifiers too. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/shortcuts.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 2326b24..15141c8 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -41,14 +41,11 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> { key.map(|k| (m, k)) } -/// Cmd and Ctrl are separate keys only on macOS. -const MAC: bool = cfg!(target_os = "macos"); - /// Whether the held modifiers `got` fire a shortcut that asks for `want`. fn mods_match(want: Modifiers, got: Modifiers, mac: bool) -> bool { // Off macOS egui reports Ctrl as both `ctrl` and `command`: Ctrl is the shortcut's Cmd, and a shortcut's // Ctrl (the Mac Control key) has no key of its own there, so Ctrl+X is Cut, not Cmd+Ctrl+X. - let ctrl = if mac { want.ctrl == got.ctrl || got.mac_cmd } else { !want.ctrl }; + let ctrl = if mac { want.ctrl == got.ctrl } else { !want.ctrl }; want.command == got.command && want.shift == got.shift && want.alt == got.alt && ctrl } @@ -70,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, MAC))); + soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac))); if let Some(c) = hit { let id = c.id; if crate::menus::invoke_shortcut_dialog(app, id) { @@ -87,7 +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 => { @@ -99,7 +98,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool { let _ = app.run("transport.record", json!({})); true } - Key::S if search_chord(m, MAC) => { + Key::S if search_chord(m, mac) => { let _ = app.run("window.search", json!({})); true } @@ -269,6 +268,7 @@ mod tests { 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)); } From a4c371b18b20ea810c460d14ce049578fe849c41 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 10:56:45 -0400 Subject: [PATCH 22/26] Revert "Do not yank the view to the playhead on play start" This reverts commit 0ffd2a14418e5b191689781477c2ce0997d600da. --- crates/ui-egui/src/edit_window.rs | 8 +++----- crates/ui-egui/src/lib.rs | 11 ++++------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 5e0f6f2..5c13332 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1726,11 +1726,9 @@ fn follow_update( /// 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. Pressing play never -/// yanks the view: when the playhead starts off-screen the view stays put -/// until the playhead walks back into it. Scrolling back to the playhead, -/// stopping, or re-selecting a scrolling mode resumes the follow. -/// `none` and `after_playback` never scroll during playback. +/// 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; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7a1a69e..4fc47a9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -324,13 +324,10 @@ impl SoundApp { } self.engine.transport.playing = true; self.engine.transport.position = from; - // A fresh start does not yank the view: hold the follow until the - // first overlay frame decides. If the playhead is already on-screen - // the hold clears at once and the follow runs as usual; if it is - // off-screen the view stays where it is until the playhead walks - // back into view. The tracker is re-synced so the current view is - // not mistaken for an outside move on that first frame. - self.edit_layout.follow_hold = true; + // 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; } From 6c3f2dbac8295a7ea5dc8a25f5f3782c4bd26f3e Mon Sep 17 00:00:00 2001 From: t Date: Fri, 9 Oct 2026 10:57:18 -0400 Subject: [PATCH 23/26] After Playback: scroll via view.scroll, and only when the stop point is off-screen Stop no longer writes the session's scroll directly (everything is a command) and leaves the view alone when the stopped position is visible. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4fc47a9..f9f6b20 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -354,10 +354,19 @@ impl SoundApp { self.engine.session_mut().edit.selection = Range::point(pos); } if after_playback { - // After Playback never scrolls while playing, so jump the view to - // the stopped position now; every other mode keeps its view. + // 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); - self.engine.session_mut().edit.zoom.scroll = at; + 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; } From 916f453690cbe0f98a15680458b8ed7e1656db14 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 9 Oct 2026 10:58:38 -0400 Subject: [PATCH 24/26] mix: no click while only monitoring input with the transport stopped The stopped position does not advance, so a beat at the stop point re-rendered its first 30 ms every block: a ~94 Hz buzz whenever a track was record-armed or monitoring input. Regression test included. Co-Authored-By: Claude Opus 5.5 --- crates/mix/src/lib.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 28e9d1d..ef70b57 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -661,7 +661,9 @@ impl MixEngine { } // Metronome: summed after the master fader, so it reaches the speakers without passing // through any track, send or insert. Only the playback engine sets `metronome`. - if self.metronome && s.edit.click && !(s.edit.flag("click.only_during_record") && !self.recording) { + // 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. @@ -2004,6 +2006,20 @@ 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); From c04e79714c8819b31c87beb066ca3d7a7430e81a Mon Sep 17 00:00:00 2001 From: t Date: Fri, 9 Oct 2026 11:00:17 -0400 Subject: [PATCH 25/26] fonts: fontconfig on FreeBSD too; accept only TrueType/OpenType results egui panics on Type 1 or bitmap font bytes, which fc-match can return on minimal systems, so check the sfnt magic first. Mask the variable-font named instance out of %{index}. Fix the unused 'bold' warning on macOS and Windows. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/fonts.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs index 46b99e1..bcaed2d 100644 --- a/crates/ui-egui/src/fonts.rs +++ b/crates/ui-egui/src/fonts.rs @@ -26,8 +26,9 @@ const BOLD: &[(&str, u32)] = &[ ("/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", 0), ]; -#[cfg(not(target_arch = "wasm32"))] -#[cfg(target_os = "linux")] +/// 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()?; @@ -37,8 +38,13 @@ fn load_from_fontconfig(bold: bool) -> Option { let stdout = String::from_utf8(output.stdout).ok()?; let mut lines = stdout.lines(); let path = lines.next()?.trim(); - let index = lines.next()?.trim().parse().ok()?; + // 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) @@ -49,10 +55,12 @@ fn load(cands: &[(&str, u32)], bold: bool) -> Option { if std::env::var_os("SOUNDCRAFT_NO_SYSTEM_FONTS").is_some() { return None; } - #[cfg(target_os = "linux")] + #[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); From d5f70bf09e20a1171833687250894099d8b03196 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 9 Oct 2026 11:05:24 -0400 Subject: [PATCH 26/26] dialogs: keep the Dialog enum rustfmt-clean A doc comment on one variant made rustfmt expand every variant. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 39f3cf8..8bfe6f0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -17,8 +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 }, - /// Tempo change at a position, from double-clicking the tempo ruler. - TempoChange { at: Samples, bpm: f64 }, + 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)> },