mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 02:26:46 -06:00
Merge origin/main into desktop-log-file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -9,3 +9,4 @@ plan/
|
||||
/target-*/
|
||||
/craft-fonts/
|
||||
plans/
|
||||
.DS_Store
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
<a href="#for-agents-and-scripts">Agents & 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
|
||||
|
||||
@@ -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…</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Executable
+72
@@ -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: ../../..
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user