diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 88694ec6..ce126e3a 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -54,4 +54,14 @@ jobs: desktop-file-validate packaging/linux/ai.storyteller.soundcraft.desktop sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.soundcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml" appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml" - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.soundcraft.yml')); assert m['id'] == 'ai.storyteller.soundcraft' and m['command'] == 'soundcraft'" + python3 - <<'EOF' + import yaml + src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.yml")) + bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml")) + for m in (src, bundle): + assert m["id"] == "ai.storyteller.soundcraft" and m["command"] == "soundcraft", m["id"] + assert "--socket=pulseaudio" in m["finish-args"], "audio needs --socket=pulseaudio" + for key in ("runtime", "runtime-version", "sdk", "finish-args", "add-extensions"): + assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" + print("Flatpak manifests ok") + EOF diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c10b5e5..b1c0a39a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,11 @@ # Release pipeline: every push to `release` builds signed installers for every platform and # creates or updates the draft GitHub Release "SoundCraft v". See docs/releasing.md. # -# All jobs run in the `release` environment (restricted to the `release` branch), which holds -# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a -# warning, never a failed build. +# The jobs that sign (macOS, Windows) and the one that drafts the release run in the `release` +# environment (restricted to the `release` branch), which holds the signing secrets. Every secret +# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The +# other jobs sign nothing and get no secrets, so a dispatch on any branch builds and checks them +# (a dry run); the signed jobs are refused there, and so the draft release is skipped. name: Release on: @@ -35,7 +37,7 @@ jobs: version: name: version runs-on: ubuntu-latest - environment: release + # No `environment: release`: this job reads no secrets (and gates every other job). outputs: version: ${{ steps.v.outputs.version }} date: ${{ steps.v.outputs.date }} @@ -157,7 +159,7 @@ jobs: # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. runs-on: ${{ matrix.runner }} - environment: release + # No `environment: release`: this job signs nothing, so it gets no release secrets. strategy: fail-fast: false matrix: @@ -176,7 +178,7 @@ jobs: sudo apt-get install -y --no-install-recommends \ libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ libasound2-dev pkg-config \ - desktop-file-utils appstream python3-yaml + desktop-file-utils appstream python3-yaml zsync curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" @@ -200,6 +202,9 @@ jobs: for appimage in dist/release/*.AppImage; do chmod +x "$appimage" APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version + # Update information is embedded, and the .zsync for delta updates sits beside it. + "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|storytold|soundcraft|latest|soundcraft-*-linux-${{ matrix.arch }}.AppImage.zsync' + test -s "$appimage.zsync" done - uses: actions/upload-artifact@v4 with: @@ -207,13 +212,51 @@ jobs: path: dist/release/* if-no-files-found: error + flatpak: + name: Flatpak ${{ matrix.arch }} + needs: [version, linux] + # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust + # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. + runs-on: ${{ matrix.runner }} + # No `environment: release`: this job signs nothing, so it gets no release secrets. + strategy: + fail-fast: false + matrix: + include: + - { arch: x86_64, runner: ubuntu-24.04 } + - { arch: aarch64, runner: ubuntu-24.04-arm } + env: + SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} + SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: release-linux-${{ matrix.arch }} + path: dist/linux + - name: Install flatpak-builder + run: | + sudo apt-get update + # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's + # `appstreamcli compose`, which renders the scalable icon and fails without it. + sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common + # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - name: Build the Flatpak bundle + run: packaging/linux/flatpak-bundle.sh "dist/linux/soundcraft-$SOUNDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" + - uses: actions/upload-artifact@v4 + with: + name: release-flatpak-${{ matrix.arch }} + path: dist/release/*.flatpak + if-no-files-found: error + freebsd: name: FreeBSD x86_64 needs: version # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image as freebsd.yml). runs-on: ubuntu-latest timeout-minutes: 150 - environment: release + # No `environment: release`: this job signs nothing, so it gets no release secrets. env: SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} @@ -253,7 +296,7 @@ jobs: name: Web (wasm) needs: version runs-on: ubuntu-latest - environment: release + # No `environment: release`: this job signs nothing, so it gets no release secrets. env: SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} @@ -284,7 +327,8 @@ jobs: release: name: Draft GitHub Release - needs: [version, macos, windows, linux, freebsd, web] + # Needs the signed jobs, which only run on the `release` branch: a branch dispatch never drafts. + needs: [version, macos, windows, linux, flatpak, freebsd, web] runs-on: ubuntu-latest environment: release permissions: diff --git a/.gitignore b/.gitignore index 75bd19e5..901797f7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ plan/ /target-*/ /craft-fonts/ plans/ +.DS_Store diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 69f0f0bb..084f4240 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -38,6 +38,7 @@ and, at runtime, the operating system's own UI fonts when available (never redis | `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | | `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | | `assets/app-icon/icon.svg` | SoundCraft contributors | original (hand-placed SVG paths; placeholder app icon) | MIT OR Apache-2.0 | +| `packaging/macos/dmg/` (all files) | @XusBadia | original (macOS DMG window background and layout, from the app icon; see `packaging/macos/dmg/README.md`) | MIT OR Apache-2.0 | | `assets/app-icon/soundcraft-1024.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 | | `assets/app-icon/soundcraft-macos-512.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 | | `assets/app-icon/soundcraft.icns` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 | diff --git a/Cargo.lock b/Cargo.lock index 759f472a..e6719efc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3361,7 +3361,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.1.0" +version = "0.3.0" dependencies = [ "eframe", "egui", @@ -3376,7 +3376,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.1.0" +version = "0.3.0" dependencies = [ "log", "serde", @@ -3386,7 +3386,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.1.0" +version = "0.3.0" dependencies = [ "log", "proptest", @@ -3398,7 +3398,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.1.0" +version = "0.3.0" dependencies = [ "log", "serde_json", @@ -3408,7 +3408,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.1.0" +version = "0.3.0" dependencies = [ "clap-sys", "libloading", @@ -3420,7 +3420,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.1.0" +version = "0.3.0" dependencies = [ "serde_json", "soundcraft-audio-io", @@ -3431,7 +3431,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.1.0" +version = "0.3.0" dependencies = [ "log", "proptest", @@ -3442,7 +3442,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.1.0" +version = "0.3.0" dependencies = [ "log", "proptest", @@ -3462,7 +3462,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.1.0" +version = "0.3.0" dependencies = [ "proptest", "serde", @@ -3472,7 +3472,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.1.0" +version = "0.3.0" dependencies = [ "log", "rayon", @@ -3488,7 +3488,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.1.0" +version = "0.3.0" dependencies = [ "proptest", "serde", @@ -3501,7 +3501,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.1.0" +version = "0.3.0" dependencies = [ "cpal", "log", @@ -3514,7 +3514,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.1.0" +version = "0.3.0" dependencies = [ "proptest", "serde", @@ -3524,7 +3524,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.1.0" +version = "0.3.0" dependencies = [ "egui", "egui_kittest", @@ -3549,7 +3549,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.1.0" +version = "0.3.0" dependencies = [ "image", "proptest", @@ -3560,7 +3560,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.1.0" +version = "0.3.0" dependencies = [ "libloading", "log", @@ -3575,7 +3575,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.1.0" +version = "0.3.0" dependencies = [ "eframe", "egui", @@ -5122,7 +5122,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.1.0" +version = "0.3.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 35520baf..6f07dfbe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.1.0" +version = "0.3.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/README.md b/README.md index 8ff13d80..da490e2a 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Agents & scripts · How it's built · Status · + Downloads · The Crafting Apps · License

