Merge origin/main into fix/macos-native-menu-bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
acf3237bed
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -3403,7 +3403,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -3419,7 +3419,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
|
|
@ -3429,7 +3429,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
|
|
@ -3441,7 +3441,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
|
|
@ -3451,7 +3451,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading",
|
||||
|
|
@ -3463,7 +3463,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
|
|
@ -3474,7 +3474,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
|
|
@ -3485,7 +3485,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
|
|
@ -3505,7 +3505,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -3515,7 +3515,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
|
|
@ -3531,7 +3531,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -3544,7 +3544,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
|
|
@ -3557,7 +3557,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -3567,7 +3567,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
|
|
@ -3592,7 +3592,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"proptest",
|
||||
|
|
@ -3603,7 +3603,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
"log",
|
||||
|
|
@ -3618,7 +3618,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -5165,7 +5165,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
66
README.md
66
README.md
|
|
@ -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>
|
||||
|
|
@ -157,6 +158,28 @@ Useful shortcuts: <kbd>Space</kbd> play/stop, <kbd>⌘</kbd><kbd>=</kbd> Mix/Edi
|
|||
separate, <kbd>⌘</kbd><kbd>D</kbd> duplicate, <kbd>Enter</kbd> new marker, <kbd>⌘</kbd><kbd>⇧</kbd><kbd>N</kbd>
|
||||
new tracks. **Setup › Keyboard Shortcuts** lists them all.
|
||||
|
||||
### Logs
|
||||
|
||||
The desktop app writes its `log` records to standard error and to `logs/soundcraft.log` in the
|
||||
settings directory, beside `ui.json`, `Autosave/` and `Presets/` (Linux `~/.config/soundcraft/logs/`,
|
||||
or `$XDG_CONFIG_HOME/soundcraft/logs/`; macOS `~/Library/Application Support/SoundCraft/logs/`;
|
||||
Windows `%APPDATA%\SoundCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so
|
||||
this file is what to attach to a bug report: the engine's panic report, audio devices that failed to
|
||||
open or broke, plugins that refused their stored state, CLAP plugins' own messages and files that
|
||||
failed to open land there. Each launch moves the previous log to `soundcraft.1.log` (and that one to
|
||||
`soundcraft.2.log`), so the log of a run that crashed survives the next start. The file stops
|
||||
growing at 16 MiB. `--version` writes no file, and runs with `SOUNDCRAFT_NO_PREFS` log to standard
|
||||
error only.
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for SoundCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,soundcraft_mix=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`soundcraft*=debug`). |
|
||||
| `SOUNDCRAFT_NO_PREFS` | No preferences read or written and no log file (agents' test runs). |
|
||||
|
||||
The realtime audio thread never writes a record itself: the logger keeps the first one it logs
|
||||
(without waiting or allocating), counts the rest, and the UI thread writes them. The logger is
|
||||
`apps/soundcraft/src/logging.rs`. The web app logs to the browser console instead.
|
||||
|
||||
## For agents and scripts
|
||||
|
||||
Everything you can click is also a command with an id and JSON parameters, and the same commands
|
||||
|
|
@ -213,6 +236,49 @@ honest status, what's next and our effort estimates are in [`ROADMAP.md`](ROADMA
|
|||
menu-by-menu comparison is in [`docs/parity.md`](docs/parity.md). Bug reports and wish lists are
|
||||
very welcome, in the issues or on Discord.
|
||||
|
||||
## Downloads
|
||||
|
||||
**Download SoundCraft** from GitHub: the [latest release](https://github.com/storytold/soundcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/soundcraft/releases) has earlier versions and their notes. `<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>
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
|||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("soundcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
let actual = listener.local_addr().map(|a| a.port()).unwrap_or(port);
|
||||
eprintln!("soundcraft: control server listening on 127.0.0.1:{actual}");
|
||||
log::info!("control server listening on 127.0.0.1:{actual}");
|
||||
println!("SOUNDCRAFT_CONTROL_PORT={actual}");
|
||||
let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
|
|
@ -28,7 +28,7 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
|||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
eprintln!("soundcraft: control thread failed: {e}");
|
||||
log::error!("control thread failed: {e}");
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,796 @@
|
|||
//! The desktop app's logger: `log::` records go to standard error and to a log file.
|
||||
//!
|
||||
//! Without a logger every `log::warn!`/`log::error!` in the workspace (the engine's panic report,
|
||||
//! audio devices that failed to open or broke, plugins that refused their stored state or failed
|
||||
//! to activate, CLAP plugins' own log messages, decode errors on import) vanished. A launch from a
|
||||
//! desktop menu has no terminal, so the file is what a bug report can attach:
|
||||
//! `<settings dir>/logs/soundcraft.log`, beside `ui.json`, `Autosave/` and `Presets/`. Each start
|
||||
//! moves the previous log to `soundcraft.1.log` (and that one to `.2`), so the log of a run that
|
||||
//! crashed survives the next launch. Runs without preferences (`SOUNDCRAFT_NO_PREFS`, agents' test
|
||||
//! runs) log to standard error only, so they don't rotate away the user's own logs.
|
||||
//!
|
||||
//! Levels: `info` for SoundCraft's own crates, `warn` for everything else (wgpu and naga are
|
||||
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
|
||||
//! `warn,soundcraft_mix=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
|
||||
//! that starts with it (`soundcraft*=debug`).
|
||||
//!
|
||||
//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and
|
||||
//! written once the file is attached. Writing never panics: a file that can't be created or
|
||||
//! written leaves standard error as the only sink.
|
||||
//!
|
||||
//! Realtime audio: a record takes a lock and writes to standard error and a file, which the audio
|
||||
//! callback must never do. Code that runs on the audio thread logs anyway (the cpal error callback,
|
||||
//! the synth's full event queue, a hosted plugin's failed `process`, a CLAP plugin calling the
|
||||
//! host's `log` from its audio thread, the panic hook). So the cpal callbacks mark their thread
|
||||
//! (`soundcraft_playback::mark_audio_thread`; the mix engine carries the mark into the worker
|
||||
//! threads that render strips for it), and a record from a marked thread is not written
|
||||
//! there: the first one is kept in a fixed buffer (`try_lock`, no allocation, never waits) and the
|
||||
//! rest are only counted (an atomic). Another thread writes them out: the UI thread every frame
|
||||
//! ([`AppLogger::report_audio_thread`]) and any other thread before its own next record.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use log::LevelFilter;
|
||||
|
||||
/// The current log file's name inside the log directory.
|
||||
pub const LOG_FILE: &str = "soundcraft.log";
|
||||
/// The log directory inside the settings directory.
|
||||
pub const LOG_DIR: &str = "logs";
|
||||
/// How many previous logs are kept (`soundcraft.1.log` … `soundcraft.<KEEP>.log`).
|
||||
pub const KEEP: usize = 2;
|
||||
/// The log file stops growing past this size (a runaway warning can't fill the disk).
|
||||
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// Records kept in memory until the log file is attached.
|
||||
pub const MAX_PENDING: usize = 512;
|
||||
/// The built-in filter when `RUST_LOG` is unset or empty.
|
||||
pub const DEFAULT_FILTER: &str = "warn,soundcraft*=info";
|
||||
/// The longest message kept from the audio thread (longer ones are cut, marked with `…`).
|
||||
pub const REALTIME_MESSAGE_BYTES: usize = 480;
|
||||
/// The longest target kept from the audio thread.
|
||||
const REALTIME_TARGET_BYTES: usize = 96;
|
||||
/// The thread name shown for records from the audio thread.
|
||||
const AUDIO_THREAD: &str = "audio";
|
||||
|
||||
/// Per-target level filter parsed from env_logger-style directives.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Filter {
|
||||
default: LevelFilter,
|
||||
directives: Vec<(String, LevelFilter)>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
|
||||
/// directive names log errors only.
|
||||
pub fn parse(spec: &str) -> Filter {
|
||||
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
|
||||
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
match part.split_once('=') {
|
||||
Some((name, level)) => {
|
||||
let name = name.trim();
|
||||
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
|
||||
f.directives.push((name.to_owned(), level));
|
||||
}
|
||||
}
|
||||
None => match parse_level(part) {
|
||||
Some(level) => f.default = level,
|
||||
// A bare target name: everything from it (env_logger does the same).
|
||||
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
|
||||
},
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The level that applies to `target` (the most specific matching directive wins).
|
||||
pub fn level_for(&self, target: &str) -> LevelFilter {
|
||||
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
|
||||
}
|
||||
|
||||
/// The most verbose level any target can reach (for `log::set_max_level`).
|
||||
pub fn max(&self) -> LevelFilter {
|
||||
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_level(s: &str) -> Option<LevelFilter> {
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
|
||||
/// `name*` matches every target starting with `name`.
|
||||
fn matches(name: &str, target: &str) -> bool {
|
||||
match name.strip_suffix('*') {
|
||||
Some(prefix) => target.starts_with(prefix),
|
||||
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
||||
/// clock can't overflow).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("soundcraft.{n}.log")) }
|
||||
}
|
||||
|
||||
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
|
||||
/// and create a fresh, empty log file in `dir` (created if missing).
|
||||
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
for n in (1..=KEEP).rev() {
|
||||
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let path = numbered(dir, 0);
|
||||
let file = File::create(&path)?;
|
||||
Ok((path, file))
|
||||
}
|
||||
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
/// then appended to it until it reaches its size cap.
|
||||
pub struct Sink {
|
||||
file: Option<File>,
|
||||
pending: Vec<String>,
|
||||
dropped: usize,
|
||||
written: u64,
|
||||
max_bytes: u64,
|
||||
capped: bool,
|
||||
}
|
||||
|
||||
impl Sink {
|
||||
pub fn new(max_bytes: u64) -> Sink {
|
||||
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
|
||||
}
|
||||
|
||||
/// Append `line` to the file, or keep it until [`Sink::attach`].
|
||||
pub fn write(&mut self, line: &str) {
|
||||
if self.file.is_none() {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
if self.pending.len() < MAX_PENDING {
|
||||
self.pending.push(line.to_owned());
|
||||
} else {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.append(line);
|
||||
}
|
||||
|
||||
/// Start writing to `file`, first the lines kept so far.
|
||||
pub fn attach(&mut self, file: File) {
|
||||
self.file = Some(file);
|
||||
for line in std::mem::take(&mut self.pending) {
|
||||
self.append(&line);
|
||||
}
|
||||
if self.dropped > 0 {
|
||||
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
|
||||
self.dropped = 0;
|
||||
self.append(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
/// There will be no log file: forget the lines kept for it and keep no more.
|
||||
pub fn no_file(&mut self) {
|
||||
self.file = None;
|
||||
self.pending = Vec::new();
|
||||
self.dropped = 0;
|
||||
self.capped = true;
|
||||
}
|
||||
|
||||
fn append(&mut self, line: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.file.as_mut() else { return };
|
||||
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
||||
let text = if self.written.saturating_add(len) > self.max_bytes {
|
||||
self.capped = true;
|
||||
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
||||
} else {
|
||||
self.written = self.written.saturating_add(len);
|
||||
line.to_owned()
|
||||
};
|
||||
// A full disk or a vanished file must not take the app down: drop the file sink (and
|
||||
// stop buffering, since no file will come).
|
||||
if file.write_all(text.as_bytes()).is_err() {
|
||||
self.file = None;
|
||||
self.capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text formatted into a fixed buffer: no allocation, cut at a character boundary when full.
|
||||
struct Bounded<const N: usize> {
|
||||
buf: [u8; N],
|
||||
len: usize,
|
||||
cut: bool,
|
||||
}
|
||||
|
||||
impl<const N: usize> Bounded<N> {
|
||||
fn new() -> Self {
|
||||
Bounded { buf: [0; N], len: 0, cut: false }
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &str {
|
||||
// Only whole characters are ever copied in, so this never fails.
|
||||
self.buf.get(..self.len).and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("")
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> std::fmt::Write for Bounded<N> {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
let room = N.saturating_sub(self.len);
|
||||
let mut n = s.len().min(room);
|
||||
while !s.is_char_boundary(n) {
|
||||
n = n.saturating_sub(1);
|
||||
}
|
||||
if let (Some(dst), Some(src)) = (self.buf.get_mut(self.len..self.len.saturating_add(n)), s.as_bytes().get(..n)) {
|
||||
dst.copy_from_slice(src);
|
||||
self.len = self.len.saturating_add(n);
|
||||
}
|
||||
if n < s.len() {
|
||||
self.cut = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The first record the audio thread logged since the last report.
|
||||
struct Held {
|
||||
at: SystemTime,
|
||||
level: log::Level,
|
||||
target: Bounded<REALTIME_TARGET_BYTES>,
|
||||
message: Bounded<REALTIME_MESSAGE_BYTES>,
|
||||
}
|
||||
|
||||
/// Records from the realtime audio thread, waiting for another thread to write them.
|
||||
#[derive(Default)]
|
||||
struct Realtime {
|
||||
/// Records logged on the audio thread since the last report (the held one included).
|
||||
records: AtomicUsize,
|
||||
/// The first of them; the audio thread only `try_lock`s it.
|
||||
held: Mutex<Option<Held>>,
|
||||
}
|
||||
|
||||
impl Realtime {
|
||||
/// Audio thread: count `record` and keep it if it is the first. Never waits, never allocates.
|
||||
fn keep(&self, record: &log::Record<'_>) {
|
||||
self.records.fetch_add(1, Ordering::Relaxed);
|
||||
if let Ok(mut held) = self.held.try_lock()
|
||||
&& held.is_none()
|
||||
{
|
||||
let mut target = Bounded::new();
|
||||
let _ = target.write_str(record.target());
|
||||
let mut message = Bounded::new();
|
||||
let _ = write!(message, "{}", record.args());
|
||||
*held = Some(Held { at: SystemTime::now(), level: record.level(), target, message });
|
||||
}
|
||||
}
|
||||
|
||||
/// Another thread: the lines to write for what the audio thread logged (empty when nothing).
|
||||
fn take_lines(&self) -> String {
|
||||
if self.records.load(Ordering::Relaxed) == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let held = self.held.lock().unwrap_or_else(PoisonError::into_inner).take();
|
||||
let records = self.records.swap(0, Ordering::Relaxed);
|
||||
let mut lines = String::new();
|
||||
if let Some(h) = &held {
|
||||
let mut message = h.message.as_str().to_owned();
|
||||
if h.message.cut {
|
||||
message.push('…');
|
||||
}
|
||||
lines.push_str(&format_line(×tamp(h.at), h.level, AUDIO_THREAD, h.target.as_str(), &message));
|
||||
}
|
||||
let more = records.saturating_sub(usize::from(held.is_some()));
|
||||
if more > 0 {
|
||||
let thread = std::thread::current();
|
||||
let note = format!("{more} more log records from the realtime audio thread were not written (only the first is kept)");
|
||||
lines.push_str(&format_line(×tamp(SystemTime::now()), log::Level::Warn, thread.name().unwrap_or("?"), module_path!(), ¬e));
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
|
||||
pub struct AppLogger {
|
||||
filter: Filter,
|
||||
stderr: bool,
|
||||
sink: Mutex<Sink>,
|
||||
realtime: Realtime,
|
||||
}
|
||||
|
||||
impl AppLogger {
|
||||
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
|
||||
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), realtime: Realtime::default() }
|
||||
}
|
||||
|
||||
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
|
||||
/// An unusable directory leaves standard error as the only sink ([`AppLogger::no_file`]).
|
||||
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
|
||||
match rotate(dir) {
|
||||
Ok((path, file)) => {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
|
||||
Ok(path)
|
||||
}
|
||||
Err(e) => {
|
||||
self.no_file();
|
||||
Err(format!("{}: {e}", dir.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// No log file this run (no settings directory, `SOUNDCRAFT_NO_PREFS`, or the directory can't
|
||||
/// be used): stop keeping records for one.
|
||||
pub fn no_file(&self) {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file();
|
||||
}
|
||||
|
||||
/// Write what the audio thread logged since the last report (never call it on the audio thread).
|
||||
pub fn report_audio_thread(&self) {
|
||||
let lines = self.realtime.take_lines();
|
||||
if !lines.is_empty() {
|
||||
self.emit(&lines);
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard error, then the sink; the lock covers one write of already formatted text.
|
||||
fn emit(&self, text: &str) {
|
||||
if self.stderr {
|
||||
// No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting.
|
||||
let _ = std::io::stderr().write_all(text.as_bytes());
|
||||
}
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(text);
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for AppLogger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
metadata.level() <= self.filter.level_for(metadata.target())
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
if soundcraft_playback::on_audio_thread() {
|
||||
self.realtime.keep(record);
|
||||
return;
|
||||
}
|
||||
self.report_audio_thread();
|
||||
// Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock.
|
||||
let thread = std::thread::current();
|
||||
let line =
|
||||
format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
self.emit(&line);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
|
||||
/// logger was installed first. Call [`AppLogger::attach_dir`] or [`AppLogger::no_file`] once the
|
||||
/// settings directory is known.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
|
||||
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
|
||||
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
|
||||
let max = filter.max();
|
||||
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
|
||||
log::set_logger(logger).ok()?;
|
||||
log::set_max_level(max);
|
||||
Some(logger)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use log::Log;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("soundcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &str) {
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
}
|
||||
|
||||
/// Run `f` on a thread the playback crate marks as the realtime audio thread.
|
||||
fn on_audio_thread<T: Send>(f: impl FnOnce() -> T + Send) -> T {
|
||||
std::thread::scope(|s| {
|
||||
s.spawn(|| {
|
||||
soundcraft_playback::mark_audio_thread();
|
||||
f()
|
||||
})
|
||||
.join()
|
||||
.expect("audio thread")
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_soundcraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("soundcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("soundcraft::control_server"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("soundcraft_playback"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("soundcraft_clap_host::ffi"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("cpal"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,soundcraft_mix=trace,soundcraft_mix::pdc=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("soundcraft_mix::render"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("soundcraft_mix::pdc"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("warn,soundcraft_ui*=debug").level_for("soundcraft_ui_egui::mix_window"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("warn,soundcraft_ui*=debug").level_for("soundcraft_engine"), LevelFilter::Warn);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive; the last bare level wins.
|
||||
assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
let long = "a".repeat(100_000);
|
||||
let specs = [
|
||||
"",
|
||||
",,,",
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"soundcraft=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=debug",
|
||||
"a=b=c",
|
||||
"warn,,soundcraft=DEBUG",
|
||||
" \t ",
|
||||
"\u{0}",
|
||||
"é*=trace",
|
||||
&long,
|
||||
];
|
||||
for spec in specs {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["soundcraft", "", "🦀", "é", "a::b"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,soundcraft=DEBUG").level_for("soundcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped");
|
||||
assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target");
|
||||
assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("soundcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("soundcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("soundcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
assert!(rotate(&dir.join("below")).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line(
|
||||
"2026-10-08T07:59:17.728Z",
|
||||
log::Level::Warn,
|
||||
"main",
|
||||
"soundcraft_mix",
|
||||
"eq_7band: the plugin did not accept its stored state"
|
||||
),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] soundcraft_mix: eq_7band: the plugin did not accept its stored state\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_max_pending_lines_drop_nothing() {
|
||||
let dir = temp_dir("exact");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), MAX_PENDING);
|
||||
assert!(!text.contains("dropped"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
||||
assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
record(&logger, log::Level::Info, "soundcraft", "SoundCraft starting");
|
||||
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(&logger, log::Level::Debug, "soundcraft", "below info");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("soundcraft: SoundCraft starting")), "{text}");
|
||||
assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("soundcraft_engine").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// A second launch rotates: the first log becomes `.1`.
|
||||
let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE));
|
||||
assert_eq!(read(&dir.join("soundcraft.1.log")), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_from_many_threads_are_whole_lines() {
|
||||
let dir = temp_dir("threads");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
std::thread::scope(|s| {
|
||||
for _ in 0..4 {
|
||||
s.spawn(|| {
|
||||
for _ in 0..100 {
|
||||
record(&logger, log::Level::Info, "soundcraft", "from a worker");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 400);
|
||||
assert!(text.lines().all(|l| l.ends_with("soundcraft: from a worker")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
record(&logger, log::Level::Info, "soundcraft", "kept for a file");
|
||||
let e = logger.attach_dir(&dir).expect_err("a file is not a log directory");
|
||||
assert!(e.contains(&dir.display().to_string()), "{e}");
|
||||
record(&logger, log::Level::Error, "x", "still fine");
|
||||
let sink = logger.sink.lock().expect("sink");
|
||||
assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come");
|
||||
drop(sink);
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_without_a_log_file_keeps_nothing_for_one() {
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
record(&logger, log::Level::Info, "soundcraft", "early");
|
||||
logger.no_file();
|
||||
record(&logger, log::Level::Info, "soundcraft", "late");
|
||||
let sink = logger.sink.lock().expect("sink");
|
||||
assert!(sink.file.is_none() && sink.pending.is_empty() && sink.dropped == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_lock_does_not_stop_logging() {
|
||||
let dir = temp_dir("poison");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _guard = logger.sink.lock();
|
||||
panic!("poison the sink");
|
||||
}));
|
||||
assert!(logger.sink.is_poisoned());
|
||||
record(&logger, log::Level::Info, "soundcraft", "after the panic");
|
||||
assert!(read(&path).contains("after the panic"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// Realtime audio: the audio thread never takes the sink lock or touches the file.
|
||||
|
||||
#[test]
|
||||
fn an_audio_thread_record_never_waits_for_the_sink() {
|
||||
let dir = temp_dir("rt-nowait");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
// Another thread holds the sink (a slow disk, say) while the audio thread logs.
|
||||
let held = logger.sink.lock().expect("sink");
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::scope(|s| {
|
||||
s.spawn(|| {
|
||||
soundcraft_playback::mark_audio_thread();
|
||||
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: device unplugged");
|
||||
let _ = tx.send(());
|
||||
});
|
||||
let done = rx.recv_timeout(Duration::from_secs(10)).is_ok();
|
||||
drop(held);
|
||||
assert!(done, "the audio thread blocked on the sink lock");
|
||||
});
|
||||
assert_eq!(read(&path), "", "nothing is written from the audio thread itself");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_audio_thread_record_is_written_later_with_a_count_of_the_rest() {
|
||||
let dir = temp_dir("rt-report");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
on_audio_thread(|| {
|
||||
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: device unplugged");
|
||||
for _ in 0..41 {
|
||||
record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: still unplugged");
|
||||
}
|
||||
// Filtered out: neither kept nor counted.
|
||||
record(&logger, log::Level::Debug, "soundcraft_clap_host::plugin", "process failed");
|
||||
});
|
||||
assert_eq!(read(&path), "");
|
||||
logger.report_audio_thread();
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines.first().is_some_and(|l| l.contains(" WARN [audio] soundcraft_playback: audio stream error: device unplugged")), "{text}");
|
||||
assert!(lines.get(1).is_some_and(|l| l.contains("41 more log records from the realtime audio thread were not written")), "{text}");
|
||||
// Reported once.
|
||||
logger.report_audio_thread();
|
||||
assert_eq!(read(&path), text);
|
||||
// The next record from any other thread reports what the audio thread kept since, first.
|
||||
on_audio_thread(|| record(&logger, log::Level::Error, "soundcraft_clap_host::ffi", "clap plugin: out of voices"));
|
||||
record(&logger, log::Level::Info, "soundcraft", "UI thread");
|
||||
let text = read(&path);
|
||||
let tail: Vec<&str> = text.lines().skip(2).collect();
|
||||
assert_eq!(tail.len(), 2, "{text}");
|
||||
assert!(tail.first().is_some_and(|l| l.contains(" ERROR [audio] soundcraft_clap_host::ffi: clap plugin: out of voices")), "{text}");
|
||||
assert!(tail.get(1).is_some_and(|l| l.ends_with("soundcraft: UI thread")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_audio_thread_record_is_cut_at_a_character_boundary() {
|
||||
let dir = temp_dir("rt-long");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let long = "é".repeat(REALTIME_MESSAGE_BYTES);
|
||||
on_audio_thread(|| record(&logger, log::Level::Warn, "soundcraft", &long));
|
||||
logger.report_audio_thread();
|
||||
let text = read(&path);
|
||||
let message = text.split("soundcraft: ").nth(1).expect("message");
|
||||
assert!(message.ends_with("…\n"), "{text}");
|
||||
assert!(message.len() <= REALTIME_MESSAGE_BYTES + "…\n".len(), "{}", message.len());
|
||||
assert!(message.trim_end_matches(['…', '\n']).chars().all(|c| c == 'é'));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,10 +5,14 @@
|
|||
//! `--control <port>` (or `SOUNDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server
|
||||
//! (`0` picks a free port and prints it): `{"id":1,"method":"ui.inspect","params":{}}` →
|
||||
//! `{"id":1,"ok":true,"result":…}`. See `soundcraft_ui_egui::control` for the methods.
|
||||
//!
|
||||
//! `log` records go to standard error and `<settings dir>/logs/soundcraft.log` (`RUST_LOG` sets the
|
||||
//! levels); see [`logging`].
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod control_server;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
|
||||
|
|
@ -16,17 +20,21 @@ use soundcraft_engine::Engine;
|
|||
use soundcraft_ui_egui::{Services, SoundApp, UiState};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct App(SoundApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// What the realtime audio thread logged is written here, off that thread.
|
||||
if let Some(logger) = self.1 {
|
||||
logger.report_audio_thread();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(menu) = &mut self.1 {
|
||||
if let Some(menu) = &mut self.2 {
|
||||
menu.process(&mut self.0);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(menu) = &mut self.1 {
|
||||
if let Some(menu) = &mut self.2 {
|
||||
menu.sync(&self.0);
|
||||
}
|
||||
// Files dropped on the window: sessions open, audio/MIDI import.
|
||||
|
|
@ -60,6 +68,9 @@ impl eframe::App for App {
|
|||
}
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0.ui);
|
||||
if let Some(logger) = self.1 {
|
||||
logger.report_audio_thread();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -78,8 +89,9 @@ fn open_path(app: &mut SoundApp, p: &str) {
|
|||
}
|
||||
}
|
||||
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
let base = if cfg!(target_os = "macos") {
|
||||
/// The per-user settings directory: `ui.json`, `Autosave/`, `Presets/` and `logs/` live here.
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/SoundCraft"))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("SoundCraft"))
|
||||
|
|
@ -88,12 +100,20 @@ fn prefs_path() -> Option<std::path::PathBuf> {
|
|||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join("soundcraft"))
|
||||
};
|
||||
base.map(|b| b.join("ui.json"))
|
||||
}
|
||||
}
|
||||
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
config_dir().map(|b| b.join("ui.json"))
|
||||
}
|
||||
|
||||
/// Runs without preferences (`SOUNDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
|
||||
fn prefs_enabled() -> bool {
|
||||
std::env::var_os("SOUNDCRAFT_NO_PREFS").is_none()
|
||||
}
|
||||
|
||||
fn load_prefs() -> Option<UiState> {
|
||||
if std::env::var_os("SOUNDCRAFT_NO_PREFS").is_some() {
|
||||
if !prefs_enabled() {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(prefs_path()?).ok()?;
|
||||
|
|
@ -101,7 +121,7 @@ fn load_prefs() -> Option<UiState> {
|
|||
}
|
||||
|
||||
fn save_prefs(ui: &UiState) {
|
||||
if std::env::var_os("SOUNDCRAFT_NO_PREFS").is_some() {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = prefs_path() {
|
||||
|
|
@ -153,14 +173,14 @@ fn app_icon() -> Option<egui::IconData> {
|
|||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so every start-up record (and the engine's panic hook, installed with the first
|
||||
// Engine) is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.iter().any(|a| a == "--version" || a == "-V") {
|
||||
println!("SoundCraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
if std::env::var_os("RUST_LOG").is_none() {
|
||||
// Quiet by default; `RUST_LOG=info` for more.
|
||||
}
|
||||
let demo = args.iter().any(|a| a == "--demo" || a == "--sample");
|
||||
let no_audio = args.iter().any(|a| a == "--no-audio");
|
||||
let control_port: Option<u16> = args
|
||||
|
|
@ -175,6 +195,24 @@ fn main() -> eframe::Result {
|
|||
.filter(|(i, a)| !a.starts_with("--") && !(i > &0 && args.get(i - 1).is_some_and(|p| p == "--control")))
|
||||
.map(|(_, a)| a.clone())
|
||||
.collect();
|
||||
log::info!("SoundCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
|
||||
// The log file lives in the settings directory; opened after the arguments, so `--version`
|
||||
// leaves no file behind. Records logged until now are written to it first. Runs without
|
||||
// preferences (agents' test runs) log to standard error only, so they don't rotate away the
|
||||
// user's own logs.
|
||||
if let Some(logger) = logger {
|
||||
match config_dir() {
|
||||
_ if !prefs_enabled() => logger.no_file(),
|
||||
Some(dir) => match logger.attach_dir(&dir.join(logging::LOG_DIR)) {
|
||||
Ok(path) => log::info!("log file {}", path.display()),
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
},
|
||||
None => {
|
||||
logger.no_file();
|
||||
log::warn!("no log file: no settings directory (HOME, XDG_CONFIG_HOME or APPDATA is not set)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
|
||||
let mut options = eframe::NativeOptions {
|
||||
|
|
@ -221,6 +259,7 @@ fn main() -> eframe::Result {
|
|||
};
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
logger,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu,
|
||||
)))
|
||||
|
|
|
|||
|
|
@ -21,3 +21,13 @@
|
|||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.hamada147]
|
||||
real_name = "Ahmed Moussa"
|
||||
display_name = "Ahmed Moussa"
|
||||
sync_github_name = true
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -1000,12 +1011,47 @@ fn structure_fingerprint(s: &Session) -> usize {
|
|||
h.finish() as usize
|
||||
}
|
||||
|
||||
std::thread_local! {
|
||||
static AUDIO_THREAD: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Mark the calling thread as the realtime audio thread (`soundcraft_playback`'s cpal callbacks
|
||||
/// do, every call: one thread-local store). Strips [`MixEngine::render`] hands to worker threads
|
||||
/// count as the audio thread while they render for it. Code that might run there asks
|
||||
/// [`on_audio_thread`] before doing anything that blocks; the desktop app's logger does, so a
|
||||
/// `log::` record from the audio thread never takes a lock or touches a file.
|
||||
pub fn mark_audio_thread() {
|
||||
AUDIO_THREAD.set(true);
|
||||
}
|
||||
|
||||
/// True on the thread [`mark_audio_thread`] marked, and on a worker while it renders strips for
|
||||
/// it. Lock-free and allocation-free.
|
||||
pub fn on_audio_thread() -> bool {
|
||||
AUDIO_THREAD.get()
|
||||
}
|
||||
|
||||
/// Run `f` on this thread as (or as not) the audio thread, then restore the thread's own mark
|
||||
/// (also when `f` unwinds): a pool worker renders for realtime playback and offline bounces alike.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn as_audio_thread(audio: bool, f: impl FnOnce()) {
|
||||
struct Restore(bool);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
AUDIO_THREAD.set(self.0);
|
||||
}
|
||||
}
|
||||
let _restore = Restore(AUDIO_THREAD.replace(audio));
|
||||
f();
|
||||
}
|
||||
|
||||
/// Process strips, in parallel on native targets when there are enough of them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn run_strips<T: Send>(work: &mut [T], f: impl Fn(&mut T) + Sync + Send) {
|
||||
use rayon::prelude::*;
|
||||
if work.len() >= 4 {
|
||||
work.par_iter_mut().for_each(f);
|
||||
// The workers render on behalf of this thread: on the audio thread, they are it too.
|
||||
let audio = on_audio_thread();
|
||||
work.par_iter_mut().for_each(|w| as_audio_thread(audio, || f(w)));
|
||||
} else {
|
||||
work.iter_mut().for_each(f);
|
||||
}
|
||||
|
|
@ -1075,6 +1121,67 @@ fn receives_solo(s: &Session, t: &Track) -> bool {
|
|||
.any(|o| o.mixer.solo && (o.mixer.output == Route::Bus(*b) || o.mixer.sends.iter().flatten().any(|sn| sn.target == Route::Bus(*b))))
|
||||
}
|
||||
|
||||
/// Metronome: a short synthesized blip on every beat of the tempo map, accented on beat 1 of
|
||||
/// each bar. Settings come from `setup.click_countoff` (`click.*` values).
|
||||
mod click {
|
||||
use super::*;
|
||||
|
||||
/// Blip length in seconds.
|
||||
const LEN_S: f64 = 0.03;
|
||||
|
||||
pub fn render(s: &Session, pos: Samples, frames: usize, main: &mut [Vec<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) };
|
||||
|
|
@ -1642,6 +1749,50 @@ fn create_plugin(id: &str) -> Option<Box<dyn Plugin>> {
|
|||
.or_else(|| soundcraft_au_host::create(id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod audio_thread_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_a_marked_thread_is_the_audio_thread() {
|
||||
assert!(!on_audio_thread());
|
||||
let marked = std::thread::spawn(|| {
|
||||
let before = on_audio_thread();
|
||||
mark_audio_thread();
|
||||
(before, on_audio_thread())
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
assert_eq!(marked, (false, true));
|
||||
assert!(!on_audio_thread(), "marking another thread leaves this one alone");
|
||||
}
|
||||
|
||||
/// What `on_audio_thread` says inside each strip job, for strips run from a thread that is
|
||||
/// (or is not) the audio thread.
|
||||
fn seen_by_strips(audio: bool) -> Vec<bool> {
|
||||
std::thread::spawn(move || {
|
||||
if audio {
|
||||
mark_audio_thread();
|
||||
}
|
||||
let mut work = vec![None; 64];
|
||||
run_strips(&mut work, |w| {
|
||||
// Long enough that the pool's workers take part.
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
*w = Some(on_audio_thread());
|
||||
});
|
||||
work.into_iter().map(|w| w.unwrap()).collect()
|
||||
})
|
||||
.join()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_rendered_for_the_audio_thread_count_as_the_audio_thread_and_only_then() {
|
||||
assert!(seen_by_strips(true).iter().all(|&rt| rt), "a worker rendering for the audio thread is on it");
|
||||
assert!(seen_by_strips(false).iter().all(|&rt| !rt), "the workers don't stay marked afterwards");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -1934,6 +2085,76 @@ mod tests {
|
|||
assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_monitoring_does_not_click() {
|
||||
let mut s = Session::default();
|
||||
s.edit.click = true;
|
||||
let mut eng = MixEngine::new(48_000.0, 512);
|
||||
eng.metronome = true;
|
||||
eng.monitor_only = true;
|
||||
let mut out = vec![vec![0.0f32; 512]; 2];
|
||||
for _ in 0..4 {
|
||||
eng.render(&s, 0, 512, &mut out);
|
||||
assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders `len` frames from zero the way playback does (metronome on), in blocks of `block`.
|
||||
fn render_playback(s: &Session, len: usize, block: usize, recording: bool) -> Vec<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();
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ use std::sync::{Arc, Mutex, PoisonError};
|
|||
|
||||
const BLOCK: usize = 512;
|
||||
|
||||
/// The realtime audio thread marker lives in the mix engine, which carries it into the strips it
|
||||
/// renders on worker threads; the cpal callbacks here set it.
|
||||
pub use soundcraft_mix::{mark_audio_thread, on_audio_thread};
|
||||
|
||||
/// Meter values for the UI.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MeterSnapshot {
|
||||
|
|
@ -99,6 +103,7 @@ struct AudioState {
|
|||
fn new_engine(sr: f32) -> MixEngine {
|
||||
let mut m = MixEngine::new(sr, BLOCK);
|
||||
m.set_external_instances(true);
|
||||
m.metronome = true;
|
||||
m
|
||||
}
|
||||
|
||||
|
|
@ -222,7 +227,7 @@ impl AudioState {
|
|||
if self.mix.input.len() != ch {
|
||||
self.mix.input = vec![vec![0.0; BLOCK]; ch];
|
||||
}
|
||||
ring.pop_into(&mut self.mix.input, n);
|
||||
ring.pop_into(&mut self.mix.input, n, self.session.sample_rate.hz());
|
||||
}
|
||||
self.mix.monitor_only = !self.playing;
|
||||
self.mix.recording = self.recording;
|
||||
|
|
@ -390,11 +395,19 @@ impl Player {
|
|||
let rate = config.sample_rate.0;
|
||||
let channels = usize::from(config.channels);
|
||||
let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate), gtx);
|
||||
let err_fn = |e: cpal::StreamError| log::warn!("audio stream error: {e}");
|
||||
// cpal may call this on the audio thread (on ALSA in a loop while the device
|
||||
// stays broken): marked, so the logger hands the record off instead of writing it.
|
||||
let err_fn = |e: cpal::StreamError| {
|
||||
mark_audio_thread();
|
||||
log::warn!("audio stream error: {e}");
|
||||
};
|
||||
use cpal::traits::{DeviceTrait, StreamTrait};
|
||||
match device.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| state.fill(data, channels),
|
||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||
mark_audio_thread();
|
||||
state.fill(data, channels);
|
||||
},
|
||||
err_fn,
|
||||
None,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -50,13 +50,14 @@ impl Recorder {
|
|||
let config = cfg.config();
|
||||
let shared = Arc::new(Shared { armed: AtomicBool::new(false), data: Mutex::new(Vec::new()) });
|
||||
let sh = Arc::clone(&shared);
|
||||
let monitor = InputRing::new(usize::from(config.channels));
|
||||
let monitor = InputRing::new(usize::from(config.channels), config.sample_rate.0);
|
||||
let mon = Arc::clone(&monitor);
|
||||
let mut backlog: Vec<f32> = Vec::new();
|
||||
let stream = device
|
||||
.build_input_stream(
|
||||
&config,
|
||||
move |data: &[f32], _: &cpal::InputCallbackInfo| {
|
||||
crate::mark_audio_thread();
|
||||
mon.push(data);
|
||||
if !sh.armed.load(Ordering::Relaxed) {
|
||||
backlog.clear();
|
||||
|
|
@ -72,7 +73,11 @@ impl Recorder {
|
|||
backlog.extend_from_slice(data);
|
||||
}
|
||||
},
|
||||
|e| log::warn!("input stream error: {e}"),
|
||||
|e| {
|
||||
// Possibly the audio thread (see `crate::mark_audio_thread`).
|
||||
crate::mark_audio_thread();
|
||||
log::warn!("input stream error: {e}");
|
||||
},
|
||||
None,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
|
@ -140,47 +145,133 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Ring state behind the lock: interleaved input frames at the input device's rate, plus the
|
||||
/// fractional read position (in input frames) of the next output sample.
|
||||
struct RingState {
|
||||
data: std::collections::VecDeque<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") {
|
||||
|
|
|
|||
|
|
@ -234,6 +234,16 @@ pub struct SoundApp {
|
|||
pub video: video_track::VideoPool,
|
||||
}
|
||||
|
||||
/// Whether another automation-write pass is due: the transport moved at least
|
||||
/// `step` since `last` in either direction. `last == i64::MIN` marks "never
|
||||
/// written", so the first pass after (re)start is always due. Saturating
|
||||
/// arithmetic keeps restarts and loop wraps overflow-free: the previous
|
||||
/// `(pos - last).abs()` panicked in debug builds on the first playing frame,
|
||||
/// when `last` is still `i64::MIN`.
|
||||
fn automation_step_due(pos: Samples, last: Samples, step: Samples) -> bool {
|
||||
last == i64::MIN || pos.saturating_sub(last) >= step || last.saturating_sub(pos) >= step
|
||||
}
|
||||
|
||||
impl SoundApp {
|
||||
pub fn new(engine: Engine, player: Option<Player>, services: Services) -> Self {
|
||||
SoundApp {
|
||||
|
|
@ -295,6 +305,10 @@ impl SoundApp {
|
|||
if id == "app.quit" {
|
||||
self.quit_requested = true;
|
||||
}
|
||||
if id == "options.scrolling" {
|
||||
// Re-selecting a scrolling mode resumes the playhead follow.
|
||||
self.edit_layout.follow_hold = false;
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -323,6 +337,12 @@ impl SoundApp {
|
|||
}
|
||||
self.engine.transport.playing = true;
|
||||
self.engine.transport.position = from;
|
||||
// A fresh start resumes the playhead follow: clear a manual hold and
|
||||
// re-sync the follow tracker so the current view is not mistaken for
|
||||
// an outside move on the first frame.
|
||||
self.edit_layout.follow_hold = false;
|
||||
self.edit_layout.last_scroll = self.engine.session().edit.zoom.scroll;
|
||||
self.edit_layout.last_follow_to = None;
|
||||
}
|
||||
|
||||
fn stop_play(&mut self) {
|
||||
|
|
@ -335,11 +355,33 @@ impl SoundApp {
|
|||
self.sim = None;
|
||||
self.engine.transport.playing = false;
|
||||
self.engine.transport.recording = false;
|
||||
let s = self.engine.session();
|
||||
if s.edit.insertion_follows_playback {
|
||||
let stop_at = self.engine.transport.position;
|
||||
// Snapshot the session flags first: `session_mut` below needs a
|
||||
// mutable borrow, so no session borrow may span it.
|
||||
let (follow_insertion, after_playback) = {
|
||||
let s = self.engine.session();
|
||||
(s.edit.insertion_follows_playback, s.edit.scrolling == "after_playback")
|
||||
};
|
||||
if follow_insertion {
|
||||
let pos = self.engine.transport.position;
|
||||
self.engine.session_mut().edit.selection = Range::point(pos);
|
||||
}
|
||||
if after_playback {
|
||||
// After Playback never scrolls while playing, so bring the stopped
|
||||
// position into view now (only if it is off-screen); every other
|
||||
// mode keeps its view.
|
||||
let at = stop_at.max(0);
|
||||
let (scroll, spp) = {
|
||||
let z = &self.engine.session().edit.zoom;
|
||||
(z.scroll, z.samples_per_px.max(0.01))
|
||||
};
|
||||
let width = f64::from(self.edit_layout.timeline[2] - self.edit_layout.timeline[0]).max(0.0);
|
||||
let end = scroll.saturating_add((width * spp) as Samples);
|
||||
if at < scroll || at >= end {
|
||||
let _ = self.engine.execute("view.scroll", &json!({ "to": at }));
|
||||
}
|
||||
}
|
||||
self.edit_layout.follow_hold = false;
|
||||
}
|
||||
|
||||
fn start_recording(&mut self) {
|
||||
|
|
@ -534,7 +576,7 @@ impl SoundApp {
|
|||
}
|
||||
let pos = self.position();
|
||||
let step = self.engine.session().sample_rate.samples(0.02);
|
||||
if (pos - self.last_write_at).abs() < step {
|
||||
if !automation_step_due(pos, self.last_write_at, step) {
|
||||
return;
|
||||
}
|
||||
self.last_write_at = pos;
|
||||
|
|
@ -829,3 +871,27 @@ fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) {
|
|||
}
|
||||
d.gr = if gr > d.gr { gr } else { d.gr * fall };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::automation_step_due;
|
||||
|
||||
#[test]
|
||||
fn automation_first_pass_after_start_is_due_without_overflow() {
|
||||
// `last_write_at` starts (and resets) at `i64::MIN`: the previous
|
||||
// `(pos - last).abs()` panicked here in debug builds.
|
||||
assert!(automation_step_due(0, i64::MIN, 960));
|
||||
assert!(automation_step_due(48_000, i64::MIN, 960));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_write_throttles_to_step() {
|
||||
assert!(!automation_step_due(1_000, 900, 960));
|
||||
assert!(automation_step_due(2_000, 900, 960));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_write_fires_after_loop_wrap() {
|
||||
assert!(automation_step_due(0, 1_000_000, 960));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
|
|
|
|||
|
|
@ -41,6 +41,12 @@ process in parallel; plugin delay compensation aligns every path. The same engin
|
|||
bounces offline (`render_range`) and realtime playback (`soundcraft_playback::Player`, which owns
|
||||
it on the audio thread and receives new session snapshots through a channel).
|
||||
|
||||
The cpal callbacks mark their thread (`soundcraft_playback::mark_audio_thread`). Code that may run
|
||||
there can ask `on_audio_thread()` before doing anything that blocks; the desktop app's logger does,
|
||||
so a `log::` record from the audio thread (a stream error, a full synth event queue, a hosted
|
||||
plugin's failed `process`, a CLAP plugin's own log call) is kept without waiting and written later
|
||||
by the UI thread (see README › Logs).
|
||||
|
||||
## Agent control
|
||||
|
||||
The desktop app's control channel (JSON lines over TCP) exposes engine commands, inspection,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue