Merge origin/main into desktop-log-file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-09 18:12:03 -04:00
co-authored by Claude Opus 5.5
35 changed files with 1293 additions and 111 deletions
+11 -1
View File
@@ -54,4 +54,14 @@ jobs:
desktop-file-validate packaging/linux/ai.storyteller.soundcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.soundcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.soundcraft.yml')); assert m['id'] == 'ai.storyteller.soundcraft' and m['command'] == 'soundcraft'"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.soundcraft" and m["command"] == "soundcraft", m["id"]
assert "--socket=pulseaudio" in m["finish-args"], "audio needs --socket=pulseaudio"
for key in ("runtime", "runtime-version", "sdk", "finish-args", "add-extensions"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
+53 -9
View File
@@ -1,9 +1,11 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "SoundCraft v<version>". See docs/releasing.md.
#
# All jobs run in the `release` environment (restricted to the `release` branch), which holds
# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
# warning, never a failed build.
# The jobs that sign (macOS, Windows) and the one that drafts the release run in the `release`
# environment (restricted to the `release` branch), which holds the signing secrets. Every secret
# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
# other jobs sign nothing and get no secrets, so a dispatch on any branch builds and checks them
# (a dry run); the signed jobs are refused there, and so the draft release is skipped.
name: Release
on:
@@ -35,7 +37,7 @@ jobs:
version:
name: version
runs-on: ubuntu-latest
environment: release
# No `environment: release`: this job reads no secrets (and gates every other job).
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
@@ -157,7 +159,7 @@ jobs:
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
environment: release
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
@@ -176,7 +178,7 @@ jobs:
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
libasound2-dev pkg-config \
desktop-file-utils appstream python3-yaml
desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
@@ -200,6 +202,9 @@ jobs:
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
# Update information is embedded, and the .zsync for delta updates sits beside it.
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|storytold|soundcraft|latest|soundcraft-*-linux-${{ matrix.arch }}.AppImage.zsync'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
with:
@@ -207,13 +212,51 @@ jobs:
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/soundcraft-$SOUNDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image as freebsd.yml).
runs-on: ubuntu-latest
timeout-minutes: 150
environment: release
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -253,7 +296,7 @@ jobs:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
environment: release
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -284,7 +327,8 @@ jobs:
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, freebsd, web]
# Needs the signed jobs, which only run on the `release` branch: a branch dispatch never drafts.
needs: [version, macos, windows, linux, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
+1
View File
@@ -9,3 +9,4 @@ plan/
/target-*/
/craft-fonts/
plans/
.DS_Store
+1
View File
@@ -38,6 +38,7 @@ and, at runtime, the operating system's own UI fonts when available (never redis
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `assets/app-icon/icon.svg` | SoundCraft contributors | original (hand-placed SVG paths; placeholder app icon) | MIT OR Apache-2.0 |
| `packaging/macos/dmg/` (all files) | @XusBadia | original (macOS DMG window background and layout, from the app icon; see `packaging/macos/dmg/README.md`) | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft-1024.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft-macos-512.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft.icns` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
Generated
+18 -18
View File
@@ -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",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.1.0"
version = "0.3.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+44
View File
@@ -54,6 +54,7 @@
<a href="#for-agents-and-scripts">Agents &amp; scripts</a> ·
<a href="#how-its-built">How it's built</a> ·
<a href="#status-and-roadmap">Status</a> ·
<a href="#downloads">Downloads</a> ·
<a href="#the-crafting-apps">The Crafting Apps</a> ·
<a href="#license-and-credits">License</a>
</p>
@@ -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. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows
| Build | Installer | Portable |
|---|---|---|
| x64 (64-bit Intel/AMD) | `soundcraft-<ver>-windows-x64.msi` | `soundcraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `soundcraft-<ver>-windows-arm64.msi` | `soundcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `soundcraft-<ver>-windows-x86.msi` | `soundcraft-<ver>-windows-x86-portable.zip` |
Installers and executables are code-signed.
### macOS
| Build | File | Notes |
|---|---|---|
| App, universal (Apple silicon + Intel) | `soundcraft-<ver>-macos-universal.dmg` | Signed and notarized |
| Command-line tool, universal | `soundcraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
### Linux
| Format | x86_64 | aarch64 (ARM64) | Notes |
|---|---|---|---|
| AppImage | `soundcraft-<ver>-linux-x86_64.AppImage` | `soundcraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
| Flatpak | `soundcraft-<ver>-linux-x86_64.flatpak` | `soundcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
| Debian/Ubuntu | `soundcraft-<ver>-linux-x86_64.deb` | `soundcraft-<ver>-linux-aarch64.deb` | |
| Fedora/RHEL/openSUSE | `soundcraft-<ver>-linux-x86_64.rpm` | `soundcraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `soundcraft-<ver>-linux-x86_64.tar.gz` | `soundcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
### FreeBSD
| Build | File |
|---|---|
| x86_64 | `soundcraft-<ver>-freebsd-x86_64.tar.gz` |
### Web (WebAssembly)
| Build | File | Notes |
|---|---|---|
| Static site | `soundcraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
## The Crafting Apps
SoundCraft is one of the **Crafting Apps**: free, open-source creative tools from the
+21
View File
@@ -15,6 +15,27 @@
</head>
<body>
<canvas id="soundcraft_canvas"></canvas>
<script>
// cpal (wasm) builds the output stream at startup, before any user gesture, so the
// WebAudio AudioContext stays 'suspended': no audio is heard and the transport clock
// (driven by the audio callback) never advances. Capture the AudioContext cpal creates
// and resume it on the first user gesture. Runs before the wasm module is instantiated.
(function () {
var Orig = window.AudioContext || window.webkitAudioContext;
if (!Orig) return;
var ctxs = [];
function Wrapped(opts) { var c = new Orig(opts); ctxs.push(c); return c; }
Wrapped.prototype = Orig.prototype;
window.AudioContext = Wrapped;
if (window.webkitAudioContext) window.webkitAudioContext = Wrapped;
function resumeAll() {
ctxs.forEach(function (c) { if (c.state === 'suspended') { c.resume().catch(function () {}); } });
}
['pointerdown', 'mousedown', 'touchstart', 'keydown'].forEach(function (ev) {
window.addEventListener(ev, resumeAll, { capture: true });
});
})();
</script>
<div id="soundcraft_loading">Loading SoundCraft&hellip;</div>
</body>
</html>
+5
View File
@@ -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"
+5
View File
@@ -278,6 +278,11 @@ pub fn gain_to_db(g: f32) -> f32 {
(20.0 * a.log10()).max(MIN_DB)
}
/// MIDI note number to frequency in Hz (equal temperament, A4 = note 69 = 440 Hz).
pub fn midi_to_hz(note: f64) -> f64 {
440.0 * 2f64.powf((note - 69.0) / 12.0)
}
#[cfg(test)]
mod trait_default_tests {
use super::*;
+18
View File
@@ -359,3 +359,21 @@ fn step_marker(e: &mut Engine, dir: i64) -> Result<Value> {
e.transport_requests.push(crate::TransportRequest::Locate(t));
Ok(json!({"at": t}))
}
#[cfg(test)]
mod tests {
use crate::demo::demo_engine;
use serde_json::json;
/// The tempo-ruler dialog runs `event.tempo` with `bpm` and `at`; the change must land at that position.
#[test]
fn tempo_change_at_position_is_applied() {
let mut e = demo_engine();
let sr = e.session().sample_rate;
let at = sr.samples(4.0);
e.execute("event.tempo", &json!({"bpm": 133.5, "at": at})).unwrap();
let s = e.session();
let tick = s.tempo.samples_to_ticks(at, s.sample_rate);
assert!((s.tempo.tempo_at_tick(tick) - 133.5).abs() < 1e-9);
}
}
+2 -6
View File
@@ -110,10 +110,6 @@ fn drums(sr: f64, frames: usize) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
(k, s, h)
}
fn midi_hz(n: f64) -> f64 {
440.0 * 2f64.powf((n - 69.0) / 12.0)
}
/// Chord roots per bar (A minor, F, C, G progression), as MIDI notes.
const PROG: [[u8; 4]; 4] = [[57, 60, 64, 69], [53, 57, 60, 65], [48, 52, 55, 60], [55, 59, 62, 67]];
@@ -127,7 +123,7 @@ fn bass(sr: f64, frames: usize) -> Vec<f32> {
for (eighth, semi, beats) in pattern {
let at = bar * beat * 4 + eighth * beat / 2;
let len = (beats * beat as f64) as usize;
let f = midi_hz(root + semi);
let f = soundcraft_dsp::midi_to_hz(root + semi);
let mut ph = 0.0f64;
for i in 0..len {
let Some(o) = out.get_mut(at + i) else { break };
@@ -152,7 +148,7 @@ fn pad(sr: f64, frames: usize) -> (Vec<f32>, Vec<f32>) {
let chord = PROG[bar % 4];
let at = bar * bar_len;
for (vi, n) in chord.iter().enumerate() {
let f = midi_hz(f64::from(*n) + 12.0);
let f = soundcraft_dsp::midi_to_hz(f64::from(*n) + 12.0);
for (side, buf) in [(-1.0f64, &mut l), (1.0, &mut r)] {
let det = 1.0 + side * 0.0035 * (vi as f64 + 1.0);
let mut ph = vi as f64 * 0.17;
+142
View File
@@ -299,6 +299,9 @@ pub struct MixEngine {
pub monitor_only: bool,
/// Recording: record-armed tracks hear their input (auto input).
pub recording: bool,
/// Mix the metronome (Options > Click) into the output. Playback turns this on; offline renders
/// (bounces, stems, exports) leave it off so the click is never printed into a file.
pub metronome: bool,
/// Third-party instances come from [`MixEngine::adopt`] instead of being created in `sync`.
external: bool,
/// Instances handed over by the host, waiting for `sync` to place them.
@@ -363,6 +366,7 @@ impl MixEngine {
input: Vec::new(),
monitor_only: false,
recording: false,
metronome: false,
external: false,
adopt: HashMap::new(),
retired: Vec::new(),
@@ -655,6 +659,13 @@ impl MixEngine {
self.meters.entry(t.id).or_default().measure(&self.main, frames, 0.0);
}
}
// Metronome: summed after the master fader, so it reaches the speakers without passing
// through any track, send or insert. Only the playback engine sets `metronome`.
// Not while monitoring only (transport stopped): the position does not advance there, so the
// start of the same beat would repeat every block as a buzz.
if self.metronome && !self.monitor_only && s.edit.click && !(s.edit.flag("click.only_during_record") && !self.recording) {
click::render(s, pos, frames, &mut self.main);
}
// Output: min(out.len(), main channels) channels; any further output channels are silent.
for (c, o) in out.iter_mut().enumerate() {
let src = self.main.get(c);
@@ -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<f32>]) {
let sr = s.sample_rate;
let rate = sr.as_f64();
let len = (LEN_S * rate) as i64;
let end = pos.saturating_add(frames as i64);
let gain = f64::from(db_to_gain(s.edit.value("click.volume_db", -6.0).clamp(-60.0, 12.0) as f32));
let accent = (note_hz(s.edit.value("click.accent_note", 84.0)), s.edit.value("click.accent_velocity", 127.0));
let normal = (note_hz(s.edit.value("click.normal_note", 72.0)), s.edit.value("click.normal_velocity", 100.0));
// Start one blip length early so a click that began in an earlier block still rings into this one.
let t0 = s.tempo.samples_to_ticks(pos.saturating_sub(len), sr).max(0).saturating_sub(1);
let mut tick = t0.saturating_sub(s.tempo.bar_beat_at_tick(t0).tick);
// Blocks hold a few beats at most; the guard only protects against a malformed tempo map.
for _ in 0..4096 {
let at = s.tempo.tick_to_samples(tick, sr);
if at >= end {
break;
}
if at.saturating_add(len) > pos {
let (hz, vel) = if s.tempo.bar_beat_at_tick(tick).beat == 1 { accent } else { normal };
let amp = gain * (vel.clamp(1.0, 127.0) / 127.0);
blip(main, at - pos, frames, hz, amp as f32, rate);
}
tick = tick.saturating_add(s.tempo.meter_at_tick(tick).ticks_per_beat());
}
}
/// Adds one decaying sine blip whose first sample sits `rel` frames into the block (negative
/// when it began before the block) to every channel.
fn blip(main: &mut [Vec<f32>], rel: i64, frames: usize, hz: f64, amp: f32, rate: f64) {
let len = (LEN_S * rate) as usize;
let w = std::f64::consts::TAU * hz / rate;
let first = usize::try_from(-rel).unwrap_or(0);
for i in first..len {
let Ok(at) = usize::try_from(rel + i as i64) else { continue };
if at >= frames {
break;
}
let env = (-6.0 * i as f64 / len as f64).exp() as f32;
let v = ((w * i as f64).sin() as f32) * env * amp;
for ch in main.iter_mut() {
if let Some(d) = ch.get_mut(at) {
*d += v;
}
}
}
}
/// Click pitch for a MIDI note setting (clamped to the MIDI range).
fn note_hz(note: f64) -> f64 {
soundcraft_dsp::midi_to_hz(note.clamp(0.0, 127.0))
}
}
fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut [Vec<f32>], _scratch: &mut [f32]) {
let ClipContent::Audio { source, offset } = clip.content else { return };
let block = Range { start: pos, end: pos.saturating_add(frames as i64) };
@@ -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<Vec<f32>> {
let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
eng.metronome = true;
eng.recording = recording;
let mut out = vec![vec![0.0f32; len]; 2];
let mut tmp = vec![vec![0.0f32; block]; 2];
let mut done = 0usize;
while done < len {
let n = (len - done).min(block);
eng.render(s, done as i64, n, &mut tmp);
for (o, t) in out.iter_mut().zip(tmp.iter()) {
if let (Some(d), Some(src)) = (o.get_mut(done..done + n), t.get(..n)) {
d.copy_from_slice(src);
}
}
done += n;
}
out
}
#[test]
fn click_sounds_on_every_beat_only_in_playback() {
let mut s = Session::default();
let beat = s.sample_rate.as_f64() as usize * 60 / 120; // 120 BPM quarter notes
let len = beat * 8;
s.edit.click = true;
// Offline renders (bounces, stems) never print the metronome.
let offline = render_range(&s, Range::new(0, len as i64), 512);
assert!(offline.iter().all(|c| c.iter().all(|x| *x == 0.0)));
let out = render_playback(&s, len, 512, false);
for b in 0..8usize {
let at = b * beat;
let on: f32 = out[0][at..at + 200].iter().map(|x| x.abs()).fold(0.0, f32::max);
let off: f32 = out[0][at + beat / 2..at + beat / 2 + 200].iter().map(|x| x.abs()).fold(0.0, f32::max);
assert!(on > 0.1, "beat {b} should click, peak {on}");
assert_eq!(off, 0.0, "between beats must stay silent");
assert_eq!(out[0][at], out[1][at], "both channels get the click");
}
// Blocks that start mid-beat render the same clicks as a single pass.
let split = render_playback(&s, len, 97, false);
assert_eq!(out, split);
}
#[test]
fn click_only_during_record_follows_recording() {
let mut s = Session::default();
s.edit.click = true;
s.edit.set_flag("click.only_during_record", true);
let idle = render_playback(&s, 48_000, 512, false);
assert!(idle.iter().all(|c| c.iter().all(|x| *x == 0.0)));
let rec = render_playback(&s, 48_000, 512, true);
assert!(rec.iter().any(|c| c.iter().any(|x| x.abs() > 0.1)));
}
/// A session with one track of `fmt` playing a distinct DC level per channel (0.1, 0.2, …).
fn multichannel_session(fmt: ChannelFormat, main: ChannelFormat, frames: usize) -> (Session, TrackId) {
let mut s = Session::default();
+2 -1
View File
@@ -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;
+104 -18
View File
@@ -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<f32> = 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<f32>,
phase: f64,
}
/// A small interleaved FIFO from the input callback to the output callback, for input
/// monitoring. Both sides only `try_lock`; it holds at most ~0.5 s and drops the oldest frames.
#[derive(Default)]
/// The input device may run at a different rate than the session, so the reader converts
/// rates with linear interpolation (cheap, allocation-free, fine for monitoring).
pub struct InputRing {
data: Mutex<std::collections::VecDeque<f32>>,
state: Mutex<RingState>,
pub channels: std::sync::atomic::AtomicUsize,
/// Input device sample rate in Hz (0 when unknown, which reads as "same as the session").
rate: u32,
/// Most samples kept: half a second of input, or 24 000 frames when the rate is unknown.
cap: usize,
}
impl InputRing {
pub fn new(channels: usize) -> Arc<InputRing> {
pub fn new(channels: usize, rate: u32) -> Arc<InputRing> {
let ch = channels.max(1);
let frames = if rate == 0 { 24_000 } else { rate as usize / 2 };
Arc::new(InputRing {
data: Mutex::new(std::collections::VecDeque::with_capacity(48_000 * channels.max(1))),
channels: std::sync::atomic::AtomicUsize::new(channels.max(1)),
state: Mutex::new(RingState { data: std::collections::VecDeque::with_capacity(rate.max(48_000) as usize * ch), phase: 0.0 }),
channels: std::sync::atomic::AtomicUsize::new(ch),
rate,
cap: frames * ch,
})
}
pub fn push(&self, samples: &[f32]) {
if let Ok(mut d) = self.data.try_lock() {
let cap = 24_000 * self.channels.load(Ordering::Relaxed).max(1);
d.extend(samples.iter().copied());
while d.len() > cap {
d.pop_front();
if let Ok(mut s) = self.state.try_lock() {
s.data.extend(samples.iter().copied());
while s.data.len() > self.cap {
s.data.pop_front();
}
}
}
/// Fill `out` (planar) with up to `frames` frames; missing frames are silence.
pub fn pop_into(&self, out: &mut [Vec<f32>], frames: usize) {
/// Fill `out` (planar, at `session_rate`) with up to `frames` frames, converting from the
/// input rate. Missing frames are silence.
pub fn pop_into(&self, out: &mut [Vec<f32>], frames: usize, session_rate: u32) {
let ch = self.channels.load(Ordering::Relaxed).max(1);
for c in out.iter_mut() {
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
}
let Ok(mut d) = self.data.try_lock() else { return };
let avail = (d.len() / ch).min(frames);
for f in 0..avail {
let Ok(mut s) = self.state.try_lock() else { return };
// Input frames consumed per output frame.
let step = if self.rate == 0 || session_rate == 0 { 1.0 } else { f64::from(self.rate) / f64::from(session_rate) };
for f in 0..frames {
// Drop input frames we have moved past; keep the one at `phase` and the next one.
while s.phase >= 1.0 && s.data.len() >= 2 * ch {
s.data.drain(..ch);
s.phase -= 1.0;
}
if s.phase >= 1.0 || s.data.len() < 2 * ch {
break;
}
let fr = s.phase as f32;
for c in 0..ch {
let v = d.pop_front().unwrap_or(0.0);
let a = s.data.get(c).copied().unwrap_or(0.0);
let b = s.data.get(ch + c).copied().unwrap_or(a);
if let Some(x) = out.get_mut(c).and_then(|o| o.get_mut(f)) {
*x = v;
*x = a + (b - a) * fr;
}
}
s.phase += step;
}
}
}
#[cfg(test)]
mod ring_tests {
use super::InputRing;
/// A 1 kHz sine recorded at 16 kHz must come out as a 1 kHz sine at 48 kHz, across calls.
#[test]
fn monitor_converts_input_rate_to_session_rate() {
let ring = InputRing::new(1, 16_000);
let input: Vec<f32> = (0..4_000).map(|i| (2.0 * std::f64::consts::PI * 1_000.0 * f64::from(i) / 16_000.0).sin() as f32).collect();
ring.push(&input);
let mut out = vec![vec![0.0f32; 48]];
let mut k = 0usize;
for _ in 0..10 {
ring.pop_into(&mut out, 48, 48_000);
for v in &out[0] {
let want = (2.0 * std::f64::consts::PI * 1_000.0 * k as f64 / 48_000.0).sin() as f32;
assert!((v - want).abs() < 0.05, "sample {k}: got {v}, want {want}");
k += 1;
}
}
}
/// Input faster than the session (96 kHz into 48 kHz): every other frame is read, in order.
#[test]
fn monitor_decimates_when_input_is_faster() {
let ring = InputRing::new(1, 96_000);
let input: Vec<f32> = (0..1_000).map(|i| i as f32).collect();
ring.push(&input);
let mut out = vec![vec![0.0f32; 100]];
ring.pop_into(&mut out, 100, 48_000);
for (k, v) in out[0].iter().enumerate() {
assert_eq!(*v, (2 * k) as f32, "output {k}");
}
}
/// Same rate: samples pass through unchanged.
#[test]
fn monitor_passes_through_at_equal_rates() {
let ring = InputRing::new(2, 48_000);
let input: Vec<f32> = (0..200).map(|i| i as f32).collect();
ring.push(&input);
let mut out = vec![vec![0.0f32; 50], vec![0.0f32; 50]];
ring.pop_into(&mut out, 50, 48_000);
assert_eq!(out[0][0], 0.0);
assert_eq!(out[1][0], 1.0);
assert_eq!(out[0][49], 98.0);
}
/// Nothing buffered: silence, not stale data.
#[test]
fn monitor_underrun_is_silence() {
let ring = InputRing::new(1, 16_000);
let mut out = vec![vec![1.0f32; 16]];
ring.pop_into(&mut out, 16, 48_000);
assert!(out[0].iter().all(|v| *v == 0.0));
}
}
+27
View File
@@ -6,6 +6,7 @@ use egui::{Align2, vec2};
use serde_json::{Value, json};
use soundcraft_engine::Engine;
use soundcraft_model::{ClipId, TrackId};
use soundcraft_time::Samples;
#[derive(Debug, Clone)]
pub enum Dialog {
@@ -16,6 +17,7 @@ pub enum Dialog {
RenameClip { id: ClipId, name: String },
PathPrompt { cmd: String, title: String, path: String, key: String },
Number { cmd: String, title: String, key: String, value: f64, suffix: String },
TempoChange { at: Samples, bpm: f64 }, // from double-clicking the tempo ruler
Fades { shape: String },
StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 },
Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> },
@@ -38,6 +40,7 @@ impl Dialogs {
Dialog::RenameClip { .. } => "rename_clip",
Dialog::PathPrompt { .. } => "path",
Dialog::Number { .. } => "number",
Dialog::TempoChange { .. } => "tempo_change",
Dialog::Fades { .. } => "fades",
Dialog::StripSilence { .. } => "strip_silence",
Dialog::Group { .. } => "group",
@@ -167,6 +170,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::RenameTrack { .. } => "Rename Track",
Dialog::RenameClip { .. } => "Rename Clip",
Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(),
Dialog::TempoChange { .. } => "Tempo Change (BPM)",
Dialog::Fades { .. } => "Fades",
Dialog::StripSilence { .. } => "Strip Silence",
Dialog::Group { .. } => "Create Group",
@@ -315,6 +319,16 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
action = Some((cmd.clone(), v));
}
}
Dialog::TempoChange { at, bpm } => {
ui.horizontal(|ui| {
// Same limits the engine enforces (time crate `valid_bpm`).
ui.add(egui::DragValue::new(bpm).speed(0.1).range(5.0..=1000.0));
ui.label("bpm");
});
if buttons(ui, "OK", enter) {
action = Some(tempo_change_action(*at, *bpm));
}
}
Dialog::Fades { shape } => {
for s in soundcraft_model::FadeShape::ALL {
ui.radio_value(shape, s.label().to_lowercase(), s.label());
@@ -422,6 +436,11 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
pressed
}
/// The command the tempo-change dialog runs on OK.
fn tempo_change_action(at: Samples, bpm: f64) -> (String, Value) {
("event.tempo".into(), json!({"bpm": bpm, "at": at}))
}
fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) {
let Some(process) = app.ui.audiosuite.clone() else { return };
let mut open = true;
@@ -495,4 +514,12 @@ mod tests {
assert!(d.open_for_command(&e, "file.score_setup"));
assert!(matches!(d.open, Some(Dialog::ScoreSetup { bars_per_system: 4, .. })));
}
#[test]
fn tempo_change_dialog_runs_a_real_command_at_its_position() {
let (cmd, params) = tempo_change_action(96_000, 133.5);
assert!(soundcraft_engine::command_specs().iter().any(|c| c.id == cmd), "{cmd} is not a command");
assert_eq!(params["at"], 96_000);
assert_eq!(params["bpm"], 133.5);
}
}
+120 -12
View File
@@ -32,6 +32,18 @@ pub struct EditLayout {
pub rows: Vec<(u64, [f32; 4])>,
pub scroll_y: f32,
pub content_h: f32,
/// Follow-hold: the view was moved by something other than the playhead
/// follow (a manual pan, scrollbar, universe jump, or programmatic
/// scroll) during playback, so timeline auto-scroll stays paused until
/// the playhead is scrolled back into view (or the transport
/// stops/starts, or a scrolling mode is re-selected). Lets the timeline
/// be inspected anywhere while playing.
pub follow_hold: bool,
/// Session `zoom.scroll` seen by the last overlay frame, for telling our
/// own follow jumps apart from outside view moves.
pub last_scroll: Samples,
/// Follow target issued by the last overlay frame (`None` when none).
pub last_follow_to: Option<Samples>,
}
pub fn x_of(s: &Session, tl: Rect, at: Samples) -> f32 {
@@ -372,13 +384,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[
{
let at = snap(&s, sample_at(&s, tl, p.x).max(0));
let bpm = s.tempo.tempo_at_tick(s.tempo.samples_to_ticks(at, sr));
app.dialogs.open = Some(crate::dialogs::Dialog::Number {
cmd: format!("event.tempo@{at}"),
title: "Tempo Change (BPM)".into(),
key: "bpm".into(),
value: bpm,
suffix: "bpm".into(),
});
app.dialogs.open = Some(crate::dialogs::Dialog::TempoChange { at, bpm });
}
let mut last_label = f32::MIN;
for ev in s.tempo.tempos() {
@@ -1372,6 +1378,20 @@ fn clip_at(track: &Track, at: Samples) -> Option<&Clip> {
fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) {
let id = ui.id().with(("lane", track.id.0));
let resp = ui.interact(lane, id, Sense::click_and_drag());
// The clip menu is attached before the early return below: once the menu is open the pointer
// is over the popup, the lane has no hover position, and the rest of this function bails out.
// The clip is remembered at the right-click so the menu keeps its target while it is used.
let menu_clip_id = id.with("menu_clip");
if resp.secondary_clicked() {
let clip = resp.interact_pointer_pos().and_then(|p| {
let s = app.engine.session();
clip_at(track, sample_at(s, tl, p.x).max(0)).map(|c| c.id)
});
ui.ctx().data_mut(|d| d.insert_temp(menu_clip_id, clip));
}
// The clip may have been removed or moved since the right-click; only use it if it still exists.
let menu_clip = ui.ctx().data(|d| d.get_temp::<Option<ClipId>>(menu_clip_id)).flatten().filter(|c| track.clips().iter().any(|x| x.id == *c));
resp.context_menu(|ui| clip_context_menu(app, ui, menu_clip));
// Audio files dropped from the Clip List.
if let Some(src) = resp.dnd_release_payload::<crate::DragSource>()
&& let Some(p) = ui.ctx().pointer_latest_pos()
@@ -1613,7 +1633,6 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
{
let _ = app.run("edit.select", json!({"clips": [c.id.0]}));
}
resp.context_menu(|ui| clip_context_menu(app, ui, hit.as_ref().map(|c| c.id)));
}
fn automation_id(p: &AutoParam) -> String {
@@ -1679,7 +1698,44 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
}
}
/// Next follow state after observing one frame: `(hold, follow_to)`.
///
/// The view is ours to move only while nothing else moved it: when the
/// session scroll differs from both the last seen value and our own last
/// follow target, something else (a manual pan, scrollbar, universe jump,
/// or programmatic scroll) took over, so the follow pauses (`hold`) and no
/// jump is issued. Stopping, or scrolling back to the playhead, clears the
/// hold. Only `page`/`continuous`/`center` ever issue follow jumps while
/// playing; `none`/`after_playback` leave the view alone.
fn follow_update(
playing: bool,
mode_follow: bool,
playhead_visible: bool,
cur: Samples,
last_scroll: Samples,
last_follow_to: Option<Samples>,
hold: bool,
pos: Samples,
) -> (bool, Option<Samples>) {
if !playing || playhead_visible {
return (false, None);
}
if cur != last_scroll && Some(cur) != last_follow_to {
return (mode_follow, None);
}
if hold || !mode_follow {
return (hold, None);
}
(false, Some(pos))
}
/// Selection overlay, insertion point and playhead across rulers and tracks.
/// While playing, auto-scroll keeps the playhead in view for the follow
/// modes (`page`/`continuous`/`center`) — but any outside view move (a manual
/// pan, scrollbar, universe jump, or programmatic scroll) pauses it, so the
/// timeline can be inspected anywhere during playback. Scrolling back to the
/// playhead, stopping/starting, or re-selecting a scrolling mode resumes the
/// follow. `none` and `after_playback` never scroll during playback.
fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
let t = Tokens::DARK;
let mut scroll_to: Option<Samples> = None;
@@ -1721,10 +1777,18 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
if app.is_playing() {
let x = x_of(s, tl, app.position());
painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.5, t.playhead));
// Page scrolling.
if s.edit.scrolling != "none" && (x > tl.max.x || x < tl.min.x) {
scroll_to = Some(app.position());
}
// Session reads first (owned copies), then the layout mutation: the
// two borrow disjoint fields.
let pos = app.position();
let cur = s.edit.zoom.scroll;
let mode_follow = matches!(s.edit.scrolling.as_str(), "page" | "continuous" | "center");
let visible = x >= tl.min.x && x <= tl.max.x;
let layout = &mut app.edit_layout;
let (hold, follow_to) = follow_update(true, mode_follow, visible, cur, layout.last_scroll, layout.last_follow_to, layout.follow_hold, pos);
layout.follow_hold = hold;
layout.last_scroll = cur;
layout.last_follow_to = follow_to;
scroll_to = follow_to;
}
// Ghost of a clip move.
if let Some(Gesture::MoveClips { clips, delta, to_track, .. }) = &app.gesture {
@@ -1763,3 +1827,47 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
let _ = app.engine.execute("view.scroll", &json!({"to": at}));
}
}
#[cfg(test)]
mod tests {
use super::follow_update;
#[test]
fn follow_jumps_to_offscreen_playhead_while_playing() {
// Steady view, follow mode, playhead left the screen: jump to it.
assert_eq!(follow_update(true, true, false, 1_000, 1_000, None, false, 96_000), (false, Some(96_000)));
}
#[test]
fn follow_leaves_onscreen_playhead_alone() {
assert_eq!(follow_update(true, true, true, 1_000, 1_000, None, false, 96_000), (false, None));
}
#[test]
fn follow_never_scrolls_when_stopped_nor_in_free_modes() {
assert_eq!(follow_update(false, true, false, 1_000, 1_000, None, false, 96_000), (false, None));
// `none`/`after_playback` never follow, even with a steady view.
assert_eq!(follow_update(true, false, false, 1_000, 1_000, None, false, 96_000), (false, None));
assert_eq!(follow_update(true, false, false, 9_000, 1_000, None, false, 96_000), (false, None));
}
#[test]
fn outside_view_move_pauses_the_follow() {
// The view moved and it was not our own jump: pause, no jump.
assert_eq!(follow_update(true, true, false, 9_000, 1_000, None, false, 96_000), (true, None));
// A held view stays held while it sits still elsewhere.
assert_eq!(follow_update(true, true, false, 9_000, 9_000, None, true, 96_000), (true, None));
}
#[test]
fn own_follow_jump_is_not_mistaken_for_a_takeover() {
// The scroll equals the target we issued last frame: keep following.
assert_eq!(follow_update(true, true, false, 96_000, 1_000, Some(96_000), false, 97_000), (false, Some(97_000)));
}
#[test]
fn hold_clears_when_playhead_returns_or_transport_stops() {
assert_eq!(follow_update(true, true, true, 9_000, 9_000, None, true, 96_000), (false, None));
assert_eq!(follow_update(false, true, false, 9_000, 9_000, None, true, 96_000), (false, None));
}
}
+34 -4
View File
@@ -26,11 +26,41 @@ const BOLD: &[(&str, u32)] = &[
("/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", 0),
];
/// Asks fontconfig for the desktop's UI font. Only TrueType/OpenType results are used: egui cannot
/// read Type 1 or bitmap fonts, which `fc-match` may return on minimal systems.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn load_from_fontconfig(bold: bool) -> Option<FontData> {
let pattern = if bold { "sans-serif:style=Bold" } else { "sans-serif" };
let output = std::process::Command::new("fc-match").args(["--format=%{file}\\n%{index}\\n", pattern]).output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
let mut lines = stdout.lines();
let path = lines.next()?.trim();
// The upper 16 bits carry a variable font's named instance; the face index is the lower 16.
let index = lines.next()?.trim().parse::<u32>().ok()? & 0xFFFF;
let bytes = std::fs::read(path).ok()?;
let sfnt = matches!(bytes.get(..4), Some(b"\0\x01\0\0" | b"OTTO" | b"true" | b"ttcf"));
if !sfnt {
return None;
}
let mut font = FontData::from_owned(bytes);
font.index = index;
Some(font)
}
#[cfg(not(target_arch = "wasm32"))]
fn load(cands: &[(&str, u32)]) -> Option<FontData> {
fn load(cands: &[(&str, u32)], bold: bool) -> Option<FontData> {
if std::env::var_os("SOUNDCRAFT_NO_SYSTEM_FONTS").is_some() {
return None;
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
if let Some(font) = load_from_fontconfig(bold) {
return Some(font);
}
#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
let _ = bold;
for (p, idx) in cands {
if let Ok(bytes) = std::fs::read(p) {
let mut fd = FontData::from_owned(bytes);
@@ -42,7 +72,7 @@ fn load(cands: &[(&str, u32)]) -> Option<FontData> {
}
#[cfg(target_arch = "wasm32")]
fn load(_: &[(&str, u32)]) -> Option<FontData> {
fn load(_: &[(&str, u32)], _: bool) -> Option<FontData> {
None
}
@@ -50,13 +80,13 @@ pub fn definitions() -> FontDefinitions {
let mut fonts = FontDefinitions::default();
let base: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
let mut bold_family = base.clone();
if let Some(reg) = load(REGULAR) {
if let Some(reg) = load(REGULAR, false) {
fonts.font_data.insert("system-regular".into(), Arc::new(reg));
if let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) {
f.insert(0, "system-regular".into());
}
}
if let Some(b) = load(BOLD) {
if let Some(b) = load(BOLD, true) {
fonts.font_data.insert("system-bold".into(), Arc::new(b));
bold_family.insert(0, "system-bold".into());
} else if fonts.font_data.contains_key("system-regular") {
+69 -3
View File
@@ -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<Player>, 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));
}
}
+52 -6
View File
@@ -41,8 +41,17 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
key.map(|k| (m, k))
}
fn mods_match(want: Modifiers, got: Modifiers) -> bool {
want.command == got.command && want.shift == got.shift && want.alt == got.alt && (want.ctrl == got.ctrl || got.mac_cmd)
/// Whether the held modifiers `got` fire a shortcut that asks for `want`.
fn mods_match(want: Modifiers, got: Modifiers, mac: bool) -> bool {
// Off macOS egui reports Ctrl as both `ctrl` and `command`: Ctrl is the shortcut's Cmd, and a shortcut's
// Ctrl (the Mac Control key) has no key of its own there, so Ctrl+X is Cut, not Cmd+Ctrl+X.
let ctrl = if mac { want.ctrl == got.ctrl } else { !want.ctrl };
want.command == got.command && want.shift == got.shift && want.alt == got.alt && ctrl
}
/// Cmd+Ctrl+S opens Search. Off macOS Ctrl+S is Save (Cmd+S), so Search is reached from the Window menu.
fn search_chord(m: Modifiers, mac: bool) -> bool {
mac && m.command && m.ctrl
}
pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
@@ -58,12 +67,14 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
})
.collect()
});
// Cmd and Ctrl are separate keys only on macOS (asked at runtime so the web build in a Mac browser agrees).
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
for (key, mods) in events {
if fixed(app, key, mods) {
if fixed(app, key, mods, mac) {
continue;
}
let hit =
soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods)));
soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac)));
if let Some(c) = hit {
let id = c.id;
if crate::menus::invoke_shortcut_dialog(app, id) {
@@ -75,7 +86,7 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
}
/// Keys with context-dependent meaning. Returns true when handled.
fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool {
fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let plain = !m.command && !m.alt && !m.ctrl && !m.shift;
match key {
Key::Space if !m.command && !m.alt => {
@@ -87,7 +98,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool {
let _ = app.run("transport.record", json!({}));
true
}
Key::S if m.command && m.ctrl => {
Key::S if search_chord(m, mac) => {
let _ = app.run("window.search", json!({}));
true
}
@@ -227,3 +238,38 @@ fn tab_clip(app: &mut SoundApp, back: bool) {
let _ = app.run("transport.locate", json!({"at": n}));
}
}
#[cfg(test)]
mod tests {
use super::{mods_match, parse, search_chord};
use egui::Modifiers;
/// Ctrl held on Windows or Linux, as egui reports it.
const PC_CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
/// Cmd held on macOS.
const MAC_CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
fn want(s: &str) -> Modifiers {
parse(s).map(|(m, _)| m).unwrap()
}
#[test]
fn ctrl_is_cmd_off_macos() {
assert!(mods_match(want("Cmd+S"), PC_CTRL, false));
assert!(mods_match(want("Cmd+Shift+N"), Modifiers { shift: true, ..PC_CTRL }, false));
assert!(mods_match(want("Cmd+X"), PC_CTRL, false));
assert!(!mods_match(want("Cmd+Ctrl+X"), PC_CTRL, false));
assert!(!mods_match(want("Cmd+S"), Modifiers::NONE, false));
assert!(!search_chord(PC_CTRL, false));
}
#[test]
fn cmd_and_ctrl_stay_apart_on_macos() {
assert!(mods_match(want("Cmd+S"), MAC_CMD, true));
assert!(!mods_match(want("Cmd+S"), Modifiers::CTRL, true));
assert!(mods_match(want("Cmd+Ctrl+X"), Modifiers { ctrl: true, ..MAC_CMD }, true));
assert!(!mods_match(want("Cmd+X"), Modifiers { ctrl: true, ..MAC_CMD }, true), "Cmd+Ctrl+X is not Cut");
assert!(!search_chord(MAC_CMD, true));
assert!(search_chord(Modifiers { ctrl: true, ..MAC_CMD }, true));
}
}
+66
View File
@@ -0,0 +1,66 @@
//! Regression: the clip context menu must stay open while the pointer moves onto it, so its
//! Delete item can be used. It used to vanish once the pointer left the lane, because the lane
//! returned early (no hover position) before the menu was drawn.
use egui::{Event, Modifiers, PointerButton, pos2, vec2};
use egui_kittest::Harness;
use soundcraft_ui_egui::{Services, SoundApp};
/// One UI frame, with queued synthetic pointer input applied first (as the control channel does).
fn step(h: &mut Harness<'_, SoundApp>) {
let mut raw = std::mem::take(h.input_mut());
h.state_mut().raw_input_hook(&mut raw);
*h.input_mut() = raw;
h.step();
}
fn press(h: &mut Harness<'_, SoundApp>, x: f32, y: f32, button: PointerButton) {
let pos = pos2(x, y);
let s = &mut h.state_mut().synthetic;
s.push(Event::PointerMoved(pos));
s.push(Event::PointerButton { pos, button, pressed: true, modifiers: Modifiers::NONE });
s.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE });
}
fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize {
let s = h.state().engine.session();
s.tracks.iter().find(|t| t.name == "Kick").map_or(0, |t| t.clips().len())
}
#[test]
fn clip_menu_stays_open_while_moving_onto_delete() {
let engine = soundcraft_engine::demo::demo_engine();
let app = SoundApp::new(engine, None, Services::default());
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
for _ in 0..6 {
step(&mut h);
}
let before = kick_clip_count(&h);
assert!(before > 0, "the demo session should have a recorded Kick clip");
// Right-click the Kick clip, then move the pointer into the menu and down to Delete.
press(&mut h, 880.0, 240.0, PointerButton::Secondary);
for _ in 0..10 {
step(&mut h);
}
// Move (no click) into the menu, as a real pointer would, so the lane loses its hover.
for (x, y) in [(900.0, 300.0), (907.0, 442.0)] {
h.state_mut().synthetic.push(Event::PointerMoved(pos2(x, y)));
for _ in 0..10 {
step(&mut h);
}
}
press(&mut h, 907.0, 442.0, PointerButton::Primary);
for _ in 0..15 {
step(&mut h);
}
assert_eq!(kick_clip_count(&h), before - 1, "clicking Delete in the clip menu should remove the clip");
}
+8 -5
View File
@@ -1059,19 +1059,19 @@ impl Instance {
self.active
}
fn bus_count(&self, media: u32, dir: u32) -> i32 {
fn bus_count(&self, media: sv::MediaTypes, dir: sv::BusDirections) -> i32 {
// SAFETY: valid component.
unsafe { self.component.getBusCount(media as sv::MediaType, dir as sv::BusDirection) }.clamp(0, MAX_BUSES)
}
fn arrangement(&self, dir: u32, index: i32) -> Option<sv::SpeakerArrangement> {
fn arrangement(&self, dir: sv::BusDirections, index: i32) -> Option<sv::SpeakerArrangement> {
let p = self.processor()?;
let mut arr: sv::SpeakerArrangement = 0;
// SAFETY: valid processor; `arr` is writable.
ok(unsafe { p.getBusArrangement(dir as sv::BusDirection, index, &mut arr) }).then_some(arr)
}
fn bus_channels(&self, dir: u32, index: i32) -> u32 {
fn bus_channels(&self, dir: sv::BusDirections, index: i32) -> u32 {
if let Some(arr) = self.arrangement(dir, index) {
return arr.count_ones().min(MAX_BUS_CHANNELS);
}
@@ -1095,7 +1095,7 @@ impl Instance {
let (din, dout) = (sv::BusDirections_::kInput, sv::BusDirections_::kOutput);
let nin = self.bus_count(audio, din);
let nout = self.bus_count(audio, dout);
let current = |s: &Self, dir: u32, n: i32| -> Vec<sv::SpeakerArrangement> {
let current = |s: &Self, dir: sv::BusDirections, n: i32| -> Vec<sv::SpeakerArrangement> {
(0..n).map(|i| s.arrangement(dir, i).unwrap_or(sv::SpeakerArr::kStereo)).collect()
};
let want = if mono { sv::SpeakerArr::kMono } else { sv::SpeakerArr::kStereo };
@@ -1166,7 +1166,10 @@ impl Instance {
self.context.tempo = 120.0;
self.context.timeSigNumerator = 4;
self.context.timeSigDenominator = 4;
self.context.state = sv::ProcessContext_::StatesAndFlags_::kTempoValid | sv::ProcessContext_::StatesAndFlags_::kTimeSigValid;
// The flag constants are the C default enum type: i32 on Windows, u32 elsewhere.
#[allow(clippy::unnecessary_cast)]
let flags = (sv::ProcessContext_::StatesAndFlags_::kTempoValid | sv::ProcessContext_::StatesAndFlags_::kTimeSigValid) as u32;
self.context.state = flags;
self.host.latency_changed.store(false, Ordering::Relaxed);
Ok(layout)
}
+6 -1
View File
@@ -9,7 +9,12 @@ fn main() {
let dir = common::fixture_dir();
let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
let mut p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate");
assert_eq!(p.editor_size(), Ok((320, 240)));
// Editor windows are hosted on macOS only; elsewhere the probe reports "unsupported".
if cfg!(target_os = "macos") {
assert_eq!(p.editor_size(), Ok((320, 240)));
} else {
assert!(p.editor_size().is_err());
}
let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle");
assert!(!ed.is_open());
assert_eq!(ed.idle().len(), 1, "the fixture's setup edit");
+6 -1
View File
@@ -212,7 +212,12 @@ fn state_saves_component_and_controller_and_restores_them() {
fn editor_view_is_probed_and_edits_come_back_as_plain_values() {
let id = id_of("Fixture Gain");
let mut p = soundcraft_vst3_host::instantiate_plugin(&id).unwrap();
assert_eq!(p.editor_size(), Ok((320, 240)));
// Editor windows are hosted on macOS only; elsewhere the probe reports "unsupported".
if cfg!(target_os = "macos") {
assert_eq!(p.editor_size(), Ok((320, 240)));
} else {
assert!(p.editor_size().is_err());
}
let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle");
// The fixture "edits" Gain to 0.9 (normalized) when it gets the component handler.
assert_eq!(ed.idle(), vec![("7".to_string(), 1.8)]);
+33 -15
View File
@@ -35,9 +35,10 @@ After the draft is published, the workflow refuses to touch that version again,
**Test runs:** *Actions → Release → Run workflow* runs the whole pipeline by hand. The optional
`version` input (such as `0.2.0-rc.1`) overrides `Cargo.toml` for that run only. The jobs apply it
with `cargo xtask version set` before building, so the binaries report it too. The run still needs
the `release` environment, which only the `release` branch can use, so pick that branch in the
dialog.
with `cargo xtask version set` before building, so the binaries report it too. Only the signing
jobs (macOS, Windows) and the draft-release job use the `release` environment, which only the
`release` branch can use. On any other branch the run is a dry run: Linux, Flatpak, FreeBSD and
web build and are checked, the signing jobs are refused, and no release is drafted.
## What gets built
@@ -49,11 +50,14 @@ dialog.
| Windows 11 ARM64 | `soundcraft-<v>-windows-arm64.msi`, `soundcraft-<v>-windows-arm64-portable.zip` | `windows-latest` (cross-compiled; `windows-arm64.yml` installs and runs it on ARM64) |
| Linux x86_64 | `soundcraft-<v>-linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` |
| Linux aarch64 | `soundcraft-<v>-linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` |
| AppImage updates | `soundcraft-<v>-linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImage |
| Flatpak x86_64, aarch64 | `soundcraft-<v>-linux-{x86_64,aarch64}.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackages the Linux tarball) |
| FreeBSD 14 x86_64 | `soundcraft-<v>-freebsd-x86_64.tar.gz` (a `/usr/local` tree) | FreeBSD 14.3 VM on `ubuntu-latest` |
| Web | `soundcraft-web-<v>.zip` (static site; see [`packaging/web/README.md`](../packaging/web/README.md)) | `ubuntu-latest` |
The Flatpak manifest (`packaging/linux/flatpak/ai.storyteller.soundcraft.yml`) is kept ready for a
Flathub submission and validated in CI, but not built there (see Linux below).
The from-source Flatpak manifest (`packaging/linux/flatpak/ai.storyteller.soundcraft.yml`) is
kept ready for a Flathub submission; the release's `.flatpak` bundles come from
`ai.storyteller.soundcraft.bundle.yml` (see Linux below).
### Audio libraries
@@ -68,7 +72,8 @@ plugins), and WebAudio in the browser.
routes ALSA to the OSS `sound(4)` devices. Without a device the player falls back to its
silent clock, so the transport still runs.
- **Flatpak**: alsa-lib and its PulseAudio plugin come with the freedesktop runtime; the
manifest grants `--socket=pulseaudio`.
manifests grant `--socket=pulseaudio` (PipeWire's pulse server answers on it). No raw ALSA
devices and no JACK: SoundCraft doesn't use either.
- **Web**: browsers start audio only after a user gesture, so the first click in the page may be
needed before sound plays. The web build has no recording.
@@ -89,6 +94,9 @@ plugins), and WebAudio in the browser.
- **Notarization:** the app is zipped and sent with `xcrun notarytool submit --wait`, then the
ticket is stapled to the app. The app goes on a DMG (`hdiutil`, with an `Applications` link).
The DMG is signed, notarized and stapled too.
Its Finder window (background, icon size and positions) comes from
[`packaging/macos/dmg/`](../packaging/macos/dmg/README.md), and its volume is named `SoundCraft`
without the version, which the window's background needs; the DMG file name keeps the version.
- **CLI:** the universal `soundcraft-cli` is signed with the hardened runtime, zipped and
notarized.
@@ -131,14 +139,24 @@ Locally on Windows: `dotnet tool install -g wix --version 5.0.2`, then
MIDI), hicolor icons from 16 px to 512 px plus a scalable SVG, AppStream metainfo, and a
shared-mime-info file declaring `application/x-soundcraft-session` (`*.scraft`).
- **AppImage** (maintained `AppImage/appimagetool`, static runtime: no libfuse2 needed).
- **AppImage** (maintained `AppImage/appimagetool`, static runtime: no libfuse2 needed). Each
embeds the update information
`gh-releases-zsync|storytold|soundcraft|latest|soundcraft-*-linux-<arch>.AppImage.zsync`, and
the `.zsync` next to it (written when `zsyncmake` from the `zsync` package is installed, as in
CI) lets AppImageUpdate fetch only the changed blocks of the newest non-pre-release.
- **.deb** and **.rpm** by [nfpm](https://nfpm.goreleaser.com/) from one `nfpm.yaml`; the
`postinst.sh` hook refreshes the desktop, MIME and icon caches.
- **.tar.gz** for people who manage their own `/opt` or `~/.local`.
- **Flatpak**: `packaging/linux/flatpak/ai.storyteller.soundcraft.yml` builds from source on the
freedesktop 25.08 runtime with Wayland/X11, `dri`, PulseAudio and `xdg-music`; other files go
through the portals. A real build needs vendored crates (`cargo-sources.json` from
`flatpak-cargo-generator.py`); the commands are in the manifest's header.
- **Flatpak**: the `flatpak` job runs `packaging/linux/flatpak-bundle.sh` on the Linux job's
tarball (no Rust build) with `flatpak/ai.storyteller.soundcraft.bundle.yml`, then installs the
bundle and runs `soundcraft-cli --version` in the sandbox. Install with
`flatpak install --user soundcraft-<v>-linux-<arch>.flatpak`. Both manifests use the freedesktop
25.08 runtime with Wayland/X11, `dri`, PulseAudio and `xdg-music`; other files go through the
portals. For the VST3 and CLAP hosts they grant `~/.vst3` and `~/.clap` read-only and mount
Flathub's `org.freedesktop.LinuxAudio.Plugins` extensions (pointed to by `VST3_PATH` and
`CLAP_PATH`); system plugin folders aren't visible in the sandbox.
`ai.storyteller.soundcraft.yml` builds from source for Flathub and needs vendored crates
(`cargo-sources.json` from `flatpak-cargo-generator.py`); the commands are in its header.
Binaries are built on Ubuntu 22.04 and need **glibc ≥ 2.35**. X11, Wayland, xkbcommon, Vulkan
and EGL are loaded at runtime, so the packages declare them as dependencies or recommendations.
@@ -164,8 +182,8 @@ through WebAudio.
## Secrets
All secrets live in the repository's **`release` environment** (*Settings → Environments →
release*), restricted to the `release` branch. Every job in `release.yml` declares
`environment: release`. Each secret is optional: if one is missing, that platform's artifacts
release*), restricted to the `release` branch. Only the jobs that sign (macOS, Windows) and the
draft-release job declare `environment: release`; the others get no secrets. Each secret is optional: if one is missing, that platform's artifacts
are unsigned and the run shows a `::warning::`. Scripts never echo secret values; certificates
are written with `umask 077` and deleted in `always()`/`finally` steps.
@@ -197,5 +215,5 @@ those tools. `apps/soundcraft/src/main.rs` sets the runtime window/Dock/taskbar
`.github/workflows/packaging-lint.yml` runs in seconds on any change to `packaging/`, the
workflows or the icons: actionlint, shellcheck, a PowerShell parse, xmllint (WiX, plists,
AppStream, MIME), `desktop-file-validate`, `appstreamcli validate`, and a YAML check of the
Flatpak manifest.
AppStream, MIME), `desktop-file-validate`, `appstreamcli validate`, and a check that the two
Flatpak manifests agree on runtime, finish-args and plugin extensions.
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Repackage a SoundCraft Linux tarball as a single-file Flatpak bundle:
#
# $DIST/soundcraft-<version>-linux-<arch>.flatpak
#
# Usage: packaging/linux/flatpak-bundle.sh [--no-test] [TARBALL]
#
# TARBALL defaults to $DIST/soundcraft-<version>-linux-<arch>.tar.gz from package.sh. The bundle
# is built for the host architecture (x86_64 or aarch64), which must match the tarball's.
# Needs flatpak, flatpak-builder and the SVG pixbuf loader (librsvg2-common) for the host's
# `appstreamcli compose`; the freedesktop runtime and SDK named in the manifest are
# installed per-user from Flathub. Unless --no-test, the bundle is then installed per-user and
# `soundcraft-cli --version` is run inside the sandbox as a smoke test.
# Manifest: packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml.
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/linux"
APP_ID=ai.storyteller.soundcraft
FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
TEST=1
TARBALL=""
while [ $# -gt 0 ]; do
case "$1" in
--no-test) TEST=0; shift ;;
-h | --help) sed -n '2,14p' "$0"; exit 0 ;;
-*) echo "unknown argument: $1" >&2; exit 2 ;;
*) TARBALL="$1"; shift ;;
esac
done
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ;;
aarch64 | arm64) ARCH=aarch64 ;;
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
esac
BASENAME="soundcraft-$VERSION-linux-$ARCH"
TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}"
[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
for tool in flatpak flatpak-builder; do
command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; }
done
echo "==> SoundCraft $VERSION Flatpak bundle for $ARCH from $(basename "$TARBALL")"
WORK="$CARGO_TARGET_DIR/flatpak-bundle"
rm -rf "$WORK"
mkdir -p "$WORK/stage"
tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1
[ -x "$WORK/stage/bin/soundcraft" ] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; }
cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml"
flatpak remote-add --user --if-not-exists flathub "$FLATHUB"
# --disable-rofiles-fuse: no FUSE in containers and CI runners.
flatpak-builder --user --install-deps-from=flathub --disable-rofiles-fuse --force-clean \
--default-branch=stable --repo="$WORK/repo" --state-dir="$WORK/state" "$WORK/build" "$WORK/$APP_ID.yml"
OUT="$DIST/$BASENAME.flatpak"
# --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing.
flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable
echo "wrote $OUT"
if [ "$TEST" = 1 ]; then
flatpak install --user -y --noninteractive --reinstall "$OUT"
flatpak info --user "$APP_ID"
flatpak run --command=soundcraft-cli "$APP_ID" --version
flatpak run --command=sh "$APP_ID" -c 'ls /app/share/applications /app/share/metainfo /app/share/mime/packages /app/share/icons/hicolor/scalable/apps'
fi
echo "==> done"
ls -lh "$OUT"
@@ -0,0 +1,73 @@
# Flatpak manifest for the single-file bundle attached to every GitHub release
# (soundcraft-<version>-linux-<arch>.flatpak). Build it with packaging/linux/flatpak-bundle.sh.
#
# It repackages the release's own Linux tarball (soundcraft-<version>-linux-<arch>.tar.gz,
# built on Ubuntu 22.04 by package.sh) instead of compiling Rust inside flatpak-builder: no
# network and no vendored crate sources during the build, a few seconds instead of 20+ minutes,
# and byte-for-byte the same binaries as the AppImage/deb/rpm. The script unpacks the tarball
# into `stage/` next to a copy of this file. The binaries need glibc >= 2.35 and load X11,
# Wayland, xkbcommon, Vulkan and libasound at runtime; the freedesktop runtime has all of them
# (plus alsa-lib's PulseAudio plugin), and the GPU driver comes from its
# org.freedesktop.Platform.GL extension.
#
# ai.storyteller.soundcraft.yml (same directory) is the from-source manifest for Flathub. Keep
# runtime-version, finish-args and add-extensions identical in both (packaging-lint checks this).
id: ai.storyteller.soundcraft
runtime: org.freedesktop.Platform
runtime-version: '25.08'
sdk: org.freedesktop.Sdk
command: soundcraft
finish-args:
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
- --socket=fallback-x11
# X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails.
- --share=ipc
# GPU access for the wgpu UI (Vulkan, or GL as a fallback).
- --device=dri
# Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its
# PulseAudio plugin to this socket, which PipeWire's pulse server provides on modern desktops
# (and PulseAudio itself on older ones). Deliberately not granted: --device=all (raw /dev/snd
# for direct ALSA hardware access, which would bypass the sound server) and JACK, which
# SoundCraft doesn't use (cpal's jack feature is off; there is no live MIDI I/O either).
- --socket=pulseaudio
# Open/Save dialogs use the file-chooser portal (rfd) and drag-and-drop the document portal,
# so broad filesystem access isn't needed. Music is granted for sessions and their
# "Audio Files" folders opened by path from the command line.
- --filesystem=xdg-music
# VST3 and CLAP plugin hosts. The per-user plugin folders the hosts scan, read-only.
# System folders (/usr/lib/vst3, /usr/lib/clap) aren't reachable from the sandbox, and plugins
# there are linked against the host's libraries rather than the runtime's; sandboxed plugins
# come from Flatpak extensions instead (add-extensions below), found through these paths.
- --filesystem=~/.vst3:ro
- --filesystem=~/.clap:ro
- --env=VST3_PATH=/app/extensions/Plugins/vst3
- --env=CLAP_PATH=/app/extensions/Plugins/clap
# Flathub's shared audio-plugin extension point (org.freedesktop.LinuxAudio.Plugins.*, the one
# Ardour, Bitwig and friends use). Optional: nothing is downloaded unless the user installs a
# plugin extension; merge-dirs lets every installed one appear under .../Plugins/{vst3,clap}.
add-extensions:
org.freedesktop.LinuxAudio.Plugins:
directory: extensions/Plugins
version: '25.08'
add-ld-path: lib
merge-dirs: vst3;clap
subdirectories: true
no-autodownload: true
modules:
- name: soundcraft
buildsystem: simple
build-commands:
- install -Dm755 bin/soundcraft /app/bin/soundcraft
- install -Dm755 bin/soundcraft-cli /app/bin/soundcraft-cli
- install -Dm644 share/applications/ai.storyteller.soundcraft.desktop /app/share/applications/ai.storyteller.soundcraft.desktop
# Session files; the runtime's shared-mime-info already knows WAV, AIFF, FLAC, MP3 and MIDI.
- install -Dm644 share/mime/packages/ai.storyteller.soundcraft.xml /app/share/mime/packages/ai.storyteller.soundcraft.xml
# Already has the release's version and date substituted by package.sh.
- install -Dm644 share/metainfo/ai.storyteller.soundcraft.metainfo.xml /app/share/metainfo/ai.storyteller.soundcraft.metainfo.xml
- mkdir -p /app/share/icons /app/share/doc && cp -R share/icons/hicolor /app/share/icons/ && cp -R share/doc/soundcraft /app/share/doc/
# Mount point for the plugin extensions (add-extensions).
- install -d /app/extensions/Plugins
sources:
- type: dir
path: stage
@@ -8,6 +8,9 @@
# flatpak run ai.storyteller.soundcraft
#
# For Flathub, replace the `dir` source with a `git` source pinned to a release tag and commit.
# ai.storyteller.soundcraft.bundle.yml repackages the release tarball into the .flatpak attached
# to each GitHub release; keep runtime-version, finish-args and add-extensions identical in both
# (packaging-lint checks this).
#
# Audio: cpal talks to ALSA. alsa-lib (headers in the Sdk, the library in the Platform) and its
# PulseAudio plugin ship with the freedesktop runtime, which routes ALSA through the PulseAudio
@@ -20,16 +23,42 @@ sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: soundcraft
finish-args:
- --share=ipc
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
- --socket=fallback-x11
# X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails.
- --share=ipc
# GPU access for the wgpu UI (Vulkan, or GL as a fallback).
- --device=dri
# Playback and recording (ALSA through the PulseAudio/PipeWire socket).
# Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its
# PulseAudio plugin to this socket, which PipeWire's pulse server provides on modern desktops
# (and PulseAudio itself on older ones). Deliberately not granted: --device=all (raw /dev/snd
# for direct ALSA hardware access, which would bypass the sound server) and JACK, which
# SoundCraft doesn't use (cpal's jack feature is off; there is no live MIDI I/O either).
- --socket=pulseaudio
# Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document
# portal, so broad filesystem access isn't needed. Music is granted for sessions and their
# Open/Save dialogs use the file-chooser portal (rfd) and drag-and-drop the document portal,
# so broad filesystem access isn't needed. Music is granted for sessions and their
# "Audio Files" folders opened by path from the command line.
- --filesystem=xdg-music
# VST3 and CLAP plugin hosts. The per-user plugin folders the hosts scan, read-only.
# System folders (/usr/lib/vst3, /usr/lib/clap) aren't reachable from the sandbox, and plugins
# there are linked against the host's libraries rather than the runtime's; sandboxed plugins
# come from Flatpak extensions instead (add-extensions below), found through these paths.
- --filesystem=~/.vst3:ro
- --filesystem=~/.clap:ro
- --env=VST3_PATH=/app/extensions/Plugins/vst3
- --env=CLAP_PATH=/app/extensions/Plugins/clap
# Flathub's shared audio-plugin extension point (org.freedesktop.LinuxAudio.Plugins.*, the one
# Ardour, Bitwig and friends use). Optional: nothing is downloaded unless the user installs a
# plugin extension; merge-dirs lets every installed one appear under .../Plugins/{vst3,clap}.
add-extensions:
org.freedesktop.LinuxAudio.Plugins:
directory: extensions/Plugins
version: '25.08'
add-ld-path: lib
merge-dirs: vst3;clap
subdirectories: true
no-autodownload: true
build-options:
append-path: /usr/lib/sdk/rust-stable/bin
modules:
@@ -51,6 +80,8 @@ modules:
install -d /app/share/metainfo;
sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/"
packaging/linux/ai.storyteller.soundcraft.metainfo.xml.in > /app/share/metainfo/ai.storyteller.soundcraft.metainfo.xml
# Mount point for the plugin extensions (add-extensions).
- install -d /app/extensions/Plugins
sources:
- type: dir
path: ../../..
+25 -5
View File
@@ -2,6 +2,7 @@
# Build and package SoundCraft for Linux (<arch> is x86_64 or aarch64):
#
# $DIST/soundcraft-<version>-linux-<arch>.AppImage any distro with glibc >= the build host's
# $DIST/soundcraft-<version>-linux-<arch>.AppImage.zsync delta updates (needs zsyncmake)
# $DIST/soundcraft-<version>-linux-<arch>.deb Debian, Ubuntu, Mint, Pop!_OS, ...
# $DIST/soundcraft-<version>-linux-<arch>.rpm Fedora, openSUSE, RHEL, ...
# $DIST/soundcraft-<version>-linux-<arch>.tar.gz plain FHS-style tree (bin/, share/)
@@ -10,7 +11,8 @@
#
# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage
# (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04,
# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli.
# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli,
# zsyncmake (the zsync package) for the AppImage's .zsync.
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
@@ -23,7 +25,7 @@ while [ $# -gt 0 ]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
--formats) FORMATS="$2"; shift 2 ;;
-h | --help) sed -n '2,15p' "$0"; exit 0 ;;
-h | --help) sed -n '2,17p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
@@ -108,11 +110,29 @@ if has appimage; then
chmod +x "$TOOL"
fi
fi
OUT="$DIST/$BASENAME.AppImage"
# Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be
# relative).
OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage"
APPDIR="$(cd "$APPDIR" && pwd)"
TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")"
# A .zsync left by an earlier run would hide a missing zsyncmake and describe another file.
rm -f "$OUT.zsync"
# Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and
# fetch only the blocks that changed in a newer release, through the .zsync published next to
# each AppImage on GitHub Releases. `latest` is the newest published release that is not a
# pre-release. A fork's builds point at its own releases through GITHUB_REPOSITORY.
REPO="${GITHUB_REPOSITORY:-storytold/soundcraft}"
UPDATE_INFO="gh-releases-zsync|${REPO%%/*}|${REPO#*/}|latest|soundcraft-*-linux-$ARCH.AppImage.zsync"
# Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime,
# so users don't need libfuse2 either.
ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream "$APPDIR" "$OUT"
# so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync
# package) appimagetool also writes the .zsync, into its working directory, hence the cd.
(cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT")
echo "wrote $OUT"
if [ -s "$OUT.zsync" ]; then
echo "wrote $OUT.zsync"
else
warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it"
fi
fi
"$STAGE/usr/bin/soundcraft-cli" --version
+44
View File
@@ -0,0 +1,44 @@
# macOS DMG window
What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the
`Applications` link side by side. `package.sh` copies these files into the image; nothing here is
generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no
Finder scripting on CI, nothing extra installed in the signing job).
| File | What |
|---|---|
| `background.svg` | Source of the background: the app icon (`assets/app-icon/icon.svg`, linked, not copied) cropped as a cover on the SoundCraft colour field (`#14a9c4`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. No paper grain: noise doesn't compress, and this keeps `background.tiff` small. |
| `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (16-colour palette, Deflate, sRGB). Goes to `.background/background.tiff`. |
| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, SoundCraft.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. |
| `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. |
## The volume name has no version
The mounted volume is called `SoundCraft`, not `SoundCraft <version>`. The layout points at the background
through an alias that includes the volume name, and Finder resolves it by that name: with a
versioned name the window keeps its size and icon positions but shows no background (tested).
The DMG file name (`soundcraft-<version>-macos-<arch>.dmg`) still carries the version.
## Rules
- **Finder draws the icon labels in black in light and dark mode** when a window has a background,
so the area under both icons stays light (Paper).
- **Nothing goes inside the icon boxes:** artwork keeps 10 pt clear of each 128 pt icon box and of
the label strip under it.
## Regenerate
Edit `background.svg` (or the layout constants in `generate.py`), then run, on any OS:
```sh
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
python3 packaging/macos/dmg/generate.py # needs resvg on PATH
```
Both outputs are byte-for-byte reproducible and carry nothing from the machine that ran it: the
SVG renders without any fonts, and `dmg-layout.DS_Store` is written from scratch, its background
alias holding only the volume name and `/.background/background.tiff`. If you add text to the SVG,
outline it (`usvg` from resvg converts text to paths). The window is 660 × 432 with Finder's 32 pt
title bar; the content area is 660 × 400.
This follows VectorCraft's `packaging/macos/dmg/` (storytold/vectorcraft#493).
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

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