@@ -235,6 +236,49 @@ honest status, what's next and our effort estimates are in [`ROADMAP.md`](ROADMA menu-by-menu comparison is in [`docs/parity.md`](docs/parity.md). Bug reports and wish lists are very welcome, in the issues or on Discord. +## Downloads + +**Download SoundCraft** from GitHub: the [latest release](https://github.com/storytold/soundcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/soundcraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. + +### Windows + +| Build | Installer | Portable | +|---|---|---| +| x64 (64-bit Intel/AMD) | `soundcraft--windows-x64.msi` | `soundcraft--windows-x64-portable.zip` | +| arm64 (Snapdragon and other ARM PCs) | `soundcraft--windows-arm64.msi` | `soundcraft--windows-arm64-portable.zip` | +| x86 (32-bit) | `soundcraft--windows-x86.msi` | `soundcraft--windows-x86-portable.zip` | + +Installers and executables are code-signed. + +### macOS + +| Build | File | Notes | +|---|---|---| +| App, universal (Apple silicon + Intel) | `soundcraft--macos-universal.dmg` | Signed and notarized | +| Command-line tool, universal | `soundcraft-cli--macos-universal.zip` | Signed and notarized | + +### Linux + +| Format | x86_64 | aarch64 (ARM64) | Notes | +|---|---|---|---| +| AppImage | `soundcraft--linux-x86_64.AppImage` | `soundcraft--linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) | +| Flatpak | `soundcraft--linux-x86_64.flatpak` | `soundcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` | +| Debian/Ubuntu | `soundcraft--linux-x86_64.deb` | `soundcraft--linux-aarch64.deb` | | +| Fedora/RHEL/openSUSE | `soundcraft--linux-x86_64.rpm` | `soundcraft--linux-aarch64.rpm` | | +| Tarball | `soundcraft--linux-x86_64.tar.gz` | `soundcraft--linux-aarch64.tar.gz` | Unpack anywhere | + +### FreeBSD + +| Build | File | +|---|---| +| x86_64 | `soundcraft--freebsd-x86_64.tar.gz` | + +### Web (WebAssembly) + +| Build | File | Notes | +|---|---|---| +| Static site | `soundcraft-web-.zip` | Runs in a modern browser; host it on any static server | + ## The Crafting Apps SoundCraft is one of the **Crafting Apps**: free, open-source creative tools from the diff --git a/apps/soundcraft-web/index.html b/apps/soundcraft-web/index.html index b68f2d77..af72b21b 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 fd3d0317..78cc705d 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -22,6 +22,11 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false +[people.hamada147] +real_name = "Ahmed Moussa" +display_name = "Ahmed Moussa" +sync_github_name = true + [people.switch87] real_name = "Gert Pellin" display_name = "Gert Pellin" diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs index 6b6c0235..7572ac3c 100644 --- a/crates/dsp/src/lib.rs +++ b/crates/dsp/src/lib.rs @@ -278,6 +278,11 @@ pub fn gain_to_db(g: f32) -> f32 { (20.0 * a.log10()).max(MIN_DB) } +/// MIDI note number to frequency in Hz (equal temperament, A4 = note 69 = 440 Hz). +pub fn midi_to_hz(note: f64) -> f64 { + 440.0 * 2f64.powf((note - 69.0) / 12.0) +} + #[cfg(test)] mod trait_default_tests { use super::*; diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index bfbb6df8..34fb355d 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -359,3 +359,21 @@ fn step_marker(e: &mut Engine, dir: i64) -> Result { e.transport_requests.push(crate::TransportRequest::Locate(t)); Ok(json!({"at": t})) } + +#[cfg(test)] +mod tests { + use crate::demo::demo_engine; + use serde_json::json; + + /// The tempo-ruler dialog runs `event.tempo` with `bpm` and `at`; the change must land at that position. + #[test] + fn tempo_change_at_position_is_applied() { + let mut e = demo_engine(); + let sr = e.session().sample_rate; + let at = sr.samples(4.0); + e.execute("event.tempo", &json!({"bpm": 133.5, "at": at})).unwrap(); + let s = e.session(); + let tick = s.tempo.samples_to_ticks(at, s.sample_rate); + assert!((s.tempo.tempo_at_tick(tick) - 133.5).abs() < 1e-9); + } +} diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index bc8a2a2e..1e7daa00 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -110,10 +110,6 @@ fn drums(sr: f64, frames: usize) -> (Vec, Vec, Vec) { (k, s, h) } -fn midi_hz(n: f64) -> f64 { - 440.0 * 2f64.powf((n - 69.0) / 12.0) -} - /// Chord roots per bar (A minor, F, C, G progression), as MIDI notes. const PROG: [[u8; 4]; 4] = [[57, 60, 64, 69], [53, 57, 60, 65], [48, 52, 55, 60], [55, 59, 62, 67]]; @@ -127,7 +123,7 @@ fn bass(sr: f64, frames: usize) -> Vec { for (eighth, semi, beats) in pattern { let at = bar * beat * 4 + eighth * beat / 2; let len = (beats * beat as f64) as usize; - let f = midi_hz(root + semi); + let f = soundcraft_dsp::midi_to_hz(root + semi); let mut ph = 0.0f64; for i in 0..len { let Some(o) = out.get_mut(at + i) else { break }; @@ -152,7 +148,7 @@ fn pad(sr: f64, frames: usize) -> (Vec, Vec) { let chord = PROG[bar % 4]; let at = bar * bar_len; for (vi, n) in chord.iter().enumerate() { - let f = midi_hz(f64::from(*n) + 12.0); + let f = soundcraft_dsp::midi_to_hz(f64::from(*n) + 12.0); for (side, buf) in [(-1.0f64, &mut l), (1.0, &mut r)] { let det = 1.0 + side * 0.0035 * (vi as f64 + 1.0); let mut ph = vi as f64 * 0.17; diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index e604bffe..70cdd1c7 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -299,6 +299,9 @@ pub struct MixEngine { pub monitor_only: bool, /// Recording: record-armed tracks hear their input (auto input). pub recording: bool, + /// Mix the metronome (Options > Click) into the output. Playback turns this on; offline renders + /// (bounces, stems, exports) leave it off so the click is never printed into a file. + pub metronome: bool, /// Third-party instances come from [`MixEngine::adopt`] instead of being created in `sync`. external: bool, /// Instances handed over by the host, waiting for `sync` to place them. @@ -363,6 +366,7 @@ impl MixEngine { input: Vec::new(), monitor_only: false, recording: false, + metronome: false, external: false, adopt: HashMap::new(), retired: Vec::new(), @@ -655,6 +659,13 @@ impl MixEngine { self.meters.entry(t.id).or_default().measure(&self.main, frames, 0.0); } } + // Metronome: summed after the master fader, so it reaches the speakers without passing + // through any track, send or insert. Only the playback engine sets `metronome`. + // Not while monitoring only (transport stopped): the position does not advance there, so the + // start of the same beat would repeat every block as a buzz. + if self.metronome && !self.monitor_only && s.edit.click && !(s.edit.flag("click.only_during_record") && !self.recording) { + click::render(s, pos, frames, &mut self.main); + } // Output: min(out.len(), main channels) channels; any further output channels are silent. for (c, o) in out.iter_mut().enumerate() { let src = self.main.get(c); @@ -1110,6 +1121,67 @@ fn receives_solo(s: &Session, t: &Track) -> bool { .any(|o| o.mixer.solo && (o.mixer.output == Route::Bus(*b) || o.mixer.sends.iter().flatten().any(|sn| sn.target == Route::Bus(*b)))) } +/// Metronome: a short synthesized blip on every beat of the tempo map, accented on beat 1 of +/// each bar. Settings come from `setup.click_countoff` (`click.*` values). +mod click { + use super::*; + + /// Blip length in seconds. + const LEN_S: f64 = 0.03; + + pub fn render(s: &Session, pos: Samples, frames: usize, main: &mut [Vec]) { + let sr = s.sample_rate; + let rate = sr.as_f64(); + let len = (LEN_S * rate) as i64; + let end = pos.saturating_add(frames as i64); + let gain = f64::from(db_to_gain(s.edit.value("click.volume_db", -6.0).clamp(-60.0, 12.0) as f32)); + let accent = (note_hz(s.edit.value("click.accent_note", 84.0)), s.edit.value("click.accent_velocity", 127.0)); + let normal = (note_hz(s.edit.value("click.normal_note", 72.0)), s.edit.value("click.normal_velocity", 100.0)); + // Start one blip length early so a click that began in an earlier block still rings into this one. + let t0 = s.tempo.samples_to_ticks(pos.saturating_sub(len), sr).max(0).saturating_sub(1); + let mut tick = t0.saturating_sub(s.tempo.bar_beat_at_tick(t0).tick); + // Blocks hold a few beats at most; the guard only protects against a malformed tempo map. + for _ in 0..4096 { + let at = s.tempo.tick_to_samples(tick, sr); + if at >= end { + break; + } + if at.saturating_add(len) > pos { + let (hz, vel) = if s.tempo.bar_beat_at_tick(tick).beat == 1 { accent } else { normal }; + let amp = gain * (vel.clamp(1.0, 127.0) / 127.0); + blip(main, at - pos, frames, hz, amp as f32, rate); + } + tick = tick.saturating_add(s.tempo.meter_at_tick(tick).ticks_per_beat()); + } + } + + /// Adds one decaying sine blip whose first sample sits `rel` frames into the block (negative + /// when it began before the block) to every channel. + fn blip(main: &mut [Vec], rel: i64, frames: usize, hz: f64, amp: f32, rate: f64) { + let len = (LEN_S * rate) as usize; + let w = std::f64::consts::TAU * hz / rate; + let first = usize::try_from(-rel).unwrap_or(0); + for i in first..len { + let Ok(at) = usize::try_from(rel + i as i64) else { continue }; + if at >= frames { + break; + } + let env = (-6.0 * i as f64 / len as f64).exp() as f32; + let v = ((w * i as f64).sin() as f32) * env * amp; + for ch in main.iter_mut() { + if let Some(d) = ch.get_mut(at) { + *d += v; + } + } + } + } + + /// Click pitch for a MIDI note setting (clamped to the MIDI range). + fn note_hz(note: f64) -> f64 { + soundcraft_dsp::midi_to_hz(note.clamp(0.0, 127.0)) + } +} + fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut [Vec], _scratch: &mut [f32]) { let ClipContent::Audio { source, offset } = clip.content else { return }; let block = Range { start: pos, end: pos.saturating_add(frames as i64) }; @@ -2013,6 +2085,76 @@ mod tests { assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0))); } + #[test] + fn stopped_monitoring_does_not_click() { + let mut s = Session::default(); + s.edit.click = true; + let mut eng = MixEngine::new(48_000.0, 512); + eng.metronome = true; + eng.monitor_only = true; + let mut out = vec![vec![0.0f32; 512]; 2]; + for _ in 0..4 { + eng.render(&s, 0, 512, &mut out); + assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0))); + } + } + + /// Renders `len` frames from zero the way playback does (metronome on), in blocks of `block`. + fn render_playback(s: &Session, len: usize, block: usize, recording: bool) -> Vec> { + let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); + eng.metronome = true; + eng.recording = recording; + let mut out = vec![vec![0.0f32; len]; 2]; + let mut tmp = vec![vec![0.0f32; block]; 2]; + let mut done = 0usize; + while done < len { + let n = (len - done).min(block); + eng.render(s, done as i64, n, &mut tmp); + for (o, t) in out.iter_mut().zip(tmp.iter()) { + if let (Some(d), Some(src)) = (o.get_mut(done..done + n), t.get(..n)) { + d.copy_from_slice(src); + } + } + done += n; + } + out + } + + #[test] + fn click_sounds_on_every_beat_only_in_playback() { + let mut s = Session::default(); + let beat = s.sample_rate.as_f64() as usize * 60 / 120; // 120 BPM quarter notes + let len = beat * 8; + s.edit.click = true; + // Offline renders (bounces, stems) never print the metronome. + let offline = render_range(&s, Range::new(0, len as i64), 512); + assert!(offline.iter().all(|c| c.iter().all(|x| *x == 0.0))); + + let out = render_playback(&s, len, 512, false); + for b in 0..8usize { + let at = b * beat; + let on: f32 = out[0][at..at + 200].iter().map(|x| x.abs()).fold(0.0, f32::max); + let off: f32 = out[0][at + beat / 2..at + beat / 2 + 200].iter().map(|x| x.abs()).fold(0.0, f32::max); + assert!(on > 0.1, "beat {b} should click, peak {on}"); + assert_eq!(off, 0.0, "between beats must stay silent"); + assert_eq!(out[0][at], out[1][at], "both channels get the click"); + } + // Blocks that start mid-beat render the same clicks as a single pass. + let split = render_playback(&s, len, 97, false); + assert_eq!(out, split); + } + + #[test] + fn click_only_during_record_follows_recording() { + let mut s = Session::default(); + s.edit.click = true; + s.edit.set_flag("click.only_during_record", true); + let idle = render_playback(&s, 48_000, 512, false); + assert!(idle.iter().all(|c| c.iter().all(|x| *x == 0.0))); + let rec = render_playback(&s, 48_000, 512, true); + assert!(rec.iter().any(|c| c.iter().any(|x| x.abs() > 0.1))); + } + /// A session with one track of `fmt` playing a distinct DC level per channel (0.1, 0.2, …). fn multichannel_session(fmt: ChannelFormat, main: ChannelFormat, frames: usize) -> (Session, TrackId) { let mut s = Session::default(); diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index 0621503b..eb1db9b0 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -103,6 +103,7 @@ struct AudioState { fn new_engine(sr: f32) -> MixEngine { let mut m = MixEngine::new(sr, BLOCK); m.set_external_instances(true); + m.metronome = true; m } @@ -226,7 +227,7 @@ impl AudioState { if self.mix.input.len() != ch { self.mix.input = vec![vec![0.0; BLOCK]; ch]; } - ring.pop_into(&mut self.mix.input, n); + ring.pop_into(&mut self.mix.input, n, self.session.sample_rate.hz()); } self.mix.monitor_only = !self.playing; self.mix.recording = self.recording; diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index a4a4abd4..09eb6847 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 @@ -145,47 +145,133 @@ mod tests { } } +/// Ring state behind the lock: interleaved input frames at the input device's rate, plus the +/// fractional read position (in input frames) of the next output sample. +struct RingState { + data: std::collections::VecDeque, + phase: f64, +} + /// A small interleaved FIFO from the input callback to the output callback, for input /// monitoring. Both sides only `try_lock`; it holds at most ~0.5 s and drops the oldest frames. -#[derive(Default)] +/// The input device may run at a different rate than the session, so the reader converts +/// rates with linear interpolation (cheap, allocation-free, fine for monitoring). pub struct InputRing { - data: Mutex>, + state: Mutex, pub channels: std::sync::atomic::AtomicUsize, + /// Input device sample rate in Hz (0 when unknown, which reads as "same as the session"). + rate: u32, + /// Most samples kept: half a second of input, or 24 000 frames when the rate is unknown. + cap: usize, } impl InputRing { - pub fn new(channels: usize) -> Arc { + pub fn new(channels: usize, rate: u32) -> Arc { + let ch = channels.max(1); + let frames = if rate == 0 { 24_000 } else { rate as usize / 2 }; Arc::new(InputRing { - data: Mutex::new(std::collections::VecDeque::with_capacity(48_000 * channels.max(1))), - channels: std::sync::atomic::AtomicUsize::new(channels.max(1)), + state: Mutex::new(RingState { data: std::collections::VecDeque::with_capacity(rate.max(48_000) as usize * ch), phase: 0.0 }), + channels: std::sync::atomic::AtomicUsize::new(ch), + rate, + cap: frames * ch, }) } pub fn push(&self, samples: &[f32]) { - if let Ok(mut d) = self.data.try_lock() { - let cap = 24_000 * self.channels.load(Ordering::Relaxed).max(1); - d.extend(samples.iter().copied()); - while d.len() > cap { - d.pop_front(); + if let Ok(mut s) = self.state.try_lock() { + s.data.extend(samples.iter().copied()); + while s.data.len() > self.cap { + s.data.pop_front(); } } } - /// Fill `out` (planar) with up to `frames` frames; missing frames are silence. - pub fn pop_into(&self, out: &mut [Vec], frames: usize) { + /// Fill `out` (planar, at `session_rate`) with up to `frames` frames, converting from the + /// input rate. Missing frames are silence. + pub fn pop_into(&self, out: &mut [Vec], frames: usize, session_rate: u32) { let ch = self.channels.load(Ordering::Relaxed).max(1); for c in out.iter_mut() { c.iter_mut().take(frames).for_each(|x| *x = 0.0); } - let Ok(mut d) = self.data.try_lock() else { return }; - let avail = (d.len() / ch).min(frames); - for f in 0..avail { + let Ok(mut s) = self.state.try_lock() else { return }; + // Input frames consumed per output frame. + let step = if self.rate == 0 || session_rate == 0 { 1.0 } else { f64::from(self.rate) / f64::from(session_rate) }; + for f in 0..frames { + // Drop input frames we have moved past; keep the one at `phase` and the next one. + while s.phase >= 1.0 && s.data.len() >= 2 * ch { + s.data.drain(..ch); + s.phase -= 1.0; + } + if s.phase >= 1.0 || s.data.len() < 2 * ch { + break; + } + let fr = s.phase as f32; for c in 0..ch { - let v = d.pop_front().unwrap_or(0.0); + let a = s.data.get(c).copied().unwrap_or(0.0); + let b = s.data.get(ch + c).copied().unwrap_or(a); if let Some(x) = out.get_mut(c).and_then(|o| o.get_mut(f)) { - *x = v; + *x = a + (b - a) * fr; } } + s.phase += step; } } } + +#[cfg(test)] +mod ring_tests { + use super::InputRing; + + /// A 1 kHz sine recorded at 16 kHz must come out as a 1 kHz sine at 48 kHz, across calls. + #[test] + fn monitor_converts_input_rate_to_session_rate() { + let ring = InputRing::new(1, 16_000); + let input: Vec = (0..4_000).map(|i| (2.0 * std::f64::consts::PI * 1_000.0 * f64::from(i) / 16_000.0).sin() as f32).collect(); + ring.push(&input); + let mut out = vec![vec![0.0f32; 48]]; + let mut k = 0usize; + for _ in 0..10 { + ring.pop_into(&mut out, 48, 48_000); + for v in &out[0] { + let want = (2.0 * std::f64::consts::PI * 1_000.0 * k as f64 / 48_000.0).sin() as f32; + assert!((v - want).abs() < 0.05, "sample {k}: got {v}, want {want}"); + k += 1; + } + } + } + + /// Input faster than the session (96 kHz into 48 kHz): every other frame is read, in order. + #[test] + fn monitor_decimates_when_input_is_faster() { + let ring = InputRing::new(1, 96_000); + let input: Vec = (0..1_000).map(|i| i as f32).collect(); + ring.push(&input); + let mut out = vec![vec![0.0f32; 100]]; + ring.pop_into(&mut out, 100, 48_000); + for (k, v) in out[0].iter().enumerate() { + assert_eq!(*v, (2 * k) as f32, "output {k}"); + } + } + + /// Same rate: samples pass through unchanged. + #[test] + fn monitor_passes_through_at_equal_rates() { + let ring = InputRing::new(2, 48_000); + let input: Vec = (0..200).map(|i| i as f32).collect(); + ring.push(&input); + let mut out = vec![vec![0.0f32; 50], vec![0.0f32; 50]]; + ring.pop_into(&mut out, 50, 48_000); + assert_eq!(out[0][0], 0.0); + assert_eq!(out[1][0], 1.0); + assert_eq!(out[0][49], 98.0); + } + + /// Nothing buffered: silence, not stale data. + #[test] + fn monitor_underrun_is_silence() { + let ring = InputRing::new(1, 16_000); + let mut out = vec![vec![1.0f32; 16]]; + ring.pop_into(&mut out, 16, 48_000); + assert!(out[0].iter().all(|v| *v == 0.0)); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6815d60d..8bfe6f04 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6,6 +6,7 @@ use egui::{Align2, vec2}; use serde_json::{Value, json}; use soundcraft_engine::Engine; use soundcraft_model::{ClipId, TrackId}; +use soundcraft_time::Samples; #[derive(Debug, Clone)] pub enum Dialog { @@ -16,6 +17,7 @@ pub enum Dialog { RenameClip { id: ClipId, name: String }, PathPrompt { cmd: String, title: String, path: String, key: String }, Number { cmd: String, title: String, key: String, value: f64, suffix: String }, + TempoChange { at: Samples, bpm: f64 }, // from double-clicking the tempo ruler Fades { shape: String }, StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 }, Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> }, @@ -38,6 +40,7 @@ impl Dialogs { Dialog::RenameClip { .. } => "rename_clip", Dialog::PathPrompt { .. } => "path", Dialog::Number { .. } => "number", + Dialog::TempoChange { .. } => "tempo_change", Dialog::Fades { .. } => "fades", Dialog::StripSilence { .. } => "strip_silence", Dialog::Group { .. } => "group", @@ -167,6 +170,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::RenameTrack { .. } => "Rename Track", Dialog::RenameClip { .. } => "Rename Clip", Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(), + Dialog::TempoChange { .. } => "Tempo Change (BPM)", Dialog::Fades { .. } => "Fades", Dialog::StripSilence { .. } => "Strip Silence", Dialog::Group { .. } => "Create Group", @@ -315,6 +319,16 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { action = Some((cmd.clone(), v)); } } + Dialog::TempoChange { at, bpm } => { + ui.horizontal(|ui| { + // Same limits the engine enforces (time crate `valid_bpm`). + ui.add(egui::DragValue::new(bpm).speed(0.1).range(5.0..=1000.0)); + ui.label("bpm"); + }); + if buttons(ui, "OK", enter) { + action = Some(tempo_change_action(*at, *bpm)); + } + } Dialog::Fades { shape } => { for s in soundcraft_model::FadeShape::ALL { ui.radio_value(shape, s.label().to_lowercase(), s.label()); @@ -422,6 +436,11 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool { pressed } +/// The command the tempo-change dialog runs on OK. +fn tempo_change_action(at: Samples, bpm: f64) -> (String, Value) { + ("event.tempo".into(), json!({"bpm": bpm, "at": at})) +} + fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { let Some(process) = app.ui.audiosuite.clone() else { return }; let mut open = true; @@ -495,4 +514,12 @@ mod tests { assert!(d.open_for_command(&e, "file.score_setup")); assert!(matches!(d.open, Some(Dialog::ScoreSetup { bars_per_system: 4, .. }))); } + + #[test] + fn tempo_change_dialog_runs_a_real_command_at_its_position() { + let (cmd, params) = tempo_change_action(96_000, 133.5); + assert!(soundcraft_engine::command_specs().iter().any(|c| c.id == cmd), "{cmd} is not a command"); + assert_eq!(params["at"], 96_000); + assert_eq!(params["bpm"], 133.5); + } } diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 17a54b08..6ba40c7c 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -32,6 +32,18 @@ pub struct EditLayout { pub rows: Vec<(u64, [f32; 4])>, pub scroll_y: f32, pub content_h: f32, + /// Follow-hold: the view was moved by something other than the playhead + /// follow (a manual pan, scrollbar, universe jump, or programmatic + /// scroll) during playback, so timeline auto-scroll stays paused until + /// the playhead is scrolled back into view (or the transport + /// stops/starts, or a scrolling mode is re-selected). Lets the timeline + /// be inspected anywhere while playing. + pub follow_hold: bool, + /// Session `zoom.scroll` seen by the last overlay frame, for telling our + /// own follow jumps apart from outside view moves. + pub last_scroll: Samples, + /// Follow target issued by the last overlay frame (`None` when none). + pub last_follow_to: Option, } pub fn x_of(s: &Session, tl: Rect, at: Samples) -> f32 { @@ -372,13 +384,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ { let at = snap(&s, sample_at(&s, tl, p.x).max(0)); let bpm = s.tempo.tempo_at_tick(s.tempo.samples_to_ticks(at, sr)); - app.dialogs.open = Some(crate::dialogs::Dialog::Number { - cmd: format!("event.tempo@{at}"), - title: "Tempo Change (BPM)".into(), - key: "bpm".into(), - value: bpm, - suffix: "bpm".into(), - }); + app.dialogs.open = Some(crate::dialogs::Dialog::TempoChange { at, bpm }); } let mut last_label = f32::MIN; for ev in s.tempo.tempos() { @@ -1372,6 +1378,20 @@ fn clip_at(track: &Track, at: Samples) -> Option<&Clip> { fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) { let id = ui.id().with(("lane", track.id.0)); let resp = ui.interact(lane, id, Sense::click_and_drag()); + // The clip menu is attached before the early return below: once the menu is open the pointer + // is over the popup, the lane has no hover position, and the rest of this function bails out. + // The clip is remembered at the right-click so the menu keeps its target while it is used. + let menu_clip_id = id.with("menu_clip"); + if resp.secondary_clicked() { + let clip = resp.interact_pointer_pos().and_then(|p| { + let s = app.engine.session(); + clip_at(track, sample_at(s, tl, p.x).max(0)).map(|c| c.id) + }); + ui.ctx().data_mut(|d| d.insert_temp(menu_clip_id, clip)); + } + // The clip may have been removed or moved since the right-click; only use it if it still exists. + let menu_clip = ui.ctx().data(|d| d.get_temp::>(menu_clip_id)).flatten().filter(|c| track.clips().iter().any(|x| x.id == *c)); + resp.context_menu(|ui| clip_context_menu(app, ui, menu_clip)); // Audio files dropped from the Clip List. if let Some(src) = resp.dnd_release_payload::() && let Some(p) = ui.ctx().pointer_latest_pos() @@ -1613,7 +1633,6 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, { let _ = app.run("edit.select", json!({"clips": [c.id.0]})); } - resp.context_menu(|ui| clip_context_menu(app, ui, hit.as_ref().map(|c| c.id))); } fn automation_id(p: &AutoParam) -> String { @@ -1679,7 +1698,44 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option) { } } +/// Next follow state after observing one frame: `(hold, follow_to)`. +/// +/// The view is ours to move only while nothing else moved it: when the +/// session scroll differs from both the last seen value and our own last +/// follow target, something else (a manual pan, scrollbar, universe jump, +/// or programmatic scroll) took over, so the follow pauses (`hold`) and no +/// jump is issued. Stopping, or scrolling back to the playhead, clears the +/// hold. Only `page`/`continuous`/`center` ever issue follow jumps while +/// playing; `none`/`after_playback` leave the view alone. +fn follow_update( + playing: bool, + mode_follow: bool, + playhead_visible: bool, + cur: Samples, + last_scroll: Samples, + last_follow_to: Option, + hold: bool, + pos: Samples, +) -> (bool, Option) { + if !playing || playhead_visible { + return (false, None); + } + if cur != last_scroll && Some(cur) != last_follow_to { + return (mode_follow, None); + } + if hold || !mode_follow { + return (hold, None); + } + (false, Some(pos)) +} + /// Selection overlay, insertion point and playhead across rulers and tracks. +/// While playing, auto-scroll keeps the playhead in view for the follow +/// modes (`page`/`continuous`/`center`) — but any outside view move (a manual +/// pan, scrollbar, universe jump, or programmatic scroll) pauses it, so the +/// timeline can be inspected anywhere during playback. Scrolling back to the +/// playhead, stopping/starting, or re-selecting a scrolling mode resumes the +/// follow. `none` and `after_playback` never scroll during playback. fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { let t = Tokens::DARK; let mut scroll_to: Option = None; @@ -1721,10 +1777,18 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { if app.is_playing() { let x = x_of(s, tl, app.position()); painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.5, t.playhead)); - // Page scrolling. - if s.edit.scrolling != "none" && (x > tl.max.x || x < tl.min.x) { - scroll_to = Some(app.position()); - } + // Session reads first (owned copies), then the layout mutation: the + // two borrow disjoint fields. + let pos = app.position(); + let cur = s.edit.zoom.scroll; + let mode_follow = matches!(s.edit.scrolling.as_str(), "page" | "continuous" | "center"); + let visible = x >= tl.min.x && x <= tl.max.x; + let layout = &mut app.edit_layout; + let (hold, follow_to) = follow_update(true, mode_follow, visible, cur, layout.last_scroll, layout.last_follow_to, layout.follow_hold, pos); + layout.follow_hold = hold; + layout.last_scroll = cur; + layout.last_follow_to = follow_to; + scroll_to = follow_to; } // Ghost of a clip move. if let Some(Gesture::MoveClips { clips, delta, to_track, .. }) = &app.gesture { @@ -1763,3 +1827,47 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { let _ = app.engine.execute("view.scroll", &json!({"to": at})); } } + +#[cfg(test)] +mod tests { + use super::follow_update; + + #[test] + fn follow_jumps_to_offscreen_playhead_while_playing() { + // Steady view, follow mode, playhead left the screen: jump to it. + assert_eq!(follow_update(true, true, false, 1_000, 1_000, None, false, 96_000), (false, Some(96_000))); + } + + #[test] + fn follow_leaves_onscreen_playhead_alone() { + assert_eq!(follow_update(true, true, true, 1_000, 1_000, None, false, 96_000), (false, None)); + } + + #[test] + fn follow_never_scrolls_when_stopped_nor_in_free_modes() { + assert_eq!(follow_update(false, true, false, 1_000, 1_000, None, false, 96_000), (false, None)); + // `none`/`after_playback` never follow, even with a steady view. + assert_eq!(follow_update(true, false, false, 1_000, 1_000, None, false, 96_000), (false, None)); + assert_eq!(follow_update(true, false, false, 9_000, 1_000, None, false, 96_000), (false, None)); + } + + #[test] + fn outside_view_move_pauses_the_follow() { + // The view moved and it was not our own jump: pause, no jump. + assert_eq!(follow_update(true, true, false, 9_000, 1_000, None, false, 96_000), (true, None)); + // A held view stays held while it sits still elsewhere. + assert_eq!(follow_update(true, true, false, 9_000, 9_000, None, true, 96_000), (true, None)); + } + + #[test] + fn own_follow_jump_is_not_mistaken_for_a_takeover() { + // The scroll equals the target we issued last frame: keep following. + assert_eq!(follow_update(true, true, false, 96_000, 1_000, Some(96_000), false, 97_000), (false, Some(97_000))); + } + + #[test] + fn hold_clears_when_playhead_returns_or_transport_stops() { + assert_eq!(follow_update(true, true, true, 9_000, 9_000, None, true, 96_000), (false, None)); + assert_eq!(follow_update(false, true, false, 9_000, 9_000, None, true, 96_000), (false, None)); + } +} diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs index 6905934d..bcaed2df 100644 --- a/crates/ui-egui/src/fonts.rs +++ b/crates/ui-egui/src/fonts.rs @@ -26,11 +26,41 @@ const BOLD: &[(&str, u32)] = &[ ("/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", 0), ]; +/// Asks fontconfig for the desktop's UI font. Only TrueType/OpenType results are used: egui cannot +/// read Type 1 or bitmap fonts, which `fc-match` may return on minimal systems. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn load_from_fontconfig(bold: bool) -> Option { + let pattern = if bold { "sans-serif:style=Bold" } else { "sans-serif" }; + let output = std::process::Command::new("fc-match").args(["--format=%{file}\\n%{index}\\n", pattern]).output().ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8(output.stdout).ok()?; + let mut lines = stdout.lines(); + let path = lines.next()?.trim(); + // The upper 16 bits carry a variable font's named instance; the face index is the lower 16. + let index = lines.next()?.trim().parse::().ok()? & 0xFFFF; + let bytes = std::fs::read(path).ok()?; + let sfnt = matches!(bytes.get(..4), Some(b"\0\x01\0\0" | b"OTTO" | b"true" | b"ttcf")); + if !sfnt { + return None; + } + let mut font = FontData::from_owned(bytes); + font.index = index; + Some(font) +} + #[cfg(not(target_arch = "wasm32"))] -fn load(cands: &[(&str, u32)]) -> Option { +fn load(cands: &[(&str, u32)], bold: bool) -> Option { if std::env::var_os("SOUNDCRAFT_NO_SYSTEM_FONTS").is_some() { return None; } + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + if let Some(font) = load_from_fontconfig(bold) { + return Some(font); + } + #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] + let _ = bold; for (p, idx) in cands { if let Ok(bytes) = std::fs::read(p) { let mut fd = FontData::from_owned(bytes); @@ -42,7 +72,7 @@ fn load(cands: &[(&str, u32)]) -> Option { } #[cfg(target_arch = "wasm32")] -fn load(_: &[(&str, u32)]) -> Option { +fn load(_: &[(&str, u32)], _: bool) -> Option { None } @@ -50,13 +80,13 @@ pub fn definitions() -> FontDefinitions { let mut fonts = FontDefinitions::default(); let base: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); let mut bold_family = base.clone(); - if let Some(reg) = load(REGULAR) { + if let Some(reg) = load(REGULAR, false) { fonts.font_data.insert("system-regular".into(), Arc::new(reg)); if let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) { f.insert(0, "system-regular".into()); } } - if let Some(b) = load(BOLD) { + if let Some(b) = load(BOLD, true) { fonts.font_data.insert("system-bold".into(), Arc::new(b)); bold_family.insert(0, "system-bold".into()); } else if fonts.font_data.contains_key("system-regular") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 18982a07..8ff964dc 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 { @@ -292,6 +302,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 +334,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 +352,33 @@ impl SoundApp { self.sim = None; self.engine.transport.playing = false; self.engine.transport.recording = false; - let s = self.engine.session(); - if s.edit.insertion_follows_playback { + let stop_at = self.engine.transport.position; + // Snapshot the session flags first: `session_mut` below needs a + // mutable borrow, so no session borrow may span it. + let (follow_insertion, after_playback) = { + let s = self.engine.session(); + (s.edit.insertion_follows_playback, s.edit.scrolling == "after_playback") + }; + if follow_insertion { let pos = self.engine.transport.position; self.engine.session_mut().edit.selection = Range::point(pos); } + if after_playback { + // After Playback never scrolls while playing, so bring the stopped + // position into view now (only if it is off-screen); every other + // mode keeps its view. + let at = stop_at.max(0); + let (scroll, spp) = { + let z = &self.engine.session().edit.zoom; + (z.scroll, z.samples_per_px.max(0.01)) + }; + let width = f64::from(self.edit_layout.timeline[2] - self.edit_layout.timeline[0]).max(0.0); + let end = scroll.saturating_add((width * spp) as Samples); + if at < scroll || at >= end { + let _ = self.engine.execute("view.scroll", &json!({ "to": at })); + } + } + self.edit_layout.follow_hold = false; } fn start_recording(&mut self) { @@ -531,7 +573,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 +866,27 @@ fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) { } d.gr = if gr > d.gr { gr } else { d.gr * fall }; } + +#[cfg(test)] +mod tests { + use super::automation_step_due; + + #[test] + fn automation_first_pass_after_start_is_due_without_overflow() { + // `last_write_at` starts (and resets) at `i64::MIN`: the previous + // `(pos - last).abs()` panicked here in debug builds. + assert!(automation_step_due(0, i64::MIN, 960)); + assert!(automation_step_due(48_000, i64::MIN, 960)); + } + + #[test] + fn automation_write_throttles_to_step() { + assert!(!automation_step_due(1_000, 900, 960)); + assert!(automation_step_due(2_000, 900, 960)); + } + + #[test] + fn automation_write_fires_after_loop_wrap() { + assert!(automation_step_due(0, 1_000_000, 960)); + } +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index cc35c907..15141c85 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -41,8 +41,17 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> { key.map(|k| (m, k)) } -fn mods_match(want: Modifiers, got: Modifiers) -> bool { - want.command == got.command && want.shift == got.shift && want.alt == got.alt && (want.ctrl == got.ctrl || got.mac_cmd) +/// Whether the held modifiers `got` fire a shortcut that asks for `want`. +fn mods_match(want: Modifiers, got: Modifiers, mac: bool) -> bool { + // Off macOS egui reports Ctrl as both `ctrl` and `command`: Ctrl is the shortcut's Cmd, and a shortcut's + // Ctrl (the Mac Control key) has no key of its own there, so Ctrl+X is Cut, not Cmd+Ctrl+X. + let ctrl = if mac { want.ctrl == got.ctrl } else { !want.ctrl }; + want.command == got.command && want.shift == got.shift && want.alt == got.alt && ctrl +} + +/// Cmd+Ctrl+S opens Search. Off macOS Ctrl+S is Save (Cmd+S), so Search is reached from the Window menu. +fn search_chord(m: Modifiers, mac: bool) -> bool { + mac && m.command && m.ctrl } pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { @@ -58,12 +67,14 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { }) .collect() }); + // Cmd and Ctrl are separate keys only on macOS (asked at runtime so the web build in a Mac browser agrees). + let mac = ctx.os() == egui::os::OperatingSystem::Mac; for (key, mods) in events { - if fixed(app, key, mods) { + if fixed(app, key, mods, mac) { continue; } let hit = - soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods))); + soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac))); if let Some(c) = hit { let id = c.id; if crate::menus::invoke_shortcut_dialog(app, id) { @@ -75,7 +86,7 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { } /// Keys with context-dependent meaning. Returns true when handled. -fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool { +fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let plain = !m.command && !m.alt && !m.ctrl && !m.shift; match key { Key::Space if !m.command && !m.alt => { @@ -87,7 +98,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool { let _ = app.run("transport.record", json!({})); true } - Key::S if m.command && m.ctrl => { + Key::S if search_chord(m, mac) => { let _ = app.run("window.search", json!({})); true } @@ -227,3 +238,38 @@ fn tab_clip(app: &mut SoundApp, back: bool) { let _ = app.run("transport.locate", json!({"at": n})); } } + +#[cfg(test)] +mod tests { + use super::{mods_match, parse, search_chord}; + use egui::Modifiers; + + /// Ctrl held on Windows or Linux, as egui reports it. + const PC_CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + /// Cmd held on macOS. + const MAC_CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + + fn want(s: &str) -> Modifiers { + parse(s).map(|(m, _)| m).unwrap() + } + + #[test] + fn ctrl_is_cmd_off_macos() { + assert!(mods_match(want("Cmd+S"), PC_CTRL, false)); + assert!(mods_match(want("Cmd+Shift+N"), Modifiers { shift: true, ..PC_CTRL }, false)); + assert!(mods_match(want("Cmd+X"), PC_CTRL, false)); + assert!(!mods_match(want("Cmd+Ctrl+X"), PC_CTRL, false)); + assert!(!mods_match(want("Cmd+S"), Modifiers::NONE, false)); + assert!(!search_chord(PC_CTRL, false)); + } + + #[test] + fn cmd_and_ctrl_stay_apart_on_macos() { + assert!(mods_match(want("Cmd+S"), MAC_CMD, true)); + assert!(!mods_match(want("Cmd+S"), Modifiers::CTRL, true)); + assert!(mods_match(want("Cmd+Ctrl+X"), Modifiers { ctrl: true, ..MAC_CMD }, true)); + assert!(!mods_match(want("Cmd+X"), Modifiers { ctrl: true, ..MAC_CMD }, true), "Cmd+Ctrl+X is not Cut"); + assert!(!search_chord(MAC_CMD, true)); + assert!(search_chord(Modifiers { ctrl: true, ..MAC_CMD }, true)); + } +} diff --git a/crates/ui-egui/tests/clip_menu.rs b/crates/ui-egui/tests/clip_menu.rs new file mode 100644 index 00000000..71684a92 --- /dev/null +++ b/crates/ui-egui/tests/clip_menu.rs @@ -0,0 +1,66 @@ +//! Regression: the clip context menu must stay open while the pointer moves onto it, so its +//! Delete item can be used. It used to vanish once the pointer left the lane, because the lane +//! returned early (no hover position) before the menu was drawn. + +use egui::{Event, Modifiers, PointerButton, pos2, vec2}; +use egui_kittest::Harness; +use soundcraft_ui_egui::{Services, SoundApp}; + +/// One UI frame, with queued synthetic pointer input applied first (as the control channel does). +fn step(h: &mut Harness<'_, SoundApp>) { + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().raw_input_hook(&mut raw); + *h.input_mut() = raw; + h.step(); +} + +fn press(h: &mut Harness<'_, SoundApp>, x: f32, y: f32, button: PointerButton) { + let pos = pos2(x, y); + let s = &mut h.state_mut().synthetic; + s.push(Event::PointerMoved(pos)); + s.push(Event::PointerButton { pos, button, pressed: true, modifiers: Modifiers::NONE }); + s.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE }); +} + +fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize { + let s = h.state().engine.session(); + s.tracks.iter().find(|t| t.name == "Kick").map_or(0, |t| t.clips().len()) +} + +#[test] +fn clip_menu_stays_open_while_moving_onto_delete() { + let engine = soundcraft_engine::demo::demo_engine(); + let app = SoundApp::new(engine, None, Services::default()); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..6 { + step(&mut h); + } + let before = kick_clip_count(&h); + assert!(before > 0, "the demo session should have a recorded Kick clip"); + + // Right-click the Kick clip, then move the pointer into the menu and down to Delete. + press(&mut h, 880.0, 240.0, PointerButton::Secondary); + for _ in 0..10 { + step(&mut h); + } + // Move (no click) into the menu, as a real pointer would, so the lane loses its hover. + for (x, y) in [(900.0, 300.0), (907.0, 442.0)] { + h.state_mut().synthetic.push(Event::PointerMoved(pos2(x, y))); + for _ in 0..10 { + step(&mut h); + } + } + press(&mut h, 907.0, 442.0, PointerButton::Primary); + for _ in 0..15 { + step(&mut h); + } + + assert_eq!(kick_clip_count(&h), before - 1, "clicking Delete in the clip menu should remove the clip"); +} diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index b8ee3e30..c7c60c4c 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -1059,19 +1059,19 @@ impl Instance { self.active } - fn bus_count(&self, media: u32, dir: u32) -> i32 { + fn bus_count(&self, media: sv::MediaTypes, dir: sv::BusDirections) -> i32 { // SAFETY: valid component. unsafe { self.component.getBusCount(media as sv::MediaType, dir as sv::BusDirection) }.clamp(0, MAX_BUSES) } - fn arrangement(&self, dir: u32, index: i32) -> Option { + fn arrangement(&self, dir: sv::BusDirections, index: i32) -> Option { let p = self.processor()?; let mut arr: sv::SpeakerArrangement = 0; // SAFETY: valid processor; `arr` is writable. ok(unsafe { p.getBusArrangement(dir as sv::BusDirection, index, &mut arr) }).then_some(arr) } - fn bus_channels(&self, dir: u32, index: i32) -> u32 { + fn bus_channels(&self, dir: sv::BusDirections, index: i32) -> u32 { if let Some(arr) = self.arrangement(dir, index) { return arr.count_ones().min(MAX_BUS_CHANNELS); } @@ -1095,7 +1095,7 @@ impl Instance { let (din, dout) = (sv::BusDirections_::kInput, sv::BusDirections_::kOutput); let nin = self.bus_count(audio, din); let nout = self.bus_count(audio, dout); - let current = |s: &Self, dir: u32, n: i32| -> Vec { + let current = |s: &Self, dir: sv::BusDirections, n: i32| -> Vec { (0..n).map(|i| s.arrangement(dir, i).unwrap_or(sv::SpeakerArr::kStereo)).collect() }; let want = if mono { sv::SpeakerArr::kMono } else { sv::SpeakerArr::kStereo }; @@ -1166,7 +1166,10 @@ impl Instance { self.context.tempo = 120.0; self.context.timeSigNumerator = 4; self.context.timeSigDenominator = 4; - self.context.state = sv::ProcessContext_::StatesAndFlags_::kTempoValid | sv::ProcessContext_::StatesAndFlags_::kTimeSigValid; + // The flag constants are the C default enum type: i32 on Windows, u32 elsewhere. + #[allow(clippy::unnecessary_cast)] + let flags = (sv::ProcessContext_::StatesAndFlags_::kTempoValid | sv::ProcessContext_::StatesAndFlags_::kTimeSigValid) as u32; + self.context.state = flags; self.host.latency_changed.store(false, Ordering::Relaxed); Ok(layout) } diff --git a/crates/vst3-host/tests/editor_main_thread.rs b/crates/vst3-host/tests/editor_main_thread.rs index ab5d28c3..b6fbaa50 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 23970d2c..e3603b7a 100644 --- a/crates/vst3-host/tests/fixture_e2e.rs +++ b/crates/vst3-host/tests/fixture_e2e.rs @@ -212,7 +212,12 @@ fn state_saves_component_and_controller_and_restores_them() { fn editor_view_is_probed_and_edits_come_back_as_plain_values() { let id = id_of("Fixture Gain"); let mut p = soundcraft_vst3_host::instantiate_plugin(&id).unwrap(); - assert_eq!(p.editor_size(), Ok((320, 240))); + // Editor windows are hosted on macOS only; elsewhere the probe reports "unsupported". + if cfg!(target_os = "macos") { + assert_eq!(p.editor_size(), Ok((320, 240))); + } else { + assert!(p.editor_size().is_err()); + } let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle"); // The fixture "edits" Gain to 0.9 (normalized) when it gets the component handler. assert_eq!(ed.idle(), vec![("7".to_string(), 1.8)]); diff --git a/docs/releasing.md b/docs/releasing.md index 1d89fcd4..e68b9a5f 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -35,9 +35,10 @@ After the draft is published, the workflow refuses to touch that version again, **Test runs:** *Actions → Release → Run workflow* runs the whole pipeline by hand. The optional `version` input (such as `0.2.0-rc.1`) overrides `Cargo.toml` for that run only. The jobs apply it -with `cargo xtask version set` before building, so the binaries report it too. The run still needs -the `release` environment, which only the `release` branch can use, so pick that branch in the -dialog. +with `cargo xtask version set` before building, so the binaries report it too. Only the signing +jobs (macOS, Windows) and the draft-release job use the `release` environment, which only the +`release` branch can use. On any other branch the run is a dry run: Linux, Flatpak, FreeBSD and +web build and are checked, the signing jobs are refused, and no release is drafted. ## What gets built @@ -49,11 +50,14 @@ dialog. | Windows 11 ARM64 | `soundcraft--windows-arm64.msi`, `soundcraft--windows-arm64-portable.zip` | `windows-latest` (cross-compiled; `windows-arm64.yml` installs and runs it on ARM64) | | Linux x86_64 | `soundcraft--linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` | | Linux aarch64 | `soundcraft--linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | +| AppImage updates | `soundcraft--linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImage | +| Flatpak x86_64, aarch64 | `soundcraft--linux-{x86_64,aarch64}.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackages the Linux tarball) | | FreeBSD 14 x86_64 | `soundcraft--freebsd-x86_64.tar.gz` (a `/usr/local` tree) | FreeBSD 14.3 VM on `ubuntu-latest` | | Web | `soundcraft-web-.zip` (static site; see [`packaging/web/README.md`](../packaging/web/README.md)) | `ubuntu-latest` | -The Flatpak manifest (`packaging/linux/flatpak/ai.storyteller.soundcraft.yml`) is kept ready for a -Flathub submission and validated in CI, but not built there (see Linux below). +The from-source Flatpak manifest (`packaging/linux/flatpak/ai.storyteller.soundcraft.yml`) is +kept ready for a Flathub submission; the release's `.flatpak` bundles come from +`ai.storyteller.soundcraft.bundle.yml` (see Linux below). ### Audio libraries @@ -68,7 +72,8 @@ plugins), and WebAudio in the browser. routes ALSA to the OSS `sound(4)` devices. Without a device the player falls back to its silent clock, so the transport still runs. - **Flatpak**: alsa-lib and its PulseAudio plugin come with the freedesktop runtime; the - manifest grants `--socket=pulseaudio`. + manifests grant `--socket=pulseaudio` (PipeWire's pulse server answers on it). No raw ALSA + devices and no JACK: SoundCraft doesn't use either. - **Web**: browsers start audio only after a user gesture, so the first click in the page may be needed before sound plays. The web build has no recording. @@ -89,6 +94,9 @@ plugins), and WebAudio in the browser. - **Notarization:** the app is zipped and sent with `xcrun notarytool submit --wait`, then the ticket is stapled to the app. The app goes on a DMG (`hdiutil`, with an `Applications` link). The DMG is signed, notarized and stapled too. + Its Finder window (background, icon size and positions) comes from + [`packaging/macos/dmg/`](../packaging/macos/dmg/README.md), and its volume is named `SoundCraft` + without the version, which the window's background needs; the DMG file name keeps the version. - **CLI:** the universal `soundcraft-cli` is signed with the hardened runtime, zipped and notarized. @@ -131,14 +139,24 @@ Locally on Windows: `dotnet tool install -g wix --version 5.0.2`, then MIDI), hicolor icons from 16 px to 512 px plus a scalable SVG, AppStream metainfo, and a shared-mime-info file declaring `application/x-soundcraft-session` (`*.scraft`). -- **AppImage** (maintained `AppImage/appimagetool`, static runtime: no libfuse2 needed). +- **AppImage** (maintained `AppImage/appimagetool`, static runtime: no libfuse2 needed). Each + embeds the update information + `gh-releases-zsync|storytold|soundcraft|latest|soundcraft-*-linux-.AppImage.zsync`, and + the `.zsync` next to it (written when `zsyncmake` from the `zsync` package is installed, as in + CI) lets AppImageUpdate fetch only the changed blocks of the newest non-pre-release. - **.deb** and **.rpm** by [nfpm](https://nfpm.goreleaser.com/) from one `nfpm.yaml`; the `postinst.sh` hook refreshes the desktop, MIME and icon caches. - **.tar.gz** for people who manage their own `/opt` or `~/.local`. -- **Flatpak**: `packaging/linux/flatpak/ai.storyteller.soundcraft.yml` builds from source on the - freedesktop 25.08 runtime with Wayland/X11, `dri`, PulseAudio and `xdg-music`; other files go - through the portals. A real build needs vendored crates (`cargo-sources.json` from - `flatpak-cargo-generator.py`); the commands are in the manifest's header. +- **Flatpak**: the `flatpak` job runs `packaging/linux/flatpak-bundle.sh` on the Linux job's + tarball (no Rust build) with `flatpak/ai.storyteller.soundcraft.bundle.yml`, then installs the + bundle and runs `soundcraft-cli --version` in the sandbox. Install with + `flatpak install --user soundcraft--linux-.flatpak`. Both manifests use the freedesktop + 25.08 runtime with Wayland/X11, `dri`, PulseAudio and `xdg-music`; other files go through the + portals. For the VST3 and CLAP hosts they grant `~/.vst3` and `~/.clap` read-only and mount + Flathub's `org.freedesktop.LinuxAudio.Plugins` extensions (pointed to by `VST3_PATH` and + `CLAP_PATH`); system plugin folders aren't visible in the sandbox. + `ai.storyteller.soundcraft.yml` builds from source for Flathub and needs vendored crates + (`cargo-sources.json` from `flatpak-cargo-generator.py`); the commands are in its header. Binaries are built on Ubuntu 22.04 and need **glibc ≥ 2.35**. X11, Wayland, xkbcommon, Vulkan and EGL are loaded at runtime, so the packages declare them as dependencies or recommendations. @@ -164,8 +182,8 @@ through WebAudio. ## Secrets All secrets live in the repository's **`release` environment** (*Settings → Environments → -release*), restricted to the `release` branch. Every job in `release.yml` declares -`environment: release`. Each secret is optional: if one is missing, that platform's artifacts +release*), restricted to the `release` branch. Only the jobs that sign (macOS, Windows) and the +draft-release job declare `environment: release`; the others get no secrets. Each secret is optional: if one is missing, that platform's artifacts are unsigned and the run shows a `::warning::`. Scripts never echo secret values; certificates are written with `umask 077` and deleted in `always()`/`finally` steps. @@ -197,5 +215,5 @@ those tools. `apps/soundcraft/src/main.rs` sets the runtime window/Dock/taskbar `.github/workflows/packaging-lint.yml` runs in seconds on any change to `packaging/`, the workflows or the icons: actionlint, shellcheck, a PowerShell parse, xmllint (WiX, plists, -AppStream, MIME), `desktop-file-validate`, `appstreamcli validate`, and a YAML check of the -Flatpak manifest. +AppStream, MIME), `desktop-file-validate`, `appstreamcli validate`, and a check that the two +Flatpak manifests agree on runtime, finish-args and plugin extensions. diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh new file mode 100755 index 00000000..1b400266 --- /dev/null +++ b/packaging/linux/flatpak-bundle.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Repackage a SoundCraft Linux tarball as a single-file Flatpak bundle: +# +# $DIST/soundcraft--linux-.flatpak +# +# Usage: packaging/linux/flatpak-bundle.sh [--no-test] [TARBALL] +# +# TARBALL defaults to $DIST/soundcraft--linux-.tar.gz from package.sh. The bundle +# is built for the host architecture (x86_64 or aarch64), which must match the tarball's. +# Needs flatpak, flatpak-builder and the SVG pixbuf loader (librsvg2-common) for the host's +# `appstreamcli compose`; the freedesktop runtime and SDK named in the manifest are +# installed per-user from Flathub. Unless --no-test, the bundle is then installed per-user and +# `soundcraft-cli --version` is run inside the sandbox as a smoke test. +# Manifest: packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml. +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" +APP_ID=ai.storyteller.soundcraft +FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo + +TEST=1 +TARBALL="" +while [ $# -gt 0 ]; do + case "$1" in + --no-test) TEST=0; shift ;; + -h | --help) sed -n '2,14p' "$0"; exit 0 ;; + -*) echo "unknown argument: $1" >&2; exit 2 ;; + *) TARBALL="$1"; shift ;; + esac +done + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ;; + aarch64 | arm64) ARCH=aarch64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +esac +BASENAME="soundcraft-$VERSION-linux-$ARCH" +TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}" +[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } +for tool in flatpak flatpak-builder; do + command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } +done + +echo "==> SoundCraft $VERSION Flatpak bundle for $ARCH from $(basename "$TARBALL")" + +WORK="$CARGO_TARGET_DIR/flatpak-bundle" +rm -rf "$WORK" +mkdir -p "$WORK/stage" +tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 +[ -x "$WORK/stage/bin/soundcraft" ] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; } +cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" + +flatpak remote-add --user --if-not-exists flathub "$FLATHUB" +# --disable-rofiles-fuse: no FUSE in containers and CI runners. +flatpak-builder --user --install-deps-from=flathub --disable-rofiles-fuse --force-clean \ + --default-branch=stable --repo="$WORK/repo" --state-dir="$WORK/state" "$WORK/build" "$WORK/$APP_ID.yml" + +OUT="$DIST/$BASENAME.flatpak" +# --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing. +flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable +echo "wrote $OUT" + +if [ "$TEST" = 1 ]; then + flatpak install --user -y --noninteractive --reinstall "$OUT" + flatpak info --user "$APP_ID" + flatpak run --command=soundcraft-cli "$APP_ID" --version + flatpak run --command=sh "$APP_ID" -c 'ls /app/share/applications /app/share/metainfo /app/share/mime/packages /app/share/icons/hicolor/scalable/apps' +fi +echo "==> done" +ls -lh "$OUT" diff --git a/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml new file mode 100644 index 00000000..ff502f02 --- /dev/null +++ b/packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml @@ -0,0 +1,73 @@ +# Flatpak manifest for the single-file bundle attached to every GitHub release +# (soundcraft--linux-.flatpak). Build it with packaging/linux/flatpak-bundle.sh. +# +# It repackages the release's own Linux tarball (soundcraft--linux-.tar.gz, +# built on Ubuntu 22.04 by package.sh) instead of compiling Rust inside flatpak-builder: no +# network and no vendored crate sources during the build, a few seconds instead of 20+ minutes, +# and byte-for-byte the same binaries as the AppImage/deb/rpm. The script unpacks the tarball +# into `stage/` next to a copy of this file. The binaries need glibc >= 2.35 and load X11, +# Wayland, xkbcommon, Vulkan and libasound at runtime; the freedesktop runtime has all of them +# (plus alsa-lib's PulseAudio plugin), and the GPU driver comes from its +# org.freedesktop.Platform.GL extension. +# +# ai.storyteller.soundcraft.yml (same directory) is the from-source manifest for Flathub. Keep +# runtime-version, finish-args and add-extensions identical in both (packaging-lint checks this). +id: ai.storyteller.soundcraft +runtime: org.freedesktop.Platform +runtime-version: '25.08' +sdk: org.freedesktop.Sdk +command: soundcraft +finish-args: + # Display: Wayland first; X11 only when no Wayland compositor is running. + - --socket=wayland + - --socket=fallback-x11 + # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. + - --share=ipc + # GPU access for the wgpu UI (Vulkan, or GL as a fallback). + - --device=dri + # Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its + # PulseAudio plugin to this socket, which PipeWire's pulse server provides on modern desktops + # (and PulseAudio itself on older ones). Deliberately not granted: --device=all (raw /dev/snd + # for direct ALSA hardware access, which would bypass the sound server) and JACK, which + # SoundCraft doesn't use (cpal's jack feature is off; there is no live MIDI I/O either). + - --socket=pulseaudio + # Open/Save dialogs use the file-chooser portal (rfd) and drag-and-drop the document portal, + # so broad filesystem access isn't needed. Music is granted for sessions and their + # "Audio Files" folders opened by path from the command line. + - --filesystem=xdg-music + # VST3 and CLAP plugin hosts. The per-user plugin folders the hosts scan, read-only. + # System folders (/usr/lib/vst3, /usr/lib/clap) aren't reachable from the sandbox, and plugins + # there are linked against the host's libraries rather than the runtime's; sandboxed plugins + # come from Flatpak extensions instead (add-extensions below), found through these paths. + - --filesystem=~/.vst3:ro + - --filesystem=~/.clap:ro + - --env=VST3_PATH=/app/extensions/Plugins/vst3 + - --env=CLAP_PATH=/app/extensions/Plugins/clap +# Flathub's shared audio-plugin extension point (org.freedesktop.LinuxAudio.Plugins.*, the one +# Ardour, Bitwig and friends use). Optional: nothing is downloaded unless the user installs a +# plugin extension; merge-dirs lets every installed one appear under .../Plugins/{vst3,clap}. +add-extensions: + org.freedesktop.LinuxAudio.Plugins: + directory: extensions/Plugins + version: '25.08' + add-ld-path: lib + merge-dirs: vst3;clap + subdirectories: true + no-autodownload: true +modules: + - name: soundcraft + buildsystem: simple + build-commands: + - install -Dm755 bin/soundcraft /app/bin/soundcraft + - install -Dm755 bin/soundcraft-cli /app/bin/soundcraft-cli + - install -Dm644 share/applications/ai.storyteller.soundcraft.desktop /app/share/applications/ai.storyteller.soundcraft.desktop + # Session files; the runtime's shared-mime-info already knows WAV, AIFF, FLAC, MP3 and MIDI. + - install -Dm644 share/mime/packages/ai.storyteller.soundcraft.xml /app/share/mime/packages/ai.storyteller.soundcraft.xml + # Already has the release's version and date substituted by package.sh. + - install -Dm644 share/metainfo/ai.storyteller.soundcraft.metainfo.xml /app/share/metainfo/ai.storyteller.soundcraft.metainfo.xml + - mkdir -p /app/share/icons /app/share/doc && cp -R share/icons/hicolor /app/share/icons/ && cp -R share/doc/soundcraft /app/share/doc/ + # Mount point for the plugin extensions (add-extensions). + - install -d /app/extensions/Plugins + sources: + - type: dir + path: stage diff --git a/packaging/linux/flatpak/ai.storyteller.soundcraft.yml b/packaging/linux/flatpak/ai.storyteller.soundcraft.yml index c26df340..bec9ffb5 100644 --- a/packaging/linux/flatpak/ai.storyteller.soundcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.soundcraft.yml @@ -8,6 +8,9 @@ # flatpak run ai.storyteller.soundcraft # # For Flathub, replace the `dir` source with a `git` source pinned to a release tag and commit. +# ai.storyteller.soundcraft.bundle.yml repackages the release tarball into the .flatpak attached +# to each GitHub release; keep runtime-version, finish-args and add-extensions identical in both +# (packaging-lint checks this). # # Audio: cpal talks to ALSA. alsa-lib (headers in the Sdk, the library in the Platform) and its # PulseAudio plugin ship with the freedesktop runtime, which routes ALSA through the PulseAudio @@ -20,16 +23,42 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: soundcraft finish-args: - - --share=ipc + # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 + # X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails. + - --share=ipc + # GPU access for the wgpu UI (Vulkan, or GL as a fallback). - --device=dri - # Playback and recording (ALSA through the PulseAudio/PipeWire socket). + # Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its + # PulseAudio plugin to this socket, which PipeWire's pulse server provides on modern desktops + # (and PulseAudio itself on older ones). Deliberately not granted: --device=all (raw /dev/snd + # for direct ALSA hardware access, which would bypass the sound server) and JACK, which + # SoundCraft doesn't use (cpal's jack feature is off; there is no live MIDI I/O either). - --socket=pulseaudio - # Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document - # portal, so broad filesystem access isn't needed. Music is granted for sessions and their + # Open/Save dialogs use the file-chooser portal (rfd) and drag-and-drop the document portal, + # so broad filesystem access isn't needed. Music is granted for sessions and their # "Audio Files" folders opened by path from the command line. - --filesystem=xdg-music + # VST3 and CLAP plugin hosts. The per-user plugin folders the hosts scan, read-only. + # System folders (/usr/lib/vst3, /usr/lib/clap) aren't reachable from the sandbox, and plugins + # there are linked against the host's libraries rather than the runtime's; sandboxed plugins + # come from Flatpak extensions instead (add-extensions below), found through these paths. + - --filesystem=~/.vst3:ro + - --filesystem=~/.clap:ro + - --env=VST3_PATH=/app/extensions/Plugins/vst3 + - --env=CLAP_PATH=/app/extensions/Plugins/clap +# Flathub's shared audio-plugin extension point (org.freedesktop.LinuxAudio.Plugins.*, the one +# Ardour, Bitwig and friends use). Optional: nothing is downloaded unless the user installs a +# plugin extension; merge-dirs lets every installed one appear under .../Plugins/{vst3,clap}. +add-extensions: + org.freedesktop.LinuxAudio.Plugins: + directory: extensions/Plugins + version: '25.08' + add-ld-path: lib + merge-dirs: vst3;clap + subdirectories: true + no-autodownload: true build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: @@ -51,6 +80,8 @@ modules: install -d /app/share/metainfo; sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/" packaging/linux/ai.storyteller.soundcraft.metainfo.xml.in > /app/share/metainfo/ai.storyteller.soundcraft.metainfo.xml + # Mount point for the plugin extensions (add-extensions). + - install -d /app/extensions/Plugins sources: - type: dir path: ../../.. diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index ba0b6c73..65e9a600 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -2,6 +2,7 @@ # Build and package SoundCraft for Linux ( is x86_64 or aarch64): # # $DIST/soundcraft--linux-.AppImage any distro with glibc >= the build host's +# $DIST/soundcraft--linux-.AppImage.zsync delta updates (needs zsyncmake) # $DIST/soundcraft--linux-.deb Debian, Ubuntu, Mint, Pop!_OS, ... # $DIST/soundcraft--linux-.rpm Fedora, openSUSE, RHEL, ... # $DIST/soundcraft--linux-.tar.gz plain FHS-style tree (bin/, share/) @@ -10,7 +11,8 @@ # # Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage # (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, -# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli. +# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, +# zsyncmake (the zsync package) for the AppImage's .zsync. set -euo pipefail # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" @@ -23,7 +25,7 @@ while [ $# -gt 0 ]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; - -h | --help) sed -n '2,15p' "$0"; exit 0 ;; + -h | --help) sed -n '2,17p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done @@ -108,11 +110,29 @@ if has appimage; then chmod +x "$TOOL" fi fi - OUT="$DIST/$BASENAME.AppImage" + # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be + # relative). + OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" + APPDIR="$(cd "$APPDIR" && pwd)" + TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")" + # A .zsync left by an earlier run would hide a missing zsyncmake and describe another file. + rm -f "$OUT.zsync" + # Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and + # fetch only the blocks that changed in a newer release, through the .zsync published next to + # each AppImage on GitHub Releases. `latest` is the newest published release that is not a + # pre-release. A fork's builds point at its own releases through GITHUB_REPOSITORY. + REPO="${GITHUB_REPOSITORY:-storytold/soundcraft}" + UPDATE_INFO="gh-releases-zsync|${REPO%%/*}|${REPO#*/}|latest|soundcraft-*-linux-$ARCH.AppImage.zsync" # Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime, - # so users don't need libfuse2 either. - ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream "$APPDIR" "$OUT" + # so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync + # package) appimagetool also writes the .zsync, into its working directory, hence the cd. + (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" + if [ -s "$OUT.zsync" ]; then + echo "wrote $OUT.zsync" + else + warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it" + fi fi "$STAGE/usr/bin/soundcraft-cli" --version diff --git a/packaging/macos/dmg/README.md b/packaging/macos/dmg/README.md new file mode 100644 index 00000000..5bbc1690 --- /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 00000000..2f076870 --- /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 00000000..feeb2f90 Binary files /dev/null and b/packaging/macos/dmg/background.tiff differ diff --git a/packaging/macos/dmg/dmg-layout.DS_Store b/packaging/macos/dmg/dmg-layout.DS_Store new file mode 100644 index 00000000..95d4596c Binary files /dev/null and b/packaging/macos/dmg/dmg-layout.DS_Store differ diff --git a/packaging/macos/dmg/generate.py b/packaging/macos/dmg/generate.py new file mode 100644 index 00000000..f731e494 --- /dev/null +++ b/packaging/macos/dmg/generate.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Regenerate background.tiff and dmg-layout.DS_Store for the macOS DMG window (see README.md here). + +Needs resvg on PATH and: + pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3 + +Runs on any OS, no Mac needed. Both outputs come from this script alone, so they carry nothing +from the machine that made them: no local paths, user or disk names, dates or volume UUIDs. + + python3 packaging/macos/dmg/generate.py +""" + +import datetime +import os +import shutil +import subprocess +import sys +import tempfile + +from ds_store import DSStore +from mac_alias import Alias, TargetInfo, VolumeInfo +from PIL import Image, ImageCms + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# Keep in sync with package.sh (volume name) and README.md (layout). +VOLUME = "SoundCraft" +WIDTH, HEIGHT = 660, 400 # window content, pt; the background's 1x size +TITLE_BAR = 32 +ICON_SIZE = 128 +PALETTE = 16 # colours in background.tiff +ICONS = {"SoundCraft.app": (326, 205), "Applications": (574, 205)} +# A fixed date for the alias and the colour profile. Finder never matches it against the image +# (each build is a new volume): it finds the background by volume name and path. +FIXED_DATE = datetime.datetime(2026, 10, 8, tzinfo=datetime.timezone.utc) + + +def render(svg, width, out): + # The SVG's text is already outlined and no fonts are loaded, so every machine draws the + # same pixels. + subprocess.run( + ["resvg", "--skip-system-fonts", "-w", str(width), svg, out], + check=True, + ) + with Image.open(out) as im: + return im.convert("RGB") + + +def quantize(im): + # No dithering: dither noise compresses badly and the artwork has flat fills. + return im.quantize(PALETTE, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE) + + +def srgb_profile(): + # LittleCMS's built-in sRGB ("No copyright, use freely"), with its creation date fixed. + icc = bytearray(ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes()) + d = FIXED_DATE + icc[24:36] = b"".join(v.to_bytes(2, "big") for v in (d.year, d.month, d.day, 0, 0, 0)) + if icc[84:100] != bytes(16): + sys.exit("error: the sRGB profile has an ID; recompute it after fixing the date") + return bytes(icc) + + +def write_tiff(svg, out): + """1x (72 dpi) and 2x (144 dpi) in one TIFF, as `tiffutil -cathidpicheck` writes them.""" + with tempfile.TemporaryDirectory() as tmp: + one, two = ( + quantize(render(svg, WIDTH * s, os.path.join(tmp, f"{s}x.png"))) for s in (1, 2) + ) + two.encoderinfo = {"dpi": (144, 144)} # the rest as the first page + # Opaque 16-colour palette, Deflate: the artwork is three inks plus antialiasing, and a palette + # keeps the file under ~100 KB (repository assets must stay small). As PhotoCraft's. + one.save( + out, + "TIFF", + save_all=True, + append_images=[two], + compression="tiff_adobe_deflate", # brand-ok: Pillow's name for TIFF compression 8 (Deflate) + dpi=(72, 72), + icc_profile=srgb_profile(), + ) + with Image.open(out) as im: + im.seek(1) + if im.info.get("dpi") != (144, 144): + sys.exit("error: the 2x page isn't 144 dpi (needs Pillow 12 or later)") + strips = [] + for page in (0, 1): + im.seek(page) + strips += zip(im.tag_v2[273], im.tag_v2[279]) # StripOffsets, StripByteCounts + # TIFF keeps directories and tag data on even offsets. When a strip ends on an odd one, libtiff + # leaves the padding byte after it uninitialised, so the file changed from run to run with + # identical pixels. Zero it: nothing refers to that byte. + starts = {o for o, _ in strips} + with open(out, "r+b") as f: + for o, n in strips: + if (o + n) % 2 and o + n not in starts: + f.seek(o + n) + f.write(bytes(1)) + + +def background_alias(): + # What Finder stores for /.background/background.tiff on the volume, minus anything about the + # machine (dmgbuild writes the same kind of alias from a mounted image). Disk type 5 (ejectable), + # the volume flags and the folder/file ids (25, 26) are what Finder wrote on a test image; the ids + # are only hints, as the volume's creation date. + volume = VolumeInfo(VOLUME, FIXED_DATE, b"H+", 5, 0x0D02, b"\0\0") + volume.posix_path = f"/Volumes/{VOLUME}" + target = TargetInfo(0, "background.tiff", 25, 26, FIXED_DATE, b"\0\0\0\0", b"\0\0\0\0") + target.folder_name = ".background" + target.cnid_path = [25] + target.carbon_path = f"{VOLUME}:.background:\0background.tiff" + target.posix_path = "/.background/background.tiff" + return Alias(volume=volume, target=target).to_bytes() + + +def write_ds_store(out): + if os.path.exists(out): + os.remove(out) + with DSStore.open(out, "w+") as d: + d["."]["bwsp"] = { + "ShowStatusBar": False, + "ShowToolbar": False, + "ShowTabView": False, + "ContainerShowSidebar": False, + "ShowSidebar": False, + "WindowBounds": f"{{{{200, 528}}, {{{WIDTH}, {HEIGHT + TITLE_BAR}}}}}", + } + d["."]["icvp"] = { + "viewOptionsVersion": 1, + "backgroundType": 2, + "backgroundImageAlias": background_alias(), + "backgroundColorRed": 1.0, + "backgroundColorGreen": 1.0, + "backgroundColorBlue": 1.0, + "gridOffsetX": 0.0, + "gridOffsetY": 0.0, + "gridSpacing": 100.0, + "arrangeBy": "none", + "showIconPreview": True, + "showItemInfo": False, + "labelOnBottom": True, + "textSize": 16.0, + "iconSize": float(ICON_SIZE), + } + d["."]["vSrn"] = ("long", 1) + for name, pos in ICONS.items(): + d[name]["Iloc"] = pos + + +def main(): + if not shutil.which("resvg"): + sys.exit("error: resvg not found (brew install resvg / cargo install resvg --locked)") + write_tiff(os.path.join(HERE, "background.svg"), os.path.join(HERE, "background.tiff")) + write_ds_store(os.path.join(HERE, "dmg-layout.DS_Store")) + + +if __name__ == "__main__": + main() diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 60b7ac0a..a898e3d2 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"