Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| deb5fc6e8f | |||
| 6299bd1db3 | |||
| 1eb21f7293 | |||
| e27bb59b0c | |||
| 09172264e9 | |||
| 097fbd0c8a | |||
| 30944cd0ab |
@@ -0,0 +1,12 @@
|
||||
# Strip upstream (ArtCraft / Discord) branding. Run: perl tools/craft-skills/skills/debrand/debrand.pl . (--check in CI)
|
||||
delete docs/brand
|
||||
# MIT/Apache texts stay verbatim. The copyright holder line stays everywhere (legal requirement).
|
||||
keep ^(LICENSE-(MIT|APACHE)|\.debrand)$
|
||||
dropline ^ATTRIBUTION\.md$ :: \|\s*`docs/brand/
|
||||
sub (?s)\nThe ArtCraft name, wordmark and logos in docs/brand/.*?LICENSE-brand\.txt\.\n =>
|
||||
sub \n- The ArtCraft trademarks in `docs/brand/` are the only exception, under `docs/brand/LICENSE-brand\.txt`\. =>
|
||||
sub until the owner draws SoundCraft's icon in ArtCraft like => until the owner draws SoundCraft's icon like
|
||||
sub https://getartcraft\.com/apps/soundcraft => https://github.com/storytold/soundcraft
|
||||
sub https://discord\.gg/artcraft => https://github.com/storytold/soundcraft/issues
|
||||
forbid (?i)artcraft(?! Team and the SoundCraft contributors)
|
||||
forbid (?i)discord
|
||||
@@ -3,20 +3,16 @@
|
||||
# player falls back to its silent clock when the VM has no sound device.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
|
||||
# or dependency change: a drifted dependency still shows up within a day, and always before a
|
||||
# release ships. PRs that change the FreeBSD packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "47 3 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "crates/playback/**"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -34,9 +34,3 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
|
||||
@@ -4,17 +4,19 @@
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
|
||||
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "53 5 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "tools/craft-skills"]
|
||||
path = tools/craft-skills
|
||||
url = https://git.marcospaulo.dev.br/storytold/craft-skills.git
|
||||
@@ -16,7 +16,6 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
|
||||
- **Every bundled asset gets a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (path, author, source, license) in the same commit that adds it. `cargo xtask assets` enforces this; CI fails otherwise.
|
||||
- Never copy files out of the Pro Tools bundle (`/Applications/Pro Tools.app`): no plugins, presets, icons, sounds, session templates or text. Observing the running app (screenshots kept under `plan/`, menu names) is fine; copying its resources is not.
|
||||
- Demo audio is synthesised in code (`crates/engine/src/demo.rs`). Demo and README media must be ours or public domain.
|
||||
- The ArtCraft trademarks in `docs/brand/` are the only exception, under `docs/brand/LICENSE-brand.txt`.
|
||||
|
||||
## Clean-room
|
||||
Pro Tools is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic sessions, take screenshots by window id (`screencapture -l <id>`), stored only under `plan/protools/screenshots/`. Never read, disassemble or copy anything inside the Pro Tools bundle; never copy Avid icons, artwork, plugin names, presets, or wording beyond feature names. Never copy GPL/AGPL/LGPL code (Ardour, Audacity, LMMS, Freeverb sources…). Our plugins have our own names.
|
||||
|
||||
@@ -29,14 +29,6 @@ and, at runtime, the operating system's own UI fonts when available (never redis
|
||||
|
||||
| Path | Author | Source | License |
|
||||
|---|---|---|---|
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `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 |
|
||||
|
||||
@@ -785,7 +785,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
|
||||
source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=22544b6d619d4f16779b8bdfa3f0b642a86fc065#22544b6d619d4f16779b8bdfa3f0b642a86fc065"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
@@ -2142,18 +2142,6 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
@@ -3424,14 +3412,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"soundcraft-engine",
|
||||
@@ -3442,7 +3428,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3452,7 +3438,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -3464,7 +3450,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -3474,7 +3460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading",
|
||||
@@ -3486,7 +3472,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
@@ -3497,7 +3483,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -3508,7 +3494,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -3525,12 +3511,11 @@ dependencies = [
|
||||
"soundcraft-video",
|
||||
"soundcraft-vst3-host",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3540,7 +3525,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -3556,7 +3541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3569,7 +3554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
@@ -3582,7 +3567,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3592,7 +3577,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -3618,7 +3603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"proptest",
|
||||
@@ -3629,7 +3614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
"log",
|
||||
@@ -3644,16 +3629,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"soundcraft-engine",
|
||||
"soundcraft-model",
|
||||
"soundcraft-playback",
|
||||
"soundcraft-ui-egui",
|
||||
"wasm-bindgen",
|
||||
@@ -4113,12 +4094,6 @@ dependencies = [
|
||||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -5201,7 +5176,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -5409,19 +5384,6 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
|
||||
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.1"
|
||||
version = "0.5.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -38,12 +38,10 @@ proptest = "1"
|
||||
# audio
|
||||
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
|
||||
rustfft = "6"
|
||||
# Session bundles (one file holding a session and its audio, for the browser): stored entries only.
|
||||
zip = { version = "9", default-features = false }
|
||||
cpal = "0.16"
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065", features = ["serde"] }
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
|
||||
@@ -3,10 +3,6 @@ Copyright (c) 2026 ArtCraft Team and the SoundCraft contributors.
|
||||
Licensed under the MIT License or the Apache License, Version 2.0, at your option
|
||||
(LICENSE-MIT, LICENSE-APACHE).
|
||||
|
||||
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
|
||||
not open source and are not covered by the license above; their terms are in
|
||||
docs/brand/LICENSE-brand.txt.
|
||||
|
||||
Third-party material
|
||||
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
|
||||
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">SoundCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
@@ -27,27 +18,12 @@
|
||||
<img alt="Status: pre-alpha" src="https://img.shields.io/badge/status-pre--alpha-orange?style=flat-square">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/apps/soundcraft"><b>SoundCraft on getartcraft.com</b></a> ·
|
||||
<a href="https://getartcraft.com/">ArtCraft</a> ·
|
||||
<a href="https://getartcraft.com/apps">All Crafting Apps</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="SoundCraft's Edit window with a nine-track session: drums, bass, pad, keys and lead, a reverb aux and a master fader" src="docs/images/edit.png" width="100%">
|
||||
<br>
|
||||
<sub>The Edit window: tracks, waveforms, MIDI, rulers, markers and the transport, playing the built-in demo song (every sound in it is synthesised by SoundCraft itself).</sub>
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> **ArtCraft is a community of artists from all walks of life.** Painters, photographers,
|
||||
> filmmakers, illustrators, designers, animators, hobbyists, and people who picked up a pencil
|
||||
> last week. If you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
|
||||
|
||||
<p align="center">
|
||||
<a href="#what-it-does">What it does</a> ·
|
||||
<a href="#getting-started">Getting started</a> ·
|
||||
@@ -107,8 +83,7 @@ where things are.
|
||||
Scrubber tools; cut, copy, paste, duplicate, repeat, shift, insert silence, separate (at
|
||||
selection, on grid, at transients), heal, consolidate, strip silence, nudge; fades and
|
||||
crossfades in five shapes, drawn straight from the clip corners; playlists with comping lanes;
|
||||
clip gain lines; edit groups; single-key Commands Focus shortcuts; trackpad scrolling that
|
||||
follows your fingers and pinch-to-zoom around the pointer; undo for everything.
|
||||
clip gain lines; edit groups; single-key Commands Focus shortcuts; undo for everything.
|
||||
- **Mixing:** fader and pan with automation, pre/post sends, busses, aux inputs, master faders,
|
||||
VCA masters, routing folders, solo modes, mute and solo groups, phase invert and trim, clip
|
||||
effects, and automatic plugin delay compensation.
|
||||
@@ -117,8 +92,7 @@ where things are.
|
||||
- **Plugins:** 7-band and 1-band EQ, compressor/limiter, expander/gate, de-esser, maximizer,
|
||||
channel strip, room and plate reverbs, mod delay, chorus, flanger, phaser, saturator, lo-fi,
|
||||
rectifier, pitch shifter, time shift, gain, trim, invert, DC removal, signal generator, dither,
|
||||
a subtractive synth, a drum synth and a sampler (plays any WAV, AIFF, FLAC, MP3 or OGG across the
|
||||
keyboard), all original. **CLAP plugins** load too. Any processor
|
||||
a subtractive synth and a drum synth, all original. **CLAP plugins** load too. Any processor
|
||||
runs offline as AudioSuite, and user presets save per plugin.
|
||||
- **MIDI:** instrument and MIDI tracks, a piano-roll MIDI editor with a velocity lane, an event
|
||||
list, a score view, step input, quantize, transpose, real-time properties, Standard MIDI Files,
|
||||
@@ -248,7 +222,7 @@ estimates are in [`ROADMAP.md`](ROADMAP.md); the full assessment is in
|
||||
[`docs/target-app-parity.md`](docs/target-app-parity.md), the ranked list of what's missing in
|
||||
[`docs/gaps.md`](docs/gaps.md), and the menu-by-menu comparison in
|
||||
[`docs/parity-checklist.md`](docs/parity-checklist.md). Bug reports and wish lists are
|
||||
very welcome, in the issues or on Discord.
|
||||
very welcome, in the issues.
|
||||
|
||||
## Downloads
|
||||
|
||||
@@ -279,10 +253,6 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
|
||||
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
|
||||
and web backend defaults are unchanged.
|
||||
|
||||
DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcompiler.dll` or
|
||||
`dxil.dll` is needed ("Parent device is lost" at start, #185). `$env:WGPU_DX12_COMPILER = "dxc"`
|
||||
(or `"auto"`) selects wgpu's DXC handling again for troubleshooting.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
@@ -317,44 +287,22 @@ RISC-V (riscv64): `soundcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (glibc
|
||||
## The Crafting Apps
|
||||
|
||||
SoundCraft is one of the **Crafting Apps**: free, open-source creative tools from the
|
||||
[ArtCraft](https://getartcraft.com/) team, each written from scratch in Rust and each able to
|
||||
[storytold](https://github.com/storytold) team, each written from scratch in Rust and each able to
|
||||
stand on its own.
|
||||
|
||||
| | App | What it's for | Code | Learn more |
|
||||
|:-:|---|---|---|---|
|
||||
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | **Recording, editing and mixing audio · you are here** | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
| | App | What it's for | Code |
|
||||
|:-:|---|---|---|
|
||||
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | **Recording, editing and mixing audio · you are here** | [GitHub](https://github.com/storytold/soundcraft) |
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
|
||||
</p>
|
||||
|
||||
<h3 align="center">Come make things with us</h3>
|
||||
|
||||
<p align="center">
|
||||
Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film,
|
||||
set type, and people still figuring out what they like to make. Share what you're working on,
|
||||
ask for help, tell us what's broken, or tell us what you wish these tools could do.
|
||||
Whatever your medium and however long you've been at it, you're welcome here.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
|
||||
<a href="https://getartcraft.com/">getartcraft.com</a> ·
|
||||
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
|
||||
<a href="https://getartcraft.com/apps/soundcraft">SoundCraft</a>
|
||||
</p>
|
||||
|
||||
## License and credits
|
||||
|
||||
SoundCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
|
||||
@@ -367,18 +315,8 @@ Every icon in the interface is drawn in code, the app icon is original, and the
|
||||
screenshots' audio are synthesised by SoundCraft itself, so there are no samples or artwork from
|
||||
anyone else in this repository.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
|
||||
part of this repository and SoundCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Avid and Pro Tools are trademarks or registered trademarks of Avid Technology, Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Avid Technology, Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
|
||||
|
||||
@@ -20,12 +20,11 @@ use soundcraft_engine::Engine;
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
|
||||
/// Print without panicking on a closed pipe. A write error must not skip later work
|
||||
/// such as `--bounce` or `--save`, and must not by itself report success.
|
||||
/// Print without panicking on a closed pipe.
|
||||
macro_rules! outln {
|
||||
($($t:tt)*) => {{
|
||||
let mut o = std::io::stdout().lock();
|
||||
let _ = writeln!(o, $($t)*);
|
||||
if writeln!(o, $($t)*).is_err() { return ExitCode::SUCCESS; }
|
||||
}};
|
||||
}
|
||||
|
||||
@@ -188,7 +187,7 @@ fn finish(e: &mut Engine, args: &[String]) -> ExitCode {
|
||||
}
|
||||
}
|
||||
if let Some(path) = arg_value(args, "--save") {
|
||||
match e.execute("session.save", &json!({"path": path, "replace": true})) {
|
||||
match e.execute("session.save", &json!({"path": path})) {
|
||||
Ok(v) => outln!("{v}"),
|
||||
Err(err) => return fail(err),
|
||||
}
|
||||
@@ -295,39 +294,6 @@ fn describe(args: &[String]) -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Control-channel methods. Every other dotted id is an engine command.
|
||||
fn is_control_method(method: &str) -> bool {
|
||||
matches!(
|
||||
method,
|
||||
"engine.execute"
|
||||
| "command"
|
||||
| "engine.commands"
|
||||
| "engine.parity"
|
||||
| "session.inspect"
|
||||
| "document.inspect"
|
||||
| "ui.inspect"
|
||||
| "ui.menu.list"
|
||||
| "ui.menu.invoke"
|
||||
| "ui.set"
|
||||
| "ui.move"
|
||||
| "ui.click"
|
||||
| "ui.drag"
|
||||
| "ui.key"
|
||||
| "ui.text"
|
||||
| "ui.screenshot"
|
||||
| "app.quit"
|
||||
)
|
||||
}
|
||||
|
||||
/// A command id is shorthand for `engine.execute`. Documented control methods stay methods.
|
||||
fn app_request(method: &str, params: Value) -> (String, Value) {
|
||||
if is_control_method(method) || !method.contains('.') {
|
||||
(method.to_string(), params)
|
||||
} else {
|
||||
("engine.execute".to_string(), json!({"command": method, "params": params}))
|
||||
}
|
||||
}
|
||||
|
||||
fn app(args: &[String]) -> ExitCode {
|
||||
let port = arg_value(args, "--port").or_else(|| std::env::var("SOUNDCRAFT_CONTROL_PORT").ok()).unwrap_or_else(|| "7979".into());
|
||||
let positional: Vec<&String> = args
|
||||
@@ -345,8 +311,17 @@ fn app(args: &[String]) -> ExitCode {
|
||||
None => json!({}),
|
||||
};
|
||||
let mut r = Remote::new(&port);
|
||||
// A bare command id is shorthand for engine.execute. Control-channel methods stay methods.
|
||||
let (m, p) = app_request(method, params);
|
||||
// A bare command id is shorthand for engine.execute.
|
||||
let (m, p) = if method.contains('.')
|
||||
&& !method.starts_with("ui.")
|
||||
&& !method.starts_with("engine.")
|
||||
&& !method.starts_with("session.")
|
||||
&& !method.starts_with("app.")
|
||||
{
|
||||
("engine.execute".to_string(), json!({"command": method, "params": params}))
|
||||
} else {
|
||||
(method.to_string(), params)
|
||||
};
|
||||
match r.call(&m, p) {
|
||||
Ok(v) => {
|
||||
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
@@ -405,23 +380,6 @@ fn parity(args: &[String]) -> ExitCode {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_ids_are_execute_shorthand_and_control_methods_stay_methods() {
|
||||
let (m, p) = app_request("engine.history", json!({}));
|
||||
assert_eq!(m, "engine.execute");
|
||||
assert_eq!(p["command"], "engine.history");
|
||||
let (m, p) = app_request("ui.theme", json!({"mode": "light"}));
|
||||
assert_eq!(m, "engine.execute");
|
||||
assert_eq!(p["params"]["mode"], "light");
|
||||
let (m, _) = app_request("session.new", json!({"name": "Session A"}));
|
||||
assert_eq!(m, "engine.execute");
|
||||
let (m, _) = app_request("track.new", json!({}));
|
||||
assert_eq!(m, "engine.execute");
|
||||
for method in ["session.inspect", "engine.commands", "engine.parity", "ui.screenshot", "app.quit"] {
|
||||
assert_eq!(app_request(method, json!({})).0, method);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_media_gets_one_warning_each() {
|
||||
assert!(missing_warnings(&[]).is_empty());
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
//! A closed stdout must not abort `run` or `script` before `--bounce` and `--save`.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
fn bin() -> Command {
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
|
||||
cmd.env("SOUNDCRAFT_NO_PREFS", "1");
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Drop the read end so the child's stdout writes fail with `EPIPE`.
|
||||
fn closed_stdout() -> std::process::Stdio {
|
||||
let (reader, writer) = std::io::pipe().expect("pipe");
|
||||
drop(reader);
|
||||
Stdio::from(writer)
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("sc-cli-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_with_closed_stdout_still_bounces_and_saves() {
|
||||
let dir = temp_dir("run");
|
||||
let wav = dir.join("mix.wav");
|
||||
let session = dir.join("Synthetic.scraft");
|
||||
let output = bin()
|
||||
.arg("run")
|
||||
.arg("--cmd")
|
||||
.arg(r#"track.new={"name":"Synthetic","count":1,"format":"stereo"}"#)
|
||||
.arg("--bounce")
|
||||
.arg(&wav)
|
||||
.arg("--save")
|
||||
.arg(&session)
|
||||
.arg("--start")
|
||||
.arg("0")
|
||||
.arg("--end")
|
||||
.arg("0.01")
|
||||
.stdout(closed_stdout())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(wav.is_file(), "bounce was skipped");
|
||||
assert!(session.is_file(), "save was skipped");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_with_closed_stdout_still_bounces_and_saves() {
|
||||
let dir = temp_dir("script");
|
||||
let script = dir.join("edit.scraft.txt");
|
||||
std::fs::write(&script, "track.new {\"name\":\"Synthetic\",\"count\":1,\"format\":\"stereo\"}\n").unwrap();
|
||||
let wav = dir.join("mix.wav");
|
||||
let session = dir.join("Synthetic.scraft");
|
||||
let output = bin()
|
||||
.arg("script")
|
||||
.arg(&script)
|
||||
.arg("--bounce")
|
||||
.arg(&wav)
|
||||
.arg("--save")
|
||||
.arg(&session)
|
||||
.arg("--start")
|
||||
.arg("0")
|
||||
.arg("--end")
|
||||
.arg("0.01")
|
||||
.stdout(closed_stdout())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(wav.is_file());
|
||||
assert!(session.is_file());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_then_save_with_closed_stdout_writes_the_wav() {
|
||||
let dir = temp_dir("bounce");
|
||||
let wav = dir.join("mix.wav");
|
||||
let session = dir.join("Empty.scraft");
|
||||
let output = bin()
|
||||
.arg("run")
|
||||
.arg("--bounce")
|
||||
.arg(&wav)
|
||||
.arg("--save")
|
||||
.arg(&session)
|
||||
.arg("--start")
|
||||
.arg("0")
|
||||
.arg("--end")
|
||||
.arg("0.01")
|
||||
.stdout(closed_stdout())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(wav.is_file());
|
||||
assert!(session.is_file());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_bounce_destination_still_fails() {
|
||||
let dir = temp_dir("bad-bounce");
|
||||
let missing = dir.join("no-such-dir").join("mix.wav");
|
||||
let output = bin()
|
||||
.arg("run")
|
||||
.arg("--bounce")
|
||||
.arg(&missing)
|
||||
.arg("--start")
|
||||
.arg("0")
|
||||
.arg("--end")
|
||||
.arg("0.01")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success(), "a bounce that cannot be written must fail");
|
||||
assert!(!missing.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -17,14 +17,10 @@ path = "src/main.rs"
|
||||
soundcraft-engine = { workspace = true }
|
||||
soundcraft-ui-egui = { workspace = true }
|
||||
soundcraft-playback = { workspace = true }
|
||||
soundcraft-model = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
js-sys = "0.3"
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "CssStyleDeclaration"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location"] }
|
||||
|
||||
@@ -7,10 +7,7 @@
|
||||
//!
|
||||
//! Differences from the desktop app:
|
||||
//! - no TCP control channel (browsers can't listen on sockets);
|
||||
//! - no file system: File > Open Session…, Import > Audio…/MIDI… and files dropped on the page are
|
||||
//! read as bytes through the browser's file picker; a session is saved as ONE bundle file
|
||||
//! (`<name>.scraftz`: the `.scraft` and its `Audio Files/`), and every save or export arrives as a
|
||||
//! download (the demo session is still loaded on start);
|
||||
//! - no native file dialogs: the demo session is loaded on start;
|
||||
//! - browsers only start audio after a user gesture, so the first click may be needed before
|
||||
//! playback is heard.
|
||||
//!
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
//! The browser shell: the eframe web runner around [`SoundApp`].
|
||||
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::{Engine, io};
|
||||
use soundcraft_ui_egui::dialogs::Dialog;
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
@@ -14,7 +10,6 @@ const LOADING_ID: &str = "soundcraft_loading";
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
io::set_deliver(download);
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
|
||||
log::error!("no document");
|
||||
@@ -41,7 +36,7 @@ pub fn start() {
|
||||
let engine = if query().contains("empty") { Engine::default() } else { soundcraft_engine::demo::demo_engine() };
|
||||
let player = soundcraft_playback::Player::new(Arc::new(engine.session().clone()));
|
||||
let app = SoundApp::new(engine, Some(player), Services::default());
|
||||
Ok(Box::new(WebShell { app, inbox: Rc::default() }))
|
||||
Ok(Box::new(WebShell(app)))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -58,182 +53,18 @@ fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// What a file read from the browser is for.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Purpose {
|
||||
Session,
|
||||
Audio,
|
||||
Midi,
|
||||
}
|
||||
|
||||
const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION];
|
||||
const AUDIO_EXTS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"];
|
||||
const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"];
|
||||
|
||||
impl Purpose {
|
||||
/// A dropped file goes where its extension says: sessions open, MIDI and audio import.
|
||||
fn of(name: &str) -> Self {
|
||||
let ext = name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).unwrap_or_default();
|
||||
if SESSION_EXTS.contains(&ext.as_str()) {
|
||||
Self::Session
|
||||
} else if MIDI_EXTS.contains(&ext.as_str()) {
|
||||
Self::Midi
|
||||
} else {
|
||||
Self::Audio
|
||||
}
|
||||
}
|
||||
|
||||
/// The command whose path prompt this replaces in the browser.
|
||||
fn for_command(cmd: &str) -> Option<Self> {
|
||||
match cmd {
|
||||
"session.open" => Some(Self::Session),
|
||||
"file.import_audio" => Some(Self::Audio),
|
||||
"file.import_midi" => Some(Self::Midi),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn filter(self) -> (&'static str, &'static [&'static str]) {
|
||||
match self {
|
||||
Self::Session => ("SoundCraft Session", SESSION_EXTS),
|
||||
Self::Audio => ("Audio", AUDIO_EXTS),
|
||||
Self::Midi => ("MIDI", MIDI_EXTS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Files picked or dropped, read asynchronously (browsers can only read files that way) and
|
||||
/// handled on the next frame. The browser has no paths: files travel as bytes.
|
||||
type Inbox = Rc<RefCell<Vec<(Purpose, String, Vec<u8>)>>>;
|
||||
|
||||
struct WebShell {
|
||||
app: SoundApp,
|
||||
inbox: Inbox,
|
||||
}
|
||||
|
||||
impl WebShell {
|
||||
/// File > Open Session… / Import > Audio… / MIDI… would show a text box for a path, which the
|
||||
/// browser can't read (#63, #64). Show the browser's file picker instead.
|
||||
fn take_path_prompt(&mut self, ctx: &egui::Context) {
|
||||
let purpose = match &self.app.dialogs.open {
|
||||
Some(Dialog::PathPrompt { cmd, .. }) => Purpose::for_command(cmd),
|
||||
_ => None,
|
||||
};
|
||||
let Some(purpose) = purpose else { return };
|
||||
self.app.dialogs.open = None;
|
||||
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let (label, exts) = purpose.filter();
|
||||
let Some(file) = rfd::AsyncFileDialog::new().add_filter(label, exts).pick_file().await else { return };
|
||||
let bytes = file.read().await;
|
||||
inbox.borrow_mut().push((purpose, file.file_name(), bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
fn take_drops(&mut self, ctx: &egui::Context) {
|
||||
// In the browser a dropped file's `path()` is only its name: read its bytes instead.
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "Audio.wav".into());
|
||||
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox.borrow_mut().push((Purpose::of(&name), name, bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) {
|
||||
let e = &mut self.app.engine;
|
||||
let at = e.session().edit.selection.start.max(0);
|
||||
let result = match purpose {
|
||||
Purpose::Session => {
|
||||
let _ = self.app.run("transport.stop", json!({}));
|
||||
io::open_session_bytes(&mut self.app.engine, name, bytes).map(|missing| {
|
||||
if missing.is_empty() { format!("Opened {name}") } else { format!("Opened {name} ({} missing media)", missing.len()) }
|
||||
})
|
||||
}
|
||||
// Imports are edits: one undo step each, like the import commands.
|
||||
Purpose::Audio => {
|
||||
let before = e.session_arc();
|
||||
io::import_audio_bytes(e, name, bytes, None, None, at).map(|_| {
|
||||
e.push_undo("Import Audio", before);
|
||||
format!("Imported {name}")
|
||||
})
|
||||
}
|
||||
Purpose::Midi => {
|
||||
let before = e.session_arc();
|
||||
io::import_midi_bytes(e, bytes, at, true).map(|_| {
|
||||
e.push_undo("Import MIDI", before);
|
||||
format!("Imported {name}")
|
||||
})
|
||||
}
|
||||
};
|
||||
self.app.ui.status = result.unwrap_or_else(|err| err.to_string());
|
||||
}
|
||||
}
|
||||
struct WebShell(SoundApp);
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.take_drops(ctx);
|
||||
self.take_path_prompt(ctx);
|
||||
let pending = std::mem::take(&mut *self.inbox.borrow_mut());
|
||||
for (purpose, name, bytes) in pending {
|
||||
self.handle(purpose, &name, &bytes);
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
// A prompt opened during this frame becomes a picker on the next one.
|
||||
self.take_path_prompt(ctx);
|
||||
self.0.logic(ctx);
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
self.0.raw_input_hook(raw);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui);
|
||||
self.0.ui(ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand a saved or exported file to the person as a download named `name` (the engine's delivery
|
||||
/// hook on the web).
|
||||
fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("download failed: {e:?}");
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type(match name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).as_deref() {
|
||||
Some("scraftz") => "application/zip",
|
||||
Some("wav") => "audio/wav",
|
||||
Some("aif" | "aiff") => "audio/aiff",
|
||||
Some("flac") => "audio/flac",
|
||||
Some("mid" | "midi") => "audio/midi",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("txt") => "text/plain",
|
||||
Some("json" | "scraft") => "application/json",
|
||||
Some("musicxml") => "application/vnd.recordare.musicxml+xml",
|
||||
_ => "application/octet-stream",
|
||||
});
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
a.style().set_property("display", "none").map_err(js)?;
|
||||
document.body().ok_or("no body")?.append_child(&a).map_err(js)?;
|
||||
a.click();
|
||||
a.remove();
|
||||
// Revoke after the click was dispatched; the download keeps its own reference.
|
||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -28,5 +28,3 @@ winresource = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { version = "0.19.2", default-features = false }
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] }
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
|
||||
|
||||
use eframe::{
|
||||
NativeOptions,
|
||||
egui_wgpu::WgpuSetup,
|
||||
wgpu::{Backends, Dx12Compiler},
|
||||
};
|
||||
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
|
||||
|
||||
/// Default to DirectX 12 with FXC before creating the wgpu instance. Explicit
|
||||
/// `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choices, read by the caller, take precedence.
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>, compiler_override: Option<Dx12Compiler>) {
|
||||
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
|
||||
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
// eframe's default includes GL. Creating that backend can crash inside AMD's
|
||||
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
|
||||
@@ -16,16 +12,6 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>
|
||||
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
|
||||
// workaround without mutating process environment variables.
|
||||
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
|
||||
// Auto loads whichever dxcompiler.dll the DLL search path finds; SoundCraft ships
|
||||
// none, and without a matching dxil.dll the unsigned-DXIL warning fails wgpu's
|
||||
// indirect-validation pipeline and loses the device before the first frame
|
||||
// (issue #185). FXC uses the compiler that is part of Windows.
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler = compiler_override.unwrap_or(Dx12Compiler::Fxc);
|
||||
log::info!(
|
||||
"Windows graphics: backends={:?}, DX12 compiler={:?}",
|
||||
create.instance_descriptor.backends,
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +21,7 @@ mod tests {
|
||||
|
||||
fn configured_backends(backend_override: Option<Backends>) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure(&mut options, backend_override, None);
|
||||
configure(&mut options, backend_override);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
@@ -52,38 +38,6 @@ mod tests {
|
||||
assert_eq!(configured_backends(None), Backends::DX12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_default_uses_fxc_instead_of_searching_for_dxc() {
|
||||
let mut options = NativeOptions::default();
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
// Auto can find an unrelated DXC without its matching DXIL runtime (issue #185).
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler = Dx12Compiler::Auto;
|
||||
}
|
||||
configure(&mut options, None, None);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_compiler_override_is_preserved() {
|
||||
for compiler in [Dx12Compiler::Fxc, Dx12Compiler::Auto, Dx12Compiler::DynamicDxc { dxc_path: "custom/dxcompiler.dll".into() }] {
|
||||
let mut options = NativeOptions::default();
|
||||
configure(&mut options, None, Some(compiler.clone()));
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
match (compiler, create.instance_descriptor.backend_options.dx12.shader_compiler) {
|
||||
(Dx12Compiler::Fxc, Dx12Compiler::Fxc) | (Dx12Compiler::Auto, Dx12Compiler::Auto) => {}
|
||||
(Dx12Compiler::DynamicDxc { dxc_path: expected }, Dx12Compiler::DynamicDxc { dxc_path: actual }) => {
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
(expected, actual) => panic!("compiler override changed from {expected:?} to {actual:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_backend_override_is_preserved() {
|
||||
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
|
||||
|
||||
@@ -20,20 +20,11 @@ mod logging;
|
||||
mod native_menu;
|
||||
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, UiState, dialogs};
|
||||
use soundcraft_ui_egui::{Services, SoundApp, UiState};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
|
||||
/// Sends the title only when it changes, remembered here as X11 and Wayland can't read it back.
|
||||
fn set_title(ctx: &egui::Context, title: String) {
|
||||
let id = egui::Id::new("window_title");
|
||||
if ctx.data(|d| d.get_temp::<String>(id)).as_deref() != Some(title.as_str()) {
|
||||
ctx.data_mut(|d| d.insert_temp(id, title.clone()));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -57,16 +48,7 @@ impl eframe::App for App {
|
||||
}
|
||||
if self.0.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
} else if ctx.input(|i| i.viewport().close_requested()) && dialogs::ask_to_save(&mut self.0, "app.quit", &serde_json::json!({})) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
}
|
||||
soundcraft_ui_egui::fit_window_to_screen(ctx);
|
||||
let (inner, maximized) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().maximized.unwrap_or(false)));
|
||||
self.0.ui.window_maximized = maximized;
|
||||
if let Some(r) = inner.filter(|_| !maximized) {
|
||||
self.0.ui.window_size = (r.size() * ctx.zoom_factor()).round().into();
|
||||
}
|
||||
self.0.ui.ui_scale = self.0.extra.ui_scale;
|
||||
// With the system menu bar there is no egui bar showing the window and dirty state, so the
|
||||
// title carries them; elsewhere the egui menu bar already does.
|
||||
let title = if self.0.native_menu_bar {
|
||||
@@ -79,7 +61,7 @@ impl eframe::App for App {
|
||||
} else {
|
||||
format!("{} — SoundCraft", self.0.engine.session().name)
|
||||
};
|
||||
set_title(ctx, title);
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
@@ -102,7 +84,7 @@ impl eframe::App for App {
|
||||
fn open_path(app: &mut SoundApp, p: &str) {
|
||||
let lower = p.to_ascii_lowercase();
|
||||
let r = if lower.ends_with(".scraft") {
|
||||
dialogs::run_or_ask(app, "session.open", serde_json::json!({"path": p}))
|
||||
app.run("session.open", serde_json::json!({"path": p}))
|
||||
} else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") {
|
||||
app.run("file.import_midi", serde_json::json!({"path": p}))
|
||||
} else {
|
||||
@@ -153,6 +135,17 @@ fn save_prefs(ui: &UiState) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows and Linux: no OS title bar; the menu bar row is the title bar
|
||||
/// (`soundcraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated top row.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
/// Whether this start draws its own title bar: Windows and Linux do, unless Settings ▸ Appearance ▸
|
||||
/// Use System Title Bar asks for the system's. Read from the saved settings before the window
|
||||
/// opens; a missing or unreadable file keeps the default.
|
||||
fn custom_titlebar() -> bool {
|
||||
CUSTOM_TITLEBAR && !load_prefs().map(|u| u.system_title_bar).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|purpose: &str, _exts: &[&str]| {
|
||||
@@ -175,9 +168,6 @@ fn services() -> Services {
|
||||
}
|
||||
d.save_file().map(|p| p.to_string_lossy().into_owned())
|
||||
})),
|
||||
pick_folder: Some(Box::new(|start: &str| {
|
||||
rfd::FileDialog::new().set_directory(start).pick_folder().map(|p| p.to_string_lossy().into_owned())
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,33 +224,39 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
|
||||
let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
|
||||
let prefs = load_prefs().unwrap_or_default();
|
||||
let custom_titlebar = custom_titlebar();
|
||||
let mut options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("SoundCraft")
|
||||
.with_inner_size(sane_window_size(prefs.window_size))
|
||||
.with_maximized(prefs.window_maximized)
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!custom_titlebar)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id("ai.storyteller.soundcraft"),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(icon) = app_icon() {
|
||||
options.viewport = options.viewport.with_icon(icon);
|
||||
}
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
eframe::run_native(
|
||||
"SoundCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let player = if no_audio { None } else { Some(soundcraft_playback::Player::new(Arc::new(engine.session().clone()))) };
|
||||
let mut app = SoundApp::new(engine, player, services());
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave")));
|
||||
app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets")));
|
||||
app.extra.ui_scale = prefs.ui_scale;
|
||||
app.ui = prefs;
|
||||
if let Some(ui) = load_prefs() {
|
||||
app.ui = ui;
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
@@ -290,25 +286,12 @@ fn main() -> eframe::Result {
|
||||
)
|
||||
}
|
||||
|
||||
/// A saved window size, or the default when the prefs file holds nonsense (NaN, negative, tiny).
|
||||
fn sane_window_size([w, h]: [f32; 2]) -> [f32; 2] {
|
||||
if w.is_finite() && h.is_finite() && w >= 960.0 && h >= 600.0 { [w.min(16_384.0), h.min(16_384.0)] } else { [1600.0, 1000.0] }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::set_title;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_title_is_sent_only_when_it_changes() {
|
||||
let ctx = egui::Context::default();
|
||||
let titles = |title: &str| {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| set_title(ui.ctx(), title.to_string()));
|
||||
out.textures_delta.clear();
|
||||
out.viewport_output.values().flat_map(|v| &v.commands).filter(|c| matches!(c, egui::ViewportCommand::Title(_))).count()
|
||||
};
|
||||
assert_eq!(titles("Song — SoundCraft"), 1);
|
||||
assert_eq!(titles("Song — SoundCraft"), 0);
|
||||
assert_eq!(titles("Song * — SoundCraft"), 1);
|
||||
fn custom_titlebar_defaults_true_on_non_mac() {
|
||||
assert_eq!(CUSTOM_TITLEBAR, !cfg!(target_os = "macos"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! macOS system menu bar. All actions use the same menu dispatch as the egui host.
|
||||
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::NSApplication;
|
||||
use muda::{CheckMenuItem, Menu, MenuEvent, PredefinedMenuItem, Submenu};
|
||||
use soundcraft_ui_egui::{SoundApp, menus};
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
@@ -38,9 +36,8 @@ impl NativeMenu {
|
||||
app_menu.append(&PredefinedMenuItem::separator())?;
|
||||
app_menu.append(&PredefinedMenuItem::services(None))?;
|
||||
app_menu.append(&PredefinedMenuItem::separator())?;
|
||||
// muda's own Hide items take ⌘H and ⌥⌘H, which are Heal Separation and Copy to Send.
|
||||
app_menu.append(&MenuItem::with_id("hide", "Hide SoundCraft", true, None))?;
|
||||
app_menu.append(&MenuItem::with_id("hide_others", "Hide Others", true, None))?;
|
||||
app_menu.append(&PredefinedMenuItem::hide(Some("Hide SoundCraft")))?;
|
||||
app_menu.append(&PredefinedMenuItem::hide_others(None))?;
|
||||
app_menu.append(&PredefinedMenuItem::show_all(None))?;
|
||||
app_menu.append(&PredefinedMenuItem::separator())?;
|
||||
native.add_item(app, &app_menu, "Quit SoundCraft", "", Some("app.quit".into()))?;
|
||||
@@ -85,12 +82,7 @@ impl NativeMenu {
|
||||
while let Ok(event) = self.events.try_recv() {
|
||||
// A click changes state (and Cocoa flips the item's check mark): sync on the next pass.
|
||||
self.last_sync = None;
|
||||
if (event.id == "hide" || event.id == "hide_others")
|
||||
&& let Some(mtm) = MainThreadMarker::new()
|
||||
{
|
||||
let ns = NSApplication::sharedApplication(mtm);
|
||||
if event.id == "hide" { ns.hide(None) } else { ns.hideOtherApplications(None) }
|
||||
} else if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id)
|
||||
if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id)
|
||||
&& let Some(command) = &entry.command
|
||||
&& menus::item_state(app, &entry.path, Some(command)).0
|
||||
{
|
||||
@@ -113,11 +105,6 @@ impl NativeMenu {
|
||||
self.last_sync = Some(now);
|
||||
for entry in &mut self.entries {
|
||||
let (enabled, checked) = menus::item_state(app, &entry.path, entry.command.as_deref());
|
||||
if let Some(label) = menus::live_label(app, entry.command.as_deref())
|
||||
&& entry.item.text() != label
|
||||
{
|
||||
entry.item.set_text(label);
|
||||
}
|
||||
if enabled != entry.enabled {
|
||||
entry.item.set_enabled(enabled);
|
||||
entry.enabled = enabled;
|
||||
|
||||
@@ -5,7 +5,7 @@ owl-template framing (craftrules `standards/icon-design.md`): a full-bleed colou
|
||||
in three-quarter view with its bill open and its eye on the viewer, its breast and wing running off
|
||||
the bottom and right edges, three song arcs leaving the bill.
|
||||
|
||||
This is a **placeholder** until the owner draws SoundCraft's icon in ArtCraft like the other apps'
|
||||
This is a **placeholder** until the owner draws SoundCraft's icon like the other apps'
|
||||
icons. Replace `icon.svg` and run `packaging/icons.sh`; nothing else needs to change.
|
||||
|
||||
## Palette
|
||||
|
||||
@@ -35,8 +35,3 @@ sync_github_name = false
|
||||
[people.salignatmoandal]
|
||||
real_name = "Mawen Salignat-Moandal"
|
||||
sync_github_name = true
|
||||
|
||||
[people.vbasky]
|
||||
real_name = "Vikram Bhaskaran"
|
||||
display_name = "Vikram Bhaskaran"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
//! MP4, CAF, Matroska, ADPCM/A-law/µ-law WAV, ...) through `symphonia`.
|
||||
//! * [`encode`] writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32`, and FLAC (built-in
|
||||
//! encoder), with optional TPDF dither.
|
||||
//! * [`wav_float_header`] heads a float WAV written while it grows (recording).
|
||||
//! * [`peaks`] builds multi-resolution min/max overviews for waveform drawing.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -290,13 +289,3 @@ pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u
|
||||
pub fn wav_channel_mask(bytes: &[u8]) -> Option<u32> {
|
||||
wav::read_channel_mask(bytes)
|
||||
}
|
||||
|
||||
/// The header of a 32-bit float WAV holding `frames` frames (little-endian samples follow it). Its
|
||||
/// length doesn't depend on `frames`, so a recorder can rewrite it in place as the file grows; it
|
||||
/// switches to RF64 above 4 GiB.
|
||||
pub fn wav_float_header(channels: usize, sample_rate: u32, frames: u64) -> Result<Vec<u8>> {
|
||||
if channels == 0 || sample_rate == 0 {
|
||||
return Err(AudioError::Encode("a WAV needs channels and a sample rate".into()));
|
||||
}
|
||||
wav::header(channels, sample_rate, (32, true), None, None, frames, false)
|
||||
}
|
||||
|
||||
@@ -276,8 +276,60 @@ pub(crate) fn encode(
|
||||
BitDepth::Float32 => (32, true),
|
||||
};
|
||||
let bytes = usize::from(bits / 8);
|
||||
let head = header(channels, buf.sample_rate, (bits, is_float), bwf, mask, frames as u64, force_rf64)?;
|
||||
let data_len = (bytes * channels).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
let block = bytes.checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
|
||||
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
|
||||
let data_len = block.checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
|
||||
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
|
||||
|
||||
let mut fmt = Vec::with_capacity(40);
|
||||
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
|
||||
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
|
||||
fmt.extend_from_slice(&buf.sample_rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&buf.sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
|
||||
fmt.extend_from_slice(&block_align.to_le_bytes());
|
||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||
if extensible {
|
||||
fmt.extend_from_slice(&22u16.to_le_bytes());
|
||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||
fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes());
|
||||
fmt.extend_from_slice(&fmt_tag.to_le_bytes());
|
||||
fmt.extend_from_slice(&GUID_TAIL);
|
||||
}
|
||||
|
||||
let mut head = Vec::with_capacity(1024);
|
||||
let rf64_body_len = 28usize;
|
||||
// Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place).
|
||||
let mut meta = Vec::new();
|
||||
if let Some(bwf) = bwf {
|
||||
push_chunk(&mut meta, b"bext", &bext_chunk(bwf));
|
||||
}
|
||||
push_chunk(&mut meta, b"fmt ", &fmt);
|
||||
if is_float {
|
||||
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
|
||||
}
|
||||
let total = 4 + (8 + rf64_body_len) + meta.len() + 8 + data_len + (data_len & 1);
|
||||
let rf64 = force_rf64 || u32::try_from(total).is_err();
|
||||
|
||||
if rf64 {
|
||||
head.extend_from_slice(b"RF64");
|
||||
head.extend_from_slice(&u32::MAX.to_le_bytes());
|
||||
head.extend_from_slice(b"WAVE");
|
||||
let mut ds64 = Vec::with_capacity(rf64_body_len);
|
||||
ds64.extend_from_slice(&(total as u64).to_le_bytes());
|
||||
ds64.extend_from_slice(&(data_len as u64).to_le_bytes());
|
||||
ds64.extend_from_slice(&(frames as u64).to_le_bytes());
|
||||
ds64.extend_from_slice(&0u32.to_le_bytes());
|
||||
push_chunk(&mut head, b"ds64", &ds64);
|
||||
} else {
|
||||
head.extend_from_slice(b"RIFF");
|
||||
head.extend_from_slice(&(total as u32).to_le_bytes());
|
||||
head.extend_from_slice(b"WAVE");
|
||||
push_chunk(&mut head, b"JUNK", &[0u8; 28]);
|
||||
}
|
||||
head.extend_from_slice(&meta);
|
||||
head.extend_from_slice(b"data");
|
||||
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
|
||||
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1))
|
||||
@@ -300,70 +352,3 @@ pub(crate) fn encode(
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Everything before the sample data of a WAV holding `frames` frames.
|
||||
pub(crate) fn header(
|
||||
channels: usize,
|
||||
sample_rate: u32,
|
||||
(bits, is_float): (u16, bool),
|
||||
bwf: Option<&BwfInfo>,
|
||||
mask: Option<u32>,
|
||||
frames: u64,
|
||||
force_rf64: bool,
|
||||
) -> Result<Vec<u8>> {
|
||||
let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
|
||||
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
|
||||
let data_len = u64::from(block_align).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
|
||||
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
|
||||
|
||||
let mut fmt = Vec::with_capacity(40);
|
||||
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
|
||||
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
|
||||
fmt.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
|
||||
fmt.extend_from_slice(&block_align.to_le_bytes());
|
||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||
if extensible {
|
||||
fmt.extend_from_slice(&22u16.to_le_bytes());
|
||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||
fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes());
|
||||
fmt.extend_from_slice(&fmt_tag.to_le_bytes());
|
||||
fmt.extend_from_slice(&GUID_TAIL);
|
||||
}
|
||||
|
||||
let mut head = Vec::with_capacity(1024);
|
||||
let rf64_body_len = 28usize;
|
||||
// Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place).
|
||||
let mut meta = Vec::new();
|
||||
if let Some(bwf) = bwf {
|
||||
push_chunk(&mut meta, b"bext", &bext_chunk(bwf));
|
||||
}
|
||||
push_chunk(&mut meta, b"fmt ", &fmt);
|
||||
if is_float {
|
||||
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
|
||||
}
|
||||
let total = (4 + (8 + rf64_body_len) + meta.len() + 8) as u64 + data_len + (data_len & 1);
|
||||
let rf64 = force_rf64 || u32::try_from(total).is_err();
|
||||
|
||||
if rf64 {
|
||||
head.extend_from_slice(b"RF64");
|
||||
head.extend_from_slice(&u32::MAX.to_le_bytes());
|
||||
head.extend_from_slice(b"WAVE");
|
||||
let mut ds64 = Vec::with_capacity(rf64_body_len);
|
||||
ds64.extend_from_slice(&total.to_le_bytes());
|
||||
ds64.extend_from_slice(&data_len.to_le_bytes());
|
||||
ds64.extend_from_slice(&frames.to_le_bytes());
|
||||
ds64.extend_from_slice(&0u32.to_le_bytes());
|
||||
push_chunk(&mut head, b"ds64", &ds64);
|
||||
} else {
|
||||
head.extend_from_slice(b"RIFF");
|
||||
head.extend_from_slice(&(total as u32).to_le_bytes());
|
||||
head.extend_from_slice(b"WAVE");
|
||||
push_chunk(&mut head, b"JUNK", &[0u8; 28]);
|
||||
}
|
||||
head.extend_from_slice(&meta);
|
||||
head.extend_from_slice(b"data");
|
||||
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
|
||||
Ok(head)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use soundcraft_audio_io::{
|
||||
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe, wav_float_header,
|
||||
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe,
|
||||
};
|
||||
|
||||
fn sine(sr: u32, channels: usize, frames: usize) -> AudioBuffer {
|
||||
@@ -268,19 +268,3 @@ fn multichannel_wav_speaker_masks() {
|
||||
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(2), &o16, 0x3).unwrap();
|
||||
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_wav_header_is_rewritten_in_place_as_the_file_grows() {
|
||||
let take = sine(48_000, 2, 1_000);
|
||||
let mut file = wav_float_header(2, 48_000, 0).unwrap();
|
||||
let len = file.len();
|
||||
file.extend(take.interleaved().iter().flat_map(|s| s.to_le_bytes()));
|
||||
assert_eq!(decode(&file, None).unwrap().1.frames(), 1_000);
|
||||
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 600).unwrap());
|
||||
assert_eq!(decode(&file, None).unwrap().1.frames(), 600);
|
||||
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 1_000).unwrap());
|
||||
assert_eq!(file, encode(&take, &EncodeOptions { bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap());
|
||||
let big = wav_float_header(2, 48_000, 600_000_000).unwrap();
|
||||
assert_eq!((&big[..4], big.len()), (&b"RF64"[..], len));
|
||||
assert!(wav_float_header(0, 48_000, 0).is_err());
|
||||
}
|
||||
|
||||
@@ -54,12 +54,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
&[],
|
||||
),
|
||||
tool("open_session", "Open a .scraft session file.", json!({"path": {"type": "string"}}), &["path"]),
|
||||
tool(
|
||||
"save_session",
|
||||
"Save the session (path required the first time; replace: true to save over another existing file).",
|
||||
json!({"path": {"type": "string"}, "replace": {"type": "boolean"}}),
|
||||
&[],
|
||||
),
|
||||
tool("save_session", "Save the session (path required the first time).", json!({"path": {"type": "string"}}), &[]),
|
||||
tool(
|
||||
"import_audio",
|
||||
"Import an audio file (WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF) onto a new or existing track.",
|
||||
@@ -132,20 +127,11 @@ impl<B: Backend> Server<B> {
|
||||
}
|
||||
"batch" => {
|
||||
let keep = args.get("keep_going").and_then(Value::as_bool).unwrap_or(false);
|
||||
let Some(calls) = args.get("calls").and_then(Value::as_array) else {
|
||||
return error_result("`calls` must be an array");
|
||||
};
|
||||
let calls = args.get("calls").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
let mut failed = false;
|
||||
for c in calls.iter().take(1000) {
|
||||
let Some(cmd) = c.get("command").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else {
|
||||
out.push(json!({"command": "", "ok": false, "error": "`command` required"}));
|
||||
failed = true;
|
||||
if !keep {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let cmd = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
let r = self.exec(cmd, c.get("params").cloned().unwrap_or(json!({})));
|
||||
match r {
|
||||
Ok(v) => out.push(json!({"command": cmd, "ok": true, "result": v})),
|
||||
@@ -158,9 +144,7 @@ impl<B: Backend> Server<B> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Any failure sets isError so agents that only check that flag notice;
|
||||
// keep_going still runs the rest and the payload lists every call.
|
||||
if failed { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) }
|
||||
if failed && !keep { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) }
|
||||
}
|
||||
"inspect_session" => self.backend.call("session.inspect", json!({"detail": a("detail")})),
|
||||
"new_session" => self.exec("session.new", args.clone()),
|
||||
|
||||
@@ -135,39 +135,3 @@ fn errors_are_reported_not_panics() {
|
||||
let r = rpc(&mut s, 2, "tools/call", json!({"name": "screenshot", "arguments": {}}));
|
||||
assert_eq!(r["result"]["isError"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_keep_going_still_sets_is_error_on_partial_failure() {
|
||||
let mut s = Server::new(Headless::new(Engine::default()));
|
||||
let r = rpc(
|
||||
&mut s,
|
||||
1,
|
||||
"tools/call",
|
||||
json!({"name": "batch", "arguments": {"keep_going": true, "calls": [
|
||||
{"command": "track.new", "params": {"count": 1, "name": "Kept"}},
|
||||
{"command": "mix.volume", "params": {"track": "Nope", "db": -6}},
|
||||
{"command": "track.new", "params": {"count": 1, "name": "After"}},
|
||||
]}}),
|
||||
);
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
let text = r["result"]["content"][0]["text"].as_str().unwrap();
|
||||
let results: Value = serde_json::from_str(text).unwrap();
|
||||
let arr = results.as_array().unwrap();
|
||||
assert_eq!(arr.len(), 3, "{results}");
|
||||
assert_eq!(arr[0]["ok"], true);
|
||||
assert_eq!(arr[1]["ok"], false);
|
||||
assert_eq!(arr[2]["ok"], true);
|
||||
let sess = session(&mut s);
|
||||
assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "Kept"));
|
||||
assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "After"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_rejects_non_array_calls() {
|
||||
let mut s = Server::new(Headless::new(Engine::default()));
|
||||
let r = rpc(&mut s, 1, "tools/call", json!({"name": "batch", "arguments": {"calls": {"command": "track.new"}}}));
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
let text = r["result"]["content"][0]["text"].as_str().unwrap();
|
||||
assert!(text.contains("`calls` must be an array"), "{text}");
|
||||
assert_eq!(session(&mut s)["tracks"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
//! SoundCraft DSP.
|
||||
//!
|
||||
//! Realtime plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utilities and three
|
||||
//! Realtime plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utilities and two
|
||||
//! instruments), metering (peak, RMS, ITU-R BS.1770 loudness), spectrum analysis and offline
|
||||
//! AudioSuite-style whole-buffer processing.
|
||||
//!
|
||||
@@ -28,8 +28,6 @@ pub use offline::FadeShape;
|
||||
pub use osc::Waveform;
|
||||
pub use plugins::eq::{eq1_response, eq7_response};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Plugin category, used to group the insert menu.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Category {
|
||||
@@ -199,13 +197,6 @@ pub trait Plugin: Send {
|
||||
fn note_on(&mut self, _offset: usize, _note: u8, _velocity: u8) {}
|
||||
fn note_off(&mut self, _offset: usize, _note: u8) {}
|
||||
fn all_notes_off(&mut self) {}
|
||||
/// Sample-playing instruments (the Sampler): the recorded audio to play, `None` to clear it.
|
||||
/// `false` when the plugin does not play samples. Does not allocate; sounding voices stop.
|
||||
/// The previous sample is dropped here, so hosts keep their own reference to it (the
|
||||
/// session's source pool does) to keep deallocation off the audio thread.
|
||||
fn set_sample(&mut self, _sample: Option<Arc<dyn SampleSource>>) -> bool {
|
||||
false
|
||||
}
|
||||
/// The plugin's complete internal state as an opaque blob (third-party plugins), for saving
|
||||
/// in the session. `None` when the plugin has no state beyond its parameters (built-ins) or
|
||||
/// the plugin refused. May allocate: never call it in steady-state audio processing.
|
||||
@@ -249,40 +240,6 @@ pub trait PluginEditor: Send {
|
||||
fn set_param(&mut self, _id: &str, _value: f32) {}
|
||||
}
|
||||
|
||||
/// Recorded audio a sample-playing instrument reads: planar `f32` at its own sample rate (the
|
||||
/// instrument converts rates as it plays). Implementations must be cheap to read from the audio
|
||||
/// thread: no locks, no I/O.
|
||||
pub trait SampleSource: Send + Sync {
|
||||
/// Sample rate of the audio in Hz.
|
||||
fn sample_rate(&self) -> f32;
|
||||
fn num_channels(&self) -> usize;
|
||||
/// One channel's frames (`None` past the last channel).
|
||||
fn channel(&self, ch: usize) -> Option<&[f32]>;
|
||||
/// Frames per channel (the shortest channel, so every index below it is readable).
|
||||
fn frames(&self) -> usize {
|
||||
(0..self.num_channels()).filter_map(|c| self.channel(c)).map(<[f32]>::len).min().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A sample held in memory (tests, generated audio).
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct SampleBuffer {
|
||||
pub sample_rate: f32,
|
||||
pub channels: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
impl SampleSource for SampleBuffer {
|
||||
fn sample_rate(&self) -> f32 {
|
||||
self.sample_rate
|
||||
}
|
||||
fn num_channels(&self) -> usize {
|
||||
self.channels.len()
|
||||
}
|
||||
fn channel(&self, ch: usize) -> Option<&[f32]> {
|
||||
self.channels.get(ch).map(Vec::as_slice)
|
||||
}
|
||||
}
|
||||
|
||||
/// The plugin registry.
|
||||
pub fn plugins() -> &'static [&'static PluginInfo] {
|
||||
plugins::REGISTRY
|
||||
@@ -341,21 +298,4 @@ mod trait_default_tests {
|
||||
assert!(p.editor().is_none(), "{}", info.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_sampler_takes_samples() {
|
||||
for info in plugins() {
|
||||
let mut p = create(info.id).unwrap();
|
||||
let s: Arc<dyn SampleSource> = Arc::new(SampleBuffer { sample_rate: 48_000.0, channels: vec![vec![0.0; 16]] });
|
||||
assert_eq!(p.set_sample(Some(s)), info.id == "sampler", "{}", info.id);
|
||||
assert_eq!(p.set_sample(None), info.id == "sampler", "{}", info.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_buffer_frames_is_the_shortest_channel() {
|
||||
let s = SampleBuffer { sample_rate: 44_100.0, channels: vec![vec![0.0; 10], vec![0.0; 7]] };
|
||||
assert_eq!(s.frames(), 7);
|
||||
assert_eq!(SampleBuffer::default().frames(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ pub mod harmonic;
|
||||
pub mod modulation;
|
||||
pub mod pitch;
|
||||
pub mod reverb;
|
||||
pub mod sampler;
|
||||
pub mod strip;
|
||||
pub mod synth;
|
||||
pub mod utility;
|
||||
@@ -42,7 +41,6 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[
|
||||
&utility::DITHER_INFO,
|
||||
&synth::SYNTH_INFO,
|
||||
&drums::DRUM_INFO,
|
||||
&sampler::SAMPLER_INFO,
|
||||
];
|
||||
|
||||
/// Constructs an (unprepared) plugin by id.
|
||||
@@ -74,7 +72,6 @@ pub(crate) fn instantiate(id: &str) -> Option<Box<dyn Plugin>> {
|
||||
"dither" => Box::new(utility::Dither::new()),
|
||||
"subtractive_synth" => Box::new(synth::SubtractiveSynth::new()),
|
||||
"drum_synth" => Box::new(drums::DrumSynth::new()),
|
||||
"sampler" => Box::new(sampler::Sampler::new()),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,817 +0,0 @@
|
||||
//! Sampler: plays one recorded sample across the keyboard, 32 voices.
|
||||
//!
|
||||
//! Each note plays the sample pitched from its root note (an octave up plays twice as fast) with
|
||||
//! 4-point cubic interpolation, which also converts the sample's own rate to the host's. Key and
|
||||
//! velocity ranges, an amp ADSR with velocity sensitivity, a sample start point, forward or
|
||||
//! ping-pong loops, and gate or one-shot playback. The host hands the audio over with
|
||||
//! [`Plugin::set_sample`]; without one the Sampler is silent.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::synth::{Adsr, AdsrRates, Event, EventKind, EventQueue, MAX_EVENTS, Stage};
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
|
||||
use crate::util::{Smoother, frames_in, ms_to_samples, sane_channels, sane_sr};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, SampleSource, Unit, db_to_gain};
|
||||
|
||||
const PLAY_MODES: &[&str] = &["Gate", "One Shot"];
|
||||
const LOOP_MODES: &[&str] = &["Off", "Forward", "Ping-Pong"];
|
||||
|
||||
const ROOT: usize = 0;
|
||||
const TRANSPOSE: usize = 1;
|
||||
const FINE: usize = 2;
|
||||
const KEY_TRACK: usize = 3;
|
||||
const KEY_LOW: usize = 4;
|
||||
const KEY_HIGH: usize = 5;
|
||||
const VEL_LOW: usize = 6;
|
||||
const VEL_HIGH: usize = 7;
|
||||
const VEL_SENS: usize = 8;
|
||||
const ATTACK: usize = 9;
|
||||
const DECAY: usize = 10;
|
||||
const SUSTAIN: usize = 11;
|
||||
const RELEASE: usize = 12;
|
||||
const PLAY_MODE: usize = 13;
|
||||
const START: usize = 14;
|
||||
const LOOP_MODE: usize = 15;
|
||||
const LOOP_START: usize = 16;
|
||||
const LOOP_END: usize = 17;
|
||||
const LEVEL: usize = 18;
|
||||
|
||||
static SAMPLER_PARAMS: [ParamInfo; 19] = [
|
||||
lin("root_note", "Root Note", 0.0, 127.0, 60.0, Unit::None),
|
||||
lin("transpose", "Transpose", -48.0, 48.0, 0.0, Unit::Semitones),
|
||||
lin("fine_tune", "Fine Tune", -100.0, 100.0, 0.0, Unit::Cents),
|
||||
toggle("key_track", "Key Tracking", true),
|
||||
lin("key_low", "Lowest Key", 0.0, 127.0, 0.0, Unit::None),
|
||||
lin("key_high", "Highest Key", 0.0, 127.0, 127.0, Unit::None),
|
||||
lin("vel_low", "Lowest Velocity", 1.0, 127.0, 1.0, Unit::None),
|
||||
lin("vel_high", "Highest Velocity", 1.0, 127.0, 127.0, Unit::None),
|
||||
lin("velocity", "Velocity Sens", 0.0, 100.0, 70.0, Unit::Percent),
|
||||
log("attack", "Attack", 0.1, 5000.0, 1.0, Unit::Ms),
|
||||
log("decay", "Decay", 1.0, 10000.0, 300.0, Unit::Ms),
|
||||
lin("sustain", "Sustain", 0.0, 100.0, 100.0, Unit::Percent),
|
||||
log("release", "Release", 1.0, 10000.0, 150.0, Unit::Ms),
|
||||
choice("play_mode", "Play Mode", PLAY_MODES, 0),
|
||||
lin("start", "Sample Start", 0.0, 100.0, 0.0, Unit::Percent),
|
||||
choice("loop_mode", "Loop", LOOP_MODES, 0),
|
||||
lin("loop_start", "Loop Start", 0.0, 100.0, 0.0, Unit::Percent),
|
||||
lin("loop_end", "Loop End", 0.0, 100.0, 100.0, Unit::Percent),
|
||||
lin("level", "Level", -48.0, 12.0, 0.0, Unit::Db),
|
||||
];
|
||||
|
||||
pub static SAMPLER_INFO: PluginInfo = PluginInfo {
|
||||
id: "sampler",
|
||||
name: "Sampler",
|
||||
short_name: "Sampler",
|
||||
category: Category::Instrument,
|
||||
params: &SAMPLER_PARAMS,
|
||||
is_instrument: true,
|
||||
audiosuite: false,
|
||||
};
|
||||
|
||||
const VOICES: usize = 32;
|
||||
/// Fastest playback step in sample frames per output frame (keeps hostile pitch bounded).
|
||||
const MAX_STEP: f64 = 256.0;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum LoopMode {
|
||||
Off,
|
||||
Forward,
|
||||
PingPong,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Voice {
|
||||
note: u8,
|
||||
/// Key still down (gate mode only; one-shot voices are never held).
|
||||
held: bool,
|
||||
gain: f32,
|
||||
/// Read position in sample frames.
|
||||
pos: f64,
|
||||
step: f64,
|
||||
forward: bool,
|
||||
looping: bool,
|
||||
env: Adsr,
|
||||
}
|
||||
|
||||
impl Voice {
|
||||
const fn new() -> Self {
|
||||
Self { note: 0, held: false, gain: 0.0, pos: 0.0, step: 1.0, forward: true, looping: false, env: Adsr::new() }
|
||||
}
|
||||
|
||||
fn active(&self) -> bool {
|
||||
self.env.stage != Stage::Idle
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
*self = Voice::new();
|
||||
}
|
||||
}
|
||||
|
||||
/// Values derived from the parameters (refreshed on every change).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Derived {
|
||||
root: f64,
|
||||
/// Transpose plus fine tune, in semitones.
|
||||
offset: f64,
|
||||
key_track: bool,
|
||||
keys: (u8, u8),
|
||||
vels: (u8, u8),
|
||||
sens: f32,
|
||||
one_shot: bool,
|
||||
start: f64,
|
||||
loop_mode: LoopMode,
|
||||
/// Loop start and end as fractions of the sample (start <= end).
|
||||
loop_span: (f64, f64),
|
||||
}
|
||||
|
||||
/// A MIDI key or velocity parameter as a whole number in `lo..=127`.
|
||||
fn whole(v: f32, lo: u8) -> u8 {
|
||||
if v.is_finite() { v.round().clamp(f32::from(lo), 127.0) as u8 } else { lo }
|
||||
}
|
||||
|
||||
fn ordered(a: u8, b: u8) -> (u8, u8) {
|
||||
(a.min(b), a.max(b))
|
||||
}
|
||||
|
||||
/// 4-point cubic (Catmull-Rom) interpolation of `x` at fractional frame `pos` (>= 0). Reads
|
||||
/// outside the slice are silence.
|
||||
#[inline]
|
||||
fn hermite(x: &[f32], pos: f64) -> f32 {
|
||||
let i = pos as usize;
|
||||
let t = (pos - i as f64) as f32;
|
||||
let at = |k: usize| x.get(k).copied().unwrap_or(0.0);
|
||||
let x0 = at(i);
|
||||
if t == 0.0 {
|
||||
return if x0.is_finite() { x0 } else { 0.0 };
|
||||
}
|
||||
let xm1 = at(i.saturating_sub(1));
|
||||
let x1 = at(i.saturating_add(1));
|
||||
let x2 = at(i.saturating_add(2));
|
||||
let c1 = 0.5 * (x1 - xm1);
|
||||
let c2 = xm1 - 2.5 * x0 + 2.0 * x1 - 0.5 * x2;
|
||||
let c3 = 0.5 * (x2 - xm1) + 1.5 * (x0 - x1);
|
||||
let y = ((c3 * t + c2) * t + c1) * t + x0;
|
||||
// A float file can hold NaN or infinities; they play as silence.
|
||||
if y.is_finite() { y } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Plays a sample across the keyboard.
|
||||
pub struct Sampler {
|
||||
p: Params,
|
||||
d: Derived,
|
||||
sr: f32,
|
||||
ch: usize,
|
||||
sample: Option<Arc<dyn SampleSource>>,
|
||||
/// Frames in the sample (0 = nothing to play).
|
||||
len: usize,
|
||||
/// The sample's own rate.
|
||||
sample_sr: f32,
|
||||
voices: [Voice; VOICES],
|
||||
events: EventQueue,
|
||||
rates: AdsrRates,
|
||||
level: Smoother,
|
||||
}
|
||||
|
||||
impl Default for Sampler {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Sampler {
|
||||
pub fn new() -> Self {
|
||||
let mut s = Self {
|
||||
p: Params::new(&SAMPLER_PARAMS),
|
||||
d: Derived {
|
||||
root: 60.0,
|
||||
offset: 0.0,
|
||||
key_track: true,
|
||||
keys: (0, 127),
|
||||
vels: (1, 127),
|
||||
sens: 0.7,
|
||||
one_shot: false,
|
||||
start: 0.0,
|
||||
loop_mode: LoopMode::Off,
|
||||
loop_span: (0.0, 1.0),
|
||||
},
|
||||
sr: 48_000.0,
|
||||
ch: 0,
|
||||
sample: None,
|
||||
len: 0,
|
||||
sample_sr: 48_000.0,
|
||||
voices: [Voice::new(); VOICES],
|
||||
events: EventQueue::new(),
|
||||
rates: AdsrRates::default(),
|
||||
level: Smoother::new(1.0),
|
||||
};
|
||||
s.update();
|
||||
s
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
let v = |i| self.p.v(i);
|
||||
let pct = |i| f64::from(v(i) / 100.0).clamp(0.0, 1.0);
|
||||
let (ls, le) = (pct(LOOP_START), pct(LOOP_END));
|
||||
self.d = Derived {
|
||||
root: f64::from(whole(v(ROOT), 0)),
|
||||
offset: f64::from(v(TRANSPOSE)) + f64::from(v(FINE)) / 100.0,
|
||||
key_track: self.p.on(KEY_TRACK),
|
||||
keys: ordered(whole(v(KEY_LOW), 0), whole(v(KEY_HIGH), 0)),
|
||||
vels: ordered(whole(v(VEL_LOW), 1), whole(v(VEL_HIGH), 1)),
|
||||
sens: (v(VEL_SENS) / 100.0).clamp(0.0, 1.0),
|
||||
one_shot: self.p.choice(PLAY_MODE) == 1,
|
||||
start: pct(START),
|
||||
loop_mode: match self.p.choice(LOOP_MODE) {
|
||||
1 => LoopMode::Forward,
|
||||
2 => LoopMode::PingPong,
|
||||
_ => LoopMode::Off,
|
||||
},
|
||||
loop_span: (ls.min(le), ls.max(le)),
|
||||
};
|
||||
self.rates = AdsrRates::new(v(ATTACK), v(DECAY), v(SUSTAIN) / 100.0, v(RELEASE), self.sr);
|
||||
self.level.set(db_to_gain(v(LEVEL)));
|
||||
}
|
||||
|
||||
/// The loop region in sample frames, when it is long enough to loop.
|
||||
fn loop_frames(&self) -> Option<(f64, f64)> {
|
||||
if self.d.loop_mode == LoopMode::Off {
|
||||
return None;
|
||||
}
|
||||
let len = self.len as f64;
|
||||
let (a, b) = (self.d.loop_span.0 * len, self.d.loop_span.1 * len);
|
||||
(b - a >= 1.0).then_some((a, b))
|
||||
}
|
||||
|
||||
fn start_note(&mut self, note: u8, vel: u8) {
|
||||
if vel == 0 {
|
||||
self.stop_note(note);
|
||||
return;
|
||||
}
|
||||
let d = self.d;
|
||||
let (note, vel) = (note.min(127), vel.min(127));
|
||||
if self.len == 0 || note < d.keys.0 || note > d.keys.1 || vel < d.vels.0 || vel > d.vels.1 {
|
||||
return;
|
||||
}
|
||||
let semis = if d.key_track { f64::from(note) - d.root } else { 0.0 } + d.offset;
|
||||
let step = f64::from(self.sample_sr) / f64::from(self.sr) * 2f64.powf(semis / 12.0);
|
||||
if !step.is_finite() || step <= 0.0 {
|
||||
return;
|
||||
}
|
||||
if !d.one_shot {
|
||||
// Retriggering a held key releases the earlier voice instead of stacking it.
|
||||
self.stop_note(note);
|
||||
}
|
||||
let idx = self
|
||||
.voices
|
||||
.iter()
|
||||
.position(|v| !v.active())
|
||||
.or_else(|| {
|
||||
self.voices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by(|(_, a), (_, b)| (a.held, a.env.level).partial_cmp(&(b.held, b.env.level)).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.map(|(i, _)| i)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let start = d.start * self.len.saturating_sub(1) as f64;
|
||||
let looping = self.loop_frames().is_some_and(|(_, end)| start < end);
|
||||
let gain = (1.0 - d.sens) + d.sens * f32::from(vel) / 127.0;
|
||||
if let Some(v) = self.voices.get_mut(idx) {
|
||||
*v = Voice { note, held: !d.one_shot, gain, pos: start, step: step.min(MAX_STEP), forward: true, looping, env: Adsr::new() };
|
||||
v.env.gate_on();
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_note(&mut self, note: u8) {
|
||||
for v in self.voices.iter_mut().filter(|v| v.held && v.note == note) {
|
||||
v.held = false;
|
||||
v.env.gate_off();
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&mut self, kind: EventKind) {
|
||||
match kind {
|
||||
EventKind::On(n, v) => self.start_note(n, v),
|
||||
EventKind::Off(n) => self.stop_note(n),
|
||||
EventKind::AllOff => {
|
||||
for v in &mut self.voices {
|
||||
v.held = false;
|
||||
v.env.gate_off();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds every voice's output for frame `n` into `io` and advances the voices.
|
||||
#[inline]
|
||||
fn render_frame(&mut self, io: &mut [Vec<f32>], ch: usize, n: usize, level: f32) {
|
||||
let Some(sample) = self.sample.as_deref() else { return };
|
||||
let nch = sample.num_channels();
|
||||
let len = self.len as f64;
|
||||
let mode = self.d.loop_mode;
|
||||
let region = self.loop_frames();
|
||||
for v in &mut self.voices {
|
||||
if !v.active() {
|
||||
continue;
|
||||
}
|
||||
let a = v.env.next(&self.rates) * v.gain * level;
|
||||
for (c, b) in io.iter_mut().take(ch).enumerate() {
|
||||
let src = if nch == 1 { 0 } else { c };
|
||||
if let (Some(x), Some(data)) = (b.get_mut(n), sample.channel(src)) {
|
||||
*x += hermite(data, v.pos) * a;
|
||||
}
|
||||
}
|
||||
let r = if v.looping { region } else { None };
|
||||
advance(v, len, r, mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves a voice's read position one output frame on, looping or ending it.
|
||||
#[inline]
|
||||
fn advance(v: &mut Voice, len: f64, region: Option<(f64, f64)>, mode: LoopMode) {
|
||||
v.pos = if v.forward { v.pos + v.step } else { v.pos - v.step };
|
||||
match region {
|
||||
Some((ls, le)) if mode == LoopMode::PingPong => {
|
||||
if v.forward && v.pos >= le {
|
||||
v.pos = (le - (v.pos - le)).max(ls);
|
||||
v.forward = false;
|
||||
} else if !v.forward && v.pos <= ls {
|
||||
v.pos = (ls + (ls - v.pos)).min(le);
|
||||
v.forward = true;
|
||||
}
|
||||
}
|
||||
Some((ls, le)) => {
|
||||
if v.pos >= le {
|
||||
v.pos = ls + (v.pos - ls) % (le - ls);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if v.pos >= len || !v.pos.is_finite() {
|
||||
v.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
if !v.pos.is_finite() || v.pos < 0.0 {
|
||||
v.stop();
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for Sampler {
|
||||
param_plumbing!(SAMPLER_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.ch = sane_channels(channels);
|
||||
self.level.set_time(20.0, self.sr);
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
for v in &mut self.voices {
|
||||
v.stop();
|
||||
}
|
||||
self.events.clear();
|
||||
self.level.snap();
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.ch.min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
for b in io.iter_mut().take(ch) {
|
||||
b.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||||
}
|
||||
let mut evs = [Event { offset: 0, kind: EventKind::AllOff }; MAX_EVENTS];
|
||||
let count = {
|
||||
let sorted = self.events.sorted();
|
||||
for (d, s) in evs.iter_mut().zip(sorted) {
|
||||
*d = *s;
|
||||
}
|
||||
sorted.len().min(MAX_EVENTS)
|
||||
};
|
||||
self.events.clear();
|
||||
let mut next = 0;
|
||||
for n in 0..frames {
|
||||
while let Some(e) = evs.get(next).filter(|_| next < count) {
|
||||
if e.offset > n && n + 1 < frames {
|
||||
break;
|
||||
}
|
||||
let kind = e.kind;
|
||||
self.apply(kind);
|
||||
next += 1;
|
||||
}
|
||||
let level = self.level.next();
|
||||
self.render_frame(io, ch, n, level);
|
||||
}
|
||||
// Events for an empty block take effect immediately.
|
||||
for e in evs.iter().take(count).skip(next) {
|
||||
self.apply(e.kind);
|
||||
}
|
||||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
let release = ms_to_samples(self.p.v(RELEASE), self.sr);
|
||||
// A one-shot keeps playing its sample after the note ends.
|
||||
let rest = if self.d.one_shot { self.len as f32 * self.sr / self.sample_sr } else { 0.0 };
|
||||
let t = release + rest;
|
||||
if t.is_finite() { t.min(1.0e9) as usize } else { 0 }
|
||||
}
|
||||
|
||||
fn note_on(&mut self, offset: usize, note: u8, velocity: u8) {
|
||||
self.events.push(offset, EventKind::On(note, velocity));
|
||||
}
|
||||
|
||||
fn note_off(&mut self, offset: usize, note: u8) {
|
||||
self.events.push(offset, EventKind::Off(note));
|
||||
}
|
||||
|
||||
fn all_notes_off(&mut self) {
|
||||
self.events.push(0, EventKind::AllOff);
|
||||
}
|
||||
|
||||
fn set_sample(&mut self, sample: Option<Arc<dyn SampleSource>>) -> bool {
|
||||
for v in &mut self.voices {
|
||||
v.stop();
|
||||
}
|
||||
self.len = sample.as_deref().map_or(0, |s| if s.num_channels() == 0 { 0 } else { s.frames() });
|
||||
let rate = sample.as_deref().map_or(self.sr, |s| s.sample_rate());
|
||||
// An unusable rate plays at the host rate rather than not at all.
|
||||
self.sample_sr = if rate.is_finite() && rate > 0.0 { rate.min(1_536_000.0) } else { self.sr };
|
||||
self.sample = sample;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::SampleBuffer;
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
const SR: f32 = 48_000.0;
|
||||
|
||||
fn sampler(sample: SampleBuffer) -> Sampler {
|
||||
let mut s = Sampler::new();
|
||||
s.prepare(SR, 512, 2);
|
||||
assert!(s.set_sample(Some(Arc::new(sample))));
|
||||
s
|
||||
}
|
||||
|
||||
fn mono(sample_rate: f32, data: Vec<f32>) -> SampleBuffer {
|
||||
SampleBuffer { sample_rate, channels: vec![data] }
|
||||
}
|
||||
|
||||
fn sine(freq: f32, sr: f32, len: usize) -> Vec<f32> {
|
||||
(0..len).map(|i| 0.5 * (TAU * freq * i as f32 / sr).sin()).collect()
|
||||
}
|
||||
|
||||
/// Renders `len` frames in 512-frame blocks; returns both channels.
|
||||
fn run(s: &mut Sampler, len: usize) -> [Vec<f32>; 2] {
|
||||
let mut out = [Vec::new(), Vec::new()];
|
||||
let mut buf = vec![vec![1.0f32; 512]; 2];
|
||||
let mut done = 0;
|
||||
while done < len {
|
||||
let n = 512.min(len - done);
|
||||
s.process(&mut buf, n);
|
||||
out[0].extend_from_slice(&buf[0][..n]);
|
||||
out[1].extend_from_slice(&buf[1][..n]);
|
||||
done += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn peak(x: &[f32]) -> f32 {
|
||||
x.iter().fold(0.0f32, |m, v| m.max(v.abs()))
|
||||
}
|
||||
|
||||
/// Frequency from upward zero crossings.
|
||||
fn freq(x: &[f32], sr: f32) -> f32 {
|
||||
let ups: Vec<usize> = x.windows(2).enumerate().filter(|(_, w)| w[0] < 0.0 && w[1] >= 0.0).map(|(i, _)| i).collect();
|
||||
let (Some(a), Some(b)) = (ups.first(), ups.last()) else { return 0.0 };
|
||||
(ups.len() - 1) as f32 * sr / (b - a) as f32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silent_without_a_sample_and_replaces_the_buffer() {
|
||||
let mut s = Sampler::new();
|
||||
s.prepare(SR, 512, 2);
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 4800);
|
||||
assert_eq!(peak(&out[0]), 0.0);
|
||||
assert_eq!(peak(&out[1]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_note_plays_the_sample_verbatim() {
|
||||
let data = sine(440.0, SR, 9600);
|
||||
let mut s = sampler(mono(SR, data.clone()));
|
||||
s.set_param("velocity", 0.0);
|
||||
s.note_on(0, 60, 100);
|
||||
let out = run(&mut s, 9600);
|
||||
// Past the 0.1-ms-ish attack the output is the sample itself, on both sides.
|
||||
for i in 100..9600 {
|
||||
assert!((out[0][i] - data[i]).abs() < 1e-6, "frame {i}: {} vs {}", out[0][i], data[i]);
|
||||
}
|
||||
assert_eq!(out[0], out[1], "a mono sample feeds both channels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_transpose_from_the_root_note() {
|
||||
let mut s = sampler(mono(SR, sine(400.0, SR, 96_000)));
|
||||
s.note_on(0, 72, 127);
|
||||
let up = run(&mut s, 24_000);
|
||||
assert!((freq(&up[0][480..], SR) - 800.0).abs() < 2.0, "an octave up: {}", freq(&up[0][480..], SR));
|
||||
s.reset();
|
||||
s.note_on(0, 53, 127);
|
||||
let down = run(&mut s, 24_000);
|
||||
let want = 400.0 * 2f32.powf(-7.0 / 12.0);
|
||||
assert!((freq(&down[0][480..], SR) - want).abs() < 2.0);
|
||||
// Root note 48 makes key 60 play an octave up; transpose and fine tune add on top.
|
||||
s.reset();
|
||||
s.set_param("root_note", 48.0);
|
||||
s.set_param("transpose", -12.0);
|
||||
s.set_param("fine_tune", 100.0);
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 24_000);
|
||||
let want = 400.0 * 2f32.powf(1.0 / 12.0);
|
||||
assert!((freq(&out[0][480..], SR) - want).abs() < 2.0, "{}", freq(&out[0][480..], SR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_tracking_off_plays_every_key_at_the_sample_pitch() {
|
||||
let mut s = sampler(mono(SR, sine(400.0, SR, 48_000)));
|
||||
s.set_param("key_track", 0.0);
|
||||
s.note_on(0, 90, 127);
|
||||
let out = run(&mut s, 24_000);
|
||||
assert!((freq(&out[0][480..], SR) - 400.0).abs() < 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_the_sample_rate() {
|
||||
// A 300 Hz tone recorded at 22.05 kHz still plays at 300 Hz on a 48 kHz host.
|
||||
let mut s = sampler(mono(22_050.0, sine(300.0, 22_050.0, 22_050)));
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 60_000);
|
||||
assert!((freq(&out[0][480..], SR) - 300.0).abs() < 1.0, "{}", freq(&out[0][480..], SR));
|
||||
// And it lasts as long as the recording: one second.
|
||||
assert!(peak(&out[0][46_000..47_500]) > 0.1);
|
||||
assert_eq!(peak(&out[0][48_200..]), 0.0, "the sample ended");
|
||||
// A 96 kHz recording is read faster, not cut short.
|
||||
let mut s = sampler(mono(96_000.0, sine(1000.0, 96_000.0, 96_000)));
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 48_000);
|
||||
assert!((freq(&out[0][480..], SR) - 1000.0).abs() < 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_and_velocity_ranges_gate_notes() {
|
||||
let mut s = sampler(mono(SR, vec![0.5; 4800]));
|
||||
s.set_param("key_low", 48.0);
|
||||
s.set_param("key_high", 60.0);
|
||||
s.set_param("vel_low", 40.0);
|
||||
s.set_param("vel_high", 100.0);
|
||||
for (note, vel, sounds) in
|
||||
[(47, 80, false), (61, 80, false), (48, 80, true), (60, 80, true), (55, 39, false), (55, 101, false), (55, 40, true)]
|
||||
{
|
||||
s.reset();
|
||||
s.note_on(0, note, vel);
|
||||
let out = run(&mut s, 960);
|
||||
assert_eq!(peak(&out[0]) > 0.0, sounds, "note {note} velocity {vel}");
|
||||
}
|
||||
// Reversed ranges are read in order rather than muting everything.
|
||||
s.set_param("key_low", 60.0);
|
||||
s.set_param("key_high", 48.0);
|
||||
s.reset();
|
||||
s.note_on(0, 50, 80);
|
||||
assert!(peak(&run(&mut s, 960)[0]) > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn velocity_sensitivity_scales_level() {
|
||||
let level = |sens: f32, vel: u8| {
|
||||
let mut s = sampler(mono(SR, vec![0.5; 4800]));
|
||||
s.set_param("velocity", sens);
|
||||
s.note_on(0, 60, vel);
|
||||
peak(&run(&mut s, 2400)[0][1000..])
|
||||
};
|
||||
assert!((level(100.0, 127) - 0.5).abs() < 1e-4);
|
||||
assert!((level(100.0, 64) - 0.5 * 64.0 / 127.0).abs() < 1e-4);
|
||||
assert!((level(0.0, 1) - 0.5).abs() < 1e-4, "no sensitivity: every velocity is full level");
|
||||
assert!(level(70.0, 30) < level(70.0, 127));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adsr_shapes_the_note() {
|
||||
let mut s = sampler(mono(SR, vec![0.5; 96_000]));
|
||||
s.set_param("velocity", 0.0);
|
||||
s.set_param("attack", 100.0);
|
||||
s.set_param("decay", 100.0);
|
||||
s.set_param("sustain", 50.0);
|
||||
s.set_param("release", 100.0);
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 24_000);
|
||||
// Linear attack: halfway up at 50 ms, full level at 100 ms, then decay to the sustain level.
|
||||
assert!((out[0][2400] - 0.25).abs() < 0.01, "attack midpoint {}", out[0][2400]);
|
||||
assert!(peak(&out[0][4700..4900]) > 0.49);
|
||||
assert!((out[0][20_000] - 0.25).abs() < 0.005, "sustain {}", out[0][20_000]);
|
||||
s.note_off(0, 60);
|
||||
let out = run(&mut s, 24_000);
|
||||
assert!(out[0][100] > 0.2, "release starts from the sustain level");
|
||||
assert!(peak(&out[0][5000..]) < 1e-3, "released after 100 ms");
|
||||
assert_eq!(peak(&out[0][12_000..]), 0.0, "the voice ended");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_mode_stops_on_note_off_and_one_shot_plays_through() {
|
||||
let mut s = sampler(mono(SR, vec![0.5; 24_000]));
|
||||
s.set_param("release", 1.0);
|
||||
s.note_on(0, 60, 127);
|
||||
s.note_off(1000, 60);
|
||||
let out = run(&mut s, 24_000);
|
||||
assert!(peak(&out[0][..900]) > 0.1);
|
||||
assert_eq!(peak(&out[0][2000..]), 0.0, "gate: released at the note-off");
|
||||
s.set_param("play_mode", 1.0);
|
||||
s.reset();
|
||||
s.note_on(0, 60, 127);
|
||||
s.note_off(1000, 60);
|
||||
let out = run(&mut s, 30_000);
|
||||
assert!(peak(&out[0][20_000..23_900]) > 0.1, "one shot: ignores the note-off");
|
||||
assert_eq!(peak(&out[0][24_100..]), 0.0, "and stops at the end of the sample");
|
||||
assert!(s.tail_samples() >= 24_000, "a one-shot's tail covers the sample");
|
||||
// Stopping the transport still silences one-shots.
|
||||
s.reset();
|
||||
s.note_on(0, 60, 127);
|
||||
run(&mut s, 512);
|
||||
s.all_notes_off();
|
||||
assert_eq!(peak(&run(&mut s, 4800)[0][2400..]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retriggering_a_held_key_releases_the_old_voice() {
|
||||
let mut s = sampler(mono(SR, vec![0.25; 48_000]));
|
||||
s.set_param("velocity", 0.0);
|
||||
s.set_param("release", 1.0);
|
||||
s.note_on(0, 60, 127);
|
||||
s.note_on(256, 60, 127);
|
||||
let out = run(&mut s, 4800);
|
||||
assert!(peak(&out[0][2400..]) < 0.26, "voices did not stack: {}", peak(&out[0][2400..]));
|
||||
// One-shots overlap.
|
||||
s.set_param("play_mode", 1.0);
|
||||
s.reset();
|
||||
s.note_on(0, 60, 127);
|
||||
s.note_on(256, 60, 127);
|
||||
let out = run(&mut s, 4800);
|
||||
assert!((peak(&out[0][2400..]) - 0.5).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_loop_sustains_past_the_end() {
|
||||
let data = sine(480.0, SR, 4800);
|
||||
let mut s = sampler(mono(SR, data.clone()));
|
||||
s.set_param("velocity", 0.0);
|
||||
s.set_param("loop_mode", 1.0);
|
||||
s.set_param("loop_start", 50.0);
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 48_000);
|
||||
assert!(peak(&out[0][40_000..]) > 0.4, "still sounding long after the 0.1 s sample");
|
||||
// After the first pass it repeats the loop region (2400 frames, whole cycles of 480 Hz).
|
||||
for i in 0..2400 {
|
||||
assert!((out[0][4800 + i] - data[2400 + i]).abs() < 1e-4, "frame {i}");
|
||||
}
|
||||
assert!((freq(&out[0][4800..], SR) - 480.0).abs() < 1.0);
|
||||
// Without the loop the note ends with the sample.
|
||||
s.set_param("loop_mode", 0.0);
|
||||
s.reset();
|
||||
s.note_on(0, 60, 127);
|
||||
assert_eq!(peak(&run(&mut s, 9600)[0][4900..]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ping_pong_loop_reverses_direction() {
|
||||
// A rising ramp, looped ping-pong over the whole sample, rises then falls.
|
||||
let data: Vec<f32> = (0..1000).map(|i| i as f32 / 1000.0).collect();
|
||||
let mut s = sampler(mono(SR, data));
|
||||
s.set_param("velocity", 0.0);
|
||||
s.set_param("loop_mode", 2.0);
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 3000);
|
||||
assert!(out[0][900] > out[0][500], "rising");
|
||||
assert!(out[0][1500] < out[0][1100], "falling after the loop end");
|
||||
assert!(out[0][2500] > out[0][2100], "rising again after the loop start");
|
||||
assert!(out[0].iter().all(|x| x.is_finite() && *x <= 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_too_short_to_loop_plays_once() {
|
||||
let mut s = sampler(mono(SR, vec![0.5; 1000]));
|
||||
s.set_param("loop_mode", 1.0);
|
||||
s.set_param("loop_start", 40.0);
|
||||
s.set_param("loop_end", 40.0);
|
||||
s.note_on(0, 60, 127);
|
||||
assert_eq!(peak(&run(&mut s, 4800)[0][1100..]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_start_skips_into_the_sample() {
|
||||
let data: Vec<f32> = (0..1000).map(|i| i as f32 / 1000.0).collect();
|
||||
let mut s = sampler(mono(SR, data));
|
||||
s.set_param("velocity", 0.0);
|
||||
s.set_param("attack", 0.1);
|
||||
s.set_param("start", 50.0);
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 2000);
|
||||
assert!((out[0][10] - 0.509).abs() < 0.002, "{}", out[0][10]);
|
||||
assert_eq!(peak(&out[0][520..]), 0.0, "half the sample left");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_samples_keep_their_sides() {
|
||||
let sample = SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 4800], vec![-0.25; 4800]] };
|
||||
let mut s = sampler(sample);
|
||||
s.set_param("velocity", 0.0);
|
||||
s.note_on(0, 60, 127);
|
||||
let out = run(&mut s, 2400);
|
||||
assert!((out[0][1000] - 0.5).abs() < 1e-6);
|
||||
assert!((out[1][1000] + 0.25).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_offsets_are_sample_accurate() {
|
||||
let mut s = sampler(mono(SR, vec![0.5; 4800]));
|
||||
s.note_on(300, 60, 127);
|
||||
let out = run(&mut s, 512);
|
||||
assert!(out[0][..300].iter().all(|&v| v == 0.0));
|
||||
assert!(out[0][301..].iter().any(|&v| v != 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_notes_steal_voices_and_stay_bounded() {
|
||||
let mut s = sampler(mono(SR, sine(220.0, SR, 48_000)));
|
||||
s.set_param("level", 12.0);
|
||||
for n in 0..100u8 {
|
||||
s.note_on(usize::from(n), n, 127);
|
||||
}
|
||||
let out = run(&mut s, 9600);
|
||||
assert!(out[0].iter().all(|v| v.is_finite()));
|
||||
assert!(s.voices.iter().filter(|v| v.active()).count() <= VOICES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_sample_stops_the_old_voices() {
|
||||
let mut s = sampler(mono(SR, vec![0.5; 48_000]));
|
||||
s.note_on(0, 60, 127);
|
||||
run(&mut s, 512);
|
||||
assert!(s.set_sample(Some(Arc::new(mono(SR, vec![0.1; 10])))));
|
||||
assert_eq!(peak(&run(&mut s, 512)[0]), 0.0);
|
||||
assert!(s.set_sample(None));
|
||||
s.note_on(0, 60, 127);
|
||||
assert_eq!(peak(&run(&mut s, 512)[0]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_samples_and_params_never_panic() {
|
||||
let samples = [
|
||||
SampleBuffer { sample_rate: f32::NAN, channels: vec![vec![0.5; 100]] },
|
||||
SampleBuffer { sample_rate: 0.0, channels: vec![vec![0.5; 100]] },
|
||||
SampleBuffer { sample_rate: f32::INFINITY, channels: vec![vec![0.5; 100]] },
|
||||
SampleBuffer { sample_rate: 1.0e-30, channels: vec![vec![0.5; 100]] },
|
||||
SampleBuffer { sample_rate: SR, channels: vec![] },
|
||||
SampleBuffer { sample_rate: SR, channels: vec![vec![]] },
|
||||
SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 100], vec![0.5; 3]] },
|
||||
SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 1]] },
|
||||
SampleBuffer { sample_rate: SR, channels: vec![vec![f32::NAN; 100]; 9] },
|
||||
];
|
||||
let params: Vec<(&str, f32)> = SAMPLER_PARAMS.iter().flat_map(|p| [(p.id, f32::NAN), (p.id, f32::INFINITY), (p.id, -1.0e30)]).collect();
|
||||
for sample in samples {
|
||||
let mut s = Sampler::new();
|
||||
s.prepare(f32::NAN, 0, 99);
|
||||
s.set_sample(Some(Arc::new(sample)));
|
||||
for (id, v) in ¶ms {
|
||||
assert!(s.set_param(id, *v));
|
||||
for n in [0u8, 60, 127, 255] {
|
||||
s.note_on(0, n, 255);
|
||||
s.note_on(7, n, 1);
|
||||
}
|
||||
let mut io = vec![vec![0.0f32; 64]; 3];
|
||||
s.process(&mut io, 1000);
|
||||
assert!(io.iter().flatten().all(|v| v.is_finite()), "{id} = {v}");
|
||||
s.process(&mut [], 64);
|
||||
let _ = s.tail_samples();
|
||||
}
|
||||
}
|
||||
// Loops with every mode at the extremes of pitch.
|
||||
let mut s = sampler(mono(SR, sine(100.0, SR, 1000)));
|
||||
for mode in [0.0, 1.0, 2.0] {
|
||||
s.set_param("loop_mode", mode);
|
||||
for (t, root) in [(48.0, 0.0), (-48.0, 127.0)] {
|
||||
s.set_param("transpose", t);
|
||||
s.set_param("root_note", root);
|
||||
s.note_on(0, 127, 127);
|
||||
s.note_on(0, 0, 127);
|
||||
let out = run(&mut s, 4096);
|
||||
assert!(out[0].iter().all(|v| v.is_finite() && v.abs() < 4.0), "mode {mode} transpose {t}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ const VOICES: usize = 16;
|
||||
const SILENT: f32 = 1.0e-5;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) enum Stage {
|
||||
enum Stage {
|
||||
Idle,
|
||||
Attack,
|
||||
Decay,
|
||||
@@ -107,13 +107,13 @@ pub(crate) enum Stage {
|
||||
|
||||
/// Linear-attack, exponential decay/release ADSR. Times are "to -60 dB" for decay/release.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct Adsr {
|
||||
pub(crate) stage: Stage,
|
||||
pub(crate) level: f32,
|
||||
struct Adsr {
|
||||
stage: Stage,
|
||||
level: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub(crate) struct AdsrRates {
|
||||
struct AdsrRates {
|
||||
attack: f32,
|
||||
decay: f32,
|
||||
sustain: f32,
|
||||
@@ -121,29 +121,29 @@ pub(crate) struct AdsrRates {
|
||||
}
|
||||
|
||||
impl AdsrRates {
|
||||
pub(crate) fn new(a_ms: f32, d_ms: f32, s: f32, r_ms: f32, sr: f32) -> Self {
|
||||
fn new(a_ms: f32, d_ms: f32, s: f32, r_ms: f32, sr: f32) -> Self {
|
||||
let exp60 = |ms: f32| (0.001f32.ln() / ms_to_samples(ms, sr).max(1.0)).exp();
|
||||
Self { attack: 1.0 / ms_to_samples(a_ms, sr).max(1.0), decay: exp60(d_ms), sustain: s.clamp(0.0, 1.0), release: exp60(r_ms) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Adsr {
|
||||
pub(crate) const fn new() -> Self {
|
||||
const fn new() -> Self {
|
||||
Self { stage: Stage::Idle, level: 0.0 }
|
||||
}
|
||||
|
||||
pub(crate) fn gate_on(&mut self) {
|
||||
fn gate_on(&mut self) {
|
||||
self.stage = Stage::Attack;
|
||||
}
|
||||
|
||||
pub(crate) fn gate_off(&mut self) {
|
||||
fn gate_off(&mut self) {
|
||||
if self.stage != Stage::Idle {
|
||||
self.stage = Stage::Release;
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn next(&mut self, r: &AdsrRates) -> f32 {
|
||||
fn next(&mut self, r: &AdsrRates) -> f32 {
|
||||
match self.stage {
|
||||
Stage::Idle => self.level = 0.0,
|
||||
Stage::Attack => {
|
||||
|
||||
@@ -22,7 +22,6 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
zip.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
||||
@@ -107,12 +107,8 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
nc.name = new_name;
|
||||
nc.content = ClipContent::Audio { source: src, offset: 0 };
|
||||
nc.length = i64::try_from(frames).unwrap_or(nc.length);
|
||||
// Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain,
|
||||
// the same way its fades stay editable. Other processes bake a new file at unity gain.
|
||||
if !matches!(name.as_str(), "invert" | "duplicate") {
|
||||
nc.gain_db = 0.0;
|
||||
nc.gain_env.clear();
|
||||
}
|
||||
nc.gain_db = 0.0;
|
||||
nc.gain_env.clear();
|
||||
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips.retain(|c| c.id != id);
|
||||
}
|
||||
@@ -126,43 +122,3 @@ fn short(n: &str) -> String {
|
||||
let s: String = n.split('_').map(|w| w.chars().take(4).collect::<String>()).collect::<Vec<_>>().join("");
|
||||
s.chars().take(8).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_model::{ChannelFormat, Session, TrackKind};
|
||||
|
||||
fn gained_clip() -> (crate::Engine, soundcraft_model::ClipId) {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] };
|
||||
let src = crate::io::add_source(&mut s, "tone", buf, None, FileFormat::Wav);
|
||||
let id = s.new_clip_id();
|
||||
let mut c = Clip::audio(id, "tone", src, 0, 0, 64);
|
||||
c.gain_db = -6.0;
|
||||
c.gain_env = vec![(0, 0.0), (32, -12.0)];
|
||||
crate::edit::place_clip(&mut s, t, c);
|
||||
s.edit.selected_clips = vec![id];
|
||||
(crate::Engine::new(s), id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invert_and_duplicate_keep_clip_gain() {
|
||||
let (mut e, id) = gained_clip();
|
||||
e.execute("audiosuite.process", &json!({"process": "invert", "clips": [id.0]})).unwrap();
|
||||
let c = e.session().tracks[0].clips()[0].clone();
|
||||
assert!((c.gain_db + 6.0).abs() < 1e-6, "{}", c.gain_db);
|
||||
assert_eq!(c.gain_env, vec![(0, 0.0), (32, -12.0)]);
|
||||
let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0];
|
||||
assert!((sample + 0.5).abs() < 1e-3, "invert flips the file and leaves the gain on the clip, got {sample}");
|
||||
|
||||
let (mut e, id) = gained_clip();
|
||||
e.execute("audiosuite.process", &json!({"process": "duplicate", "clips": [id.0]})).unwrap();
|
||||
let c = e.session().tracks[0].clips()[0].clone();
|
||||
assert!((c.gain_db + 6.0).abs() < 1e-6, "{}", c.gain_db);
|
||||
assert_eq!(c.gain_env, vec![(0, 0.0), (32, -12.0)]);
|
||||
let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0];
|
||||
assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,10 @@ fn elastic(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let elastic = s.track(t).and_then(|x| x.elastic.clone()).filter(|a| a != "varispeed");
|
||||
// Start from the unwarped original if this clip was warped before.
|
||||
let key = |k: &str| format!("elastic.{}.{k}", id.0);
|
||||
let orig = warp_original(s, *id);
|
||||
let orig = match (s.edit.values.get(&key("src")), s.edit.values.get(&key("off")), s.edit.values.get(&key("len"))) {
|
||||
(Some(src), Some(off), Some(len)) => Some((soundcraft_model::SourceId(*src as u64), *off as i64, *len as i64)),
|
||||
_ => None,
|
||||
};
|
||||
let mut base = c.clone();
|
||||
if let Some((src, off, len)) = orig {
|
||||
base.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };
|
||||
@@ -226,12 +229,12 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let mut n = 0;
|
||||
for id in &ids {
|
||||
let key = |k: &str| format!("elastic.{}.{k}", id.0);
|
||||
let orig = warp_original(s, *id);
|
||||
let orig = (s.edit.values.get(&key("src")).copied(), s.edit.values.get(&key("off")).copied(), s.edit.values.get(&key("len")).copied());
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
c.stretch = 1.0;
|
||||
if let Some((src, off, len)) = orig {
|
||||
c.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };
|
||||
c.length = len.max(1);
|
||||
if let (Some(src), Some(off), Some(len)) = orig {
|
||||
c.content = soundcraft_model::ClipContent::Audio { source: soundcraft_model::SourceId(src as u64), offset: off as i64 };
|
||||
c.length = (len as i64).max(1);
|
||||
c.fade_out.len = c.fade_out.len.min(c.length);
|
||||
n += 1;
|
||||
}
|
||||
@@ -243,14 +246,6 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"restored": n}))
|
||||
}
|
||||
|
||||
/// The audio a warped clip was rendered from, while that file is still in the session.
|
||||
fn warp_original(s: &soundcraft_model::Session, id: ClipId) -> Option<(soundcraft_model::SourceId, Samples, Samples)> {
|
||||
let v = |k: &str| s.edit.values.get(&format!("elastic.{}.{k}", id.0)).copied();
|
||||
let src = soundcraft_model::SourceId(v("src")? as u64);
|
||||
s.source(src)?;
|
||||
Some((src, v("off")? as i64, v("len")? as i64))
|
||||
}
|
||||
|
||||
fn flag(e: &mut Engine, p: &Value, get: fn(&soundcraft_model::Clip) -> bool, set: fn(&mut soundcraft_model::Clip, bool)) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
@@ -378,20 +373,6 @@ mod tests {
|
||||
assert_eq!(e.session().find_clip(ClipId(cid)).unwrap().1.length, len0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let pad = e.session().track_by_name("Pad").map(|t| t.id).unwrap();
|
||||
e.execute("track.elastic", &json!({"track": pad, "algorithm": "polyphonic"})).unwrap();
|
||||
let clip = e.session().track(pad).unwrap().clips()[0].clone();
|
||||
e.execute("clip.elastic_properties", &json!({"clips": [clip.id], "ratio": 1.5})).unwrap();
|
||||
e.execute("clip.clear", &json!({"sources": [clip.source()]})).unwrap();
|
||||
let r = e.execute("clip.remove_warp", &json!({"clips": [clip.id]})).unwrap();
|
||||
assert_eq!(r["restored"], 0);
|
||||
let src = e.session().find_clip(clip.id).and_then(|(_, c)| c.source()).unwrap();
|
||||
assert!(e.session().source(src).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gain_nudges_one_db_at_a_time_up_to_the_ceiling() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
//! Clip List commands.
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::SourceId;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const CLEAR: &str = "clip.clear";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"clip.clear",
|
||||
"Clear Clips",
|
||||
[],
|
||||
Some("Cmd+Shift+B"),
|
||||
"{clips?: [id], sources?: [id], with_clips?: bool} — removes clips and audio files from the session (files stay on disk); default: the Clip List selection. A file still used on the timeline needs `with_clips: true`, which removes those clips too",
|
||||
has_list_selection,
|
||||
clear
|
||||
)]
|
||||
}
|
||||
|
||||
fn has_list_selection(e: &Engine) -> std::result::Result<(), String> {
|
||||
let ed = &e.session().edit;
|
||||
if ed.selected_clips.is_empty() && ed.selected_sources.is_empty() { Err("select clips or files in the Clip List".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn ids<'a, T: serde::Deserialize<'a>>(p: &'a Value, key: &str) -> Result<Option<T>> {
|
||||
p.get(key).map(|v| T::deserialize(v).map_err(|_| bad(CLEAR, format!("`{key}` must be an array of ids")))).transpose()
|
||||
}
|
||||
|
||||
fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session();
|
||||
let (clips, sources): (BTreeSet<ClipId>, BTreeSet<SourceId>) = match (ids(p, "clips")?, ids(p, "sources")?) {
|
||||
(None, None) => (s.edit.selected_clips.iter().copied().collect(), s.edit.selected_sources.iter().copied().collect()),
|
||||
(c, f) => (c.unwrap_or_default(), f.unwrap_or_default()),
|
||||
};
|
||||
let known: BTreeSet<ClipId> = s.all_clips().map(|c| c.id).collect();
|
||||
if let Some(c) = clips.iter().find(|c| !known.contains(c)) {
|
||||
return Err(bad(CLEAR, format!("no clip with id {c}")));
|
||||
}
|
||||
let files = sources.iter().map(|f| s.source(*f).ok_or_else(|| bad(CLEAR, format!("no audio file with id {f}")))).collect::<Result<Vec<_>>>()?;
|
||||
if clips.is_empty() && files.is_empty() {
|
||||
return Err(bad(CLEAR, "nothing to clear: pass `clips` or `sources`, or select them in the Clip List"));
|
||||
}
|
||||
if !bool_or(p, "with_clips", false)
|
||||
&& let Some((f, n)) = files.iter().map(|f| (f, s.clips_using(f.id).filter(|c| !clips.contains(&c.id)).count())).find(|(_, n)| *n > 0)
|
||||
{
|
||||
let plural = if n == 1 { "" } else { "s" };
|
||||
return Err(bad(CLEAR, format!("`{}` is used by {n} clip{plural} on the timeline; pass `with_clips: true` to remove them too", f.name)));
|
||||
}
|
||||
let gone: BTreeSet<ClipId> = clips.iter().copied().chain(sources.iter().flat_map(|f| s.clips_using(*f).map(|c| c.id))).collect();
|
||||
let s = e.session_mut();
|
||||
for pl in s.tracks.iter_mut().flat_map(|t| t.playlists.iter_mut()) {
|
||||
pl.clips.retain(|c| !gone.contains(&c.id));
|
||||
}
|
||||
s.sources.retain(|f| !sources.contains(&f.id));
|
||||
for f in &sources {
|
||||
s.pool.remove(*f);
|
||||
}
|
||||
s.edit.selected_clips.retain(|c| !gone.contains(c));
|
||||
s.edit.selected_sources.retain(|f| !sources.contains(f));
|
||||
Ok(json!({"clips": gone.len(), "sources": sources.len()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use soundcraft_audio_io::{AudioBuffer, FileFormat};
|
||||
|
||||
fn kick(e: &Engine) -> (SourceId, Vec<ClipId>) {
|
||||
let s = e.session();
|
||||
let f = s.sources.iter().find(|x| x.name == "Kick").map(|x| x.id).unwrap();
|
||||
(f, s.clips_using(f).map(|c| c.id).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_clip_takes_it_off_the_timeline_and_keeps_its_file() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (f, clips) = kick(&e);
|
||||
let r = e.execute("clip.clear", &json!({"clips": [clips[0]]})).unwrap();
|
||||
assert_eq!(r, json!({"clips": 1, "sources": 0}));
|
||||
assert!(e.session().find_clip(clips[0]).is_none());
|
||||
assert!(e.session().source(f).is_some());
|
||||
assert!(e.undo());
|
||||
assert!(e.session().find_clip(clips[0]).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_in_use_stays_unless_its_clips_go_too() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
e.execute("track.playlist_duplicate", &json!({"track": "Kick"})).unwrap();
|
||||
let (f, clips) = kick(&e);
|
||||
assert_eq!(clips.len(), 6);
|
||||
let err = e.execute("clip.clear", &json!({"sources": [f]})).unwrap_err();
|
||||
assert!(err.to_string().contains("used by 6 clips"), "{err}");
|
||||
let r = e.execute("clip.clear", &json!({"sources": [f], "with_clips": true})).unwrap();
|
||||
assert_eq!(r, json!({"clips": 6, "sources": 1}));
|
||||
let s = e.session();
|
||||
assert!(s.source(f).is_none() && !s.pool.contains(f));
|
||||
assert_eq!(s.clips_using(f).count(), 0);
|
||||
assert_eq!(e.undo_label(), Some("Clear Clips"));
|
||||
assert!(e.undo());
|
||||
assert!(e.session().pool.contains(f));
|
||||
assert_eq!(e.session().clips_using(f).count(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_a_files_clips_too_is_enough() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (f, clips) = kick(&e);
|
||||
let r = e.execute("clip.clear", &json!({"clips": clips, "sources": [f]})).unwrap();
|
||||
assert_eq!(r, json!({"clips": 3, "sources": 1}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clip_list_selection_is_the_default_target() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 4_800]] };
|
||||
let f = crate::io::add_source(e.session_mut(), "Spare", buf, None, FileFormat::Wav);
|
||||
let (_, clips) = kick(&e);
|
||||
e.execute("edit.select", &json!({"clips": [clips[0]]})).unwrap();
|
||||
let r = e.execute("edit.select", &json!({"sources": [f, f, 999_999]})).unwrap();
|
||||
assert_eq!(r["sources"], json!([f]));
|
||||
assert_eq!(r["clips"], json!([]));
|
||||
let r = e.execute("edit.select", &json!({"clips": [clips[0]]})).unwrap();
|
||||
assert_eq!(r["sources"], json!([]));
|
||||
e.execute("clip.clear", &json!({})).unwrap();
|
||||
assert!(e.session().find_clip(clips[0]).is_none());
|
||||
assert!(e.session().edit.selected_clips.is_empty());
|
||||
e.execute("edit.select", &json!({"sources": [f]})).unwrap();
|
||||
e.execute("clip.clear", &json!({})).unwrap();
|
||||
assert!(e.session().source(f).is_none());
|
||||
assert!(e.session().edit.selected_sources.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_targets_are_errors() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"clips": []}),
|
||||
json!({"clips": [999_999]}),
|
||||
json!({"sources": [999_999]}),
|
||||
json!({"sources": "Kick"}),
|
||||
json!({"clips": [-1]}),
|
||||
] {
|
||||
assert!(e.execute("clip.clear", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deselect_all_clears_the_clip_list_selection() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (f, _) = kick(&e);
|
||||
e.execute("edit.select", &json!({"sources": [f]})).unwrap();
|
||||
e.execute("edit.select_none", &json!({})).unwrap();
|
||||
assert!(e.session().edit.selected_sources.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -362,69 +362,4 @@ mod tests {
|
||||
assert_eq!(c.length, 18_000);
|
||||
assert_ne!(c.source(), Some(src));
|
||||
}
|
||||
|
||||
fn peak_from(ch: &[f32], start: usize) -> f32 {
|
||||
ch.get(start..).unwrap_or(&[]).iter().fold(0.0f32, |m, x| m.max(x.abs()))
|
||||
}
|
||||
|
||||
fn max_abs_diff(a: &[Vec<f32>], b: &[Vec<f32>]) -> f32 {
|
||||
a.iter().zip(b.iter()).flat_map(|(x, y)| x.iter().zip(y.iter()).map(|(p, q)| (p - q).abs())).fold(0.0f32, f32::max)
|
||||
}
|
||||
|
||||
/// Gain and effects renders bake the current varispeed. The clip must not keep stretching the result.
|
||||
#[test]
|
||||
fn render_preserves_stretched_audio() {
|
||||
for command in ["clip.gain_render", "clip.effects_render"] {
|
||||
for ratio in [0.5_f64, 1.0, 2.0] {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let tone: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect();
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
|
||||
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let id = s.new_clip_id();
|
||||
crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600));
|
||||
s.edit.selected_clips = vec![id];
|
||||
let mut e = Engine::new(s);
|
||||
e.execute("clip.gain", &json!({"clip": id.0, "db": -6.0})).unwrap();
|
||||
e.execute("clip.conform_to_tempo", &json!({"clip": id.0, "source_bpm": 120.0 * ratio})).unwrap();
|
||||
if command == "clip.effects_render" {
|
||||
e.execute("clip.effects_set", &json!({"clip": id.0, "params": {"gain": 3.0}})).unwrap();
|
||||
}
|
||||
let range = e.session().find_clip(id).unwrap().1.range();
|
||||
let before = soundcraft_mix::render_clips(e.session(), t, range);
|
||||
e.execute(command, &json!({"clip": id.0})).unwrap();
|
||||
let c = e.session().find_clip(id).unwrap().1.clone();
|
||||
assert!((c.stretch - 1.0).abs() < 1e-9, "{command} ratio {ratio} left stretch {}", c.stretch);
|
||||
assert_eq!(c.gain_db, 0.0);
|
||||
let after = soundcraft_mix::render_clips(e.session(), t, range);
|
||||
let diff = max_abs_diff(&before, &after);
|
||||
assert!(diff < 1e-5, "{command} ratio {ratio} changed the audio by {diff}");
|
||||
if ratio < 1.0 {
|
||||
let tail = before.first().map(|ch| peak_from(ch, ch.len() / 2)).unwrap_or(0.0);
|
||||
assert!(tail > 0.02, "{command} conformed tail was silent before render: {tail}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Varispeed on a non-elastic track does not change the clip length. Render still matches it.
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let tone: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect();
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
|
||||
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let id = s.new_clip_id();
|
||||
crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600));
|
||||
s.edit.selected_clips = vec![id];
|
||||
let mut e = Engine::new(s);
|
||||
e.execute("clip.elastic_properties", &json!({"clip": id.0, "ratio": 0.5})).unwrap();
|
||||
e.execute("clip.gain", &json!({"clip": id.0, "db": -6.0})).unwrap();
|
||||
let range = e.session().find_clip(id).unwrap().1.range();
|
||||
let before = soundcraft_mix::render_clips(e.session(), t, range);
|
||||
e.execute("clip.gain_render", &json!({"clip": id.0})).unwrap();
|
||||
let c = e.session().find_clip(id).unwrap().1.clone();
|
||||
assert_eq!(c.length, 9_600);
|
||||
assert!((c.stretch - 1.0).abs() < 1e-9);
|
||||
let after = soundcraft_mix::render_clips(e.session(), t, range);
|
||||
assert!(max_abs_diff(&before, &after) < 1e-5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,12 +49,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
repeat_to_fill
|
||||
),
|
||||
cmd!(noundo "edit.select_all", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, select_all),
|
||||
cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id], sources?: [id] (Clip List files), exact?: bool (ignore edit groups)}", always, select),
|
||||
cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id], exact?: bool (ignore edit groups)}", always, select),
|
||||
cmd!(noundo "edit.select_none", "Deselect All", [], None, "{}", always, |e, _| {
|
||||
let s = e.session_mut();
|
||||
s.edit.selected_tracks.clear();
|
||||
s.edit.selected_clips.clear();
|
||||
s.edit.selected_sources.clear();
|
||||
s.edit.selection.end = s.edit.selection.start;
|
||||
Ok(json!({}))
|
||||
}),
|
||||
@@ -352,12 +351,8 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let en = position_param(e, "edit.select", p, "end")?;
|
||||
let clips =
|
||||
p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::ClipId).collect::<Vec<_>>());
|
||||
let sources =
|
||||
p.get("sources").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::SourceId).collect::<Vec<_>>());
|
||||
let list_changes = clips.is_some() || sources.is_some() || st.is_some();
|
||||
let exact = bool_or(p, "exact", false);
|
||||
let s = e.session();
|
||||
let mut ed = s.edit.clone();
|
||||
let s = e.session_mut();
|
||||
if let Some(mut t) = tracks {
|
||||
// Active edit groups select all their members together.
|
||||
if !exact {
|
||||
@@ -371,11 +366,11 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let order: Vec<soundcraft_model::TrackId> = s.tracks.iter().map(|x| x.id).collect();
|
||||
t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
|
||||
}
|
||||
ed.selected_tracks = t;
|
||||
s.edit.selected_tracks = t;
|
||||
}
|
||||
match (st, en) {
|
||||
(Some(a), Some(b)) => ed.selection = soundcraft_time::Range::new(a.max(0), b.max(0)),
|
||||
(Some(a), None) => ed.selection = soundcraft_time::Range::point(a.max(0)),
|
||||
(Some(a), Some(b)) => s.edit.selection = soundcraft_time::Range::new(a.max(0), b.max(0)),
|
||||
(Some(a), None) => s.edit.selection = soundcraft_time::Range::point(a.max(0)),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(c) = clips {
|
||||
@@ -391,25 +386,17 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
if let Some(r) = range {
|
||||
ed.selection = r;
|
||||
ed.selected_tracks = trs;
|
||||
s.edit.selection = r;
|
||||
s.edit.selected_tracks = trs;
|
||||
}
|
||||
ed.selected_clips = c;
|
||||
} else if list_changes {
|
||||
ed.selected_clips.clear();
|
||||
s.edit.selected_clips = c;
|
||||
} else if st.is_some() {
|
||||
s.edit.selected_clips.clear();
|
||||
}
|
||||
if list_changes {
|
||||
let mut known: std::collections::BTreeSet<_> = s.sources.iter().map(|f| f.id).collect();
|
||||
ed.selected_sources = sources.unwrap_or_default().into_iter().filter(|id| known.remove(id)).collect();
|
||||
if s.edit.link_timeline_edit {
|
||||
s.edit.timeline_selection = s.edit.selection;
|
||||
}
|
||||
if ed.link_timeline_edit {
|
||||
ed.timeline_selection = ed.selection;
|
||||
}
|
||||
let out = json!({"selection": ed.selection, "tracks": ed.selected_tracks, "clips": ed.selected_clips, "sources": ed.selected_sources});
|
||||
if ed != s.edit {
|
||||
e.session_mut().edit = ed;
|
||||
}
|
||||
Ok(out)
|
||||
Ok(json!({"selection": s.edit.selection, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips}))
|
||||
}
|
||||
|
||||
fn shift_selection(e: &mut Engine, dir: i64) -> Result<Value> {
|
||||
@@ -765,21 +752,3 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(json!({"consolidated": n}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selecting_what_is_already_selected_leaves_the_document_alone() {
|
||||
let mut e = Engine::default();
|
||||
let ids = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone();
|
||||
let sel = json!({"tracks": [ids[0]], "start": 100, "end": 200});
|
||||
e.execute("edit.select", &sel).unwrap();
|
||||
let rev = e.revision;
|
||||
assert_eq!(e.execute("edit.select", &sel).unwrap()["tracks"], json!([ids[0]]));
|
||||
assert_eq!(e.revision, rev);
|
||||
e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 100, "end": 300})).unwrap();
|
||||
assert_ne!(e.revision, rev);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,41 +15,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"name": e.session().name, "tracks": e.session().tracks.len(), "missing": missing}))
|
||||
}),
|
||||
cmd!(noundo "session.close", "Close Session", ["File"], Some("Cmd+Shift+W"), "{}", always, |e, _| { e.replace_session(Session::default()); e.path = None; Ok(json!({})) }),
|
||||
cmd!(noundo "session.save", "Save Session", ["File"], Some("Cmd+S"), "{path?, replace?: false}", always, |e, p| {
|
||||
cmd!(noundo "session.save", "Save Session", ["File"], Some("Cmd+S"), "{path?}", always, |e, p| {
|
||||
let path = str_param(p, "path").map(str::to_string).or_else(|| e.path.clone()).ok_or_else(|| bad("session.save", "no path yet: pass `path` (Save As)"))?;
|
||||
refuse_replacing(e, "session.save", &crate::io::session_file(&path), p)?;
|
||||
let written = crate::io::save_session(e, &path)?;
|
||||
Ok(json!({"path": path, "audio_files_written": written}))
|
||||
}),
|
||||
cmd!(noundo "session.save_as", "Save Session As...", ["File"], Some("Cmd+Shift+S"), "{path, replace?: false}", always, |e, p| {
|
||||
cmd!(noundo "session.save_as", "Save Session As...", ["File"], Some("Cmd+Shift+S"), "{path}", always, |e, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("session.save_as", "`path` required"))?.to_string();
|
||||
refuse_replacing(e, "session.save_as", &crate::io::session_file(&path), p)?;
|
||||
let written = crate::io::save_session(e, &path)?;
|
||||
Ok(json!({"path": path, "audio_files_written": written}))
|
||||
}),
|
||||
cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path, replace?: false}", always, |e, p| {
|
||||
cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path}", always, |e, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("session.save_copy", "`path` required"))?.to_string();
|
||||
refuse_replacing(e, "session.save_copy", &crate::io::session_file(&path), p)?;
|
||||
let written = crate::io::save_session_copy(e, &path)?;
|
||||
let keep = e.path.clone();
|
||||
let written = crate::io::save_session(e, &path)?;
|
||||
e.path = keep;
|
||||
Ok(json!({"path": path, "audio_files_written": written}))
|
||||
}),
|
||||
cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path, replace?: false}", always, |e, p| {
|
||||
cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path}", always, |e, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("session.save_template", "`path` required"))?.to_string();
|
||||
let file = crate::io::session_file(&path);
|
||||
refuse_replacing(e, "session.save_template", &file, p)?;
|
||||
let mut s = e.session().clone();
|
||||
for t in &mut s.tracks { for pl in &mut t.playlists { pl.clips.clear() } }
|
||||
s.sources.clear();
|
||||
s.videos.clear();
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
crate::io::write_file(&file.to_string_lossy(), text.as_bytes())?;
|
||||
} else {
|
||||
crate::io::replace_file(&file.with_extension("scraft.tmp"), &file, text.as_bytes())?;
|
||||
}
|
||||
Ok(json!({"path": file}))
|
||||
std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
Ok(json!({"path": path}))
|
||||
}),
|
||||
cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", |e| if e.path.is_some() { Ok(()) } else { Err("the session has not been saved".into()) }, |e, _| {
|
||||
cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", always, |e, _| {
|
||||
let path = e.path.clone().ok_or_else(|| bad("session.revert", "the session has not been saved"))?;
|
||||
crate::io::open_session(e, &path)?;
|
||||
Ok(json!({}))
|
||||
@@ -105,30 +98,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string();
|
||||
let only = if p.get("tracks").is_some() || p.get("track").is_some() { Some(tracks_param(e, "file.export_midi", p)?) } else { None };
|
||||
let bytes = crate::io::export_midi(e, only.as_deref())?;
|
||||
crate::io::write_file(&path, &bytes)?;
|
||||
let bytes = crate::io::export_midi(e)?;
|
||||
std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}),
|
||||
cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?}", always, |e, p| {
|
||||
let text = crate::inspect::session_text(e);
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
crate::io::write_file(path, text.as_bytes())?;
|
||||
std::fs::write(path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
}
|
||||
Ok(json!({"text": text}))
|
||||
}),
|
||||
cmd!(noundo "file.export_clips", "Export Clips as Files...", [], Some("Cmd+Shift+K"), "{dir, clips?, format?: wav}", always, |e, p| {
|
||||
cmd!(noundo "file.export_clips", "Export Clips as Files...", [], None, "{dir, clips?, format?: wav}", always, |e, p| {
|
||||
let dir = str_param(p, "dir").ok_or_else(|| bad("file.export_clips", "`dir` required"))?.to_string();
|
||||
let ids = clip_ids_param(e, p);
|
||||
if ids.is_empty() {
|
||||
return Err(bad("file.export_clips", "no clips selected"));
|
||||
}
|
||||
let n = crate::io::export_clips(e, &ids, &dir)?;
|
||||
Ok(json!({"written": n, "dir": dir}))
|
||||
Ok(json!({"written": n}))
|
||||
}),
|
||||
cmd!(noundo "file.export_selected_tracks_as_session", "Selected Tracks as New Session...", ["File", "Export"], None, "{path, tracks?, replace?: false}", has_selection, |e, p| {
|
||||
cmd!(noundo "file.export_selected_tracks_as_session", "Selected Tracks as New Session...", ["File", "Export"], None, "{path, tracks?}", has_selection, |e, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.export_selected_tracks_as_session", "`path` required"))?.to_string();
|
||||
refuse_replacing(e, "file.export_selected_tracks_as_session", &crate::io::session_file(&path), p)?;
|
||||
let keep = tracks_required(e, "file.export_selected_tracks_as_session", p)?;
|
||||
let mut s = e.session().clone();
|
||||
s.tracks.retain(|t| keep.contains(&t.id) || t.kind == soundcraft_model::TrackKind::Master);
|
||||
@@ -192,14 +180,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Writing a session over an existing file needs `replace: true` (see `io::replaces`).
|
||||
fn refuse_replacing(e: &Engine, id: &str, file: &std::path::Path, p: &Value) -> Result<()> {
|
||||
if crate::io::replaces(e, id, file) && !bool_or(p, "replace", false) {
|
||||
return Err(bad(id, format!("{} already exists: pass `replace: true` to replace it", file.display())));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn new_session(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").unwrap_or("Untitled").to_string();
|
||||
let sr =
|
||||
@@ -254,17 +234,6 @@ fn import_audio_target(e: &Engine, p: &Value) -> Result<Option<TrackId>> {
|
||||
selected.map(Some).ok_or_else(|| bad("file.import_audio", "`new_tracks: false` needs `track` or a selected audio track"))
|
||||
}
|
||||
|
||||
/// `source` omitted or `main` is the main mix. Any other name is a bus, and an unknown bus is an error.
|
||||
fn bounce_bus(e: &Engine, p: &Value) -> Result<Option<soundcraft_model::BusId>> {
|
||||
let Some(name) = str_param(p, "source").map(str::trim).filter(|s| !s.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if name.eq_ignore_ascii_case("main") {
|
||||
return Ok(None);
|
||||
}
|
||||
e.session().bus_by_name(name).map(|b| Some(b.id)).ok_or_else(|| bad("file.bounce_mix", format!("unknown bus `{name}`")))
|
||||
}
|
||||
|
||||
/// `fold_down: "stereo"` bounces an ITU stereo fold-down of a surround mix.
|
||||
fn fold_down_param(p: &Value, id: &str) -> Result<bool> {
|
||||
match str_param(p, "fold_down").map(str::trim) {
|
||||
@@ -291,9 +260,8 @@ fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None };
|
||||
let normalize = bool_or(p, "normalize", false);
|
||||
let fold = fold_down_param(p, "file.bounce_mix")?;
|
||||
let bus = bounce_bus(e, p)?;
|
||||
let (bytes, stats) = crate::io::bounce_source(e, r, &opts, normalize, fold, bus)?;
|
||||
crate::io::write_file(&path, &bytes)?;
|
||||
let (bytes, stats) = crate::io::bounce_bytes_with(e, r, &opts, normalize, fold)?;
|
||||
std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
Ok(
|
||||
json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1, "true_peak_db": stats.2}),
|
||||
)
|
||||
@@ -312,7 +280,7 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result<Value>
|
||||
Some(p) => p.to_string(),
|
||||
None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(),
|
||||
};
|
||||
crate::io::write_file(&path, text.as_bytes())?;
|
||||
std::fs::write(&path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0);
|
||||
Ok(json!({"path": path, "parts": parts.len(), "measures": measures}))
|
||||
}
|
||||
@@ -397,53 +365,6 @@ mod tests {
|
||||
assert!(std::fs::remove_dir_all(&dir).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_source_renders_the_named_bus() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-bounce-source-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let wav = tone_wav(&dir, "tone.wav");
|
||||
let mut e = Engine::default();
|
||||
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
|
||||
let stem = e.session_mut().add_bus("Stem", ChannelFormat::Stereo);
|
||||
e.session_mut().add_bus("Empty", ChannelFormat::Stereo);
|
||||
let track = e.session().tracks[0].id;
|
||||
{
|
||||
let tr = e.session_mut().track_mut(track).unwrap();
|
||||
let mut send = soundcraft_model::SendSlot::new(soundcraft_model::Route::Bus(stem));
|
||||
send.level_db = -12.0;
|
||||
tr.mixer.sends[0] = Some(send);
|
||||
}
|
||||
let bounce = |e: &mut Engine, name: &str, source: Option<&str>| {
|
||||
let path = dir.join(name);
|
||||
let mut params = json!({"path": path.to_string_lossy(), "start": 0, "end": 4800, "bit_depth": "32f", "dither": false});
|
||||
if let Some(source) = source {
|
||||
params["source"] = json!(source);
|
||||
}
|
||||
let value = e.execute("file.bounce_mix", ¶ms).unwrap();
|
||||
let peak = value.get("peak_db").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
|
||||
(std::fs::read(&path).unwrap(), peak)
|
||||
};
|
||||
let (main_bytes, main_peak) = bounce(&mut e, "main.wav", None);
|
||||
let (named_main, named_peak) = bounce(&mut e, "named-main.wav", Some("main"));
|
||||
let (stem_bytes, stem_peak) = bounce(&mut e, "stem.wav", Some("STEM"));
|
||||
let (empty_bytes, empty_peak) = bounce(&mut e, "empty.wav", Some("Empty"));
|
||||
assert_eq!(main_bytes, named_main);
|
||||
assert!((main_peak - named_peak).abs() < 1e-4, "{main_peak} vs {named_peak}");
|
||||
assert!(main_peak > -20.0, "{main_peak}");
|
||||
assert_ne!(stem_bytes, main_bytes);
|
||||
assert!(main_peak - stem_peak > 6.0 && main_peak - stem_peak < 18.0, "main {main_peak} stem {stem_peak}");
|
||||
assert_ne!(empty_bytes, main_bytes);
|
||||
assert!(empty_peak < -100.0, "{empty_peak}");
|
||||
let missing = dir.join("missing.wav");
|
||||
let err = e
|
||||
.execute("file.bounce_mix", &json!({"path": missing.to_string_lossy(), "source": "NoSuchBus", "start": 0, "end": 4800, "dither": false}))
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("unknown bus"), "{err}");
|
||||
assert!(!missing.exists());
|
||||
assert!(std::fs::remove_dir_all(&dir).is_ok());
|
||||
}
|
||||
|
||||
fn remove_media(dir: &std::path::Path) {
|
||||
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let p = entry.path();
|
||||
@@ -459,7 +380,6 @@ mod tests {
|
||||
fn session_open_reports_missing_media() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-open-missing-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = dir.join("Gone.scraft").to_string_lossy().into_owned();
|
||||
e.execute("session.save_as", &json!({"path": path})).unwrap();
|
||||
let mut ok = Engine::default();
|
||||
@@ -471,16 +391,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_a_session_without_audio_creates_its_folder() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-new-folder-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = dir.join("Song").join("Song.scraft");
|
||||
Engine::default().execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(path.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn tone_engine() -> (Engine, u64) {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
|
||||
@@ -518,154 +428,4 @@ mod tests {
|
||||
assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_the_selected_clips_and_refuses_an_empty_selection() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-clips-{}", std::process::id()));
|
||||
let p = json!({"dir": dir.to_string_lossy()});
|
||||
e.execute("edit.select_none", &json!({})).unwrap();
|
||||
assert!(e.execute("file.export_clips", &p).is_err());
|
||||
e.execute("edit.select", &json!({"tracks": ["Kick"], "start": {"seconds": 10.0}, "end": {"seconds": 11.0}})).unwrap();
|
||||
let r = e.execute("file.export_clips", &p).unwrap();
|
||||
let files = std::fs::read_dir(&dir).map_or(0, Iterator::count);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(files >= 1 && r["written"] == files, "{r}, {files} files");
|
||||
}
|
||||
|
||||
fn midi_engine() -> Engine {
|
||||
use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence};
|
||||
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
|
||||
let mut s = Session::default();
|
||||
let keys = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Keys"));
|
||||
let drums = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Drums"));
|
||||
let _audio = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Room"));
|
||||
let mut keys_seq = Sequence::default();
|
||||
keys_seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 });
|
||||
keys_seq.ctrls.push(CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 90 });
|
||||
let mut drum_seq = Sequence::default();
|
||||
drum_seq.notes.push(Note { pitch: 36, velocity: 110, release_velocity: 64, channel: 9, start: 0, length: 120 });
|
||||
drum_seq.ctrls.push(CtrlEvent { tick: 20, channel: 9, kind: CtrlKind::Cc(11), value: 40 });
|
||||
let a = s.new_clip_id();
|
||||
let b = s.new_clip_id();
|
||||
crate::edit::place_clip(&mut s, keys, Clip::midi(a, "Keys", 0, 48_000, keys_seq));
|
||||
crate::edit::place_clip(&mut s, drums, Clip::midi(b, "Drums", 0, 48_000, drum_seq));
|
||||
Engine::new(s)
|
||||
}
|
||||
|
||||
fn cc_numbers(bytes: &[u8]) -> Vec<(String, u8, i32)> {
|
||||
let smf = soundcraft_midi::read_smf(bytes).unwrap();
|
||||
let mut out = Vec::new();
|
||||
for t in &smf.tracks {
|
||||
for c in &t.sequence.ctrls {
|
||||
if let soundcraft_midi::CtrlKind::Cc(n) = c.kind {
|
||||
out.push((t.name.clone(), n, c.value));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midi_export_keeps_controllers_and_honours_tracks() {
|
||||
let mut e = midi_engine();
|
||||
let dir = std::env::temp_dir().join(format!("sc-midi-export-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let all = dir.join("all.mid");
|
||||
e.execute("file.export_midi", &json!({"path": all.to_string_lossy()})).unwrap();
|
||||
let ctrls = cc_numbers(&std::fs::read(&all).unwrap());
|
||||
assert!(ctrls.iter().any(|(n, cc, v)| n == "Keys" && *cc == 7 && *v == 90), "{ctrls:?}");
|
||||
assert!(ctrls.iter().any(|(n, cc, v)| n == "Drums" && *cc == 11 && *v == 40), "{ctrls:?}");
|
||||
assert!(ctrls.iter().all(|(n, _, _)| n != "Room"));
|
||||
|
||||
let keys = dir.join("keys.mid");
|
||||
e.execute("file.export_midi", &json!({"path": keys.to_string_lossy(), "tracks": ["Keys"]})).unwrap();
|
||||
let ctrls = cc_numbers(&std::fs::read(&keys).unwrap());
|
||||
assert_eq!(ctrls.len(), 1, "{ctrls:?}");
|
||||
assert_eq!(ctrls[0].0, "Keys");
|
||||
|
||||
let err = e.execute("file.export_midi", &json!({"path": dir.join("nope.mid").to_string_lossy(), "tracks": ["Room"]})).unwrap_err();
|
||||
assert!(err.to_string().contains("none of the requested tracks hold MIDI"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_surround_creates_a_matching_track() {
|
||||
let channels: Vec<Vec<f32>> = (0..6).map(|ch| vec![0.1 * (ch as f32 + 1.0); 32]).collect();
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels };
|
||||
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
|
||||
let mut e = Engine::default();
|
||||
let v = crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, None, 0).unwrap();
|
||||
assert_eq!(v["channels"], 6);
|
||||
assert_eq!(e.session().tracks.len(), 1);
|
||||
assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51);
|
||||
let src = e.session().sources[0].id;
|
||||
let audio = e.session().pool.get(src).unwrap();
|
||||
assert_eq!(audio.buffer.channels.len(), 6);
|
||||
assert!((audio.buffer.channels[5][0] - 0.6).abs() < 1e-3);
|
||||
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"name": "Bed", "format": "5.1"})).unwrap();
|
||||
let id = e.session().tracks[0].id;
|
||||
crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, Some(id), 0).unwrap();
|
||||
assert_eq!(e.session().tracks.len(), 1);
|
||||
assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51);
|
||||
assert_eq!(e.session().tracks[0].clips().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_consolidate_keeps_distinct_audio_files() {
|
||||
let (mut e, track) = tone_engine();
|
||||
e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap();
|
||||
let cid = e.session().tracks[0].clips()[0].id;
|
||||
e.execute("clip.gain", &json!({"clip": cid.0, "db": -24.0})).unwrap();
|
||||
e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap();
|
||||
let consolidated: Vec<_> = e.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| s.path.clone()).collect();
|
||||
assert_eq!(consolidated.len(), 2, "{consolidated:?}");
|
||||
assert_ne!(consolidated[0], consolidated[1]);
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("sc-consolidate-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Tone.scraft");
|
||||
e.execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let mut opened = Engine::default();
|
||||
opened.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let files: Vec<_> = opened.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| dir.join(&s.path)).collect();
|
||||
assert_eq!(files.len(), 2, "{:?}", opened.session().sources);
|
||||
let a = std::fs::read(&files[0]).unwrap();
|
||||
let b = std::fs::read(&files[1]).unwrap();
|
||||
assert_ne!(a, b, "the second consolidation replaced the first file");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_copy_leaves_the_open_session_alone() {
|
||||
let (mut e, _) = tone_engine();
|
||||
let dir = std::env::temp_dir().join(format!("sc-save-copy-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let original = dir.join("Original.scraft");
|
||||
e.execute("session.save_as", &json!({"path": original.to_string_lossy()})).unwrap();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Extra", "format": "mono"})).unwrap();
|
||||
assert!(e.is_dirty());
|
||||
let copy_dir = dir.join("copy");
|
||||
std::fs::create_dir_all(©_dir).unwrap();
|
||||
let copy = copy_dir.join("Copy.scraft");
|
||||
e.execute("session.save_copy", &json!({"path": copy.to_string_lossy()})).unwrap();
|
||||
assert_eq!(e.path.as_deref(), Some(original.to_string_lossy().as_ref()));
|
||||
assert_eq!(e.session().name, "Original");
|
||||
assert!(e.is_dirty());
|
||||
assert_eq!(e.session().tracks.len(), 2);
|
||||
|
||||
let mut from_original = Engine::default();
|
||||
from_original.execute("session.open", &json!({"path": original.to_string_lossy()})).unwrap();
|
||||
assert_eq!(from_original.session().tracks.len(), 1);
|
||||
let mut from_copy = Engine::default();
|
||||
from_copy.execute("session.open", &json!({"path": copy.to_string_lossy()})).unwrap();
|
||||
assert_eq!(from_copy.session().tracks.len(), 2);
|
||||
assert!(from_copy.session().track_by_name("Extra").is_some());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,26 +8,15 @@ use serde_json::json;
|
||||
use soundcraft_model::{Insert, TrackKind};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"mix.instrument",
|
||||
"Set Instrument",
|
||||
[],
|
||||
None,
|
||||
"{track?, plugin: id} — an instrument track's instrument: built-in, `clap:`, `vst3:` or `au:` id",
|
||||
has_selection,
|
||||
set_instrument
|
||||
),
|
||||
cmd!(
|
||||
"mix.instrument_param",
|
||||
"Set Instrument Parameter",
|
||||
[],
|
||||
None,
|
||||
"{track?, param, value}: one parameter of an instrument track's built-in instrument (clamped into range)",
|
||||
has_selection,
|
||||
set_instrument_param
|
||||
),
|
||||
]
|
||||
vec![cmd!(
|
||||
"mix.instrument",
|
||||
"Set Instrument",
|
||||
[],
|
||||
None,
|
||||
"{track?, plugin: id} — an instrument track's instrument: built-in, `clap:`, `vst3:` or `au:` id",
|
||||
has_selection,
|
||||
set_instrument
|
||||
)]
|
||||
}
|
||||
|
||||
fn set_instrument(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
@@ -50,20 +39,6 @@ fn set_instrument(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"track": t, "plugin": info.id}))
|
||||
}
|
||||
|
||||
fn set_instrument_param(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "mix.instrument_param";
|
||||
let param = str_param(p, "param").ok_or_else(|| bad(id, "`param` required"))?.to_string();
|
||||
let value = p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(id, "`value` must be a number"))? as f32;
|
||||
let t = tracks_required(e, id, p)?.first().copied().ok_or_else(|| bad(id, "no track"))?;
|
||||
let ins = e.session_mut().track_mut(t).and_then(|tr| tr.instrument.as_mut()).ok_or_else(|| bad(id, "the track has no instrument"))?;
|
||||
// Hosted instruments keep their parameters in their own state.
|
||||
let info = soundcraft_dsp::plugin_info(&ins.plugin).ok_or_else(|| bad(id, format!("`{}` is not a built-in instrument", ins.plugin)))?;
|
||||
let pi = info.param(¶m).ok_or_else(|| bad(id, format!("`{}` has no parameter `{param}`", info.id)))?;
|
||||
let v = pi.clamp(value);
|
||||
ins.params.insert(param.clone(), v);
|
||||
Ok(json!({"track": t, "param": param, "value": v}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::Engine;
|
||||
@@ -93,24 +68,6 @@ mod tests {
|
||||
assert_eq!(instrument_of(&e, t).as_deref(), Some("subtractive_synth"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sets_a_built_in_instruments_parameters() {
|
||||
let mut e = Engine::default();
|
||||
let t = track(&mut e, "instrument");
|
||||
let r = e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": 5000})).unwrap();
|
||||
assert_eq!(r["value"], 5000.0);
|
||||
let r = e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": 1.0e9})).unwrap();
|
||||
assert_eq!(r["value"], 20000.0, "clamped");
|
||||
let v = e.session().track(t).and_then(|tr| tr.instrument.as_ref()).and_then(|i| i.params.get("cutoff").copied());
|
||||
assert_eq!(v, Some(20000.0));
|
||||
assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "nope", "value": 1})).is_err());
|
||||
assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff"})).is_err());
|
||||
assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": "loud"})).is_err());
|
||||
let audio = track(&mut e, "audio");
|
||||
assert!(e.execute("mix.instrument_param", &json!({"track": audio.0, "param": "cutoff", "value": 1})).is_err());
|
||||
e.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_effects_unknown_plugins_and_other_track_kinds() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -105,15 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
insert_params
|
||||
),
|
||||
cmd!("mix.insert_move", "Move Insert", [], None, "{track?, from, to}", has_selection, insert_move),
|
||||
cmd!(
|
||||
"mix.send",
|
||||
"Assign Send",
|
||||
[],
|
||||
None,
|
||||
"{track?, slot?: 0..9|a..j, bus: name, level_db?: 0 (kept when re-targeting), pre_fader?: false}",
|
||||
has_selection,
|
||||
send
|
||||
),
|
||||
cmd!("mix.send", "Assign Send", [], None, "{track?, slot?: 0..9|a..j, bus: name, level_db?: 0, pre_fader?: false}", has_selection, send),
|
||||
cmd!("mix.send_remove", "Remove Send", [], None, "{track?, slot}", has_selection, |e, p| {
|
||||
let (t, slot) = track_slot(e, p, "mix.send_remove", SEND_SLOTS)?;
|
||||
if let Some(tr) = e.session_mut().track_mut(t)
|
||||
@@ -398,17 +390,15 @@ fn send(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let bus = str_param(p, "bus").ok_or_else(|| bad("mix.send", "`bus` required"))?.to_string();
|
||||
let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?;
|
||||
let explicit = slot_of(p, "slot", SEND_SLOTS);
|
||||
let level = f32_or(p, "level_db", 0.0).clamp(-144.0, 12.0);
|
||||
let pre = bool_or(p, "pre_fader", false);
|
||||
let s = e.session_mut();
|
||||
let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo);
|
||||
let tr = s.track_mut(t).ok_or_else(|| bad("mix.send", "no track"))?;
|
||||
let slot = explicit.or_else(|| tr.mixer.sends.iter().position(Option::is_none)).ok_or_else(|| bad("mix.send", "all send slots are in use"))?;
|
||||
// Re-targeting an existing send keeps its level, pan, mute and pre/post setting.
|
||||
let mut sl = match tr.mixer.sends.get(slot).cloned().flatten() {
|
||||
Some(old) => SendSlot { target: soundcraft_model::Route::Bus(bid), ..old },
|
||||
None => SendSlot::new(soundcraft_model::Route::Bus(bid)),
|
||||
};
|
||||
sl.level_db = f32_or(p, "level_db", sl.level_db).clamp(-144.0, 12.0);
|
||||
sl.pre_fader = bool_or(p, "pre_fader", sl.pre_fader);
|
||||
let mut sl = SendSlot::new(soundcraft_model::Route::Bus(bid));
|
||||
sl.level_db = level;
|
||||
sl.pre_fader = pre;
|
||||
if let Some(x) = tr.mixer.sends.get_mut(slot) {
|
||||
*x = Some(sl);
|
||||
}
|
||||
@@ -544,95 +534,25 @@ fn write_current(e: &mut Engine, p: &Value, all: bool) -> Result<Value> {
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
/// Clip-gain envelope value in dB. An empty envelope contributes nothing, and the last point holds.
|
||||
fn env_db(env: &[(i64, f32)], at: i64) -> f32 {
|
||||
let mut prev: Option<(i64, f32)> = None;
|
||||
for &(t, v) in env {
|
||||
if t >= at {
|
||||
return match prev {
|
||||
Some((pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32),
|
||||
_ => v,
|
||||
};
|
||||
}
|
||||
prev = Some((t, v));
|
||||
}
|
||||
prev.map_or(0.0, |(_, v)| v)
|
||||
}
|
||||
|
||||
fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "automation.volume_to_clip_gain", p)?;
|
||||
let r = range_param(e, "automation.volume_to_clip_gain", p)?;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
let Some(tr) = s.track_mut(*t) else { continue };
|
||||
let Some(lane) = tr.lane(&AutoParam::Volume).cloned().filter(|l| !l.points.is_empty()) else { continue };
|
||||
let lane = tr.lane(&AutoParam::Volume).cloned();
|
||||
let base = tr.mixer.volume_db;
|
||||
let delta = |at: i64| lane.value_at(at, base) - base;
|
||||
let Some(lane) = lane else { continue };
|
||||
if let Some(pl) = tr.playlist_mut() {
|
||||
for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) {
|
||||
let sel0 = r.start.max(c.start);
|
||||
let sel1 = r.end.min(c.end());
|
||||
if sel1 <= sel0 {
|
||||
continue;
|
||||
}
|
||||
let rel = |at: i64| at - c.start;
|
||||
let rel0 = rel(sel0);
|
||||
let rel1 = rel(sel1);
|
||||
// Later writes win, so the selection anchors replace any point they land on.
|
||||
let mut pts: Vec<(i64, f32)> =
|
||||
c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
|
||||
if rel0 > 0 {
|
||||
pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1)));
|
||||
}
|
||||
pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0)));
|
||||
for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) {
|
||||
let at = rel(pt.at);
|
||||
pts.push((at, env_db(&c.gain_env, at) + delta(pt.at)));
|
||||
}
|
||||
// The clip's own gain points inside the selection keep their shape, offset by the lane.
|
||||
for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
|
||||
pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at))));
|
||||
}
|
||||
if sel1 - 1 > sel0 {
|
||||
let at = rel(sel1 - 1);
|
||||
pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1)));
|
||||
}
|
||||
if sel1 < c.end() {
|
||||
pts.push((rel1, env_db(&c.gain_env, rel1)));
|
||||
}
|
||||
pts.sort_by_key(|p| p.0);
|
||||
let mut env = Vec::new();
|
||||
for p in pts {
|
||||
if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
|
||||
env.pop();
|
||||
}
|
||||
env.push(p);
|
||||
}
|
||||
let mut env: Vec<(i64, f32)> =
|
||||
lane.points.iter().filter(|pt| c.range().contains(pt.at)).map(|pt| (pt.at - c.start, pt.value - base)).collect();
|
||||
env.insert(0, (0, lane.value_at(c.start, base) - base));
|
||||
env.push((c.length, lane.value_at(c.end(), base) - base));
|
||||
c.gain_env = env;
|
||||
}
|
||||
}
|
||||
// Clearing the lane is only safe when no audio plays outside the selection; otherwise the
|
||||
// held edge values outside would fall back to the fader level.
|
||||
let clips_inside = tr.clips().iter().all(|c| c.start >= r.start && c.end() <= r.end);
|
||||
let covers_all = clips_inside && lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end);
|
||||
let lane_m = tr.lane_mut(&AutoParam::Volume);
|
||||
if covers_all {
|
||||
lane_m.clear_range(r.start, r.end);
|
||||
} else {
|
||||
// Hold the fader across the selection and anchor the samples on either side, so the
|
||||
// curve outside does not interpolate through the gap or keep the old level inside.
|
||||
let before = (r.start > 0).then(|| lane.value_at(r.start - 1, base));
|
||||
let after = lane.value_at(r.end, base);
|
||||
lane_m.clear_range(r.start, r.end);
|
||||
if let Some(v) = before {
|
||||
lane_m.set_point(r.start - 1, v);
|
||||
}
|
||||
lane_m.set_point(r.start, base);
|
||||
if r.end - 1 > r.start {
|
||||
lane_m.set_point(r.end - 1, base);
|
||||
}
|
||||
lane_m.set_point(r.end, after);
|
||||
}
|
||||
tr.lane_mut(&AutoParam::Volume).clear_range(r.start, r.end);
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
@@ -686,132 +606,3 @@ fn copy_to_send(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
|
||||
use soundcraft_time::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn rms_db(before: &[f32], after: &[f32]) -> f32 {
|
||||
let energy = |xs: &[f32]| xs.iter().map(|x| x * x).sum::<f32>() / xs.len().max(1) as f32;
|
||||
10.0 * (energy(after).max(1e-20) / energy(before).max(1e-20)).log10()
|
||||
}
|
||||
|
||||
fn tone_session(points: &[(i64, f32)]) -> (Engine, TrackId) {
|
||||
let mut s = Session::default();
|
||||
let n = 8192;
|
||||
let tone: Vec<f32> = (0..n).map(|i| (i as f32 * 0.07).sin() * 0.25).collect();
|
||||
s.pool.insert(SourceId(910), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![tone] })));
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
|
||||
let id = s.new_clip_id();
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "tone", SourceId(910), 0, 0, n));
|
||||
s.track_mut(t).unwrap().mixer.volume_db = -3.0;
|
||||
for &(at, value) in points {
|
||||
s.track_mut(t).unwrap().lane_mut(&AutoParam::Volume).set_point(at, value);
|
||||
}
|
||||
s.edit.selected_tracks = vec![t];
|
||||
s.edit.selection = Range::new(2048, 6144);
|
||||
(Engine::new(s), t)
|
||||
}
|
||||
|
||||
fn rendered(s: &Session) -> Vec<f32> {
|
||||
soundcraft_mix::render_range(s, Range::new(0, 8192), 1024).into_iter().next().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn convert(e: &mut Engine, t: TrackId, start: i64, end: i64) {
|
||||
e.session_mut().edit.selection = Range::new(start, end);
|
||||
e.execute("automation.volume_to_clip_gain", &json!({"track": t.0, "start": start, "end": end})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_volume_to_clip_gain_keeps_each_region() {
|
||||
let points = [(0, -6.0), (2048, -6.0), (4096, -6.0), (6144, -6.0), (8192, -6.0)];
|
||||
let (mut e, t) = tone_session(&points);
|
||||
let before = rendered(e.session());
|
||||
convert(&mut e, t, 2048, 6144);
|
||||
let after = rendered(e.session());
|
||||
for (start, end) in [(0, 2048), (2048, 6144), (6144, 8192)] {
|
||||
let delta = rms_db(&before[start..end], &after[start..end]);
|
||||
assert!(delta.abs() < 0.02, "constant {start}..{end} changed by {delta} dB");
|
||||
}
|
||||
let tr = e.session().track(t).unwrap();
|
||||
assert!((tr.mixer.volume_db + 3.0).abs() < 1e-5);
|
||||
let lane = tr.lane(&AutoParam::Volume).unwrap();
|
||||
assert!((lane.value_at(100, 0.0) + 6.0).abs() < 1e-3);
|
||||
assert!((lane.value_at(3000, 0.0) + 3.0).abs() < 1e-3);
|
||||
assert!((lane.value_at(7000, 0.0) + 6.0).abs() < 1e-3);
|
||||
|
||||
let (mut e, t) = tone_session(&[]);
|
||||
let before = rendered(e.session());
|
||||
convert(&mut e, t, 2048, 6144);
|
||||
let after = rendered(e.session());
|
||||
let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(diff < 1e-5, "no lane changed a sample by {diff}");
|
||||
|
||||
let (mut e, t) = tone_session(&points);
|
||||
let before = rendered(e.session());
|
||||
convert(&mut e, t, 0, 8193);
|
||||
let after = rendered(e.session());
|
||||
let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(diff < 1e-4, "full selection changed a sample by {diff}");
|
||||
assert!(e.session().track(t).unwrap().lane(&AutoParam::Volume).is_none_or(|l| l.points.is_empty()));
|
||||
|
||||
for points in [
|
||||
[(0, -12.0), (2048, -9.0), (4096, -6.0), (6144, -3.0), (8192, 0.0)],
|
||||
[(0, -12.0), (2048, -6.0), (4096, -12.0), (6144, -6.0), (8192, -12.0)],
|
||||
] {
|
||||
let (mut e, t) = tone_session(&points);
|
||||
let before = rendered(e.session());
|
||||
convert(&mut e, t, 2048, 6144);
|
||||
let after = rendered(e.session());
|
||||
for (start, end) in [(0, 2048), (6144, 8192)] {
|
||||
let delta = rms_db(&before[start..end], &after[start..end]);
|
||||
assert!(delta.abs() < 0.02, "outside {start}..{end} changed by {delta} dB for {points:?}");
|
||||
}
|
||||
let delta = rms_db(&before[2048..6144], &after[2048..6144]);
|
||||
assert!(delta.abs() < 0.15, "inside changed by {delta} dB for {points:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lane_points_all_inside_the_selection_keep_the_outside_level() {
|
||||
let (mut e, t) = tone_session(&[(2048, -9.0), (6143, -9.0)]);
|
||||
let before = rendered(e.session());
|
||||
convert(&mut e, t, 2048, 6144);
|
||||
let after = rendered(e.session());
|
||||
for (start, end) in [(0, 2000), (2048, 6144), (6200, 8192)] {
|
||||
let delta = rms_db(&before[start..end], &after[start..end]);
|
||||
assert!(delta.abs() < 0.05, "{start}..{end} changed by {delta} dB");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_gain_points_inside_the_selection_are_kept() {
|
||||
let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]);
|
||||
let clip = e.session().track(t).unwrap().clips()[0].id;
|
||||
e.session_mut().find_clip_mut(clip).unwrap().gain_env = vec![(3000, 0.0), (4000, -20.0), (5000, 0.0)];
|
||||
let before = rendered(e.session());
|
||||
convert(&mut e, t, 2048, 6144);
|
||||
let after = rendered(e.session());
|
||||
for (start, end) in [(2048, 3000), (3500, 4500), (5000, 6144)] {
|
||||
let delta = rms_db(&before[start..end], &after[start..end]);
|
||||
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retargeting_a_send_keeps_its_settings() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
|
||||
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap();
|
||||
e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap();
|
||||
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap();
|
||||
let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap();
|
||||
let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id;
|
||||
assert_eq!(sl.target, soundcraft_model::Route::Bus(delay));
|
||||
assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ mod audio_midi;
|
||||
mod audiosuite;
|
||||
mod clap;
|
||||
mod clip;
|
||||
mod clip_list;
|
||||
mod clip_more;
|
||||
mod edit;
|
||||
mod edit_more;
|
||||
@@ -25,7 +24,6 @@ mod more_util;
|
||||
mod options;
|
||||
mod plugin_state;
|
||||
mod query;
|
||||
mod sampler;
|
||||
mod setup_more;
|
||||
mod surround;
|
||||
mod track;
|
||||
@@ -53,8 +51,6 @@ pub struct CommandSpec {
|
||||
pub run: Run,
|
||||
pub journal: bool,
|
||||
pub undoable: bool,
|
||||
/// Only touches `edit.zoom`, through `Engine::zoom_mut`.
|
||||
pub view: bool,
|
||||
}
|
||||
|
||||
/// Serializable description of a command.
|
||||
@@ -84,16 +80,18 @@ impl CommandSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo`/`view` variants.
|
||||
/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo` variants.
|
||||
#[macro_export]
|
||||
macro_rules! cmd {
|
||||
(query $($t:tt)*) => { $crate::cmd!(@spec false, false, false; $($t)*) };
|
||||
(noundo $($t:tt)*) => { $crate::cmd!(@spec true, false, false; $($t)*) };
|
||||
(view $($t:tt)*) => { $crate::cmd!(@spec false, false, true; $($t)*) };
|
||||
(@spec $journal:literal, $undoable:literal, $view:literal; $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: $journal, undoable: $undoable, view: $view }
|
||||
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
|
||||
};
|
||||
(noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
|
||||
};
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
|
||||
};
|
||||
($id:literal, $($t:tt)*) => { $crate::cmd!(@spec true, true, false; $id, $($t)*) };
|
||||
}
|
||||
|
||||
pub fn command_specs() -> &'static [CommandSpec] {
|
||||
@@ -118,7 +116,6 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(view_more::specs());
|
||||
v.extend(track_more::specs());
|
||||
v.extend(clip_more::specs());
|
||||
v.extend(clip_list::specs());
|
||||
v.extend(event_more::specs());
|
||||
v.extend(setup_more::specs());
|
||||
v.extend(clap::specs());
|
||||
@@ -126,7 +123,6 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(surround::specs());
|
||||
v.extend(plugin_state::specs());
|
||||
v.extend(instrument::specs());
|
||||
v.extend(sampler::specs());
|
||||
v.extend(video::specs());
|
||||
v
|
||||
})
|
||||
|
||||
@@ -17,7 +17,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
run: insert_state,
|
||||
journal: false,
|
||||
undoable: false,
|
||||
view: false,
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
//! The built-in Sampler's sample: `mix.sampler_load` decodes an audio file (WAV, AIFF, FLAC, MP3,
|
||||
//! OGG, anything `file.import_audio` reads) into a session source, resampled to the session rate,
|
||||
//! and makes it the sample an instrument track's Sampler plays; `mix.sampler_clear` removes it.
|
||||
//! The source is saved in `Audio Files/` with the session like any other audio, so a session that
|
||||
//! uses a sample reopens with it. Sampler parameters (root note, key range, ADSR, loop…) are set
|
||||
//! with `mix.instrument_param`, or all at once with `params` here.
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{Insert, TrackKind};
|
||||
use std::path::Path;
|
||||
|
||||
const SAMPLER: &str = "sampler";
|
||||
|
||||
/// Largest sample accepted (frames × channels): ten minutes of 192 kHz stereo.
|
||||
const MAX_SAMPLE_VALUES: usize = 192_000 * 600 * 2;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"mix.sampler_load",
|
||||
"Load Sampler Sample",
|
||||
[],
|
||||
None,
|
||||
"{track?, path, params?: {root_note, key_low, key_high, attack, loop_mode, …}}: an instrument track plays the file through the built-in Sampler (made its instrument if it is not already) → {track, source, name, frames, channels, sample_rate}",
|
||||
has_selection,
|
||||
load
|
||||
),
|
||||
cmd!("mix.sampler_clear", "Clear Sampler Sample", [], None, "{track?}: the track's Sampler stops playing its sample", has_selection, clear),
|
||||
]
|
||||
}
|
||||
|
||||
/// The instrument track a sampler command acts on.
|
||||
fn instrument_track(e: &Engine, id: &str, p: &Value) -> Result<TrackId> {
|
||||
let t = tracks_required(e, id, p)?.first().copied().ok_or_else(|| bad(id, "no track"))?;
|
||||
if e.session().track(t).map(|tr| tr.kind) != Some(TrackKind::Instrument) {
|
||||
return Err(bad(id, "only instrument tracks have a sampler"));
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
fn load(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "mix.sampler_load";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(id, "`path` required"))?.to_string();
|
||||
let t = instrument_track(e, id, p)?;
|
||||
let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?;
|
||||
// Validate the parameters before touching the session.
|
||||
let mut values = Vec::new();
|
||||
if let Some(v) = p.get("params") {
|
||||
let obj = v.as_object().ok_or_else(|| bad(id, "`params` must be an object"))?;
|
||||
for (k, v) in obj {
|
||||
let pi = info.param(k).ok_or_else(|| bad(id, format!("the sampler has no parameter `{k}`")))?;
|
||||
let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad(id, format!("`{k}` must be a number")))?;
|
||||
values.push((k.clone(), pi.clamp(x as f32)));
|
||||
}
|
||||
}
|
||||
let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let name = Path::new(&path).file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string();
|
||||
let ext = Path::new(&name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase);
|
||||
let (format, buf) = soundcraft_audio_io::decode(&bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
|
||||
if buf.frames() == 0 || buf.num_channels() == 0 {
|
||||
return Err(EngineError::Io(format!("{name}: no audio")));
|
||||
}
|
||||
if buf.frames().saturating_mul(buf.num_channels()) > MAX_SAMPLE_VALUES {
|
||||
return Err(EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")));
|
||||
}
|
||||
let channels = buf.num_channels();
|
||||
let s = e.session_mut();
|
||||
let src = crate::io::add_source(s, &name, buf, Some(&path), format.format);
|
||||
let (frames, rate, stem) = s.source(src).map_or((0, 0, String::new()), |x| (x.frames, x.sample_rate, x.name.clone()));
|
||||
let tr = s.track_mut(t).ok_or_else(|| bad(id, "no track"))?;
|
||||
let keep = tr.instrument.as_ref().is_some_and(|i| i.plugin == SAMPLER);
|
||||
if !keep {
|
||||
let mut ins = Insert::new(SAMPLER);
|
||||
for pi in info.params {
|
||||
ins.params.insert(pi.id.to_string(), pi.default);
|
||||
}
|
||||
tr.instrument = Some(ins);
|
||||
}
|
||||
if let Some(ins) = tr.instrument.as_mut() {
|
||||
ins.sample = Some(src);
|
||||
for (k, v) in values {
|
||||
ins.params.insert(k, v);
|
||||
}
|
||||
}
|
||||
Ok(json!({"track": t, "source": src, "name": stem, "frames": frames, "channels": channels, "sample_rate": rate}))
|
||||
}
|
||||
|
||||
fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "mix.sampler_clear";
|
||||
let t = instrument_track(e, id, p)?;
|
||||
let ins = e.session_mut().track_mut(t).and_then(|tr| tr.instrument.as_mut()).filter(|i| i.plugin == SAMPLER);
|
||||
let ins = ins.ok_or_else(|| bad(id, "the track's instrument is not the sampler"))?;
|
||||
let had = ins.sample.take();
|
||||
Ok(json!({"track": t, "cleared": had}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::Engine;
|
||||
use serde_json::json;
|
||||
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
|
||||
use soundcraft_midi::{Note, Sequence};
|
||||
use soundcraft_model::{Clip, TrackId};
|
||||
use soundcraft_time::{Range, TICKS_PER_QUARTER};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A scratch folder for one test.
|
||||
fn dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("sc-sampler-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Writes a generated tone (no recorded or third-party audio) as a 16-bit WAV.
|
||||
fn tone_wav(path: &std::path::Path, freq: f32, rate: u32, secs: f32, channels: usize) {
|
||||
let n = (rate as f32 * secs) as usize;
|
||||
let one: Vec<f32> = (0..n).map(|i| 0.5 * (std::f32::consts::TAU * freq * i as f32 / rate as f32).sin()).collect();
|
||||
let buf = AudioBuffer { sample_rate: rate, channels: vec![one; channels] };
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
std::fs::write(path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn instrument_track(e: &mut Engine) -> TrackId {
|
||||
e.execute("track.new", &json!({"kind": "instrument", "format": "stereo"})).unwrap();
|
||||
e.session().tracks.last().unwrap().id
|
||||
}
|
||||
|
||||
/// One MIDI note (pitch, start in quarter notes, length in quarters) on the track at 120 bpm.
|
||||
fn add_notes(e: &mut Engine, t: TrackId, notes: &[(u8, i64, i64)]) {
|
||||
let q = TICKS_PER_QUARTER;
|
||||
let mut seq = Sequence::default();
|
||||
for &(pitch, start, len) in notes {
|
||||
seq.notes.push(Note { pitch, velocity: 127, release_velocity: 64, channel: 0, start: start * q, length: len * q });
|
||||
}
|
||||
let s = e.session_mut();
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(s, t, Clip::midi(cid, "notes", 0, 48_000 * 8, seq));
|
||||
}
|
||||
|
||||
fn render(e: &Engine, secs: i64) -> Vec<Vec<f32>> {
|
||||
soundcraft_mix::render_range(e.session(), Range::new(0, 48_000 * secs), 512)
|
||||
}
|
||||
|
||||
fn peak(x: &[f32]) -> f32 {
|
||||
x.iter().fold(0.0f32, |m, v| m.max(v.abs()))
|
||||
}
|
||||
|
||||
fn crossings(x: &[f32]) -> usize {
|
||||
x.windows(2).filter(|w| w[0] < 0.0 && w[1] >= 0.0).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_a_sample_plays_it_and_undoes() {
|
||||
let d = dir("play");
|
||||
let wav = d.join("tone.wav");
|
||||
tone_wav(&wav, 440.0, 44_100, 2.0, 1);
|
||||
let mut e = Engine::default();
|
||||
let t = instrument_track(&mut e);
|
||||
add_notes(&mut e, t, &[(60, 0, 1), (72, 2, 1)]);
|
||||
assert!(peak(&render(&e, 2)[0]) > 0.0, "the default synth plays the notes first");
|
||||
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["frames"], 96_000, "resampled to the 48 kHz session: {r}");
|
||||
assert_eq!(r["sample_rate"], 48_000);
|
||||
assert_eq!(r["channels"], 1);
|
||||
assert_eq!(r["name"], "tone");
|
||||
let ins = e.session().track(t).unwrap().instrument.clone().unwrap();
|
||||
assert_eq!(ins.plugin, "sampler");
|
||||
assert_eq!(ins.sample.map(|s| s.0), r["source"].as_u64());
|
||||
let out = render(&e, 2);
|
||||
// Middle C plays the tone at its pitch for the first quarter (0.5 s), key 72 an octave up.
|
||||
let first = &out[0][2400..21_600];
|
||||
let second = &out[0][50_400..69_600];
|
||||
assert!(peak(first) > 0.05, "the sample plays");
|
||||
let (a, b) = (crossings(first), crossings(second));
|
||||
assert!((a as f32 - 176.0).abs() <= 2.0, "440 Hz over 0.4 s: {a}");
|
||||
assert!((b as f32 - 352.0).abs() <= 3.0, "an octave up: {b}");
|
||||
assert!(peak(&out[0][36_000..48_000]) < 1e-3, "released between the notes");
|
||||
e.execute("edit.undo", &json!({})).unwrap();
|
||||
let back = e.session().track(t).unwrap().instrument.clone().unwrap();
|
||||
assert_eq!(back.plugin, "subtractive_synth");
|
||||
assert!(back.sample.is_none());
|
||||
assert!(e.session().sources.is_empty(), "undo removes the source too");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_flac_and_aiff_too() {
|
||||
let d = dir("formats");
|
||||
let n = 4800;
|
||||
let tone: Vec<f32> = (0..n).map(|i| 0.5 * (std::f32::consts::TAU * 440.0 * i as f32 / 96_000.0).sin()).collect();
|
||||
let buf = AudioBuffer { sample_rate: 96_000, channels: vec![tone.clone(), tone] };
|
||||
let mut e = Engine::default();
|
||||
let t = instrument_track(&mut e);
|
||||
for (ext, format) in [("flac", FileFormat::Flac), ("aif", FileFormat::Aiff)] {
|
||||
let path = d.join(format!("tone.{ext}"));
|
||||
let opts = EncodeOptions { format, bit_depth: BitDepth::Int24, dither: false, bwf: None };
|
||||
std::fs::write(&path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
|
||||
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["channels"], 2, "{ext}");
|
||||
assert_eq!(r["frames"], 2400, "{ext}: 96 kHz resampled to the 48 kHz session");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instrument_params_reach_the_sampler() {
|
||||
let d = dir("params");
|
||||
let wav = d.join("tone.wav");
|
||||
tone_wav(&wav, 440.0, 48_000, 2.0, 2);
|
||||
let mut e = Engine::default();
|
||||
let t = instrument_track(&mut e);
|
||||
add_notes(&mut e, t, &[(60, 0, 1)]);
|
||||
e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"key_low": 61}})).unwrap();
|
||||
assert_eq!(peak(&render(&e, 1)[0]), 0.0, "key 60 is below the key range");
|
||||
e.execute("mix.instrument_param", &json!({"track": t.0, "param": "key_low", "value": 0})).unwrap();
|
||||
e.execute("mix.instrument_param", &json!({"track": t.0, "param": "root_note", "value": 48})).unwrap();
|
||||
let out = render(&e, 1);
|
||||
assert!((crossings(&out[0][2400..21_600]) as f32 - 352.0).abs() <= 3.0, "root note 48: key 60 plays an octave up");
|
||||
assert!(peak(&out[1]) > 0.05, "a stereo sample plays on both sides");
|
||||
assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"nope": 1}})).is_err());
|
||||
assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"root_note": "C3"}})).is_err());
|
||||
assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": 3})).is_err());
|
||||
assert_eq!(e.session().sources.len(), 1, "rejected loads add nothing");
|
||||
// Reloading keeps the sampler's settings and swaps the sample.
|
||||
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
|
||||
let ins = e.session().track(t).unwrap().instrument.clone().unwrap();
|
||||
assert_eq!(ins.params.get("root_note"), Some(&48.0));
|
||||
assert_eq!(ins.sample.map(|s| s.0), r["source"].as_u64());
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_silences_the_sampler() {
|
||||
let d = dir("clear");
|
||||
let wav = d.join("tone.wav");
|
||||
tone_wav(&wav, 440.0, 48_000, 1.0, 1);
|
||||
let mut e = Engine::default();
|
||||
let t = instrument_track(&mut e);
|
||||
add_notes(&mut e, t, &[(60, 0, 1)]);
|
||||
e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
|
||||
assert!(peak(&render(&e, 1)[0]) > 0.05);
|
||||
let r = e.execute("mix.sampler_clear", &json!({"track": t.0})).unwrap();
|
||||
assert!(r["cleared"].is_u64());
|
||||
assert_eq!(peak(&render(&e, 1)[0]), 0.0);
|
||||
e.execute("mix.instrument", &json!({"track": t.0, "plugin": "drum_synth"})).unwrap();
|
||||
assert!(e.execute("mix.sampler_clear", &json!({"track": t.0})).is_err(), "not a sampler");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sampled_session_saves_and_reopens_with_its_sample() {
|
||||
let d = dir("save");
|
||||
let wav = d.join("pluck.wav");
|
||||
tone_wav(&wav, 330.0, 22_050, 1.0, 1);
|
||||
let mut e = Engine::default();
|
||||
let t = instrument_track(&mut e);
|
||||
add_notes(&mut e, t, &[(64, 0, 1)]);
|
||||
e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"root_note": 64, "loop_mode": 1}})).unwrap();
|
||||
let before = render(&e, 1);
|
||||
let path = d.join("song.scraft");
|
||||
e.execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
std::fs::remove_file(&wav).unwrap();
|
||||
let mut e2 = Engine::default();
|
||||
e2.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let ins = e2.session().track(t).unwrap().instrument.clone().unwrap();
|
||||
assert_eq!(ins.plugin, "sampler");
|
||||
assert_eq!(ins.params.get("loop_mode"), Some(&1.0));
|
||||
let after = render(&e2, 1);
|
||||
assert!(peak(&after[0]) > 0.05, "the saved copy in Audio Files plays");
|
||||
let diff = before[0].iter().zip(&after[0]).fold(0.0f32, |m, (a, b)| m.max((a - b).abs()));
|
||||
assert!(diff < 1e-5, "same audio after reopening: {diff}");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_bad_input() {
|
||||
let d = dir("bad");
|
||||
let junk = d.join("junk.wav");
|
||||
std::fs::write(&junk, b"definitely not audio").unwrap();
|
||||
let empty = d.join("empty.wav");
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
std::fs::write(&empty, soundcraft_audio_io::encode(&AudioBuffer { sample_rate: 48_000, channels: vec![vec![]] }, &opts).unwrap()).unwrap();
|
||||
let wav = d.join("ok.wav");
|
||||
tone_wav(&wav, 440.0, 48_000, 0.1, 1);
|
||||
let mut e = Engine::default();
|
||||
let t = instrument_track(&mut e);
|
||||
e.execute("track.new", &json!({"kind": "audio"})).unwrap();
|
||||
let audio = e.session().tracks.last().unwrap().id;
|
||||
let load = |e: &mut Engine, track: u64, path: &std::path::Path| {
|
||||
e.execute("mix.sampler_load", &json!({"track": track, "path": path.to_string_lossy()}))
|
||||
};
|
||||
assert!(load(&mut e, t.0, &junk).is_err(), "not audio");
|
||||
assert!(load(&mut e, t.0, &empty).is_err(), "no frames");
|
||||
assert!(load(&mut e, t.0, &d.join("missing.wav")).is_err(), "missing file");
|
||||
assert!(load(&mut e, audio.0, &wav).is_err(), "an audio track");
|
||||
assert!(load(&mut e, 999_999, &wav).is_err(), "no such track");
|
||||
assert!(e.execute("mix.sampler_load", &json!({"track": t.0})).is_err(), "no path");
|
||||
assert!(e.execute("mix.sampler_clear", &json!({"track": audio.0})).is_err());
|
||||
assert!(e.session().sources.is_empty());
|
||||
assert_eq!(e.session().track(t).unwrap().instrument.as_ref().map(|i| i.plugin.as_str()), Some("subtractive_synth"));
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_media_plays_silence() {
|
||||
let d = dir("missing");
|
||||
let wav = d.join("tone.wav");
|
||||
tone_wav(&wav, 440.0, 48_000, 1.0, 1);
|
||||
let mut e = Engine::default();
|
||||
let t = instrument_track(&mut e);
|
||||
add_notes(&mut e, t, &[(60, 0, 1)]);
|
||||
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
|
||||
let src = soundcraft_model::SourceId(r["source"].as_u64().unwrap());
|
||||
e.session_mut().pool.remove(src);
|
||||
assert_eq!(peak(&render(&e, 1)[0]), 0.0);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
}
|
||||
@@ -436,8 +436,6 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut needed: Vec<SourceId> =
|
||||
picked.iter().flat_map(|t| t.playlists.iter().flat_map(|pl| pl.clips.iter().filter_map(|c| c.source()))).collect();
|
||||
// A Sampler's sample is session audio too.
|
||||
needed.extend(picked.iter().filter_map(|t| t.instrument.as_ref().and_then(|i| i.sample)));
|
||||
needed.sort_by_key(|x| x.0);
|
||||
needed.dedup();
|
||||
let sr = e.session().sample_rate.hz();
|
||||
@@ -483,10 +481,6 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The donor's source ids mean nothing here: remap the sample, or drop it when it didn't come along.
|
||||
if let Some(ins) = tr.instrument.as_mut() {
|
||||
ins.sample = ins.sample.and_then(|sid| map.get(&sid.0).copied());
|
||||
}
|
||||
// Busses are matched by name (created when missing).
|
||||
let remap = |s: &mut Session, r: &Route| -> Route {
|
||||
match r {
|
||||
@@ -609,26 +603,4 @@ mod tests {
|
||||
assert!(e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Nope"]})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_session_data_brings_a_samplers_sample() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-import-sampler-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let wav = dir.join("hit.wav");
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4_800]] };
|
||||
std::fs::write(&wav, soundcraft_audio_io::encode(&buf, &soundcraft_audio_io::EncodeOptions::default()).unwrap()).unwrap();
|
||||
let mut donor = Engine::default();
|
||||
donor.execute("track.new", &json!({"count": 1, "name": "Pads", "kind": "instrument"})).unwrap();
|
||||
donor.execute("mix.sampler_load", &json!({"track": "Pads", "path": wav.to_string_lossy()})).unwrap();
|
||||
let path = dir.join("donor.scraft").to_string_lossy().into_owned();
|
||||
crate::io::save_session(&mut donor, &path).unwrap();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Pads"]})).unwrap();
|
||||
let sample = e.session().track_by_name("Pads").and_then(|t| t.instrument.as_ref()).and_then(|i| i.sample).unwrap();
|
||||
let src = e.session().source(sample).unwrap();
|
||||
assert_eq!(src.name, "hit");
|
||||
assert!(e.session().pool.get(sample).is_some());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ fn save_preset(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
"comments": tr.comments,
|
||||
});
|
||||
let text = serde_json::to_string_pretty(&preset).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
crate::io::write_file(&path, text.as_bytes())?;
|
||||
std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
Ok(json!({"path": path, "track": first}))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,59 +7,54 @@ use soundcraft_time::{GridValue, NoteValue, TimeFormat};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(view "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5, None)),
|
||||
cmd!(view "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0, None)),
|
||||
cmd!(view "view.zoom_by", "Zoom By", [], None, "{factor: >1 zooms in, x?: px into the timeline to hold (else the insertion point if on screen)}", always, |e, p| {
|
||||
let f = p.get("factor").and_then(Value::as_f64).filter(|f| *f > 0.0).ok_or_else(|| bad("view.zoom_by", "factor must be a positive number"))?;
|
||||
zoom(e, 1.0 / f, p.get("x").and_then(Value::as_f64))
|
||||
}),
|
||||
cmd!(view "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| {
|
||||
let v = f64_or(p, "samples_per_px", 1024.0).clamp(MIN_SPP, MAX_SPP);
|
||||
e.zoom_mut().samples_per_px = v;
|
||||
cmd!(noundo "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5)),
|
||||
cmd!(noundo "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0)),
|
||||
cmd!(noundo "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| {
|
||||
let v = f64_or(p, "samples_per_px", 1024.0).clamp(0.25, 1_000_000.0);
|
||||
e.session_mut().edit.zoom.samples_per_px = v;
|
||||
Ok(json!({"samples_per_px": v}))
|
||||
}),
|
||||
cmd!(view "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| {
|
||||
cmd!(noundo "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| {
|
||||
let i = (i64_or(p, "preset", 1).clamp(1, 5) - 1) as usize;
|
||||
let z = e.zoom_mut();
|
||||
let s = e.session_mut();
|
||||
if bool_or(p, "store", false) {
|
||||
if let Some(slot) = z.presets.get_mut(i) { *slot = z.samples_per_px }
|
||||
} else if let Some(v) = z.presets.get(i) {
|
||||
z.samples_per_px = v.clamp(MIN_SPP, MAX_SPP);
|
||||
if let Some(z) = s.edit.zoom.presets.get_mut(i) { *z = s.edit.zoom.samples_per_px }
|
||||
} else if let Some(z) = s.edit.zoom.presets.get(i) {
|
||||
s.edit.zoom.samples_per_px = *z;
|
||||
}
|
||||
Ok(json!({"samples_per_px": z.samples_per_px}))
|
||||
Ok(json!({"samples_per_px": s.edit.zoom.samples_per_px}))
|
||||
}),
|
||||
cmd!(view "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| {
|
||||
cmd!(noundo "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| {
|
||||
let w = f64_or(p, "width_px", 1200.0).max(100.0);
|
||||
let r = e.session().edit.selection;
|
||||
let s = e.session_mut();
|
||||
let r = s.edit.selection;
|
||||
if !r.is_empty() {
|
||||
let z = e.zoom_mut();
|
||||
z.samples_per_px = (r.len() as f64 / w).clamp(MIN_SPP, MAX_SPP);
|
||||
z.scroll = r.start;
|
||||
s.edit.zoom.samples_per_px = (r.len() as f64 / w).max(0.25);
|
||||
s.edit.zoom.scroll = r.start;
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!(view "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| {
|
||||
cmd!(noundo "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| {
|
||||
let w = f64_or(p, "width_px", 1200.0).max(100.0);
|
||||
let s = e.session();
|
||||
let s = e.session_mut();
|
||||
let end = s.content_end().max(s.sample_rate.samples(30.0));
|
||||
let z = e.zoom_mut();
|
||||
z.samples_per_px = (end as f64 * 1.05 / w).clamp(MIN_SPP, MAX_SPP);
|
||||
z.scroll = 0;
|
||||
s.edit.zoom.samples_per_px = (end as f64 * 1.05 / w).max(0.25);
|
||||
s.edit.zoom.scroll = 0;
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!(view "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| {
|
||||
cmd!(noundo "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| {
|
||||
let to = position_param(e, "view.scroll", p, "to")?;
|
||||
let by = f64_or(p, "by_px", 0.0);
|
||||
let z = e.zoom_mut();
|
||||
let by = soundcraft_time::to_samples(by * z.samples_per_px);
|
||||
z.scroll = to.unwrap_or(z.scroll.saturating_add(by)).clamp(0, MAX_POSITION);
|
||||
Ok(json!({"scroll": z.scroll}))
|
||||
let s = e.session_mut();
|
||||
let spp = s.edit.zoom.samples_per_px;
|
||||
s.edit.zoom.scroll = to.unwrap_or(s.edit.zoom.scroll + soundcraft_time::to_samples(by * spp)).max(0);
|
||||
Ok(json!({"scroll": s.edit.zoom.scroll}))
|
||||
}),
|
||||
cmd!(view "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| {
|
||||
let z = e.zoom_mut();
|
||||
let w = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { z.waveform_zoom * f32_or(p, "factor", 2.0) };
|
||||
z.waveform_zoom = if w.is_finite() { w.clamp(0.25, 64.0) } else { 1.0 };
|
||||
Ok(json!({"waveform_zoom": z.waveform_zoom}))
|
||||
cmd!(noundo "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| {
|
||||
let s = e.session_mut();
|
||||
let z = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { s.edit.zoom.waveform_zoom * f32_or(p, "factor", 2.0) };
|
||||
s.edit.zoom.waveform_zoom = if z.is_finite() { z.clamp(0.25, 64.0) } else { 1.0 };
|
||||
Ok(json!({"waveform_zoom": s.edit.zoom.waveform_zoom}))
|
||||
}),
|
||||
cmd!(noundo "view.ruler", "Rulers", ["View", "Rulers"], None, "{ruler: bars_beats|min_secs|timecode|feet_frames|samples|tempo|meter|markers|key|chords, visible?: bool}", always, ruler),
|
||||
cmd!(noundo "view.main_counter", "Main Counter", ["View", "Main Counter"], None, "{format: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| {
|
||||
@@ -93,19 +88,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
const MIN_SPP: f64 = 0.25;
|
||||
const MAX_SPP: f64 = 1_000_000.0;
|
||||
|
||||
fn zoom(e: &mut Engine, f: f64, x: Option<f64>) -> Result<Value> {
|
||||
let insertion = e.session().edit.selection.start as f64;
|
||||
let z = e.zoom_mut();
|
||||
let (old, scroll) = (z.samples_per_px, z.scroll as f64);
|
||||
let new = (old * f).clamp(MIN_SPP, MAX_SPP);
|
||||
if let Some(x) = x.or_else(|| Some((insertion - scroll) / old).filter(|x| (0.0..4000.0).contains(x))) {
|
||||
z.scroll = soundcraft_time::to_samples(scroll + x * (old - new)).clamp(0, MAX_POSITION);
|
||||
fn zoom(e: &mut Engine, f: f64) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let anchor = s.edit.selection.start;
|
||||
let old = s.edit.zoom.samples_per_px;
|
||||
let new = (old * f).clamp(0.25, 1_000_000.0);
|
||||
// Keep the insertion point at the same screen x when it is visible.
|
||||
let x = (anchor - s.edit.zoom.scroll) as f64 / old;
|
||||
if (0.0..4000.0).contains(&x) {
|
||||
s.edit.zoom.scroll = (anchor - soundcraft_time::to_samples(x * new)).max(0);
|
||||
}
|
||||
z.samples_per_px = new;
|
||||
Ok(json!({"samples_per_px": new, "scroll": z.scroll}))
|
||||
s.edit.zoom.samples_per_px = new;
|
||||
Ok(json!({"samples_per_px": new}))
|
||||
}
|
||||
|
||||
fn ruler(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
@@ -163,55 +157,4 @@ mod tests {
|
||||
assert_eq!(grid_param(&json!({"v": {"seconds": 0.5}}), "v"), Some(GridValue::Seconds(0.5)));
|
||||
assert_eq!(grid_param(&json!({"v": "nonsense"}), "v"), None);
|
||||
}
|
||||
|
||||
fn view(e: &Engine) -> (f64, Samples) {
|
||||
(e.session().edit.zoom.samples_per_px, e.session().edit.zoom.scroll)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_by_holds_the_position_under_the_pointer() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("view.zoom_set", &json!({"samples_per_px": 100.0})).unwrap();
|
||||
e.execute("view.scroll", &json!({"to": 48_000})).unwrap();
|
||||
e.execute("view.zoom_by", &json!({"factor": 2.0, "x": 300.0})).unwrap();
|
||||
assert_eq!(view(&e), (50.0, 78_000 - 300 * 50));
|
||||
e.execute("view.zoom_by", &json!({"factor": 0.5, "x": 300.0})).unwrap();
|
||||
assert_eq!(view(&e), (100.0, 48_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_and_scroll_stay_in_range() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("edit.select", &json!({"start": 0, "end": 4_000_000_000_i64})).unwrap();
|
||||
e.execute("view.zoom_to_selection", &json!({})).unwrap();
|
||||
assert_eq!(view(&e).0, MAX_SPP);
|
||||
e.execute("view.zoom_by", &json!({"factor": 1e12, "x": 0.0})).unwrap();
|
||||
assert_eq!(view(&e).0, MIN_SPP);
|
||||
e.execute("view.zoom_by", &json!({"factor": 1e-300, "x": 1e300})).unwrap();
|
||||
assert_eq!(view(&e), (MAX_SPP, 0));
|
||||
for bad in [json!(0.0), json!(-2.0), json!("2"), json!(null)] {
|
||||
assert!(e.execute("view.zoom_by", &json!({"factor": bad})).is_err(), "{bad}");
|
||||
}
|
||||
e.execute("view.scroll", &json!({"by_px": 1e300})).unwrap();
|
||||
assert_eq!(view(&e).1, MAX_POSITION);
|
||||
e.execute("view.scroll", &json!({"by_px": -1e300})).unwrap();
|
||||
assert_eq!(view(&e).1, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_commands_skip_undo_the_journal_and_session_copies() {
|
||||
let mut e = Engine::default();
|
||||
let t = e.execute("track.new", &json!({})).unwrap()["tracks"][0].clone();
|
||||
let steps = e.undo_history().len();
|
||||
for db in [-1.0, -2.0] {
|
||||
e.execute_merged("mix.volume", &json!({"track": t, "db": db}), "fader").unwrap();
|
||||
e.execute("view.scroll", &json!({"by_px": 5.0})).unwrap();
|
||||
}
|
||||
assert_eq!(e.undo_history().len(), steps + 1);
|
||||
let state = |e: &Engine| (std::ptr::from_ref(e.session()), e.revision, e.journal.len());
|
||||
let before = state(&e);
|
||||
e.execute("view.zoom_by", &json!({"factor": 2.0})).unwrap();
|
||||
e.execute("view.scroll", &json!({"by_px": 1.0})).unwrap();
|
||||
assert_eq!(state(&e), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,6 @@ pub fn session(e: &Engine, full: bool) -> Value {
|
||||
})).collect::<Vec<_>>(),
|
||||
"selection": {"start": s.edit.selection.start, "end": s.edit.selection.end, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips},
|
||||
"edit_mode": s.edit.edit_mode, "tool": s.edit.tool, "grid": s.edit.grid.label(), "nudge": s.edit.nudge.label(),
|
||||
"zoom": {"samples_per_px": s.edit.zoom.samples_per_px, "scroll": s.edit.zoom.scroll},
|
||||
"transport": e.transport,
|
||||
"undo": e.undo_label(), "redo": e.redo_label(),
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::{Engine, EngineError, Result};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat};
|
||||
use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
|
||||
use soundcraft_model::{ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
|
||||
use soundcraft_time::{Range, Samples};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -11,21 +11,6 @@ use std::sync::Arc;
|
||||
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
|
||||
const MAX_IMPORT_SAMPLES: usize = 1 << 30;
|
||||
|
||||
/// A relative `Audio Files/` path no other source in `s` already uses.
|
||||
fn unique_audio_rel(s: &Session, stem: &str) -> String {
|
||||
let stem = sanitize_name(stem);
|
||||
let mut path = format!("Audio Files/{stem}.wav");
|
||||
let mut n = 2u32;
|
||||
while s.sources.iter().any(|src| src.path == path) {
|
||||
path = format!("Audio Files/{stem}-{n}.wav");
|
||||
n = n.saturating_add(1);
|
||||
if n > 10_000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// Register decoded audio as a new source. Resamples to the session rate if needed.
|
||||
pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Option<&str>, format: FileFormat) -> SourceId {
|
||||
let sr = s.sample_rate.hz();
|
||||
@@ -35,14 +20,10 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio
|
||||
}
|
||||
let id = SourceId(s.alloc());
|
||||
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or(name).to_string();
|
||||
let stored = match path {
|
||||
Some(p) => p.to_string(),
|
||||
None => unique_audio_rel(s, &stem),
|
||||
};
|
||||
s.sources.push(Source {
|
||||
id,
|
||||
name: stem.clone(),
|
||||
path: stored,
|
||||
path: path.map_or_else(|| format!("Audio Files/{stem}.wav"), str::to_string),
|
||||
channels: u16::try_from(buf.num_channels()).unwrap_or(u16::MAX),
|
||||
frames: buf.frames() as u64,
|
||||
sample_rate: sr,
|
||||
@@ -75,7 +56,7 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option
|
||||
Some(t) => t,
|
||||
None => {
|
||||
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or("Audio").to_string();
|
||||
s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels), Some(&stem))
|
||||
s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels.min(2)), Some(&stem))
|
||||
}
|
||||
};
|
||||
let cid = s.new_clip_id();
|
||||
@@ -199,8 +180,8 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Export MIDI clips as a format-1 SMF. `only` limits the file to those tracks.
|
||||
pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result<Vec<u8>> {
|
||||
/// Export all MIDI clips as a format-1 SMF.
|
||||
pub fn export_midi(e: &Engine) -> Result<Vec<u8>> {
|
||||
let s = e.session();
|
||||
let mut smf = soundcraft_midi::Smf {
|
||||
format: 1,
|
||||
@@ -210,25 +191,16 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result<Vec<u8>> {
|
||||
markers: s.markers.iter().map(|m| (s.tempo.samples_to_ticks(m.start, s.sample_rate), m.name.clone())).collect(),
|
||||
key_sigs: Vec::new(),
|
||||
};
|
||||
let chosen: Vec<_> = s.tracks.iter().filter(|t| t.kind.is_midi() && only.is_none_or(|ids| ids.contains(&t.id))).collect();
|
||||
if only.is_some() && chosen.is_empty() {
|
||||
return Err(EngineError::BadParams("file.export_midi".into(), "none of the requested tracks hold MIDI".into()));
|
||||
}
|
||||
for t in chosen {
|
||||
for t in s.tracks.iter().filter(|t| t.kind.is_midi()) {
|
||||
let mut seq = soundcraft_midi::Sequence::default();
|
||||
for c in t.clips() {
|
||||
if let ClipContent::Midi { sequence } = &c.content {
|
||||
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
|
||||
for n in &sequence.notes {
|
||||
let mut n = *n;
|
||||
n.start = n.start.saturating_add(base);
|
||||
n.start += base;
|
||||
seq.notes.push(n);
|
||||
}
|
||||
for ev in &sequence.ctrls {
|
||||
let mut ev = *ev;
|
||||
ev.tick = ev.tick.saturating_add(base);
|
||||
seq.ctrls.push(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
seq.sort();
|
||||
@@ -245,23 +217,11 @@ pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool)
|
||||
/// Render the main mix in its own format (every main channel, SMPTE/WAV order, with the WAV
|
||||
/// speaker mask of the main format), or an ITU stereo fold-down when `fold_stereo`.
|
||||
pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool, fold_stereo: bool) -> Result<(Vec<u8>, (f32, f32, f32))> {
|
||||
bounce_source(e, r, opts, normalize, fold_stereo, None)
|
||||
}
|
||||
|
||||
/// [`bounce_bytes_with`] for the main mix, or for `bus` when one is named.
|
||||
pub fn bounce_source(
|
||||
e: &Engine,
|
||||
r: Range,
|
||||
opts: &EncodeOptions,
|
||||
normalize: bool,
|
||||
fold_stereo: bool,
|
||||
bus: Option<BusId>,
|
||||
) -> Result<(Vec<u8>, (f32, f32, f32))> {
|
||||
let s = e.session();
|
||||
if r.len() > s.sample_rate.samples(4.0 * 3600.0) {
|
||||
return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into()));
|
||||
}
|
||||
let (mut ch, fmt) = render_bounce(s, r, fold_stereo, bus);
|
||||
let (mut ch, fmt) = render_main(s, r, fold_stereo);
|
||||
if normalize {
|
||||
soundcraft_dsp::offline::normalize(&mut ch, -0.1, false);
|
||||
}
|
||||
@@ -278,19 +238,8 @@ pub fn bounce_source(
|
||||
|
||||
/// The main mix over `r` and its format (folded to stereo on request).
|
||||
fn render_main(s: &Session, r: Range, fold_stereo: bool) -> (Vec<Vec<f32>>, ChannelFormat) {
|
||||
render_bounce(s, r, fold_stereo, None)
|
||||
}
|
||||
|
||||
/// The main mix, or one bus, over `r`.
|
||||
fn render_bounce(s: &Session, r: Range, fold_stereo: bool, bus: Option<BusId>) -> (Vec<Vec<f32>>, ChannelFormat) {
|
||||
let (ch, fmt) = if let Some(id) = bus {
|
||||
let fmt = s.bus(id).map(|b| b.format).unwrap_or(ChannelFormat::Stereo);
|
||||
(soundcraft_mix::render_bus(s, id, r, 1024), fmt)
|
||||
} else {
|
||||
let ch = soundcraft_mix::render_range(s, r, 1024);
|
||||
let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() };
|
||||
(ch, fmt)
|
||||
};
|
||||
let ch = soundcraft_mix::render_range(s, r, 1024);
|
||||
let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() };
|
||||
if fold_stereo && ch.len() > 2 { (soundcraft_mix::fold_down_stereo(fmt, &ch), ChannelFormat::Stereo) } else { (ch, fmt) }
|
||||
}
|
||||
|
||||
@@ -430,9 +379,6 @@ pub fn render_clip_gain(e: &mut Engine, id: soundcraft_model::ClipId) -> Result<
|
||||
cl.content = ClipContent::Audio { source: src, offset: 0 };
|
||||
cl.gain_db = 0.0;
|
||||
cl.gain_env.clear();
|
||||
// The new source is the clip as it already sounds, stretch included.
|
||||
// Keeping the old ratio would play that audio through the stretch again.
|
||||
cl.stretch = 1.0;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -510,13 +456,7 @@ pub fn sanitize_name(n: &str) -> String {
|
||||
|
||||
/// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written
|
||||
/// as 32-bit float WAV into `Audio Files/` next to it. Returns how many audio files were written.
|
||||
///
|
||||
/// In the browser there are no folders: the session and all its audio go out as one bundle file
|
||||
/// (`<name>.scraftz`, see [`session_bundle`]) through the host's delivery hook ([`set_deliver`]).
|
||||
pub fn save_session(e: &mut Engine, path: &str) -> Result<usize> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return save_session_bundle(e, path);
|
||||
}
|
||||
let (fresh, written, p) = write_session(e.session(), path, false)?;
|
||||
// Keep the in-memory document in sync (paths, unsaved flags) without an undo step.
|
||||
*e.session_mut() = fresh;
|
||||
@@ -526,186 +466,66 @@ pub fn save_session(e: &mut Engine, path: &str) -> Result<usize> {
|
||||
}
|
||||
|
||||
/// Write a copy of the session (and any audio it needs) without touching the open document —
|
||||
/// used for autosave / crash recovery. Audio already written for a source is reused.
|
||||
/// used for autosave / crash recovery. Audio files in the target folder are overwritten in place.
|
||||
pub fn save_copy(e: &Engine, path: &str) -> Result<usize> {
|
||||
write_session(e.session(), path, true).map(|(_, n, _)| n)
|
||||
}
|
||||
|
||||
/// File › Save Session Copy In: a normal session folder that does not rename, retarget or
|
||||
/// clean the session that stays open.
|
||||
pub fn save_session_copy(e: &Engine, path: &str) -> Result<usize> {
|
||||
write_session(e.session(), path, false).map(|(_, n, _)| n)
|
||||
}
|
||||
|
||||
/// Write `session` to `path`; returns the session with updated media paths, the number of audio
|
||||
/// files written and the final path.
|
||||
fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Session, usize, PathBuf)> {
|
||||
let p = session_file(path);
|
||||
let p = PathBuf::from(path);
|
||||
let p = if p.extension().is_none() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p };
|
||||
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let audio_dir = dir.join("Audio Files");
|
||||
let mut s = session.clone();
|
||||
let from = s.folder.clone();
|
||||
let same_dir = from.as_deref().is_some_and(|f| same_path(f, &dir));
|
||||
let mut written = 0;
|
||||
let shared_paths: Vec<bool> =
|
||||
s.sources.iter().enumerate().map(|(i, src)| s.sources.iter().enumerate().any(|(j, other)| j != i && other.path == src.path)).collect();
|
||||
for (i, src) in s.sources.iter_mut().enumerate() {
|
||||
if !same_dir
|
||||
&& s.pool.get(src.id).is_none()
|
||||
&& Path::new(&src.path).is_relative()
|
||||
&& let Some(f) = &from
|
||||
{
|
||||
src.path = f.join(&src.path).to_string_lossy().into_owned();
|
||||
}
|
||||
let exists_rel = same_dir && !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists();
|
||||
let shared = shared_paths.get(i).copied().unwrap_or(false);
|
||||
if exists_rel && !src.unsaved && !shared {
|
||||
continue;
|
||||
}
|
||||
let in_folder = Path::new(&src.path).strip_prefix(&audio_dir).ok().and_then(Path::to_str).map(str::to_string);
|
||||
if let Some(f) = in_folder.filter(|_| !src.unsaved && Path::new(&src.path).is_file()) {
|
||||
src.path = format!("Audio Files/{f}");
|
||||
for src in &mut s.sources {
|
||||
let rel_target = format!("Audio Files/{}.wav", sanitize_name(&src.name));
|
||||
let exists_rel = !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists();
|
||||
if exists_rel && !src.unsaved {
|
||||
continue;
|
||||
}
|
||||
let Some(audio) = s.pool.get(src.id) else { continue };
|
||||
std::fs::create_dir_all(&audio_dir).map_err(|err| EngineError::Io(format!("{}: {err}", audio_dir.display())))?;
|
||||
let mut stem = sanitize_name(&src.name);
|
||||
let mut target = rel_target.clone();
|
||||
if overwrite {
|
||||
// One file per source id so autosaves never collide or pile up.
|
||||
stem = format!("{stem}_{}", src.id.0);
|
||||
if audio_dir.join(format!("{stem}.wav")).exists() {
|
||||
src.path = format!("Audio Files/{stem}.wav");
|
||||
target = format!("Audio Files/{}_{}.wav", sanitize_name(&src.name), src.id.0);
|
||||
if dir.join(&target).exists() {
|
||||
src.path = target;
|
||||
src.unsaved = false;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
let mut k = 1;
|
||||
while dir.join(&target).exists() && !(exists_rel && target == src.path) {
|
||||
target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name));
|
||||
k += 1;
|
||||
if k > 999 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let (name, new) = store_wav(&audio_dir, &stem, &bytes)?;
|
||||
src.path = format!("Audio Files/{name}");
|
||||
std::fs::write(dir.join(&target), bytes).map_err(|err| EngineError::Io(format!("{target}: {err}")))?;
|
||||
src.path = target;
|
||||
src.unsaved = false;
|
||||
src.format = FileFormat::Wav;
|
||||
written += usize::from(new);
|
||||
written += 1;
|
||||
}
|
||||
if written > 0 {
|
||||
sync_dir(&audio_dir);
|
||||
}
|
||||
s.folder = Some(dir.clone());
|
||||
if !overwrite && let Some(stem) = p.file_stem().and_then(|x| x.to_str()) {
|
||||
s.name = stem.to_string();
|
||||
}
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
std::fs::create_dir_all(&dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?;
|
||||
// Atomic-ish write: temp file then rename.
|
||||
let tmp = p.with_extension("scraft.tmp");
|
||||
if !overwrite && std::fs::read(&p).is_ok_and(|old| old != text.as_bytes()) {
|
||||
back_up(&p);
|
||||
}
|
||||
replace_file(&tmp, &p, text.as_bytes())?;
|
||||
std::fs::write(&tmp, text).map_err(|err| EngineError::Io(format!("{}: {err}", tmp.display())))?;
|
||||
std::fs::rename(&tmp, &p).map_err(|err| EngineError::Io(format!("{}: {err}", p.display())))?;
|
||||
Ok((s, written, p))
|
||||
}
|
||||
|
||||
/// Write `bytes` to `tmp`, flush it to disk and rename it over `file`, so `file` holds either the
|
||||
/// old or the new contents, never a mix.
|
||||
pub fn replace_file(tmp: &Path, file: &Path, bytes: &[u8]) -> Result<()> {
|
||||
if let Err(err) = write_synced(tmp, bytes).and_then(|()| std::fs::rename(tmp, file)) {
|
||||
let _ = std::fs::remove_file(tmp);
|
||||
return Err(EngineError::Io(format!("{}: {err}", file.display())));
|
||||
}
|
||||
sync_dir(file.parent().unwrap_or(Path::new("")));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_dir(dir: &Path) {
|
||||
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
|
||||
let _ = std::fs::File::open(dir).and_then(|d| d.sync_all());
|
||||
}
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut f = std::fs::File::create(path)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()
|
||||
}
|
||||
|
||||
/// Copy the session file about to be replaced to `Backups/<name> backup <n>.<ext>`, keeping the
|
||||
/// newest ten.
|
||||
fn back_up(file: &Path) {
|
||||
let (Some(dir), Some(stem), Some(ext)) = (file.parent(), file.file_stem().and_then(|x| x.to_str()), file.extension().and_then(|x| x.to_str()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let backups = dir.join("Backups");
|
||||
let prefix = format!("{stem} backup ");
|
||||
let suffix = format!(".{ext}");
|
||||
let numbers: Vec<u32> = std::fs::read_dir(&backups)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|f| f.file_name().to_str()?.strip_prefix(&prefix)?.strip_suffix(&suffix)?.parse().ok())
|
||||
.collect();
|
||||
let next = numbers.iter().max().map_or(1, |n| n.saturating_add(1));
|
||||
let copied = std::fs::create_dir_all(&backups).and_then(|()| std::fs::copy(file, backups.join(format!("{prefix}{next}{suffix}"))));
|
||||
if let Err(err) = copied {
|
||||
log::warn!("no backup of {}: {err}", file.display());
|
||||
return;
|
||||
}
|
||||
for n in numbers.into_iter().filter(|n| n.saturating_add(10) <= next) {
|
||||
let _ = std::fs::remove_file(backups.join(format!("{prefix}{n}{suffix}")));
|
||||
}
|
||||
}
|
||||
|
||||
/// The file a session saved to `path` goes to: `path`, with `.scraft` added when it has no
|
||||
/// extension and isn't an existing file.
|
||||
pub fn session_file(path: &str) -> PathBuf {
|
||||
let p = PathBuf::from(path);
|
||||
if p.extension().is_none() && !p.is_file() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p }
|
||||
}
|
||||
|
||||
/// True when `command` would write a session over the existing `file`. Save, Save As and Save
|
||||
/// Copy In may write the open session's own file; other commands never ask.
|
||||
pub fn replaces(e: &Engine, command: &str, file: &Path) -> bool {
|
||||
match command {
|
||||
"session.save" | "session.save_as" | "session.save_copy" => {
|
||||
file.exists() && !e.path.as_deref().is_some_and(|own| same_path(Path::new(own), file))
|
||||
}
|
||||
"session.save_template" | "file.export_selected_tracks_as_session" => file.exists(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn same_path(a: &Path, b: &Path) -> bool {
|
||||
let full = |p: &Path| if p.as_os_str().is_empty() { Path::new(".").canonicalize() } else { p.canonicalize() };
|
||||
a == b || matches!((full(a), full(b)), (Ok(x), Ok(y)) if x == y)
|
||||
}
|
||||
|
||||
/// Store `bytes` as the first of `<stem>.wav`, `<stem>_01.wav`, … in `dir` that is free or already
|
||||
/// holds exactly these bytes, so saving never replaces audio; returns the name and whether it was
|
||||
/// written.
|
||||
fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> {
|
||||
use std::io::Write;
|
||||
for k in 0..10_000u32 {
|
||||
let name = if k == 0 { format!("{stem}.wav") } else { format!("{stem}_{k:02}.wav") };
|
||||
let path = dir.join(&name);
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||
Ok(mut f) => {
|
||||
if let Err(err) = f.write_all(bytes).and_then(|()| f.sync_all()) {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Err(EngineError::Io(format!("{}: {err}", path.display())));
|
||||
}
|
||||
return Ok((name, true));
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
if std::fs::metadata(&path).is_ok_and(|m| m.len() == bytes.len() as u64) && std::fs::read(&path).is_ok_and(|b| b == bytes) {
|
||||
return Ok((name, false));
|
||||
}
|
||||
}
|
||||
Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))),
|
||||
}
|
||||
}
|
||||
Err(EngineError::Io(format!("{}: no free file name for {stem}", dir.display())))
|
||||
}
|
||||
|
||||
/// Open a `.scraft` session and decode its audio files (missing files are reported, not fatal).
|
||||
pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
|
||||
let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
@@ -715,16 +535,20 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
|
||||
for src in s.sources.clone() {
|
||||
let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
|
||||
match std::fs::read(&p) {
|
||||
Ok(bytes) => {
|
||||
if let Err(err) = load_source_audio(&mut s, src.id, &bytes, p.extension().and_then(|x| x.to_str())) {
|
||||
missing.push(format!("{}: {err}", p.display()));
|
||||
Ok(bytes) => match soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())) {
|
||||
Ok((_, mut buf)) => {
|
||||
if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz());
|
||||
buf.sample_rate = s.sample_rate.hz();
|
||||
}
|
||||
s.pool.insert(src.id, Arc::new(SourceAudio::new(buf)));
|
||||
}
|
||||
}
|
||||
Err(err) => missing.push(format!("{}: {err}", p.display())),
|
||||
},
|
||||
Err(_) => missing.push(p.display().to_string()),
|
||||
}
|
||||
}
|
||||
missing.extend(relink_videos(&mut s, &dir));
|
||||
s.folder = Some(dir);
|
||||
e.replace_session(s);
|
||||
e.path = Some(path.to_string());
|
||||
for m in &missing {
|
||||
@@ -733,181 +557,17 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
|
||||
Ok(missing)
|
||||
}
|
||||
|
||||
/// Decode one source's audio file into the session pool, at the session rate.
|
||||
fn load_source_audio(s: &mut Session, id: SourceId, bytes: &[u8], ext: Option<&str>) -> std::result::Result<(), String> {
|
||||
let (_, mut buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?;
|
||||
if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz());
|
||||
buf.sample_rate = s.sample_rate.hz();
|
||||
/// Turn a recorded take into clips on the record-armed tracks (one undo step: "Record").
|
||||
/// `channels` is the planar input capture at `rate`; each track takes the input channels its
|
||||
/// input route names ("In 3" → channel 2), defaulting to the first channels.
|
||||
pub fn add_recording(e: &mut Engine, start: Samples, channels: Vec<Vec<f32>>, rate: u32) -> Result<Vec<soundcraft_model::ClipId>> {
|
||||
let frames = channels.first().map_or(0, Vec::len);
|
||||
if frames == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
s.pool.insert(id, Arc::new(SourceAudio::new(buf)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Hosts without a filesystem (the browser) ─────────────────────────────────────────────────
|
||||
|
||||
/// Extension of a session bundle: one zip file holding the `.scraft` session and its
|
||||
/// `Audio Files/`, the same layout as a saved session folder. The browser has no folders, so it
|
||||
/// saves and opens sessions as bundles.
|
||||
pub const BUNDLE_EXTENSION: &str = "scraftz";
|
||||
|
||||
/// Most entries and bytes a bundle may unpack to (bundles are hostile input).
|
||||
const MAX_BUNDLE_ENTRIES: usize = 10_000;
|
||||
const MAX_BUNDLE_BYTES: u64 = 1 << 30;
|
||||
|
||||
/// Hands a finished file (name, bytes) to the person on a host without a filesystem: the web app
|
||||
/// installs one that starts a download. Native builds write files and never install it.
|
||||
pub type DeliverHook = fn(&str, &[u8]) -> std::result::Result<(), String>;
|
||||
|
||||
static DELIVER: std::sync::OnceLock<DeliverHook> = std::sync::OnceLock::new();
|
||||
|
||||
/// Install the delivery hook (once, at start-up of the web app).
|
||||
pub fn set_deliver(h: DeliverHook) {
|
||||
let _ = DELIVER.set(h);
|
||||
}
|
||||
|
||||
/// Write one output file: to `path` natively; in the browser, delivered under its file name.
|
||||
/// Without this, every export on the web failed with «operation not supported on this platform».
|
||||
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
let deliver = DELIVER.get().ok_or_else(|| EngineError::Io("saving files is not available in this build".into()))?;
|
||||
let name = Path::new(path).file_name().and_then(|n| n.to_str()).filter(|n| !n.is_empty()).unwrap_or("Untitled");
|
||||
return deliver(name, bytes).map_err(EngineError::Io);
|
||||
}
|
||||
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))
|
||||
}
|
||||
|
||||
/// The session as a bundle: `<name>.scraft` plus every source's audio as 32-bit float WAV under
|
||||
/// `Audio Files/` (stored, not compressed: audio barely compresses). Returns the bytes and the
|
||||
/// session with its source paths pointing into the bundle.
|
||||
pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
|
||||
use std::io::Write as _;
|
||||
let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}"));
|
||||
let ioerr = |err: std::io::Error| EngineError::Io(format!("bundle: {err}"));
|
||||
let mut s = session.clone();
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).large_file(true);
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
for src in &mut s.sources {
|
||||
let Some(audio) = s.pool.get(src.id) else { continue };
|
||||
let base = sanitize_name(&src.name);
|
||||
let mut target = format!("Audio Files/{base}.wav");
|
||||
let mut k = 1;
|
||||
while taken.contains(&target) && k <= 9_999 {
|
||||
target = format!("Audio Files/{base}_{k:02}.wav");
|
||||
k += 1;
|
||||
}
|
||||
let opts_wav = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts_wav).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
w.start_file(target.as_str(), opts).map_err(zerr)?;
|
||||
w.write_all(&bytes).map_err(ioerr)?;
|
||||
taken.insert(target.clone());
|
||||
src.path = target;
|
||||
src.unsaved = false;
|
||||
src.format = FileFormat::Wav;
|
||||
}
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
w.start_file(format!("{}.{}", sanitize_name(&s.name), soundcraft_model::SESSION_EXTENSION), opts).map_err(zerr)?;
|
||||
w.write_all(text.as_bytes()).map_err(ioerr)?;
|
||||
let bytes = w.finish().map_err(zerr)?.into_inner();
|
||||
Ok((bytes, s))
|
||||
}
|
||||
|
||||
/// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]).
|
||||
fn save_session_bundle(e: &mut Engine, path: &str) -> Result<usize> {
|
||||
let stem = Path::new(path).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).map(str::to_string);
|
||||
let mut s = e.session().clone();
|
||||
if let Some(stem) = stem {
|
||||
s.name = stem;
|
||||
}
|
||||
let (bytes, fresh) = session_bundle(&s)?;
|
||||
let file = format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&fresh.name));
|
||||
write_file(&file, &bytes)?;
|
||||
let written = fresh.sources.iter().filter(|x| fresh.pool.get(x.id).is_some()).count();
|
||||
*e.session_mut() = fresh;
|
||||
e.path = Some(file);
|
||||
e.mark_clean();
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Open a session from bytes (the browser has no paths): a bundle ([`session_bundle`]) or a bare
|
||||
/// `.scraft`, whose audio is then reported missing. Returns the missing media, like
|
||||
/// [`open_session`].
|
||||
pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Vec<String>> {
|
||||
let (text, mut files) = if bytes.starts_with(b"PK\x03\x04") {
|
||||
read_bundle(bytes)?
|
||||
} else {
|
||||
(
|
||||
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))?,
|
||||
std::collections::HashMap::new(),
|
||||
)
|
||||
};
|
||||
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
|
||||
let mut missing = Vec::new();
|
||||
for src in s.sources.clone() {
|
||||
match files.remove(&src.path) {
|
||||
Some(audio) => {
|
||||
let ext = Path::new(&src.path).extension().and_then(|x| x.to_str()).map(str::to_string);
|
||||
if let Err(err) = load_source_audio(&mut s, src.id, &audio, ext.as_deref()) {
|
||||
missing.push(format!("{}: {err}", src.path));
|
||||
}
|
||||
}
|
||||
None => missing.push(src.path.clone()),
|
||||
}
|
||||
}
|
||||
// Movies are referenced by path, never copied into a session: none can be found here.
|
||||
missing.extend(s.videos.iter().map(|v| v.path.clone()));
|
||||
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).unwrap_or("Untitled").to_string();
|
||||
e.replace_session(s);
|
||||
e.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem)));
|
||||
for m in &missing {
|
||||
e.message(format!("missing media: {m}"));
|
||||
}
|
||||
Ok(missing)
|
||||
}
|
||||
|
||||
/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path.
|
||||
fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap<String, Vec<u8>>)> {
|
||||
use std::io::Read as _;
|
||||
let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}"));
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(zerr)?;
|
||||
if z.len() > MAX_BUNDLE_ENTRIES {
|
||||
return Err(EngineError::Io("bundle: too many files".into()));
|
||||
}
|
||||
let (mut text, mut files, mut total) = (None, std::collections::HashMap::new(), 0u64);
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).map_err(zerr)?;
|
||||
if f.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let rel = f.name().map_err(zerr)?.to_string();
|
||||
let size = f.size();
|
||||
total = total.saturating_add(size);
|
||||
if total > MAX_BUNDLE_BYTES {
|
||||
return Err(EngineError::Io("bundle: too large".into()));
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
// The declared size is hostile too: never read past it.
|
||||
f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?;
|
||||
let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(|x| x.to_str()) == Some(soundcraft_model::SESSION_EXTENSION);
|
||||
if is_session && text.is_none() {
|
||||
text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?);
|
||||
} else {
|
||||
files.insert(rel, data);
|
||||
}
|
||||
}
|
||||
let text = text.ok_or_else(|| EngineError::Io("bundle: no .scraft session inside".into()))?;
|
||||
Ok((text, files))
|
||||
}
|
||||
|
||||
/// The record-enabled audio tracks, each with the input channels it records (those its input
|
||||
/// route names, "In 3" → 2, wrapped to the device's `input_channels`) and a new take file
|
||||
/// `<track>_<NN>.wav` in the `Audio Files` of the session's folder (`unsaved_dir` until it is saved).
|
||||
pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec<(TrackId, Vec<usize>, PathBuf)> {
|
||||
let dir = e.path.as_deref().and_then(|p| Path::new(p).parent()).unwrap_or(unsaved_dir);
|
||||
let s = e.session();
|
||||
let take_no = s.sources.len() + 1;
|
||||
s.tracks
|
||||
let armed: Vec<(TrackId, usize, usize, String)> = e
|
||||
.session()
|
||||
.tracks
|
||||
.iter()
|
||||
.filter(|t| t.mixer.record_arm && t.kind == TrackKind::Audio && !t.inactive)
|
||||
.map(|t| {
|
||||
@@ -917,118 +577,28 @@ pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec<
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
let inputs = (0..t.channels()).map(|k| first.saturating_add(k) % input_channels.max(1)).collect();
|
||||
let file = |n: usize| dir.join("Audio Files").join(format!("{}_{n:02}.wav", sanitize_name(&t.name)));
|
||||
let path = (take_no..take_no + 1_000).map(file).find(|p| !p.exists()).unwrap_or_else(|| file(take_no));
|
||||
(t.id, inputs, path)
|
||||
(t.id, first, t.channels(), t.name.clone())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Turn recorded take files into clips at `start`, as one undo step ("Record"); each file is the
|
||||
/// source of its clips. With `pass` (loop recording), a take becomes one clip per pass (at most
|
||||
/// 1000), each pass after the first on a new playlist.
|
||||
pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBuf)>, pass: Option<Samples>) -> Vec<soundcraft_model::ClipId> {
|
||||
.collect();
|
||||
if armed.is_empty() {
|
||||
return Err(EngineError::BadParams("record".into(), "no tracks are record-enabled".into()));
|
||||
}
|
||||
let before = e.session_arc();
|
||||
let mut out = Vec::new();
|
||||
for (tid, path) in takes {
|
||||
let read = std::fs::read(&path).map_err(|x| x.to_string()).and_then(|b| soundcraft_audio_io::decode(&b, None).map_err(|x| x.to_string()));
|
||||
let Ok((_, buf)) = read.inspect_err(|err| e.message(format!("{}: {err}", path.display()))) else { continue };
|
||||
let take_no = e.session().sources.len() + 1;
|
||||
for (tid, first, n, name) in armed {
|
||||
let chans: Vec<Vec<f32>> = (0..n).map(|k| channels.get((first + k) % channels.len().max(1)).cloned().unwrap_or_default()).collect();
|
||||
let buf = AudioBuffer { sample_rate: rate, channels: chans };
|
||||
let clip_name = format!("{name}_{take_no:02}");
|
||||
let s = e.session_mut();
|
||||
if s.track(tid).is_none() {
|
||||
continue;
|
||||
}
|
||||
let resampled = buf.sample_rate != s.sample_rate.hz();
|
||||
let name = path.file_stem().and_then(|x| x.to_str()).unwrap_or("Take");
|
||||
let src = add_source(s, name, buf, Some(&path.to_string_lossy()), FileFormat::Wav);
|
||||
let Some(x) = s.sources.last_mut() else { continue };
|
||||
x.unsaved = resampled;
|
||||
let len = i64::try_from(x.frames).unwrap_or(0);
|
||||
let pass = pass.unwrap_or(len).max(1);
|
||||
for offset in (0..len).step_by(usize::try_from(pass).unwrap_or(usize::MAX)).take(1_000) {
|
||||
let n = pass.min(len - offset);
|
||||
if offset > 0 && n < pass / 8 {
|
||||
break;
|
||||
}
|
||||
if offset > 0
|
||||
&& let Some(t) = s.track_mut(tid)
|
||||
{
|
||||
t.playlists.push(soundcraft_model::Playlist::new(format!("{}.{:02}", t.name, t.playlists.len() + 1)));
|
||||
t.active_playlist = t.playlists.len() - 1;
|
||||
}
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(s, tid, Clip::audio(cid, name, src, offset, start, n));
|
||||
out.push(cid);
|
||||
}
|
||||
}
|
||||
if !out.is_empty() {
|
||||
e.push_undo("Record", before);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod record_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn armed(e: &mut Engine, name: &str, format: &str, input: &str) -> TrackId {
|
||||
let id = e.execute("track.new", &json!({"name": name, "format": format})).unwrap()["tracks"][0].as_u64().unwrap();
|
||||
e.execute("track.input", &json!({"tracks": [id], "input": input})).unwrap();
|
||||
e.execute("mix.record_arm", &json!({"tracks": [id], "value": true})).unwrap();
|
||||
TrackId(id)
|
||||
}
|
||||
|
||||
fn write_take(path: &Path, frames: usize) {
|
||||
let mut bytes = soundcraft_audio_io::wav_float_header(1, 48_000, frames as u64).unwrap();
|
||||
bytes.extend((0..frames).flat_map(|i| (i as f32 / frames as f32 - 0.5).to_le_bytes()));
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armed_tracks_get_new_take_files_used_in_place() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut e = Engine::default();
|
||||
let vox = armed(&mut e, "Vox", "mono", "In 2");
|
||||
let gtr = armed(&mut e, "Gtr", "stereo", "In 3-4");
|
||||
write_take(&dir.join("Audio Files/Vox_01.wav"), 10);
|
||||
let plan = plan_takes(&e, &dir, 4);
|
||||
assert_eq!(plan, vec![(vox, vec![1], dir.join("Audio Files/Vox_02.wav")), (gtr, vec![2, 3], dir.join("Audio Files/Gtr_01.wav"))]);
|
||||
assert_eq!(plan_takes(&e, &dir, 2)[1].1, vec![0, 1]);
|
||||
|
||||
let session = dir.join("Song.scraft").to_string_lossy().into_owned();
|
||||
e.execute("session.save_as", &json!({"path": session})).unwrap();
|
||||
write_take(&plan[0].2, 4_800);
|
||||
let clips = add_recording(&mut e, 96_000, vec![(vox, plan[0].2.clone())], None);
|
||||
let clip = e.session().find_clip(clips[0]).unwrap().1.clone();
|
||||
assert_eq!((clip.start, clip.length), (96_000, 4_800));
|
||||
assert_eq!(save_session(&mut e, &session).unwrap(), 0);
|
||||
let src = e.session().sources.last().unwrap().clone();
|
||||
assert_eq!((src.name.as_str(), src.path.as_str(), src.unsaved, src.frames), ("Vox_02", "Audio Files/Vox_02.wav", false, 4_800));
|
||||
e.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(e.session().find_clip(clips[0]).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_take_becomes_one_clip_per_pass_and_autosaves_in_place() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-loop-take-{}", std::process::id()));
|
||||
let path = dir.join("Audio Files/Vox_01.wav");
|
||||
write_take(&path, 1_050);
|
||||
let mut e = Engine::default();
|
||||
let vox = armed(&mut e, "Vox", "mono", "In 1");
|
||||
assert_eq!(add_recording(&mut e, 1_000, vec![(vox, path.clone())], Some(400)).len(), 3);
|
||||
assert_eq!(save_copy(&e, &dir.join("Untitled.scraft").to_string_lossy()).unwrap(), 0);
|
||||
let t = e.session().track(vox).unwrap();
|
||||
let passes: Vec<_> = t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| (c.source_offset(), c.start, c.length))).collect();
|
||||
assert_eq!(passes, vec![(0, 1_000, 400), (400, 1_000, 400), (800, 1_000, 250)]);
|
||||
assert_eq!((t.active_playlist, t.playlists[2].name.as_str()), (2, "Vox.03"));
|
||||
e.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(e.session().track(vox).unwrap().playlists.len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let src = add_source(s, &clip_name, buf, None, FileFormat::Wav);
|
||||
let len = s.source(src).map_or(0, |x| i64::try_from(x.frames).unwrap_or(0));
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(s, tid, Clip::audio(cid, clip_name, src, 0, start, len));
|
||||
out.push(cid);
|
||||
}
|
||||
e.push_undo("Record", before);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1052,166 +622,6 @@ mod save_tests {
|
||||
assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn with_take(level: f32) -> Engine {
|
||||
let mut e = Engine::default();
|
||||
let s = e.session_mut();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let src = add_source(s, "Audio 1_02", AudioBuffer { sample_rate: 48_000, channels: vec![vec![level; 4_800]] }, None, FileFormat::Wav);
|
||||
let id = s.new_clip_id();
|
||||
crate::edit::place_clip(s, t, Clip::audio(id, "Audio 1_02", src, 0, 0, 4_800));
|
||||
e
|
||||
}
|
||||
|
||||
fn reopen(path: &str) -> Engine {
|
||||
let mut r = Engine::default();
|
||||
assert_eq!(open_session(&mut r, path).unwrap(), Vec::<String>::new());
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_never_replaces_audio_of_another_session() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-no-replace-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = |p: &str| dir.join(p).to_string_lossy().into_owned();
|
||||
save_session(&mut with_take(0.5), &path("A/A.scraft")).unwrap();
|
||||
save_session(&mut with_take(-0.25), &path("A/A v2.scraft")).unwrap();
|
||||
save_session(&mut with_take(0.75), &path("B/B.scraft")).unwrap();
|
||||
save_session(&mut reopen(&path("B/B.scraft")), &path("A/B.scraft")).unwrap();
|
||||
let levels = ["A/A.scraft", "A/A v2.scraft", "B/B.scraft", "A/B.scraft"].map(|p| {
|
||||
let r = reopen(&path(p));
|
||||
r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0]
|
||||
});
|
||||
assert_eq!(levels, [0.5, -0.25, 0.75, 0.75]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidating_twice_keeps_both_renders() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-consolidate-twice-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let kick = e.session().track_by_name("Kick").unwrap().id;
|
||||
let end = e.session().content_end();
|
||||
let render = |e: &Engine| soundcraft_mix::render_clips(e.session(), kick, Range::new(0, end));
|
||||
e.execute("edit.consolidate", &json!({"tracks": [kick.0], "start": 500_000, "end": 560_000})).unwrap();
|
||||
save_session(&mut e, &path).unwrap();
|
||||
e.execute("edit.consolidate", &json!({"tracks": [kick.0], "start": 1_400_000, "end": 1_500_000})).unwrap();
|
||||
save_session(&mut e, &path).unwrap();
|
||||
assert!(render(&reopen(&path)) == render(&e));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_after_undo_reuses_the_saved_audio() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-undo-resave-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
|
||||
let mut e = with_take(0.5);
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
assert_eq!(save_session(&mut e, &path).unwrap(), 1);
|
||||
e.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(save_session(&mut e, &path).unwrap(), 0);
|
||||
assert_eq!(std::fs::read_dir(dir.join("Audio Files")).unwrap().count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_audio_is_trusted_only_in_the_folder_it_was_saved_in() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-own-folder-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = |p: &str| dir.join(p).to_string_lossy().into_owned();
|
||||
let level = |r: &Engine| r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0];
|
||||
save_session(&mut with_take(-0.25), &path("B/Other.scraft")).unwrap();
|
||||
let mut e = with_take(0.5);
|
||||
save_session(&mut e, &path("A/A.scraft")).unwrap();
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
save_session(&mut e, &path("B/B.scraft")).unwrap();
|
||||
e.execute("edit.undo", &json!({})).unwrap();
|
||||
e.execute("session.save", &json!({})).unwrap();
|
||||
assert_eq!(level(&reopen(&path("B/B.scraft"))), 0.5);
|
||||
e.execute("session.save_copy", &json!({"path": path("A/Copy.scraft")})).unwrap();
|
||||
e.execute("session.save", &json!({})).unwrap();
|
||||
assert_eq!(level(&reopen(&path("B/B.scraft"))), 0.5);
|
||||
std::fs::remove_file(dir.join("A/Audio Files/Audio 1_02.wav")).unwrap();
|
||||
let mut missing = Engine::default();
|
||||
open_session(&mut missing, &path("A/A.scraft")).unwrap();
|
||||
save_session(&mut missing, &path("B/Moved.scraft")).unwrap();
|
||||
let mut r = Engine::default();
|
||||
assert_eq!(open_session(&mut r, &path("B/Moved.scraft")).unwrap(), vec![dir.join("A/Audio Files/Audio 1_02.wav").display().to_string()]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_over_another_session_needs_replace() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-replace-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
|
||||
let mut first = with_take(0.5);
|
||||
first.execute("session.save_as", &json!({"path": path})).unwrap();
|
||||
first.execute("session.save", &json!({})).unwrap();
|
||||
first.execute("session.save_as", &json!({"path": dir.join("Song")})).unwrap();
|
||||
assert!(first.execute("session.save_template", &json!({"path": path})).is_err());
|
||||
let mut second = with_take(-0.25);
|
||||
let track = second.session().tracks[0].id.0;
|
||||
for id in ["session.save", "session.save_as", "session.save_copy", "session.save_template", "file.export_selected_tracks_as_session"] {
|
||||
let err = second.execute(id, &json!({"path": path, "tracks": [track]})).unwrap_err();
|
||||
assert!(err.to_string().contains("`replace: true`"), "{id}: {err}");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(dir.join("Audio Files")).unwrap().count(), 1);
|
||||
Engine::default().execute("session.save_as", &json!({"path": dir.join("New/New.scraft")})).unwrap();
|
||||
std::fs::copy(&path, dir.join("Song")).unwrap();
|
||||
let mut bare = Engine::default();
|
||||
open_session(&mut bare, &dir.join("Song").to_string_lossy()).unwrap();
|
||||
bare.execute("session.save", &json!({})).unwrap();
|
||||
second.execute("session.save_as", &json!({"path": path, "replace": true})).unwrap();
|
||||
let r = reopen(&path);
|
||||
assert_eq!(r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0], -0.25);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_keeps_the_last_ten_versions_as_backups() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-backups-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Song.scraft");
|
||||
let mut e = Engine::default();
|
||||
let mut versions = Vec::new();
|
||||
for i in 0..12 {
|
||||
e.execute("track.new", &json!({"name": format!("T{i}")})).unwrap();
|
||||
save_session(&mut e, &path.to_string_lossy()).unwrap();
|
||||
save_session(&mut e, &path.to_string_lossy()).unwrap();
|
||||
versions.push(std::fs::read(&path).unwrap());
|
||||
}
|
||||
let backup = |n: usize| std::fs::read(dir.join(format!("Backups/Song backup {n}.scraft"))).ok();
|
||||
assert_eq!(std::fs::read_dir(dir.join("Backups")).unwrap().count(), 10);
|
||||
assert_eq!((backup(1), backup(2), backup(11)), (None, Some(versions[1].clone()), Some(versions[10].clone())));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_gives_sources_that_share_a_path_their_own_files() {
|
||||
let mut s = Session::default();
|
||||
let loud = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] };
|
||||
let quiet = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 64]] };
|
||||
add_source(&mut s, "tone", loud, Some("Audio Files/tone.wav"), FileFormat::Wav);
|
||||
add_source(&mut s, "tone", quiet, Some("Audio Files/tone.wav"), FileFormat::Wav);
|
||||
let mut e = Engine::new(s);
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-shared-path-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Shared.scraft");
|
||||
save_session(&mut e, &path.to_string_lossy()).unwrap();
|
||||
let paths: Vec<_> = e.session().sources.iter().map(|src| src.path.clone()).collect();
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert_ne!(paths[0], paths[1], "{paths:?}");
|
||||
let a = std::fs::read(dir.join(&paths[0])).unwrap();
|
||||
let b = std::fs::read(dir.join(&paths[1])).unwrap();
|
||||
assert_ne!(a, b);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1228,75 +638,3 @@ mod stem_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bundle_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bundle_round_trips_session_and_audio() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
e.execute("mix.volume", &serde_json::json!({"track": "Kick", "db": -7.0})).unwrap();
|
||||
let (bytes, fresh) = session_bundle(e.session()).unwrap();
|
||||
assert!(bytes.starts_with(b"PK\x03\x04"));
|
||||
assert!(fresh.sources.iter().all(|s| s.path.starts_with("Audio Files/") && !s.unsaved));
|
||||
let mut r = Engine::default();
|
||||
let missing = open_session_bytes(&mut r, "Midnight Groove.scraftz", &bytes).unwrap();
|
||||
assert!(missing.is_empty(), "{missing:?}");
|
||||
assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0);
|
||||
assert_eq!(r.session().tracks.len(), e.session().tracks.len());
|
||||
for src in &e.session().sources {
|
||||
let (a, b) = (e.session().pool.get(src.id).unwrap(), r.session().pool.get(src.id).unwrap());
|
||||
assert_eq!(a.buffer.channels, b.buffer.channels, "{}: float WAV keeps every sample", src.name);
|
||||
}
|
||||
assert!(!r.is_dirty());
|
||||
assert_eq!(r.path.as_deref(), Some("Midnight Groove.scraftz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_named_sources_get_distinct_files() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let name = e.session().sources[0].name.clone();
|
||||
let s = e.session_mut();
|
||||
let n = s.sources.len();
|
||||
s.sources[1].name = name;
|
||||
let (bytes, fresh) = session_bundle(e.session()).unwrap();
|
||||
let paths: std::collections::HashSet<_> = fresh.sources.iter().map(|s| s.path.clone()).collect();
|
||||
assert_eq!(paths.len(), n);
|
||||
let mut r = Engine::default();
|
||||
assert!(open_session_bytes(&mut r, "x.scraftz", &bytes).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_session_opens_with_its_audio_reported_missing() {
|
||||
let e = crate::demo::demo_engine();
|
||||
let (_, fresh) = session_bundle(e.session()).unwrap();
|
||||
let text = fresh.to_json().unwrap();
|
||||
let mut r = Engine::default();
|
||||
let missing = open_session_bytes(&mut r, "Song.scraft", text.as_bytes()).unwrap();
|
||||
assert_eq!(missing.len(), fresh.sources.len());
|
||||
assert_eq!(r.session().tracks.len(), fresh.tracks.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_bundles_are_errors_not_panics() {
|
||||
let mut r = Engine::default();
|
||||
for bad in [&b"PK\x03\x04 not really a zip"[..], b"", b"{ not json", b"\xff\xfe\x00"] {
|
||||
assert!(open_session_bytes(&mut r, "bad.scraftz", bad).is_err());
|
||||
}
|
||||
// A valid zip without a session inside.
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
w.start_file("Audio Files/a.wav", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
let only_audio = w.finish().unwrap().into_inner();
|
||||
assert!(open_session_bytes(&mut r, "a.scraftz", &only_audio).is_err());
|
||||
assert_eq!(r.session().tracks.len(), 0, "a failed open leaves the document alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_writes_to_disk_natively() {
|
||||
let p = std::env::temp_dir().join(format!("soundcraft-write-file-{}.txt", std::process::id()));
|
||||
write_file(&p.to_string_lossy(), b"hello").unwrap();
|
||||
assert_eq!(std::fs::read(&p).unwrap(), b"hello");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,8 @@ pub mod io;
|
||||
pub mod score;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{Clip, Session, TrackId, ZoomState};
|
||||
use soundcraft_model::{Clip, Session, TrackId};
|
||||
use soundcraft_time::Samples;
|
||||
use std::cell::Cell;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
@@ -109,13 +108,9 @@ pub struct Engine {
|
||||
pub journal: Vec<(String, Value)>,
|
||||
/// Path of the session file, if saved.
|
||||
pub path: Option<String>,
|
||||
/// Revision counter; bumps on every document change but zoom/scroll (UI caches key on it).
|
||||
/// Revision counter; bumps on every document change (UI caches key on it).
|
||||
pub revision: u64,
|
||||
dirty: bool,
|
||||
/// The document as last opened or saved, for `is_dirty`.
|
||||
saved: Arc<Session>,
|
||||
/// `is_dirty` as of a revision.
|
||||
dirty_at: Cell<(u64, bool)>,
|
||||
/// Gesture key for undo coalescing (see `execute_merged`).
|
||||
merge_key: Option<String>,
|
||||
/// Messages for the UI status line / agents.
|
||||
@@ -131,10 +126,8 @@ impl Default for Engine {
|
||||
impl Engine {
|
||||
pub fn new(session: Session) -> Self {
|
||||
install_panic_hook();
|
||||
let doc = Arc::new(session);
|
||||
Engine {
|
||||
saved: Arc::clone(&doc),
|
||||
doc,
|
||||
doc: Arc::new(session),
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
history_limit: 200,
|
||||
@@ -145,7 +138,6 @@ impl Engine {
|
||||
path: None,
|
||||
revision: 1,
|
||||
dirty: false,
|
||||
dirty_at: Cell::new((0, false)),
|
||||
merge_key: None,
|
||||
messages: Vec::new(),
|
||||
}
|
||||
@@ -166,42 +158,23 @@ impl Engine {
|
||||
Arc::make_mut(&mut self.doc)
|
||||
}
|
||||
|
||||
/// Like `session_mut`, for `edit.zoom` only and without bumping `revision`.
|
||||
pub fn zoom_mut(&mut self) -> &mut ZoomState {
|
||||
self.dirty = true;
|
||||
&mut Arc::make_mut(&mut self.doc).edit.zoom
|
||||
}
|
||||
|
||||
/// Replace the whole document (open / new); clears history.
|
||||
pub fn replace_session(&mut self, s: Session) {
|
||||
self.transport = TransportStatus { position: s.edit.selection.start.max(0), ..TransportStatus::default() };
|
||||
self.transport_requests.clear();
|
||||
self.transport_requests.push(TransportRequest::ResetSession);
|
||||
self.doc = Arc::new(s);
|
||||
self.saved = Arc::clone(&self.doc);
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
self.dirty = false;
|
||||
}
|
||||
|
||||
/// Whether the document changed since it was opened or saved. Selecting, locating and zooming
|
||||
/// don't count, and undoing back to the saved state makes it clean again.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
if !self.dirty {
|
||||
return false;
|
||||
}
|
||||
let (rev, dirty) = self.dirty_at.get();
|
||||
if rev == self.revision {
|
||||
return dirty;
|
||||
}
|
||||
let dirty = !same_document(&self.saved, &self.doc);
|
||||
self.dirty_at.set((self.revision, dirty));
|
||||
dirty
|
||||
self.dirty
|
||||
}
|
||||
pub fn mark_clean(&mut self) {
|
||||
self.dirty = false;
|
||||
self.saved = Arc::clone(&self.doc);
|
||||
}
|
||||
|
||||
pub fn can_undo(&self) -> bool {
|
||||
@@ -254,19 +227,16 @@ impl Engine {
|
||||
/// Run a command by id. Never panics: escaped panics become `EngineError::Internal` and the
|
||||
/// document is restored.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
self.merge_key = None;
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
if let Err(reason) = (spec.enabled)(self) {
|
||||
// A call that names its targets (tracks, clips, a range) does not need a selection;
|
||||
// the command itself validates them.
|
||||
let names_targets = ["track", "tracks", "clip", "clips", "sources", "start", "end", "at"].iter().any(|k| params.get(k).is_some());
|
||||
let names_targets = ["track", "tracks", "clip", "clips", "start", "end", "at"].iter().any(|k| params.get(k).is_some());
|
||||
if !names_targets {
|
||||
return Err(EngineError::Disabled(id.to_string(), reason));
|
||||
}
|
||||
}
|
||||
if spec.view {
|
||||
return self.execute_view(spec, params);
|
||||
}
|
||||
self.merge_key = None;
|
||||
let before = Arc::clone(&self.doc);
|
||||
let rev = self.revision;
|
||||
let dirty = self.dirty;
|
||||
@@ -274,10 +244,12 @@ impl Engine {
|
||||
let result = match result {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
let msg =
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
|
||||
self.doc = before;
|
||||
self.revision = rev.wrapping_add(1);
|
||||
self.dirty = dirty;
|
||||
return Err(EngineError::Internal(id.to_string(), panic_message(&*p)));
|
||||
return Err(EngineError::Internal(id.to_string(), msg));
|
||||
}
|
||||
};
|
||||
match result {
|
||||
@@ -302,16 +274,6 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_view(&mut self, spec: &CommandSpec, params: &Value) -> Result<Value> {
|
||||
let zoom = self.doc.edit.zoom;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)))
|
||||
.unwrap_or_else(|p| Err(EngineError::Internal(spec.id.to_string(), panic_message(&*p))));
|
||||
if result.is_err() && self.doc.edit.zoom != zoom {
|
||||
Arc::make_mut(&mut self.doc).edit.zoom = zoom;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Execute as part of a continuous gesture (fader drag, knob turn, slider): consecutive calls
|
||||
/// with the same `key` collapse into a single undo step.
|
||||
pub fn execute_merged(&mut self, id: &str, params: &Value, key: &str) -> Result<Value> {
|
||||
@@ -356,19 +318,6 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Equal apart from what changes without editing: the selection, the playhead and the zoom.
|
||||
fn same_document(saved: &Session, doc: &Session) -> bool {
|
||||
let mut doc = doc.clone();
|
||||
let (from, to) = (&saved.edit, &mut doc.edit);
|
||||
to.selection = from.selection;
|
||||
to.timeline_selection = from.timeline_selection;
|
||||
to.selected_tracks.clone_from(&from.selected_tracks);
|
||||
to.selected_clips.clone_from(&from.selected_clips);
|
||||
to.playhead = from.playhead;
|
||||
to.zoom = from.zoom;
|
||||
*saved == doc
|
||||
}
|
||||
|
||||
fn install_panic_hook() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
@@ -380,10 +329,6 @@ fn install_panic_hook() {
|
||||
});
|
||||
}
|
||||
|
||||
fn panic_message(p: &(dyn std::any::Any + Send)) -> String {
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod merge_tests {
|
||||
use super::*;
|
||||
@@ -401,27 +346,3 @@ mod merge_tests {
|
||||
assert_eq!(e.session().track(t).unwrap().mixer.volume_db, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dirty_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selecting_locating_and_zooming_leave_the_session_clean() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
for (id, p) in [
|
||||
("edit.select", json!({"tracks": ["Kick"], "start": 0, "end": 48_000})),
|
||||
("edit.select_none", json!({})),
|
||||
("transport.locate", json!({"at": 96_000})),
|
||||
("view.zoom_in", json!({})),
|
||||
("view.scroll", json!({"to": 48_000})),
|
||||
] {
|
||||
e.execute(id, &p).unwrap();
|
||||
assert!(!e.is_dirty(), "{id}");
|
||||
}
|
||||
e.execute("mix.mute", &json!({"track": "Kick"})).unwrap();
|
||||
assert!(e.is_dirty());
|
||||
e.undo();
|
||||
assert!(!e.is_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +45,6 @@ const KEYS: &[&str] = &[
|
||||
"numerator",
|
||||
"denominator",
|
||||
"factor",
|
||||
"x",
|
||||
"by_px",
|
||||
"samples_per_px",
|
||||
"sensitivity",
|
||||
"rating",
|
||||
"direction",
|
||||
|
||||
@@ -267,8 +267,10 @@ fn read_track(body: &[u8], base: usize, conv: TickConv, g: &mut Globals, budget:
|
||||
let data = cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?;
|
||||
match kind {
|
||||
0x2F => break, // End of Track
|
||||
0x03 if name.is_none() => {
|
||||
name = Some(String::from_utf8_lossy(data).into_owned());
|
||||
0x03 => {
|
||||
if name.is_none() {
|
||||
name = Some(String::from_utf8_lossy(data).into_owned());
|
||||
}
|
||||
}
|
||||
0x06 => g.markers.push((tick, String::from_utf8_lossy(data).into_owned())),
|
||||
0x51 => {
|
||||
|
||||
@@ -91,24 +91,6 @@ fn round_trip() {
|
||||
assert_eq!(write_smf(&again).unwrap(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_track_name_wins_and_ignored_meta_preserves_following_notes() {
|
||||
let meta = |kind, data: &[u8]| [vec![0, 0xFF, kind], vlq(data.len() as u32), data.to_vec()].concat();
|
||||
for first in [&b"First"[..], &b""[..], &[0xFF][..]] {
|
||||
let body = [
|
||||
meta(0x03, first),
|
||||
meta(0x03, b"Second"),
|
||||
meta(0x7F, &[0x03, 0xFF, 0x2F]), // Unhandled metadata must remain ignored.
|
||||
vec![0, 0x90, 60, 100, 16, 0x80, 60, 64, 0, 0xFF, 0x2F, 0],
|
||||
]
|
||||
.concat();
|
||||
let smf = read_smf(&file(0, 960, &[body])).unwrap();
|
||||
assert_eq!(smf.tracks.len(), 1);
|
||||
assert_eq!(smf.tracks[0].name, String::from_utf8_lossy(first));
|
||||
assert_eq!(smf.tracks[0].sequence.notes, vec![note(60, 0, 16)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_bad_values() {
|
||||
let bad_tempo = Smf { tempos: vec![(0, 0.0)], ..Default::default() };
|
||||
|
||||
@@ -43,22 +43,14 @@
|
||||
//! [`MixEngine::capture_states`] reads every live instance's state back (for saving the session);
|
||||
//! it calls into the plugins and allocates, so a realtime host runs it only on request.
|
||||
//!
|
||||
//! A built-in instrument's parameter values are applied the same way. A sample-playing
|
||||
//! instrument (the Sampler) is handed the decoded audio of its insert's
|
||||
//! [`Insert::sample`] source straight from the session's pool, shared and never copied.
|
||||
//!
|
||||
//! [`Insert::state`]: soundcraft_model::Insert::state
|
||||
//! [`Insert::sample`]: soundcraft_model::Insert::sample
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use soundcraft_dsp::pan::{PanLaw, SpeakerPos, SurroundParams, gains};
|
||||
use soundcraft_dsp::{Plugin, SampleSource, db_to_gain};
|
||||
use soundcraft_model::{
|
||||
AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, SourceAudio, Speaker, SurroundPan, Track, TrackId, TrackKind,
|
||||
};
|
||||
use soundcraft_dsp::{Plugin, db_to_gain};
|
||||
use soundcraft_model::{AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, Speaker, SurroundPan, Track, TrackId, TrackKind};
|
||||
use soundcraft_time::{Range, Samples};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Default (stereo) main mix width. The actual width is [`main_channels`].
|
||||
pub const MAIN_CHANNELS: usize = 2;
|
||||
@@ -195,8 +187,6 @@ struct Strip {
|
||||
scratch: Vec<f32>,
|
||||
plugins: Vec<Option<PluginSlot>>,
|
||||
instrument: Option<PluginSlot>,
|
||||
/// The pool audio handed to the instrument with `set_sample` (sample-playing instruments).
|
||||
sample: Option<Arc<SourceAudio>>,
|
||||
/// MIDI notes currently sounding on the instrument (pitch), for note-offs at stops/seek.
|
||||
held: Vec<u8>,
|
||||
muted: bool,
|
||||
@@ -271,7 +261,6 @@ impl Strip {
|
||||
scratch: Vec::new(),
|
||||
plugins: Vec::new(),
|
||||
instrument: None,
|
||||
sample: None,
|
||||
held: Vec::new(),
|
||||
muted: false,
|
||||
gr: 0.0,
|
||||
@@ -304,11 +293,6 @@ pub struct MixEngine {
|
||||
order: Vec<usize>,
|
||||
/// Current total compensation delay (samples) of the mix.
|
||||
pub latency: usize,
|
||||
/// Delay already applied to audio written onto busses: the slowest track insert.
|
||||
/// A bus render drops this so the bus lines up with the timeline. The extra delay
|
||||
/// that lines direct outputs up with aux returns stays in `latency` and is removed
|
||||
/// only from the main mix.
|
||||
track_latency: usize,
|
||||
/// Live input for the next block (planar), set by the audio host for input monitoring.
|
||||
pub input: Vec<Vec<f32>>,
|
||||
/// Transport stopped: only monitored inputs sound (no clips).
|
||||
@@ -324,8 +308,6 @@ pub struct MixEngine {
|
||||
adopt: HashMap<(TrackId, usize), PreparedInstance>,
|
||||
/// Instances `sync` took out of service, for the host to drop (see [`MixEngine::take_retired`]).
|
||||
retired: Vec<Box<dyn Plugin>>,
|
||||
/// Ownership workspace reused for parallel strip processing.
|
||||
work: Vec<(usize, Strip)>,
|
||||
}
|
||||
|
||||
/// What `sync` needs to fill a slot.
|
||||
@@ -381,7 +363,6 @@ impl MixEngine {
|
||||
synced: (0, 0, 0),
|
||||
order: Vec::new(),
|
||||
latency: 0,
|
||||
track_latency: 0,
|
||||
input: Vec::new(),
|
||||
monitor_only: false,
|
||||
recording: false,
|
||||
@@ -389,7 +370,6 @@ impl MixEngine {
|
||||
external: false,
|
||||
adopt: HashMap::new(),
|
||||
retired: Vec::new(),
|
||||
work: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,9 +509,7 @@ impl MixEngine {
|
||||
let old = strip.instrument.take();
|
||||
cx.retire(old);
|
||||
strip.instrument = want.and_then(|i| cx.make(t.id, INSTRUMENT_SLOT, i, ich));
|
||||
strip.sample = None;
|
||||
}
|
||||
sync_sample(strip, want, s);
|
||||
}
|
||||
// Instances nobody claimed (the session changed again before they arrived).
|
||||
let leftovers: Vec<(TrackId, usize)> = cx.adopt.keys().copied().collect();
|
||||
@@ -590,17 +568,16 @@ impl MixEngine {
|
||||
}
|
||||
let any_solo = s.tracks.iter().any(|t| t.mixer.solo && !t.inactive);
|
||||
let order = std::mem::take(&mut self.order);
|
||||
let live = |t: &Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca);
|
||||
let live = |t: &&Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca);
|
||||
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
|
||||
// Phase 1: independent strips (audio, instrument, MIDI) in parallel.
|
||||
let mut work = std::mem::take(&mut self.work);
|
||||
work.clear();
|
||||
for &i in &order {
|
||||
let Some(t) = s.tracks.get(i).filter(|t| live(t) && !is_aux(t)) else { continue };
|
||||
if let Some(st) = self.strips.remove(&t.id) {
|
||||
work.push((i, st));
|
||||
}
|
||||
}
|
||||
let mut work: Vec<(&Track, Strip)> = order
|
||||
.iter()
|
||||
.filter_map(|&i| s.tracks.get(i))
|
||||
.filter(live)
|
||||
.filter(|t| !is_aux(t))
|
||||
.filter_map(|t| self.strips.remove(&t.id).map(|st| (t, st)))
|
||||
.collect();
|
||||
for (_, st) in work.iter_mut() {
|
||||
if st.scratch.len() < self.max_block {
|
||||
st.scratch.resize(self.max_block, 0.0);
|
||||
@@ -608,30 +585,16 @@ impl MixEngine {
|
||||
}
|
||||
let busses = std::mem::take(&mut self.busses);
|
||||
let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording };
|
||||
run_strips(&mut work, |(i, st)| {
|
||||
if let Some(t) = s.tracks.get(*i) {
|
||||
process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo);
|
||||
}
|
||||
});
|
||||
run_strips(&mut work, |(t, st)| process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo));
|
||||
self.busses = busses;
|
||||
// Delay compensation: align every strip to the slowest. Direct-to-main outputs also wait
|
||||
// for the slowest aux return. Aux latency has to be read before those outputs are routed,
|
||||
// or the first block (and a one-block bounce probe) still sees zero.
|
||||
// Delay compensation: align every strip to the slowest; direct-to-main outputs also wait
|
||||
// for the slowest aux return (aux latencies are from the previous block; they are stable).
|
||||
let pdc = s.edit.delay_compensation;
|
||||
if pdc {
|
||||
for t in s.tracks.iter().filter(|t| is_aux(t) && !t.inactive) {
|
||||
if let Some(st) = self.strips.get_mut(&t.id) {
|
||||
note_insert_latency(t, st, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
let lmax_t = if pdc { work.iter().map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 };
|
||||
let lmax_a =
|
||||
if pdc { self.strips.iter().filter(|(id, _)| s.track(**id).is_some_and(is_aux)).map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 };
|
||||
self.track_latency = lmax_t;
|
||||
self.latency = lmax_t + lmax_a;
|
||||
for (i, st) in work.iter_mut() {
|
||||
let Some(t) = s.tracks.get(*i) else { continue };
|
||||
for (t, st) in work.iter_mut() {
|
||||
let ch = st.buf.len();
|
||||
st.align.set(lmax_t.saturating_sub(st.latency), ch);
|
||||
st.align.process(&mut st.buf, frames);
|
||||
@@ -641,20 +604,15 @@ impl MixEngine {
|
||||
}
|
||||
st.out_extra.set(if t.mixer.output == Route::Main { lmax_a } else { 0 }, ch);
|
||||
}
|
||||
for (i, st) in work.iter_mut() {
|
||||
if let Some(t) = s.tracks.get(*i) {
|
||||
self.route_strip(t, st, pos, frames);
|
||||
}
|
||||
for (t, st) in work.iter_mut() {
|
||||
self.route_strip(t, st, pos, frames);
|
||||
}
|
||||
for (i, st) in work.drain(..) {
|
||||
if let Some(t) = s.tracks.get(i) {
|
||||
self.strips.insert(t.id, st);
|
||||
}
|
||||
for (t, st) in work {
|
||||
self.strips.insert(t.id, st);
|
||||
}
|
||||
self.work = work;
|
||||
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
|
||||
// Phase 2: auxes in dependency order (they read busses).
|
||||
for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(|t| live(t)).filter(|t| is_aux(t)) {
|
||||
for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(live).filter(|t| is_aux(t)) {
|
||||
let Some(mut st) = self.strips.remove(&t.id) else { continue };
|
||||
if st.scratch.len() < self.max_block {
|
||||
st.scratch.resize(self.max_block, 0.0);
|
||||
@@ -721,24 +679,6 @@ impl MixEngine {
|
||||
self.main_meter.measure(&self.main, frames, 0.0);
|
||||
}
|
||||
|
||||
/// Copy one rendered block of `bus` into `out`. A missing bus stays silent.
|
||||
fn copy_bus(&self, bus: BusId, frames: usize, out: &mut [Vec<f32>]) {
|
||||
for o in out.iter_mut() {
|
||||
for x in o.iter_mut().take(frames) {
|
||||
*x = 0.0;
|
||||
}
|
||||
}
|
||||
let Some(src) = self.busses.get(&bus) else { return };
|
||||
for (o, c) in out.iter_mut().zip(src.buf.iter()) {
|
||||
for i in 0..frames {
|
||||
let v = c.get(i).copied().unwrap_or(0.0);
|
||||
if let Some(d) = o.get_mut(i) {
|
||||
*d = if v.is_finite() { v } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send, meter and pan a processed strip into the busses / main mix.
|
||||
fn route_strip(&mut self, t: &Track, strip: &mut Strip, pos: Samples, frames: usize) {
|
||||
self.meters.entry(t.id).or_default().measure(&strip.buf, frames, strip.gr);
|
||||
@@ -886,14 +826,6 @@ fn process_strip(
|
||||
}
|
||||
TrackKind::Instrument | TrackKind::Midi => {
|
||||
if let Some(inst) = &mut strip.instrument {
|
||||
// #152: push the instrument's stored parameter values into the live plugin
|
||||
// before processing, the same way inserts do below. Without this, the values
|
||||
// loaded from a session (or set via `mix.insert_state`) never reach the plugin
|
||||
// instance and the rendered audio matches the plugin's defaults — e.g. a saved
|
||||
// `level = -12 dB` sounds the same as `level = -6 dB`.
|
||||
if let Some(ins) = &t.instrument {
|
||||
apply_params(inst, ins, t, INSTRUMENT_SLOT, pos);
|
||||
}
|
||||
schedule_notes(s, t, pos, frames, inst, &mut strip.held);
|
||||
if strip.buf.len() >= 2 {
|
||||
inst.plugin.process(&mut strip.buf, frames);
|
||||
@@ -944,11 +876,8 @@ fn process_strip(
|
||||
d.extend_from_slice(src.get(..frames).unwrap_or(&[]));
|
||||
}
|
||||
}
|
||||
// Endpoints are the first and last sample of this block. The next block starts on its own
|
||||
// first sample, so a breakpoint there does not ramp the block before it.
|
||||
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
|
||||
let mut v0 = volume_db_at(t, pos);
|
||||
let mut v1 = volume_db_at(t, last);
|
||||
let mut v1 = volume_db_at(t, pos + frames as i64);
|
||||
// Trim automation (an offset on top of the volume curve).
|
||||
if let Some(l) = t
|
||||
.automation
|
||||
@@ -957,14 +886,14 @@ fn process_strip(
|
||||
.filter(|l| !l.points.is_empty())
|
||||
{
|
||||
v0 += l.value_at(pos, 0.0);
|
||||
v1 += l.value_at(last, 0.0);
|
||||
v1 += l.value_at(pos + frames as i64, 0.0);
|
||||
}
|
||||
// VCA master: its fader offsets the member's, and its mute mutes the member.
|
||||
if let Some(vid) = t.mixer.vca
|
||||
&& let Some(vca) = s.tracks.iter().find(|x| x.id.0 == vid && x.kind == TrackKind::Vca)
|
||||
{
|
||||
v0 += volume_db_at(vca, pos);
|
||||
v1 += volume_db_at(vca, last);
|
||||
v1 += volume_db_at(vca, pos + frames as i64);
|
||||
if vca.mixer.mute {
|
||||
strip.muted = true;
|
||||
}
|
||||
@@ -973,7 +902,7 @@ fn process_strip(
|
||||
for c in strip.buf.iter_mut() {
|
||||
for i in 0..frames {
|
||||
if let Some(x) = c.get_mut(i) {
|
||||
*x *= lerp_gain(g0, g1, i, frames.saturating_sub(1));
|
||||
*x *= lerp_gain(g0, g1, i, frames);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1073,10 +1002,6 @@ fn structure_fingerprint(s: &Session) -> usize {
|
||||
snd.target.hash(&mut h);
|
||||
}
|
||||
t.instrument.as_ref().map(|x| x.plugin.as_str()).hash(&mut h);
|
||||
let sample = t.instrument.as_ref().and_then(|x| x.sample);
|
||||
sample.hash(&mut h);
|
||||
// Which decoded audio it is, so new audio under the same id (another session) re-syncs.
|
||||
sample.and_then(|id| s.pool.get(id)).map(|a| Arc::as_ptr(a) as usize).hash(&mut h);
|
||||
}
|
||||
for b in &s.busses {
|
||||
b.id.hash(&mut h);
|
||||
@@ -1277,12 +1202,10 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut
|
||||
while i < i1 {
|
||||
let seg_end = (i + STEP).min(i1);
|
||||
let rel0 = pos.saturating_add(i as i64).saturating_sub(clip.start);
|
||||
// The last sample of this step, not the first sample of the next one, so a breakpoint
|
||||
// on the boundary does not ramp the audio before it.
|
||||
let rel1 = pos.saturating_add(seg_end.saturating_sub(1) as i64).saturating_sub(clip.start);
|
||||
let rel1 = pos.saturating_add(seg_end as i64).saturating_sub(clip.start);
|
||||
let g0 = clip.gain_at(rel0);
|
||||
let g1 = clip.gain_at(rel1.min(clip.length));
|
||||
let n = (seg_end - i).saturating_sub(1).max(1) as f32;
|
||||
let n = (seg_end - i) as f32;
|
||||
for k in i..seg_end {
|
||||
let rel = pos.saturating_add(k as i64).saturating_sub(clip.start);
|
||||
let v = if (stretch - 1.0).abs() < 1e-9 {
|
||||
@@ -1355,80 +1278,21 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu
|
||||
}
|
||||
}
|
||||
|
||||
/// Session audio shared with a sample-playing instrument.
|
||||
struct PoolSample(Arc<SourceAudio>);
|
||||
|
||||
impl SampleSource for PoolSample {
|
||||
fn sample_rate(&self) -> f32 {
|
||||
self.0.buffer.sample_rate as f32
|
||||
}
|
||||
fn num_channels(&self) -> usize {
|
||||
self.0.buffer.num_channels()
|
||||
}
|
||||
fn channel(&self, ch: usize) -> Option<&[f32]> {
|
||||
self.0.channel(ch)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands a strip's instrument the audio of its insert's sample source when it changed (a new
|
||||
/// sample, a cleared one, or a new instance). Missing media plays nothing.
|
||||
fn sync_sample(strip: &mut Strip, want: Option<&soundcraft_model::Insert>, s: &Session) {
|
||||
let audio = want.and_then(|i| i.sample).and_then(|id| s.pool.get(id));
|
||||
let same = match (strip.sample.as_ref(), audio) {
|
||||
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if same {
|
||||
return;
|
||||
}
|
||||
if let Some(inst) = strip.instrument.as_mut() {
|
||||
let shared = audio.map(|a| Arc::new(PoolSample(Arc::clone(a))) as Arc<dyn SampleSource>);
|
||||
inst.plugin.set_sample(shared);
|
||||
}
|
||||
strip.sample = audio.cloned();
|
||||
}
|
||||
|
||||
/// Record insert latency without processing audio, so this block can compensate for it.
|
||||
fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) {
|
||||
let mut latency = 0usize;
|
||||
for (i, ins) in t.mixer.inserts.iter().enumerate() {
|
||||
let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue };
|
||||
if !ins.active || ins.bypass {
|
||||
continue;
|
||||
}
|
||||
apply_params(slot, ins, t, i, pos);
|
||||
latency = latency.saturating_add(slot.plugin.latency());
|
||||
}
|
||||
strip.latency = latency;
|
||||
}
|
||||
|
||||
fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) {
|
||||
// Automation lanes address insert slots; the instrument (`INSTRUMENT_SLOT`) has none, so it
|
||||
// must not pick up slot A's lanes.
|
||||
let Ok(slot_u8) = u8::try_from(i) else {
|
||||
return apply_values(slot, ins, |_, v| v);
|
||||
};
|
||||
let slot_u8 = u8::try_from(i).unwrap_or(0);
|
||||
let automated = t.mixer.automation_mode.reads()
|
||||
&& t.automation.iter().any(|l| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8) && !l.points.is_empty());
|
||||
apply_values(slot, ins, |k, val| {
|
||||
if automated
|
||||
&& let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k))
|
||||
&& !l.points.is_empty()
|
||||
{
|
||||
return l.value_at(pos, val);
|
||||
}
|
||||
val
|
||||
});
|
||||
}
|
||||
|
||||
/// Sends each stored param (as mapped by `value`) to the plugin when it changed since last time.
|
||||
fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: impl Fn(&str, f32) -> f32) {
|
||||
if slot.applied.len() != ins.params.len() {
|
||||
slot.applied = vec![f32::NAN; ins.params.len()];
|
||||
}
|
||||
for ((k, v), last) in ins.params.iter().zip(slot.applied.iter_mut()) {
|
||||
let val = value(k, *v);
|
||||
let mut val = *v;
|
||||
if automated
|
||||
&& let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k))
|
||||
&& !l.points.is_empty()
|
||||
{
|
||||
val = l.value_at(pos, val);
|
||||
}
|
||||
if val.to_bits() != last.to_bits() {
|
||||
slot.plugin.set_param(k, val);
|
||||
*last = val;
|
||||
@@ -1776,46 +1640,6 @@ pub fn render_range(s: &Session, range: Range, block: usize) -> Vec<Vec<f32>> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Offline render of one bus over `range` (planar, the bus format's channel count).
|
||||
/// Track-insert compensation is removed, so a bus fed by tracks lines up with the timeline.
|
||||
/// An unknown bus is silent.
|
||||
pub fn render_bus(s: &Session, bus: BusId, range: Range, block: usize) -> Vec<Vec<f32>> {
|
||||
let nch = s.bus(bus).map(|b| b.format.channels()).unwrap_or(0);
|
||||
if nch == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch);
|
||||
let latency = {
|
||||
let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block);
|
||||
let b = probe.max_block();
|
||||
probe.render(s, range.start, b.min(len.max(1)), &mut []);
|
||||
probe.track_latency
|
||||
};
|
||||
let total = len.saturating_add(latency);
|
||||
let mut out = vec![vec![0.0f32; total]; nch];
|
||||
let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
|
||||
let b = eng.max_block();
|
||||
let mut tmp = vec![vec![0.0f32; b]; nch];
|
||||
let mut done = 0usize;
|
||||
while done < total {
|
||||
let n = (total - done).min(b);
|
||||
eng.render(s, range.start + done as i64, n, &mut []);
|
||||
eng.copy_bus(bus, n, &mut tmp);
|
||||
for (o, t) in out.iter_mut().zip(tmp.iter()) {
|
||||
if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) {
|
||||
d.copy_from_slice(sr);
|
||||
}
|
||||
}
|
||||
done += n;
|
||||
}
|
||||
if latency > 0 {
|
||||
for c in &mut out {
|
||||
c.drain(..latency.min(c.len()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// ITU-R BS.775 stereo fold-down of planar audio in `fmt` (C and surrounds at -3 dB, LFE dropped;
|
||||
/// Ambisonics play their W channel). Mono/stereo input is returned unchanged.
|
||||
pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec<f32>]) -> Vec<Vec<f32>> {
|
||||
@@ -1976,46 +1800,6 @@ mod tests {
|
||||
use soundcraft_model::{Insert, SourceAudio, SourceId};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn synth_session(level_db: f32) -> Session {
|
||||
use soundcraft_midi::{Note, Sequence};
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
|
||||
let mut seq = Sequence::default();
|
||||
seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 });
|
||||
let cid = s.new_clip_id();
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
tr.playlist_mut().unwrap().clips.push(Clip::midi(cid, "n", 0, 24_000, seq));
|
||||
let mut ins = Insert::new("subtractive_synth");
|
||||
ins.params.insert("level".into(), level_db);
|
||||
tr.instrument = Some(ins);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_instrument_params_reach_the_plugin() {
|
||||
// #152: a saved instrument level of -12 dB must render quieter than -6 dB.
|
||||
// RMS over the held part of the note, past any parameter smoothing.
|
||||
let rms = |db: f32| {
|
||||
let out = render_range(&synth_session(db), Range::new(0, 12_000), 512);
|
||||
let tail = out[0].get(6_000..).unwrap_or_default();
|
||||
(tail.iter().map(|x| x * x).sum::<f32>() / tail.len().max(1) as f32).sqrt()
|
||||
};
|
||||
let (loud, quiet) = (rms(-6.0), rms(-12.0));
|
||||
assert!(loud > 0.0, "the synth should sound");
|
||||
assert!((quiet / loud - 0.5).abs() < 0.05, "-12 dB vs -6 dB should halve the level: {quiet} / {loud}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_slot_a_automation_does_not_drive_the_instrument() {
|
||||
let mut s = synth_session(-6.0);
|
||||
let reference = render_range(&s, Range::new(0, 12_000), 512);
|
||||
let tr = &mut s.tracks[0];
|
||||
let mut lane = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: 0, param: "level".into() });
|
||||
lane.set_point(0, -48.0);
|
||||
tr.automation.push(lane);
|
||||
assert_eq!(render_range(&s, Range::new(0, 12_000), 512), reference);
|
||||
}
|
||||
|
||||
fn session_with_dc(level: f32, frames: usize) -> (Session, TrackId) {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
@@ -2095,20 +1879,6 @@ mod tests {
|
||||
assert!(out[0][1000] > 0.1, "aux should carry the send: {}", out[0][1000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_bus_is_the_bus_not_the_main_mix() {
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
let stem = s.add_bus("Stem", ChannelFormat::Stereo);
|
||||
let empty = s.add_bus("Empty", ChannelFormat::Stereo);
|
||||
s.track_mut(t).unwrap().mixer.output = Route::Bus(stem);
|
||||
let main = render_range(&s, Range::new(0, 4800), 512);
|
||||
let bus = render_bus(&s, stem, Range::new(0, 4800), 512);
|
||||
let silent = render_bus(&s, empty, Range::new(0, 4800), 512);
|
||||
assert!(main[0][1000].abs() < 1e-4, "main should not carry a track routed to the bus: {}", main[0][1000]);
|
||||
assert!(bus[0][1000].abs() > 0.1, "bus should carry the track: {}", bus[0][1000]);
|
||||
assert!(silent.iter().all(|c| c.iter().all(|x| x.abs() < 1e-6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_automation_ramps() {
|
||||
let (mut s, t) = session_with_dc(0.5, 48_000);
|
||||
@@ -2287,53 +2057,6 @@ mod tests {
|
||||
assert_ne!(argmax(&out[0]), argmax(&out[1]));
|
||||
}
|
||||
|
||||
fn impulse_at(ch: &[f32]) -> Vec<usize> {
|
||||
ch.iter().enumerate().filter(|(_, v)| v.abs() > 1e-4).map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// An aux insert's lookahead is part of the mix latency on the first block, not the block after.
|
||||
#[test]
|
||||
fn delay_compensation_aligns_an_aux_insert() {
|
||||
let mut s = Session::default();
|
||||
let n = 8192;
|
||||
let mut left = vec![0.0f32; n];
|
||||
for at in [0usize, 2048, 8000] {
|
||||
left[at] = 0.25;
|
||||
}
|
||||
s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![left.clone(), left] })));
|
||||
let bus = s.add_bus("Test Bus", ChannelFormat::Stereo);
|
||||
let source = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Source"));
|
||||
let aux = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("Return"));
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(source).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "impulses", SourceId(901), 0, 0, n as i64));
|
||||
s.track_mut(source).unwrap().mixer.output = Route::Bus(bus);
|
||||
s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus);
|
||||
let render = |s: &Session, start: i64, end: i64| render_range(s, Range::new(start, end), 1024);
|
||||
let control = render(&s, 0, n as i64);
|
||||
assert_eq!(impulse_at(&control[0]), vec![0, 2048, 8000]);
|
||||
assert_eq!(impulse_at(&control[1]), impulse_at(&control[0]));
|
||||
|
||||
let mut comp = Insert::new("compressor");
|
||||
comp.params.insert("lookahead".into(), 5.0);
|
||||
comp.params.insert("ratio".into(), 1.0);
|
||||
s.track_mut(source).unwrap().mixer.inserts[0] = Some(comp.clone());
|
||||
let on_source = render(&s, 0, n as i64);
|
||||
assert_eq!(impulse_at(&on_source[0]), impulse_at(&control[0]));
|
||||
|
||||
s.track_mut(source).unwrap().mixer.inserts[0] = None;
|
||||
s.track_mut(aux).unwrap().mixer.inserts[0] = Some(comp);
|
||||
let on_aux = render(&s, 0, n as i64);
|
||||
assert_eq!(impulse_at(&on_aux[0]), vec![0, 2048, 8000], "aux lookahead must stay inside the bounce");
|
||||
assert_eq!(impulse_at(&on_aux[1]), impulse_at(&on_aux[0]));
|
||||
assert!((on_aux[0][8000] - 0.25).abs() < 1e-3, "{}", on_aux[0][8000]);
|
||||
let partial = render(&s, 2048, 3072);
|
||||
assert_eq!(impulse_at(&partial[0]), vec![0]);
|
||||
|
||||
s.track_mut(aux).unwrap().mixer.inserts[0].as_mut().unwrap().params.insert("lookahead".into(), 0.0);
|
||||
let zero = render(&s, 0, n as i64);
|
||||
assert_eq!(impulse_at(&zero[0]), impulse_at(&control[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_monitoring_replaces_clips() {
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
@@ -2362,21 +2085,6 @@ mod tests {
|
||||
assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_workspace_keeps_its_allocation_between_blocks() {
|
||||
let mut s = Session::default();
|
||||
for _ in 0..6 {
|
||||
s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
}
|
||||
let mut eng = MixEngine::new(48_000.0, 512);
|
||||
let mut out = vec![vec![0.0; 512]; 2];
|
||||
eng.render(&s, 0, 512, &mut out);
|
||||
let capacity = eng.work.capacity();
|
||||
assert!(capacity >= 6);
|
||||
eng.render(&s, 512, 512, &mut out);
|
||||
assert_eq!(eng.work.capacity(), capacity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_monitoring_does_not_click() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Per-track mixer state: inserts, sends, routing, fader, pan, mute/solo.
|
||||
|
||||
use crate::{AutomationMode, BusId, SourceId};
|
||||
use crate::{AutomationMode, BusId};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const INSERT_SLOTS: usize = 10;
|
||||
@@ -425,7 +425,7 @@ pub enum Route {
|
||||
pub struct Insert {
|
||||
/// Registry id from `soundcraft-dsp` (e.g. `eq7`, `comp`).
|
||||
pub plugin: String,
|
||||
#[serde(default, deserialize_with = "finite_params")]
|
||||
#[serde(default)]
|
||||
pub params: BTreeMap<String, f32>,
|
||||
#[serde(default)]
|
||||
pub bypass: bool,
|
||||
@@ -438,9 +438,6 @@ pub struct Insert {
|
||||
/// state), standard base64. Restored into a new instance before it first processes.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub state: Option<String>,
|
||||
/// The session audio source a sample-playing instrument (the built-in Sampler) plays.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sample: Option<SourceId>,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
@@ -449,15 +446,7 @@ fn yes() -> bool {
|
||||
|
||||
impl Insert {
|
||||
pub fn new(plugin: impl Into<String>) -> Self {
|
||||
Insert {
|
||||
plugin: plugin.into(),
|
||||
params: BTreeMap::new(),
|
||||
bypass: false,
|
||||
active: true,
|
||||
preset: String::from("<factory default>"),
|
||||
state: None,
|
||||
sample: None,
|
||||
}
|
||||
Insert { plugin: plugin.into(), params: BTreeMap::new(), bypass: false, active: true, preset: String::from("<factory default>"), state: None }
|
||||
}
|
||||
|
||||
/// The decoded plugin state (`None` when absent or malformed).
|
||||
@@ -588,13 +577,6 @@ pub(crate) fn finite_or(v: f32, d: f32) -> f32 {
|
||||
if v.is_finite() { v } else { d }
|
||||
}
|
||||
|
||||
/// Parameters saved as `null` (how JSON writes NaN and infinity) or beyond `f32` are dropped, so
|
||||
/// they keep their defaults instead of failing the whole session.
|
||||
fn finite_params<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BTreeMap<String, f32>, D::Error> {
|
||||
let params: BTreeMap<String, Option<f64>> = serde::Deserialize::deserialize(d)?;
|
||||
Ok(params.into_iter().filter_map(|(k, v)| Some((k, v.map(|v| v as f32).filter(|v| v.is_finite())?))).collect())
|
||||
}
|
||||
|
||||
/// Fader taper used by Pro Tools-style faders: position 0..1 ↔ dB (-inf..+12).
|
||||
pub fn fader_pos_to_db(pos: f32) -> f32 {
|
||||
let p = finite_or(pos, 0.0).clamp(0.0, 1.0);
|
||||
@@ -642,16 +624,6 @@ mod tests {
|
||||
assert_eq!(bad.state_bytes(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_saved_as_null_or_out_of_range_are_dropped() {
|
||||
let mut i = Insert::new("eq_7band");
|
||||
i.params.extend([("gain".to_string(), f32::NAN), ("q".to_string(), 0.7)]);
|
||||
let back: Insert = serde_json::from_str(&serde_json::to_string(&i).unwrap()).unwrap();
|
||||
assert_eq!(back.params, BTreeMap::from([("q".to_string(), 0.7)]));
|
||||
let big: Insert = serde_json::from_str(r#"{"plugin":"x","params":{"gain":1e39,"q":0.5}}"#).unwrap();
|
||||
assert_eq!(big.params, BTreeMap::from([("q".to_string(), 0.5)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fader_taper_round_trips() {
|
||||
for db in [-100.0f32, -60.0, -24.0, -10.0, 0.0, 6.0, 12.0] {
|
||||
|
||||
@@ -160,9 +160,6 @@ pub struct EditState {
|
||||
/// Clips explicitly selected (Grabber selection).
|
||||
#[serde(default)]
|
||||
pub selected_clips: Vec<ClipId>,
|
||||
/// Audio files selected in the Clip List.
|
||||
#[serde(default)]
|
||||
pub selected_sources: Vec<SourceId>,
|
||||
/// Playhead position when stopped.
|
||||
pub playhead: Samples,
|
||||
pub grid: GridValue,
|
||||
@@ -237,7 +234,6 @@ impl Default for EditState {
|
||||
selection: Range::default(),
|
||||
selected_tracks: Vec::new(),
|
||||
selected_clips: Vec::new(),
|
||||
selected_sources: Vec::new(),
|
||||
playhead: 0,
|
||||
grid: GridValue::default(),
|
||||
nudge: GridValue::Seconds(1.0),
|
||||
@@ -312,9 +308,6 @@ pub struct Session {
|
||||
pub midi_originals: std::collections::BTreeMap<u64, soundcraft_midi::Sequence>,
|
||||
#[serde(skip)]
|
||||
pub pool: SourcePool,
|
||||
/// The folder relative source paths point into: where the session was opened or last saved.
|
||||
#[serde(skip)]
|
||||
pub folder: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -348,7 +341,6 @@ impl Session {
|
||||
comments: String::new(),
|
||||
midi_originals: std::collections::BTreeMap::new(),
|
||||
pool: SourcePool::default(),
|
||||
folder: None,
|
||||
};
|
||||
s.edit.pre_roll = sample_rate.samples(2.0);
|
||||
s.edit.post_roll = sample_rate.samples(2.0);
|
||||
@@ -407,15 +399,6 @@ impl Session {
|
||||
self.tracks.iter_mut().find_map(|t| t.playlist_mut().and_then(|p| p.clip_mut(id)))
|
||||
}
|
||||
|
||||
/// Clips on every playlist, alternates included.
|
||||
pub fn all_clips(&self) -> impl Iterator<Item = &Clip> {
|
||||
self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| &p.clips))
|
||||
}
|
||||
|
||||
pub fn clips_using(&self, src: SourceId) -> impl Iterator<Item = &Clip> {
|
||||
self.all_clips().filter(move |c| c.source() == Some(src))
|
||||
}
|
||||
|
||||
/// End of the last clip on any track (session length).
|
||||
pub fn content_end(&self) -> Samples {
|
||||
self.tracks.iter().filter_map(|t| t.playlist().map(|p| p.end())).max().unwrap_or(0)
|
||||
|
||||
@@ -9,7 +9,6 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
soundcraft-time.workspace = true
|
||||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-model.workspace = true
|
||||
soundcraft-mix.workspace = true
|
||||
soundcraft-dsp.workspace = true
|
||||
@@ -21,5 +20,8 @@ cpal.workspace = true
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
cpal = { workspace = true, features = ["wasm-bindgen"] }
|
||||
|
||||
[dev-dependencies]
|
||||
soundcraft-audio-io.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -86,6 +86,7 @@ struct AudioState {
|
||||
playing: bool,
|
||||
end: Option<Samples>,
|
||||
looped: Option<Range>,
|
||||
block: Vec<Vec<f32>>,
|
||||
/// Rendered frames waiting to be consumed (planar, main channels) and the fractional read index.
|
||||
pending: Vec<Vec<f32>>,
|
||||
/// Stereo fold-down gains per main channel (used when the device has fewer channels).
|
||||
@@ -121,6 +122,7 @@ impl AudioState {
|
||||
playing: false,
|
||||
end: None,
|
||||
looped: None,
|
||||
block: vec![vec![0.0; BLOCK]; nch],
|
||||
pending: vec![vec![0.0; BLOCK]; nch],
|
||||
pending_len: 0,
|
||||
read: 0.0,
|
||||
@@ -149,7 +151,8 @@ impl AudioState {
|
||||
self.mix.adopt(instances);
|
||||
// The main format changed (a document edit, not steady state): resize.
|
||||
let nch = main_channels(&s);
|
||||
if self.pending.len() != nch {
|
||||
if self.block.len() != nch {
|
||||
self.block = vec![vec![0.0; BLOCK]; nch];
|
||||
self.pending = vec![vec![0.0; BLOCK]; nch];
|
||||
self.pending_len = 0;
|
||||
self.read = 0.0;
|
||||
@@ -168,8 +171,6 @@ impl AudioState {
|
||||
}
|
||||
Cmd::Stop => {
|
||||
self.playing = false;
|
||||
self.end = None;
|
||||
self.looped = None;
|
||||
self.mix.reset();
|
||||
}
|
||||
Cmd::Locate(at) => {
|
||||
@@ -196,8 +197,8 @@ impl AudioState {
|
||||
}
|
||||
|
||||
/// Render the next block at the session rate into `pending`.
|
||||
fn render_next(&mut self, quantum: usize) {
|
||||
let mut n = quantum.clamp(1, BLOCK);
|
||||
fn render_next(&mut self) {
|
||||
let mut n = BLOCK;
|
||||
if let Some(l) = self.looped
|
||||
&& self.pos >= l.end
|
||||
{
|
||||
@@ -208,7 +209,6 @@ impl AudioState {
|
||||
} else if let Some(e) = self.end {
|
||||
if self.pos >= e {
|
||||
self.playing = false;
|
||||
self.end = None;
|
||||
self.shared.playing.store(false, Ordering::Relaxed);
|
||||
n = 0;
|
||||
} else {
|
||||
@@ -231,9 +231,19 @@ impl AudioState {
|
||||
}
|
||||
self.mix.monitor_only = !self.playing;
|
||||
self.mix.recording = self.recording;
|
||||
self.mix.render(&self.session, self.pos, n, &mut self.pending);
|
||||
self.mix.render(&self.session, self.pos, n, &mut self.block);
|
||||
self.send_garbage();
|
||||
for (d, s) in self.pending.iter_mut().zip(self.block.iter()) {
|
||||
if let (Some(dd), Some(ss)) = (d.get_mut(..n), s.get(..n)) {
|
||||
dd.copy_from_slice(ss);
|
||||
}
|
||||
}
|
||||
self.pending_len = n;
|
||||
if !self.playing {
|
||||
return;
|
||||
}
|
||||
self.pos += n as i64;
|
||||
self.shared.position.store(self.pos, Ordering::Relaxed);
|
||||
if let Ok(mut m) = self.shared.meters.try_lock() {
|
||||
m.main.copy_from(&self.mix.main_meter);
|
||||
for (k, v) in &self.mix.meters {
|
||||
@@ -245,10 +255,6 @@ impl AudioState {
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.playing {
|
||||
self.pos += n as i64;
|
||||
self.shared.position.store(self.pos, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill an interleaved device buffer.
|
||||
@@ -266,15 +272,13 @@ impl AudioState {
|
||||
}
|
||||
let speed = f64::from(self.shared.speed.load(Ordering::Relaxed)) / 1000.0;
|
||||
let step = self.session.sample_rate.as_f64() / self.device_rate.max(1.0) * speed.clamp(0.1, 4.0);
|
||||
let callback_frames = out.len() / channels;
|
||||
let quantum = ((callback_frames as f64 * step).ceil() as usize).clamp(1, BLOCK);
|
||||
for frame in out.chunks_mut(channels) {
|
||||
while self.read >= self.pending_len as f64 {
|
||||
self.read -= self.pending_len as f64;
|
||||
if !self.playing && !monitoring {
|
||||
break;
|
||||
}
|
||||
self.render_next(quantum);
|
||||
self.render_next();
|
||||
if self.pending_len == 0 {
|
||||
break;
|
||||
}
|
||||
@@ -580,10 +584,6 @@ impl Player {
|
||||
self.side().editors.get(&(track, slot)).is_some_and(|e| e.is_open())
|
||||
}
|
||||
|
||||
pub fn any_editor_open(&self) -> bool {
|
||||
self.side().editors.values().any(|e| e.is_open())
|
||||
}
|
||||
|
||||
/// Shows a parameter change made elsewhere in the plugin's open editor.
|
||||
pub fn editor_set_param(&self, track: TrackId, slot: usize, id: &str, value: f32) {
|
||||
if let Some(e) = self.side().editors.get_mut(&(track, slot)) {
|
||||
@@ -622,11 +622,6 @@ impl Player {
|
||||
self.shared.speed.store(v, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Playback rate last requested, 1.0 at full speed.
|
||||
pub fn speed(&self) -> f32 {
|
||||
self.shared.speed.load(Ordering::Relaxed) as f32 / 1000.0
|
||||
}
|
||||
|
||||
pub fn position(&self) -> Samples {
|
||||
self.shared.position.load(Ordering::Relaxed)
|
||||
}
|
||||
@@ -638,21 +633,6 @@ impl Player {
|
||||
pub fn meters(&self) -> MeterSnapshot {
|
||||
self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner).clone()
|
||||
}
|
||||
|
||||
/// Copy meters into reusable UI storage. Once track/channel counts are stable this performs
|
||||
/// no allocation, unlike cloning a new snapshot every video frame.
|
||||
pub fn copy_meters(&self, out: &mut MeterSnapshot) {
|
||||
let source = self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
copy_meter_snapshot(&source, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_meter_snapshot(source: &MeterSnapshot, out: &mut MeterSnapshot) {
|
||||
out.tracks.retain(|id, _| source.tracks.contains_key(id));
|
||||
for (id, meter) in &source.tracks {
|
||||
out.tracks.entry(*id).or_default().copy_from(meter);
|
||||
}
|
||||
out.main.copy_from(&source.main);
|
||||
}
|
||||
|
||||
impl Side {
|
||||
@@ -750,21 +730,6 @@ fn open_device(want_rate: u32, want_ch: u16) -> Result<(cpal::Device, cpal::Stre
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reusable_meter_snapshot_updates_and_drops_stale_tracks() {
|
||||
let keep = TrackId(1);
|
||||
let stale = TrackId(2);
|
||||
let mut source = MeterSnapshot::default();
|
||||
source.tracks.insert(keep, StripMeter { peak: [0.5, 0.25], peaks: vec![0.5, 0.25], gain_reduction_db: 3.0 });
|
||||
source.main.peaks = vec![0.75, 0.5];
|
||||
let mut out = MeterSnapshot::default();
|
||||
out.tracks.insert(stale, StripMeter::default());
|
||||
copy_meter_snapshot(&source, &mut out);
|
||||
assert_eq!(out.tracks.len(), 1);
|
||||
assert_eq!(out.tracks[&keep], source.tracks[&keep]);
|
||||
assert_eq!(out.main, source.main);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_state_plays_and_stops_at_end() {
|
||||
let s = Arc::new(soundcraft_model::Session::default());
|
||||
@@ -783,23 +748,6 @@ mod tests {
|
||||
assert!(!shared.playing.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_device_callbacks_render_short_mix_blocks() {
|
||||
let s = Arc::new(soundcraft_model::Session::default());
|
||||
let shared = Arc::new(Shared {
|
||||
position: AtomicI64::new(0),
|
||||
playing: AtomicBool::new(false),
|
||||
speed: AtomicU32::new(1000),
|
||||
meters: Mutex::new(MeterSnapshot::default()),
|
||||
});
|
||||
let (tx, rx) = channel();
|
||||
let mut st = AudioState::new(rx, Arc::clone(&shared), s, 48_000.0, channel().0);
|
||||
tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();
|
||||
let mut out = vec![0.0f32; 2 * 64];
|
||||
st.fill(&mut out, 2);
|
||||
assert_eq!(shared.position.load(Ordering::Relaxed), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surround_main_maps_to_device_channels_or_folds_down() {
|
||||
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
|
||||
@@ -948,30 +896,4 @@ mod tests {
|
||||
assert!(p <= 1000, "position {p} should stay inside the loop");
|
||||
assert!(shared.playing.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armed_tracks_are_heard_and_metered_after_playback_stops() {
|
||||
use soundcraft_model::{ChannelFormat, TrackKind};
|
||||
let mut s = soundcraft_model::Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.track_mut(t).unwrap().mixer.record_arm = true;
|
||||
let shared = Arc::new(Shared {
|
||||
position: AtomicI64::new(0),
|
||||
playing: AtomicBool::new(false),
|
||||
speed: AtomicU32::new(1000),
|
||||
meters: Mutex::new(MeterSnapshot::default()),
|
||||
});
|
||||
let (tx, rx) = channel();
|
||||
let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::new(s), 48_000.0, channel().0);
|
||||
let ring = record::InputRing::new(1, 48_000);
|
||||
tx.send(Cmd::Input(Arc::clone(&ring))).unwrap();
|
||||
tx.send(Cmd::Play { from: 0, end: Some(1000), looped: None }).unwrap();
|
||||
let mut out = vec![0.0f32; 4096];
|
||||
st.fill(&mut out, 2);
|
||||
assert!(!shared.playing.load(Ordering::Relaxed));
|
||||
ring.push(&[0.5; 4096]);
|
||||
st.fill(&mut out, 2);
|
||||
assert!(out.iter().any(|x| x.abs() > 0.1), "the input is heard");
|
||||
assert!(shared.meters.lock().unwrap().tracks.get(&t).is_some_and(|m| m.peak[0] > 0.1), "and metered");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +1,22 @@
|
||||
//! Audio input capture for recording.
|
||||
//!
|
||||
//! A [`Recorder`] opens the default input device. While armed, the input callback copies each
|
||||
//! block into a preallocated lock-free ring (it never locks or allocates), and a writer thread
|
||||
//! streams the ring into one WAV file per take, rewriting the header every second so a crash leaves
|
||||
//! a playable file.
|
||||
//! A [`Recorder`] opens the default input device and appends interleaved samples to a shared
|
||||
//! buffer while armed. The callback only `try_lock`s; if the UI thread holds the lock the block is
|
||||
//! kept in a small local backlog and appended next time, so nothing blocks the audio thread.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use soundcraft_model::TrackId;
|
||||
|
||||
/// What [`Recorder::finish`] returns: the takes that hold audio and the first problem met.
|
||||
pub type Takes = (Vec<(TrackId, PathBuf)>, Option<String>);
|
||||
|
||||
const RING_SECONDS: usize = 4;
|
||||
|
||||
/// Single-producer single-consumer FIFO of samples (input callback → writer thread). The slots
|
||||
/// are atomics, so it needs no lock and no `unsafe`.
|
||||
struct CaptureRing {
|
||||
slots: Box<[AtomicU32]>,
|
||||
mask: usize,
|
||||
head: AtomicUsize,
|
||||
tail: AtomicUsize,
|
||||
}
|
||||
|
||||
impl CaptureRing {
|
||||
fn new(capacity: usize) -> CaptureRing {
|
||||
let n = capacity.max(2).next_power_of_two();
|
||||
CaptureRing { slots: (0..n).map(|_| AtomicU32::new(0)).collect(), mask: n - 1, head: AtomicUsize::new(0), tail: AtomicUsize::new(0) }
|
||||
}
|
||||
|
||||
/// Queue up to `n` samples (`sample(i)` is the i-th); returns how many fit.
|
||||
fn push(&self, n: usize, sample: impl Fn(usize) -> f32) -> usize {
|
||||
let head = self.head.load(Ordering::Relaxed);
|
||||
let n = n.min(self.slots.len().saturating_sub(head.wrapping_sub(self.tail.load(Ordering::Acquire))));
|
||||
for i in 0..n {
|
||||
if let Some(slot) = self.slots.get(head.wrapping_add(i) & self.mask) {
|
||||
slot.store(sample(i).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
self.head.store(head.wrapping_add(n), Ordering::Release);
|
||||
n
|
||||
}
|
||||
|
||||
fn pop_all(&self, out: &mut Vec<f32>) {
|
||||
let (tail, head) = (self.tail.load(Ordering::Relaxed), self.head.load(Ordering::Acquire));
|
||||
out.extend(
|
||||
(0..head.wrapping_sub(tail))
|
||||
.map(|i| self.slots.get(tail.wrapping_add(i) & self.mask).map_or(0.0, |s| f32::from_bits(s.load(Ordering::Relaxed)))),
|
||||
);
|
||||
self.tail.store(head, Ordering::Release);
|
||||
}
|
||||
/// Captured audio: planar f32 at `sample_rate`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Take {
|
||||
pub sample_rate: u32,
|
||||
pub channels: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
armed: AtomicBool,
|
||||
ring: CaptureRing,
|
||||
/// Samples lost while the ring was full.
|
||||
dropped: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
/// The input callback's work while armed. Samples that don't fit are owed (`owed`) and queued
|
||||
/// as silence before the next block, so the take keeps its timing.
|
||||
fn capture(&self, data: &[f32], owed: &mut usize) {
|
||||
*owed -= self.ring.push(*owed, |_| 0.0);
|
||||
let kept = if *owed == 0 { self.ring.push(data.len(), |i| data.get(i).copied().unwrap_or(0.0)) } else { 0 };
|
||||
*owed = owed.saturating_add(data.len() - kept);
|
||||
self.dropped.fetch_add(data.len() - kept, Ordering::Relaxed);
|
||||
}
|
||||
data: Mutex<Vec<f32>>,
|
||||
}
|
||||
|
||||
pub struct Recorder {
|
||||
@@ -84,9 +28,12 @@ pub struct Recorder {
|
||||
pub device_name: String,
|
||||
pub sample_rate: u32,
|
||||
pub channels: usize,
|
||||
writer: Option<std::thread::JoinHandle<Takes>>,
|
||||
}
|
||||
|
||||
/// Cap: one hour of 8-channel 192 kHz audio; 64 Mi samples (256 MB of f32) on 32-bit targets
|
||||
/// (wasm32, i686), where the 64-bit product doesn't fit in `usize`.
|
||||
const MAX_SAMPLES: usize = if usize::BITS >= 64 { (3600u64 * 192_000 * 8) as usize } else { 64 << 20 };
|
||||
|
||||
impl Recorder {
|
||||
/// Open the default input device. Errors when there is none (the caller shows a message).
|
||||
#[cfg(not(target_os = "freebsd"))]
|
||||
@@ -101,23 +48,29 @@ impl Recorder {
|
||||
return Err(format!("unsupported input sample format {:?}", cfg.sample_format()));
|
||||
}
|
||||
let config = cfg.config();
|
||||
let channels = usize::from(config.channels).max(1);
|
||||
let ring = CaptureRing::new((config.sample_rate.0 as usize).saturating_mul(channels * RING_SECONDS).min(1 << 25));
|
||||
let shared = Arc::new(Shared { armed: AtomicBool::new(false), ring, dropped: AtomicUsize::new(0) });
|
||||
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), config.sample_rate.0);
|
||||
let mon = Arc::clone(&monitor);
|
||||
let mut owed = 0;
|
||||
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::Acquire) {
|
||||
sh.capture(data, &mut owed);
|
||||
} else {
|
||||
owed = 0;
|
||||
if !sh.armed.load(Ordering::Relaxed) {
|
||||
backlog.clear();
|
||||
return;
|
||||
}
|
||||
if let Ok(mut d) = sh.data.try_lock() {
|
||||
if d.len() < MAX_SAMPLES {
|
||||
d.extend_from_slice(&backlog);
|
||||
d.extend_from_slice(data);
|
||||
}
|
||||
backlog.clear();
|
||||
} else if backlog.len() < 1 << 20 {
|
||||
backlog.extend_from_slice(data);
|
||||
}
|
||||
},
|
||||
|e| {
|
||||
@@ -129,7 +82,14 @@ impl Recorder {
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
stream.play().map_err(|e| e.to_string())?;
|
||||
Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None })
|
||||
Ok(Recorder {
|
||||
monitor,
|
||||
shared,
|
||||
_stream: Some(stream),
|
||||
device_name: name,
|
||||
sample_rate: config.sample_rate.0,
|
||||
channels: usize::from(config.channels).max(1),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "freebsd")]
|
||||
@@ -137,174 +97,51 @@ impl Recorder {
|
||||
Err("recording is not supported on this platform yet".into())
|
||||
}
|
||||
|
||||
/// Create each take's file and start recording into them: a track's take records the given
|
||||
/// input channels into a new file.
|
||||
pub fn arm(&mut self, takes: Vec<(TrackId, Vec<usize>, PathBuf)>) -> Result<(), String> {
|
||||
if self.writer.is_some() {
|
||||
return Err("already recording".into());
|
||||
}
|
||||
let takes =
|
||||
takes.into_iter().map(|(id, inputs, path)| TakeWriter::create(id, inputs, path, self.sample_rate)).collect::<Result<Vec<_>, _>>()?;
|
||||
let ring = &self.shared.ring;
|
||||
ring.tail.store(ring.head.load(Ordering::Acquire), Ordering::Release);
|
||||
self.shared.armed.store(true, Ordering::Release);
|
||||
let (shared, channels) = (Arc::clone(&self.shared), self.channels);
|
||||
match std::thread::Builder::new().name("take writer".into()).spawn(move || write_takes(&shared, takes, channels)) {
|
||||
Ok(w) => self.writer = Some(w),
|
||||
Err(e) => {
|
||||
self.shared.armed.store(false, Ordering::Release);
|
||||
return Err(e.to_string());
|
||||
/// Start capturing (clears any previous take).
|
||||
pub fn arm(&self) {
|
||||
self.shared.data.lock().unwrap_or_else(PoisonError::into_inner).clear();
|
||||
self.shared.armed.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn is_armed(&self) -> bool {
|
||||
self.shared.armed.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Stop capturing and return the take.
|
||||
pub fn take(&self) -> Take {
|
||||
self.shared.armed.store(false, Ordering::Relaxed);
|
||||
let data = std::mem::take(&mut *self.shared.data.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
deinterleave(&data, self.channels, self.sample_rate)
|
||||
}
|
||||
|
||||
/// Samples captured so far (per channel).
|
||||
pub fn captured_frames(&self) -> usize {
|
||||
self.shared.data.try_lock().map_or(0, |d| d.len() / self.channels.max(1))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deinterleave(data: &[f32], channels: usize, sample_rate: u32) -> Take {
|
||||
let ch = channels.max(1);
|
||||
let frames = data.len() / ch;
|
||||
let mut out = vec![Vec::with_capacity(frames); ch];
|
||||
for frame in data.chunks_exact(ch) {
|
||||
for (c, v) in frame.iter().enumerate() {
|
||||
if let Some(o) = out.get_mut(c) {
|
||||
o.push(*v);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> Takes {
|
||||
self.shared.armed.store(false, Ordering::Release);
|
||||
let (takes, problem) = self.writer.take().and_then(|w| w.join().ok()).unwrap_or_default();
|
||||
let lost = self.shared.dropped.swap(0, Ordering::Relaxed) / self.channels.max(1);
|
||||
let slow =
|
||||
(lost > 0).then(|| format!("{} ms of input lost: the disk was too slow", lost.saturating_mul(1000) / self.sample_rate.max(1) as usize));
|
||||
(takes, problem.or(slow))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Recorder {
|
||||
fn drop(&mut self) {
|
||||
self.finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn write_takes(shared: &Shared, mut takes: Vec<TakeWriter>, channels: usize) -> Takes {
|
||||
let (mut block, mut header_written) = (Vec::new(), Instant::now());
|
||||
loop {
|
||||
let stopping = !shared.armed.load(Ordering::Acquire);
|
||||
// The ring holds interleaved samples and a pop can end mid-frame: write whole frames and
|
||||
// keep the remainder for the next pop, so channels never shift.
|
||||
shared.ring.pop_all(&mut block);
|
||||
let whole = block.len() - block.len() % channels.max(1);
|
||||
takes.iter_mut().for_each(|t| t.write(&block[..whole], channels));
|
||||
block.drain(..whole);
|
||||
if stopping {
|
||||
break;
|
||||
}
|
||||
if header_written.elapsed() >= Duration::from_secs(1) {
|
||||
header_written = Instant::now();
|
||||
takes.iter_mut().for_each(TakeWriter::write_header);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let (mut done, mut problem) = (Vec::new(), None);
|
||||
for mut t in takes {
|
||||
t.write_header();
|
||||
if t.frames == 0 {
|
||||
let _ = std::fs::remove_file(&t.path);
|
||||
} else {
|
||||
done.push((t.id, t.path));
|
||||
}
|
||||
problem = problem.or(t.error);
|
||||
}
|
||||
(done, problem)
|
||||
}
|
||||
|
||||
/// One take's file. After an error it stops writing; its header keeps covering what was written.
|
||||
struct TakeWriter {
|
||||
id: TrackId,
|
||||
inputs: Vec<usize>,
|
||||
path: PathBuf,
|
||||
file: File,
|
||||
rate: u32,
|
||||
frames: u64,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl TakeWriter {
|
||||
fn create(id: TrackId, inputs: Vec<usize>, path: PathBuf, rate: u32) -> Result<TakeWriter, String> {
|
||||
let file = path.parent().map_or(Ok(()), std::fs::create_dir_all).and_then(|()| File::create_new(&path));
|
||||
let file = file.map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut take = TakeWriter { id, inputs, path, file, rate, frames: 0, error: None };
|
||||
take.write_header();
|
||||
take.error.take().map_or(Ok(take), Err)
|
||||
}
|
||||
|
||||
/// Append this take's channels of `block` (interleaved, `channels` wide).
|
||||
fn write(&mut self, block: &[f32], channels: usize) {
|
||||
let channels = channels.max(1);
|
||||
if self.error.is_some() {
|
||||
return;
|
||||
}
|
||||
let bytes: Vec<u8> = block
|
||||
.chunks_exact(channels)
|
||||
.flat_map(|f| self.inputs.iter().flat_map(move |&i| f.get(i).copied().unwrap_or(0.0).to_le_bytes()))
|
||||
.collect();
|
||||
match self.file.write_all(&bytes) {
|
||||
Ok(()) => self.frames += (block.len() / channels) as u64,
|
||||
Err(e) => self.fail(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_header(&mut self) {
|
||||
let header = match soundcraft_audio_io::wav_float_header(self.inputs.len(), self.rate, self.frames) {
|
||||
Ok(h) => h,
|
||||
Err(e) => return self.fail(e),
|
||||
};
|
||||
let f = &mut self.file;
|
||||
if let Err(e) = f.seek(SeekFrom::Start(0)).and_then(|_| f.write_all(&header)).and_then(|()| f.seek(SeekFrom::End(0))) {
|
||||
self.fail(e);
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(&mut self, e: impl std::fmt::Display) {
|
||||
self.error.get_or_insert_with(|| format!("{}: {e}", self.path.display()));
|
||||
}
|
||||
Take { sample_rate, channels: out }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn decode(path: &PathBuf) -> Vec<Vec<f32>> {
|
||||
soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap().1.channels
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_ring_owes_the_lost_samples_as_silence() {
|
||||
let shared = Shared { armed: AtomicBool::new(true), ring: CaptureRing::new(8), dropped: AtomicUsize::new(0) };
|
||||
let (mut owed, mut out) = (0, Vec::new());
|
||||
shared.capture(&[1.0; 6], &mut owed);
|
||||
shared.capture(&[2.0; 4], &mut owed);
|
||||
shared.ring.pop_all(&mut out);
|
||||
shared.capture(&[3.0; 2], &mut owed);
|
||||
shared.ring.pop_all(&mut out);
|
||||
assert_eq!(out, [vec![1.0; 6], vec![2.0; 2], vec![0.0; 2], vec![3.0; 2]].concat());
|
||||
assert_eq!((owed, shared.dropped.load(Ordering::Relaxed)), (0, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takes_stream_into_their_files() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let gtr = TakeWriter::create(TrackId(1), vec![1, 2], dir.join("Gtr_01.wav"), 48_000).unwrap();
|
||||
let mut vox = TakeWriter::create(TrackId(2), vec![0], dir.join("Vox_01.wav"), 48_000).unwrap();
|
||||
assert!(TakeWriter::create(TrackId(3), vec![0], dir.join("Vox_01.wav"), 48_000).is_err(), "never overwrites a file");
|
||||
vox.write(&[0.5; 300], 3);
|
||||
vox.write_header();
|
||||
vox.write(&[0.25; 150], 3);
|
||||
assert_eq!(decode(&dir.join("Vox_01.wav"))[0].len(), 100, "the header covers what it has seen");
|
||||
|
||||
let shared = Arc::new(Shared { armed: AtomicBool::new(true), ring: CaptureRing::new(1 << 16), dropped: AtomicUsize::new(0) });
|
||||
let sh = Arc::clone(&shared);
|
||||
let writer = std::thread::spawn(move || write_takes(&sh, vec![gtr, vox], 3));
|
||||
let input: Vec<f32> = (0..3 * 4_800).map(|i| i as f32 / 20_000.0).collect();
|
||||
let mut owed = 0;
|
||||
input.chunks(3 * 480).for_each(|block| shared.capture(block, &mut owed));
|
||||
shared.armed.store(false, Ordering::Release);
|
||||
let (done, problem) = writer.join().unwrap();
|
||||
let channel = |c: usize| input.iter().skip(c).step_by(3).copied().collect::<Vec<f32>>();
|
||||
assert_eq!((done.len(), problem), (2, None));
|
||||
assert_eq!(decode(&done[0].1), vec![channel(1), channel(2)]);
|
||||
assert_eq!(decode(&done[1].1), vec![[vec![0.5; 100], vec![0.25; 50], channel(0)].concat()]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
fn deinterleaves() {
|
||||
let t = super::deinterleave(&[1.0, 2.0, 3.0, 4.0, 5.0], 2, 48_000);
|
||||
assert_eq!(t.channels, vec![vec![1.0, 3.0], vec![2.0, 4.0]]);
|
||||
let t = super::deinterleave(&[], 0, 48_000);
|
||||
assert_eq!(t.channels.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,10 @@ fn main() {
|
||||
.unwrap_or((1600.0, 1000.0));
|
||||
let engine = if blank { soundcraft_engine::Engine::default() } else { soundcraft_engine::demo::demo_engine() };
|
||||
let (tx, rx) = mpsc::channel::<ControlRequest>();
|
||||
let app = SoundApp::new(engine, None, Services::default()).with_control(rx);
|
||||
let mut app = SoundApp::new(engine, None, Services::default()).with_control(rx);
|
||||
if args.iter().any(|a| a == "--custom-titlebar") {
|
||||
app.custom_titlebar = true;
|
||||
}
|
||||
let mut harness =
|
||||
egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(2.0).with_max_steps(1_000_000).wgpu().build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
//! - `engine.commands {filter?}`, `engine.parity`, `session.inspect {detail?}`, `ui.inspect`
|
||||
//! - `ui.menu.list`, `ui.menu.invoke {path: "Track > New..." | id}` (menu-style: may open dialogs)
|
||||
//! - `ui.click {x,y,button?,count?,mods?}`, `ui.move {x,y}`, `ui.drag {x,y,to_x,to_y,steps?}`
|
||||
//! - `ui.scroll {x,y,dx?,dy?,unit?,steps?,mods?}` (trackpad swipe or wheel), `ui.zoom {x,y,factor,steps?}` (pinch)
|
||||
//! - `ui.key {key, cmd?, shift?, alt?, ctrl?}`, `ui.text {text}`
|
||||
//! - `ui.screenshot {path?}` (PNG; base64 when no path), `ui.set {...UiState fields}`
|
||||
//! - `app.quit`
|
||||
@@ -92,8 +91,6 @@ fn mods(p: &Value) -> egui::Modifiers {
|
||||
fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Option<Value> {
|
||||
let p = &req.params;
|
||||
let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
|
||||
let pos = egui::pos2(f("x"), f("y"));
|
||||
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200);
|
||||
Some(match req.method.as_str() {
|
||||
"engine.execute" | "command" => {
|
||||
let id = p.get("command").or_else(|| p.get("id")).and_then(Value::as_str).unwrap_or("");
|
||||
@@ -150,10 +147,11 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
|
||||
Err(e) => err(e),
|
||||
},
|
||||
"ui.move" => {
|
||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y"))));
|
||||
ok(json!({}))
|
||||
}
|
||||
"ui.click" => {
|
||||
let pos = egui::pos2(f("x"), f("y"));
|
||||
let button = match p.get("button").and_then(Value::as_str) {
|
||||
Some("right") | Some("secondary") => egui::PointerButton::Secondary,
|
||||
_ => egui::PointerButton::Primary,
|
||||
@@ -168,7 +166,9 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
|
||||
ok(json!({}))
|
||||
}
|
||||
"ui.drag" => {
|
||||
let (a, b) = (pos, egui::pos2(f("to_x"), f("to_y")));
|
||||
let a = egui::pos2(f("x"), f("y"));
|
||||
let b = egui::pos2(f("to_x"), f("to_y"));
|
||||
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200);
|
||||
let m = mods(p);
|
||||
app.synthetic.push(egui::Event::PointerMoved(a));
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
|
||||
@@ -179,27 +179,6 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
|
||||
ok(json!({}))
|
||||
}
|
||||
"ui.scroll" => {
|
||||
let line = p.get("unit").and_then(Value::as_str) == Some("line");
|
||||
let unit = if line { egui::MouseWheelUnit::Line } else { egui::MouseWheelUnit::Point };
|
||||
let d = |k: &str| f(k).clamp(-100_000.0, 100_000.0) / steps as f32;
|
||||
let (delta, modifiers) = (egui::vec2(d("dx"), d("dy")), mods(p));
|
||||
let wheel = |phase, delta| egui::Event::MouseWheel { unit, delta, phase, modifiers };
|
||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::Start, egui::Vec2::ZERO)));
|
||||
app.synthetic.extend((0..steps).map(|_| wheel(egui::TouchPhase::Move, delta)));
|
||||
app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::End, egui::Vec2::ZERO)));
|
||||
ok(json!({}))
|
||||
}
|
||||
"ui.zoom" => {
|
||||
let Some(factor) = p.get("factor").and_then(Value::as_f64).filter(|v| *v > 0.0) else {
|
||||
return Some(err("ui.zoom needs a positive factor (> 1 zooms in)"));
|
||||
};
|
||||
let each = factor.clamp(1e-3, 1e3).powf(1.0 / steps as f64) as f32;
|
||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
app.synthetic.extend((0..steps).map(|_| egui::Event::Zoom(each)));
|
||||
ok(json!({}))
|
||||
}
|
||||
"ui.key" => {
|
||||
let name = p.get("key").and_then(Value::as_str).unwrap_or("");
|
||||
let Some(key) = key_from(name) else { return Some(err(format!("unknown key `{name}`"))) };
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::theme::{Tokens, bold};
|
||||
use egui::{Align2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_model::{ClipId, Session, SourceId, TrackId};
|
||||
use soundcraft_model::{ClipId, TrackId};
|
||||
use soundcraft_time::Samples;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -15,7 +15,6 @@ pub enum Dialog {
|
||||
Bounce { path: String, format: String, bit_depth: String, normalize: bool },
|
||||
RenameTrack { id: TrackId, name: String },
|
||||
RenameClip { id: ClipId, name: String },
|
||||
ClearClips { clips: Vec<ClipId>, sources: Vec<SourceId>, message: 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
|
||||
@@ -24,14 +23,11 @@ pub enum Dialog {
|
||||
Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> },
|
||||
Session { frame_rate: String, bit_depth: String },
|
||||
ScoreSetup { title: String, composer: String, bars_per_system: u32, show_track_names: bool },
|
||||
SaveChanges { then: (String, Value) },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Dialogs {
|
||||
pub open: Option<Dialog>,
|
||||
/// Runs once the session has been saved from `SaveChanges`.
|
||||
after_save: Option<(String, Value)>,
|
||||
}
|
||||
|
||||
impl Dialogs {
|
||||
@@ -42,7 +38,6 @@ impl Dialogs {
|
||||
Dialog::Bounce { .. } => "bounce",
|
||||
Dialog::RenameTrack { .. } => "rename_track",
|
||||
Dialog::RenameClip { .. } => "rename_clip",
|
||||
Dialog::ClearClips { .. } => "clear_clips",
|
||||
Dialog::PathPrompt { .. } => "path",
|
||||
Dialog::Number { .. } => "number",
|
||||
Dialog::TempoChange { .. } => "tempo_change",
|
||||
@@ -51,7 +46,6 @@ impl Dialogs {
|
||||
Dialog::Group { .. } => "group",
|
||||
Dialog::Session { .. } => "session",
|
||||
Dialog::ScoreSetup { .. } => "score_setup",
|
||||
Dialog::SaveChanges { .. } => "save_changes",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,23 +57,11 @@ impl Dialogs {
|
||||
self.open = Some(Dialog::RenameClip { id, name: name.to_string() });
|
||||
}
|
||||
|
||||
/// Clip List › Clear: confirm before clips and files leave the session. False when there is nothing to clear.
|
||||
pub fn open_clear(&mut self, e: &Engine, clips: Vec<ClipId>, sources: Vec<SourceId>) -> bool {
|
||||
let Some(message) = clear_message(e.session(), &clips, &sources) else { return false };
|
||||
self.open = Some(Dialog::ClearClips { clips, sources, message });
|
||||
true
|
||||
}
|
||||
|
||||
/// Open the dialog for a command; false when the command has no dialog.
|
||||
pub fn open_for_command(&mut self, e: &Engine, id: &str) -> bool {
|
||||
self.after_save = None;
|
||||
let home = default_dir();
|
||||
let name = e.session().name.clone();
|
||||
self.open = Some(match id {
|
||||
"clip.clear" => {
|
||||
let ed = &e.session().edit;
|
||||
return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone());
|
||||
}
|
||||
"track.new" => {
|
||||
Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() }
|
||||
}
|
||||
@@ -90,16 +72,10 @@ impl Dialogs {
|
||||
"file.import_audio" => Dialog::PathPrompt { cmd: id.into(), title: "Import Audio".into(), path: home, key: "path".into() },
|
||||
"file.import_midi" => Dialog::PathPrompt { cmd: id.into(), title: "Import MIDI".into(), path: home, key: "path".into() },
|
||||
"session.open" => Dialog::PathPrompt { cmd: id.into(), title: "Open Session".into(), path: home, key: "path".into() },
|
||||
"session.save" if e.path.is_some() => return false,
|
||||
"session.save" | "session.save_as" | "session.save_copy" => Dialog::PathPrompt {
|
||||
cmd: if id == "session.save_copy" { id } else { "session.save_as" }.into(),
|
||||
"session.save_as" | "session.save_copy" => Dialog::PathPrompt {
|
||||
cmd: id.into(),
|
||||
title: "Save Session As".into(),
|
||||
// The browser has no folders: the name becomes the bundle file's name.
|
||||
path: if home.is_empty() {
|
||||
format!("{name}.{}", soundcraft_engine::io::BUNDLE_EXTENSION)
|
||||
} else {
|
||||
format!("{home}/{name}/{name}.scraft")
|
||||
},
|
||||
path: format!("{home}/{name}/{name}.scraft"),
|
||||
key: "path".into(),
|
||||
},
|
||||
"edit.repeat" => Dialog::Number { cmd: id.into(), title: "Repeat".into(), key: "count".into(), value: 2.0, suffix: "times".into() },
|
||||
@@ -124,11 +100,6 @@ impl Dialogs {
|
||||
let s = soundcraft_engine::score::ScoreSetup::from_session(e.session());
|
||||
Dialog::ScoreSetup { title: s.title, composer: s.composer, bars_per_system: s.bars_per_system, show_track_names: s.show_track_names }
|
||||
}
|
||||
"clip.rename" => {
|
||||
let target = soundcraft_engine::cmd::clip_ids_param(e, &json!({})).first().and_then(|id| e.session().find_clip(*id));
|
||||
let Some((_, clip)) = target else { return false };
|
||||
Dialog::RenameClip { id: clip.id, name: clip.name.clone() }
|
||||
}
|
||||
_ => match path_dialog(id, &home, &name) {
|
||||
Some(d) => d,
|
||||
None => return false,
|
||||
@@ -138,32 +109,6 @@ impl Dialogs {
|
||||
}
|
||||
}
|
||||
|
||||
/// When `id` would close a session with unsaved changes, ask first; true when the question is
|
||||
/// open instead. A take in progress is stopped first, so it counts as a change.
|
||||
pub fn ask_to_save(app: &mut SoundApp, id: &str, params: &Value) -> bool {
|
||||
if !matches!(id, "session.new" | "session.open" | "session.close" | "session.revert" | "app.quit")
|
||||
|| soundcraft_engine::find_command(id).is_some_and(|c| (c.enabled)(&app.engine).is_err())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if app.engine.transport.recording {
|
||||
app.stop_play();
|
||||
}
|
||||
if !app.engine.is_dirty() {
|
||||
return false;
|
||||
}
|
||||
app.dialogs.open = Some(Dialog::SaveChanges { then: (id.into(), params.clone()) });
|
||||
true
|
||||
}
|
||||
|
||||
/// Runs `id`, unless it asks to save first (`ask_to_save`).
|
||||
pub fn run_or_ask(app: &mut SoundApp, id: &str, params: Value) -> Result<Value, String> {
|
||||
if ask_to_save(app, id, ¶ms) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
app.run(id, params)
|
||||
}
|
||||
|
||||
/// A path prompt for any command whose parameters start with a required `path` or `dir` (plus
|
||||
/// Print Score, whose path is optional), with a default file name from the command.
|
||||
pub fn takes_path(id: &str) -> bool {
|
||||
@@ -201,41 +146,6 @@ fn path_dialog(id: &str, home: &str, name: &str) -> Option<Dialog> {
|
||||
Some(Dialog::PathPrompt { cmd: id.into(), title, path, key: key.into() })
|
||||
}
|
||||
|
||||
fn clear_message(s: &Session, clips: &[ClipId], sources: &[SourceId]) -> Option<String> {
|
||||
let listed: std::collections::BTreeSet<ClipId> = clips.iter().copied().collect();
|
||||
let names: Vec<&str> = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| listed.contains(&c.id)).map(|c| c.name.as_str()).collect();
|
||||
let files: Vec<_> = sources.iter().filter_map(|id| s.source(*id)).collect();
|
||||
let count = |n: usize, what: &str| if n == 1 { format!("1 {what}") } else { format!("{n} {what}s") };
|
||||
let what = match (names.as_slice(), files.as_slice()) {
|
||||
([], []) => return None,
|
||||
([name], []) => format!("the clip \"{name}\""),
|
||||
([], [f]) => format!("the file \"{}\"", f.name),
|
||||
(c, []) => count(c.len(), "clip"),
|
||||
([], f) => count(f.len(), "file"),
|
||||
(c, f) => format!("{} and {}", count(c.len(), "clip"), count(f.len(), "file")),
|
||||
};
|
||||
let mut text = format!("Remove {what} from the session?");
|
||||
let used: Vec<(&str, usize)> =
|
||||
files.iter().map(|f| (f.name.as_str(), s.clips_using(f.id).filter(|c| !listed.contains(&c.id)).count())).filter(|(_, n)| *n > 0).collect();
|
||||
let n: usize = used.iter().map(|u| u.1).sum();
|
||||
let users = if n == 1 {
|
||||
"a clip on the timeline, which is removed too.".to_string()
|
||||
} else {
|
||||
format!("{n} clips on the timeline, which are removed too.")
|
||||
};
|
||||
match used.as_slice() {
|
||||
[] => {}
|
||||
[_] if files.len() == 1 => text += &format!(" It is used by {users}"),
|
||||
u if u.len() == files.len() => text += &format!(" They are used by {users}"),
|
||||
[(name, _)] => text += &format!(" \"{name}\" is used by {users}"),
|
||||
many => text += &format!(" {} of the files are used by {users}", many.len()),
|
||||
}
|
||||
if !files.is_empty() && files.iter().all(|f| !f.unsaved) {
|
||||
text += if files.len() == 1 { " The file stays on disk." } else { " The files stay on disk." };
|
||||
}
|
||||
Some(text)
|
||||
}
|
||||
|
||||
fn default_dir() -> String {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -253,15 +163,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.dialogs.open.take() else { return };
|
||||
let mut keep = true;
|
||||
let mut action: Option<(String, Value)> = None;
|
||||
let mut save_first: Option<(String, Value)> = None;
|
||||
let confirmed = matches!(d, Dialog::SaveChanges { .. });
|
||||
let title = match &d {
|
||||
Dialog::NewTracks { .. } => "New Tracks",
|
||||
Dialog::NewSession { .. } => "New Session",
|
||||
Dialog::Bounce { .. } => "Bounce Mix",
|
||||
Dialog::RenameTrack { .. } => "Rename Track",
|
||||
Dialog::RenameClip { .. } => "Rename Clip",
|
||||
Dialog::ClearClips { .. } => "Clear",
|
||||
Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(),
|
||||
Dialog::TempoChange { .. } => "Tempo Change (BPM)",
|
||||
Dialog::Fades { .. } => "Fades",
|
||||
@@ -269,8 +176,6 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
Dialog::Group { .. } => "Create Group",
|
||||
Dialog::Session { .. } => "Session Setup",
|
||||
Dialog::ScoreSetup { .. } => "Score Setup",
|
||||
Dialog::SaveChanges { then } if then.0 == "session.revert" => "Revert to Saved",
|
||||
Dialog::SaveChanges { .. } => "Unsaved Changes",
|
||||
}
|
||||
.to_string();
|
||||
let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
@@ -368,42 +273,29 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
Dialog::RenameTrack { id, name } => {
|
||||
name_field(ui, name);
|
||||
let r = ui.text_edit_singleline(name);
|
||||
r.request_focus();
|
||||
if buttons(ui, "OK", enter) {
|
||||
action = Some(("track.rename".into(), json!({"track": id.0, "name": name})));
|
||||
}
|
||||
}
|
||||
Dialog::RenameClip { id, name } => {
|
||||
name_field(ui, name);
|
||||
let r = ui.text_edit_singleline(name);
|
||||
r.request_focus();
|
||||
if buttons(ui, "OK", enter) {
|
||||
action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name})));
|
||||
}
|
||||
}
|
||||
Dialog::ClearClips { clips, sources, message } => {
|
||||
ui.set_max_width(340.0);
|
||||
ui.label(message.as_str());
|
||||
if buttons(ui, "Remove", enter) {
|
||||
action = Some(("clip.clear".into(), json!({"clips": clips, "sources": sources, "with_clips": true})));
|
||||
}
|
||||
}
|
||||
Dialog::PathPrompt { cmd, path, key, .. } => {
|
||||
ui.add(egui::TextEdit::singleline(path).desired_width(420.0));
|
||||
if let Some(pick) = &app.services.pick_open
|
||||
&& cmd != "session.save_as"
|
||||
&& cmd != "session.save_copy"
|
||||
&& key != "dir"
|
||||
&& ui.button("Browse…").clicked()
|
||||
&& let Some(p) = pick(cmd, &[])
|
||||
{
|
||||
*path = p;
|
||||
}
|
||||
if let Some(pick) = &app.services.pick_folder
|
||||
&& key == "dir"
|
||||
&& ui.button("Browse…").clicked()
|
||||
&& let Some(p) = pick(path)
|
||||
{
|
||||
*path = p;
|
||||
}
|
||||
if let Some(pick) = &app.services.pick_save
|
||||
&& (cmd == "session.save_as" || cmd == "session.save_copy")
|
||||
&& ui.button("Browse…").clicked()
|
||||
@@ -411,17 +303,9 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
{
|
||||
*path = p;
|
||||
}
|
||||
let file = soundcraft_engine::io::session_file(path);
|
||||
let replaces = soundcraft_engine::io::replaces(&app.engine, cmd, &file);
|
||||
if replaces {
|
||||
ui.label(format!("“{}” already exists. Do you want to replace it?", file.file_name().unwrap_or_default().to_string_lossy()));
|
||||
}
|
||||
if buttons(ui, if replaces { "Replace" } else { "OK" }, enter && !replaces) {
|
||||
if buttons(ui, "OK", enter) {
|
||||
let mut v = json!({});
|
||||
v[key.as_str()] = json!(path);
|
||||
if replaces {
|
||||
v["replace"] = json!(true);
|
||||
}
|
||||
action = Some((cmd.clone(), v));
|
||||
}
|
||||
}
|
||||
@@ -520,71 +404,18 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
));
|
||||
}
|
||||
}
|
||||
Dialog::SaveChanges { then } => {
|
||||
let name = &app.engine.session().name;
|
||||
let revert = then.0 == "session.revert";
|
||||
if revert || cfg!(target_arch = "wasm32") {
|
||||
let (question, ok) = if revert {
|
||||
(format!("Revert “{name}” to its last saved version?"), "Revert")
|
||||
} else {
|
||||
(format!("Close “{name}” without saving?"), "Don't Save")
|
||||
};
|
||||
ui.label(format!("{question} Your changes will be lost."));
|
||||
if buttons(ui, ok, false) {
|
||||
action = Some(then.clone());
|
||||
}
|
||||
} else {
|
||||
ui.label(format!("Save changes to “{name}” before closing?"));
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Don't Save").clicked() {
|
||||
action = Some(then.clone());
|
||||
}
|
||||
if buttons(ui, "Save", enter) {
|
||||
save_first = Some(then.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if esc || ctx.memory(|m| m.data.get_temp::<bool>(egui::Id::new("dlg_cancel")).unwrap_or(false)) {
|
||||
keep = false;
|
||||
app.dialogs.after_save = None;
|
||||
ctx.memory_mut(|m| m.data.remove::<bool>(egui::Id::new("dlg_cancel")));
|
||||
}
|
||||
if let Some(then) = save_first {
|
||||
keep = false;
|
||||
if !app.dialogs.open_for_command(&app.engine, "session.save") {
|
||||
action = Some(("session.save".into(), json!({})));
|
||||
}
|
||||
app.dialogs.after_save = Some(then);
|
||||
}
|
||||
if let Some((cmd, params)) = &action
|
||||
&& !confirmed
|
||||
&& ask_to_save(app, cmd, params)
|
||||
{
|
||||
keep = false;
|
||||
action = None;
|
||||
}
|
||||
let saving = action.as_ref().is_some_and(|(cmd, _)| cmd.starts_with("session.save"));
|
||||
if let Some((cmd, params)) = action {
|
||||
keep = false;
|
||||
match app.run(&cmd, params) {
|
||||
Ok(v) if cmd == "file.export_clips" => {
|
||||
app.ui.status = format!("Exported {} clip(s) to {}", v["written"], v["dir"].as_str().unwrap_or_default());
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => app.ui.status = e,
|
||||
if let Err(e) = app.run(&cmd, params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
if saving
|
||||
&& let Some((then, params)) = app.dialogs.after_save.take()
|
||||
&& !app.engine.is_dirty()
|
||||
&& let Err(e) = app.run(&then, params)
|
||||
{
|
||||
app.ui.status = e;
|
||||
}
|
||||
if keep {
|
||||
app.dialogs.open = Some(d);
|
||||
}
|
||||
@@ -605,17 +436,6 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
|
||||
pressed
|
||||
}
|
||||
|
||||
/// A name field that takes the focus with its text selected, so typing replaces the old name.
|
||||
fn name_field(ui: &mut egui::Ui, name: &mut String) {
|
||||
let mut out = egui::TextEdit::singleline(name).show(ui);
|
||||
if !out.response.has_focus() {
|
||||
out.response.request_focus();
|
||||
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(name.chars().count()));
|
||||
out.state.cursor.set_char_range(Some(all));
|
||||
out.state.store(ui.ctx(), out.response.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}))
|
||||
@@ -695,18 +515,6 @@ mod tests {
|
||||
assert!(matches!(d.open, Some(Dialog::ScoreSetup { bars_per_system: 4, .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_rename_opens_the_rename_dialog_for_the_selected_clip() {
|
||||
let mut e = soundcraft_engine::demo::demo_engine();
|
||||
let mut d = Dialogs::default();
|
||||
e.execute("edit.select_none", &json!({})).unwrap();
|
||||
assert!(!d.open_for_command(&e, "clip.rename"));
|
||||
let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone();
|
||||
e.execute("edit.select", &json!({"clips": [clip.id.0]})).unwrap();
|
||||
assert!(d.open_for_command(&e, "clip.rename"));
|
||||
assert!(matches!(d.open, Some(Dialog::RenameClip { id, ref name }) if id == clip.id && *name == clip.name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tempo_change_dialog_runs_a_real_command_at_its_position() {
|
||||
let (cmd, params) = tempo_change_action(96_000, 133.5);
|
||||
@@ -714,36 +522,4 @@ mod tests {
|
||||
assert_eq!(params["at"], 96_000);
|
||||
assert_eq!(params["bpm"], 133.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_always_asks_and_names_the_timeline_clips_that_go_too() {
|
||||
let mut e = soundcraft_engine::demo::demo_engine();
|
||||
let file = |e: &Engine, name: &str| e.session().sources.iter().find(|f| f.name == name).map(|f| f.id).unwrap();
|
||||
let (kick, hats) = (file(&e, "Kick"), file(&e, "Hats"));
|
||||
let kick_clips: Vec<ClipId> = e.session().clips_using(kick).map(|c| c.id).collect();
|
||||
let snare = e.session().track_by_name("Snare").unwrap().clips()[0].id;
|
||||
let message = |d: &Dialogs| match &d.open {
|
||||
Some(Dialog::ClearClips { message, .. }) => message.clone(),
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let mut d = Dialogs::default();
|
||||
assert!(!d.open_for_command(&e, "clip.clear"));
|
||||
e.execute("edit.select", &json!({"clips": [snare]})).unwrap();
|
||||
assert!(d.open_for_command(&e, "clip.clear"));
|
||||
assert_eq!(message(&d), "Remove the clip \"Snare\" from the session?");
|
||||
e.execute("edit.select", &json!({"sources": [kick]})).unwrap();
|
||||
assert!(d.open_for_command(&e, "clip.clear"));
|
||||
assert!(matches!(&d.open, Some(Dialog::ClearClips { clips, sources, .. }) if clips.is_empty() && *sources == vec![kick]));
|
||||
assert_eq!(message(&d), "Remove the file \"Kick\" from the session? It is used by 3 clips on the timeline, which are removed too.");
|
||||
assert!(d.open_clear(&e, kick_clips.clone(), vec![kick]));
|
||||
assert_eq!(message(&d), "Remove 3 clips and 1 file from the session?");
|
||||
assert!(d.open_clear(&e, kick_clips, vec![kick, hats]));
|
||||
assert_eq!(message(&d), "Remove 3 clips and 2 files from the session? \"Hats\" is used by a clip on the timeline, which is removed too.");
|
||||
e.session_mut().sources.iter_mut().for_each(|f| f.unsaved = false);
|
||||
assert!(d.open_clear(&e, vec![], vec![kick, hats]));
|
||||
assert_eq!(
|
||||
message(&d),
|
||||
"Remove 2 files from the session? They are used by 4 clips on the timeline, which are removed too. The files stay on disk."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,16 +52,15 @@ pub fn run(app: &mut SoundApp, id: &str, p: &Value) -> Option<Value> {
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
apply_customization(app, ctx);
|
||||
auto_update(app, ctx);
|
||||
renderer(app, ctx);
|
||||
ui_customization(app, ctx);
|
||||
apply_customization(app, ctx);
|
||||
}
|
||||
|
||||
fn apply_customization(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let want = (app.extra.ui_scale.clamp(0.75, 1.5), app.extra.reduce_motion);
|
||||
// Rescaling mid-drag would move the Interface scale slider out from under the pointer.
|
||||
if app.extra.applied == Some(want) || ctx.input(|i| i.pointer.any_down()) {
|
||||
if app.extra.applied == Some(want) {
|
||||
return;
|
||||
}
|
||||
// Only touch the zoom once the user has changed it, so platform scaling stays untouched.
|
||||
@@ -176,10 +175,17 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
ui.label("Auto-update configuration");
|
||||
ui.checkbox(&mut app.extra.auto_update_config, "");
|
||||
ui.end_row();
|
||||
if !cfg!(target_os = "macos") {
|
||||
ui.label("Use system title bar");
|
||||
ui.checkbox(&mut app.ui.system_title_bar, "")
|
||||
.on_hover_text("Takes effect from the next launch");
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
if ui.button("Reset").clicked() {
|
||||
app.extra.ui_scale = 1.0;
|
||||
app.extra.reduce_motion = false;
|
||||
app.ui.system_title_bar = false;
|
||||
let _ = app.run("ui.theme", json!({"mode": crate::theme::ThemeMode::default().id()}));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod panels;
|
||||
pub mod score_editor;
|
||||
pub mod shortcuts;
|
||||
pub mod theme;
|
||||
pub mod titlebar;
|
||||
pub mod toolbar;
|
||||
pub mod video_track;
|
||||
pub mod video_window;
|
||||
@@ -99,13 +100,11 @@ pub struct UiState {
|
||||
pub video_burn_in: bool,
|
||||
pub workspace_dir: String,
|
||||
pub configurations: Vec<(String, Value)>,
|
||||
/// Appearance (`ui.theme`): dark by default, light or follow the system on request.
|
||||
/// Appearance (`ui.theme`): dark by default; light, a Catppuccin flavour or the system on request.
|
||||
pub theme: theme::ThemeMode,
|
||||
/// The main window's size (logical points) and maximized state, and the interface scale, as
|
||||
/// the desktop app last saw them; it restores them at launch.
|
||||
pub window_size: [f32; 2],
|
||||
pub window_maximized: bool,
|
||||
pub ui_scale: f32,
|
||||
/// Windows and Linux: keep the system's title bar instead of drawing the window's own.
|
||||
/// Read before the window opens: it applies from the next launch.
|
||||
pub system_title_bar: bool,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
@@ -158,9 +157,7 @@ impl Default for UiState {
|
||||
workspace_dir: String::new(),
|
||||
configurations: Vec::new(),
|
||||
theme: theme::ThemeMode::Dark,
|
||||
window_size: [1600.0, 1000.0],
|
||||
window_maximized: false,
|
||||
ui_scale: 1.0,
|
||||
system_title_bar: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,7 +167,6 @@ impl Default for UiState {
|
||||
pub struct Services {
|
||||
pub pick_open: Option<Box<dyn Fn(&str, &[&str]) -> Option<String>>>,
|
||||
pub pick_save: Option<Box<dyn Fn(&str, &str) -> Option<String>>>,
|
||||
pub pick_folder: Option<Box<dyn Fn(&str) -> Option<String>>>,
|
||||
}
|
||||
|
||||
/// File-system-safe folder name for a plugin id (`clap:com.x.y` → `clap_com.x.y`).
|
||||
@@ -178,15 +174,6 @@ pub fn preset_folder_name(plugin: &str) -> String {
|
||||
plugin.chars().map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { c } else { '_' }).collect()
|
||||
}
|
||||
|
||||
/// On the first frame, maximizes a window too big for the screen it opened on (sized on a larger
|
||||
/// monitor) instead of letting it run off the edge; its saved size is kept for that monitor.
|
||||
pub fn fit_window_to_screen(ctx: &egui::Context) {
|
||||
let (inner, monitor) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().monitor_size));
|
||||
if ctx.cumulative_pass_nr() == 0 && inner.zip(monitor).is_some_and(|(r, m)| (r.size() - m).max_elem() > 0.0) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true));
|
||||
}
|
||||
}
|
||||
|
||||
/// Drag-and-drop payload: an audio file (source id) dragged from the Clip List.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct DragSource(pub u64);
|
||||
@@ -230,16 +217,18 @@ pub struct SoundApp {
|
||||
pub services: Services,
|
||||
/// Set only by a desktop host after installing an OS menu bar.
|
||||
pub native_menu_bar: bool,
|
||||
/// Windows and Linux: no OS decorations; the menu bar row is the title bar ([`titlebar`]).
|
||||
pub custom_titlebar: bool,
|
||||
/// macOS: the traffic lights sit over the top row (no system title bar).
|
||||
pub integrated_titlebar: bool,
|
||||
/// Folder for crash-recovery autosaves (native apps set it).
|
||||
pub autosave_dir: Option<std::path::PathBuf>,
|
||||
/// Folder for user plugin presets (native apps set it).
|
||||
pub preset_dir: Option<std::path::PathBuf>,
|
||||
last_autosave: Option<std::time::Instant>,
|
||||
last_autosave: f64,
|
||||
pub dialogs: dialogs::Dialogs,
|
||||
pub meters: HashMap<TrackId, MeterDisplay>,
|
||||
pub main_meter: MeterDisplay,
|
||||
/// Reused multichannel snapshot; populated at an adaptive rate by `logic`.
|
||||
meter_snapshot: soundcraft_playback::MeterSnapshot,
|
||||
pub gesture: Option<Gesture>,
|
||||
pub edit_layout: edit_window::EditLayout,
|
||||
pub midi: midi_editor::MidiEditorState,
|
||||
@@ -254,7 +243,7 @@ pub struct SoundApp {
|
||||
fonts_ready: bool,
|
||||
fonts_installed: bool,
|
||||
/// The palette last applied to the context (light?), so visuals are set only on a change.
|
||||
theme_applied: Option<bool>,
|
||||
theme_applied: Option<theme::ThemeMode>,
|
||||
/// Last panel host context, used to cancel renderer-owned gestures on explicit replacement.
|
||||
pub(crate) docking_context: Option<egui::Context>,
|
||||
/// Transport simulation when there is no player (tests, offscreen renders).
|
||||
@@ -267,7 +256,6 @@ pub struct SoundApp {
|
||||
last_write_at: Samples,
|
||||
pub frame_ms: f32,
|
||||
last_frame: Option<f64>,
|
||||
last_meter_poll: f64,
|
||||
/// Movies for the Video window and Video track (decoded in the background).
|
||||
pub video: video_track::VideoPool,
|
||||
}
|
||||
@@ -295,13 +283,14 @@ impl SoundApp {
|
||||
audio_health_report: None,
|
||||
services,
|
||||
native_menu_bar: false,
|
||||
custom_titlebar: false,
|
||||
integrated_titlebar: false,
|
||||
autosave_dir: None,
|
||||
preset_dir: None,
|
||||
last_autosave: None,
|
||||
last_autosave: 0.0,
|
||||
dialogs: dialogs::Dialogs::default(),
|
||||
meters: HashMap::new(),
|
||||
main_meter: MeterDisplay::default(),
|
||||
meter_snapshot: soundcraft_playback::MeterSnapshot::default(),
|
||||
gesture: None,
|
||||
edit_layout: edit_window::EditLayout::default(),
|
||||
midi: midi_editor::MidiEditorState::default(),
|
||||
@@ -324,7 +313,6 @@ impl SoundApp {
|
||||
last_write_at: i64::MIN,
|
||||
frame_ms: 0.0,
|
||||
last_frame: None,
|
||||
last_meter_poll: f64::NEG_INFINITY,
|
||||
video: video_track::VideoPool::default(),
|
||||
}
|
||||
}
|
||||
@@ -352,9 +340,6 @@ impl SoundApp {
|
||||
// Re-selecting a scrolling mode resumes the playhead follow.
|
||||
self.edit_layout.follow_hold = false;
|
||||
}
|
||||
if id == "track.clear_clip_indicators" {
|
||||
self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -393,8 +378,6 @@ impl SoundApp {
|
||||
|
||||
fn stop_play(&mut self) {
|
||||
if let Some(p) = &self.player {
|
||||
// Half-speed is a separate command. Stop must not leave that rate for the next Play.
|
||||
p.set_speed(1.0);
|
||||
p.stop();
|
||||
}
|
||||
if self.engine.transport.recording {
|
||||
@@ -439,14 +422,17 @@ impl SoundApp {
|
||||
return;
|
||||
}
|
||||
self.ensure_input();
|
||||
if let Err(err) = self.arm_recorder() {
|
||||
self.ui.status = format!("Cannot record: {err}");
|
||||
if self.recorder.is_none() {
|
||||
self.ui.status = "Cannot record: no audio input device".into();
|
||||
return;
|
||||
}
|
||||
if let Some(p) = &self.player {
|
||||
p.set_recording(true);
|
||||
}
|
||||
let already_playing = self.is_playing();
|
||||
if let Some(r) = &self.recorder {
|
||||
r.arm();
|
||||
}
|
||||
self.engine.transport.recording = true;
|
||||
if already_playing {
|
||||
// Punch in on the fly at the current playhead.
|
||||
@@ -472,19 +458,6 @@ impl SoundApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Arm the input for the record-enabled tracks; a session never saved records into its
|
||||
/// autosave folder.
|
||||
fn arm_recorder(&mut self) -> Result<(), String> {
|
||||
let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name);
|
||||
let unsaved_dir = self.autosave_dir.as_ref().map_or_else(std::env::temp_dir, |d| d.join(name));
|
||||
let Some(r) = &mut self.recorder else { return Err("no audio input device".into()) };
|
||||
let plan = soundcraft_engine::io::plan_takes(&self.engine, &unsaved_dir, r.channels);
|
||||
if plan.is_empty() {
|
||||
return Err("no audio track is record-enabled".into());
|
||||
}
|
||||
r.arm(plan)
|
||||
}
|
||||
|
||||
/// Reads every live third-party plugin's state from the audio engine and stores it on its
|
||||
/// insert (`mix.insert_state`), so saves and bounces include it.
|
||||
pub fn capture_plugin_states(&mut self) {
|
||||
@@ -511,14 +484,15 @@ impl SoundApp {
|
||||
}
|
||||
|
||||
/// Every two minutes, copy a modified session to the autosave folder (not while recording).
|
||||
/// Timed by the wall clock: egui's stands still while the window is hidden.
|
||||
fn autosave(&mut self) {
|
||||
fn autosave(&mut self, now: f64) {
|
||||
let Some(dir) = self.autosave_dir.clone() else { return };
|
||||
let last = *self.last_autosave.get_or_insert_with(std::time::Instant::now);
|
||||
if last.elapsed().as_secs() < 120 || !self.engine.is_dirty() || self.engine.transport.recording {
|
||||
if self.last_autosave == 0.0 {
|
||||
self.last_autosave = now;
|
||||
}
|
||||
if now - self.last_autosave < 120.0 || !self.engine.is_dirty() || self.engine.transport.recording {
|
||||
return;
|
||||
}
|
||||
self.last_autosave = Some(std::time::Instant::now());
|
||||
self.last_autosave = now;
|
||||
self.capture_plugin_states();
|
||||
let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name);
|
||||
let path = dir.join(&name).join(format!("{name}.scraft"));
|
||||
@@ -549,12 +523,6 @@ impl SoundApp {
|
||||
best.map(|(_, p)| p)
|
||||
}
|
||||
|
||||
/// Whether the meters are live: while playing, and while armed or input-monitored tracks are heard.
|
||||
fn meters_live(&self) -> bool {
|
||||
self.engine.transport.playing
|
||||
|| (self.recorder.is_some() && self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm))
|
||||
}
|
||||
|
||||
/// Open the input device once (for recording and input monitoring) and feed the player.
|
||||
fn ensure_input(&mut self) {
|
||||
if self.recorder.is_some() || self.recorder_failed || self.player.is_none() {
|
||||
@@ -579,26 +547,49 @@ impl SoundApp {
|
||||
if let Some(p) = &self.player {
|
||||
p.set_recording(false);
|
||||
}
|
||||
let Some(r) = &mut self.recorder else { return };
|
||||
let (takes, problem) = r.finish();
|
||||
let tracks = takes.len().max(1);
|
||||
let Some(r) = &self.recorder else { return };
|
||||
let take = r.take();
|
||||
let rate = take.sample_rate;
|
||||
let sel = self.engine.session().edit.selection;
|
||||
let looped = self.engine.session().edit.loop_record && !sel.is_empty();
|
||||
let (start, pass) = if looped { (sel.start, Some(sel.len())) } else { (self.record_start, None) };
|
||||
let ids = soundcraft_engine::io::add_recording(&mut self.engine, start, takes, pass);
|
||||
if let Some(p) = self.punch.take() {
|
||||
// Keep only the punch range of each new take (merged into the Record undo step).
|
||||
let clip_ids: Vec<u64> = ids.iter().map(|c| c.0).collect();
|
||||
let _ = self.engine.execute_merged(
|
||||
"edit.trim_to_fill_selection",
|
||||
&serde_json::json!({"clips": clip_ids, "start": p.start, "end": p.end}),
|
||||
"record",
|
||||
);
|
||||
if self.engine.session().edit.loop_record && !sel.is_empty() {
|
||||
// Split the capture into one take per loop pass; each pass gets its own playlist.
|
||||
let pass = usize::try_from(soundcraft_time::to_samples(sel.len() as f64 * f64::from(rate) / self.engine.session().sample_rate.as_f64()))
|
||||
.unwrap_or(0)
|
||||
.max(1);
|
||||
let total = take.channels.first().map_or(0, Vec::len);
|
||||
let passes = total.div_ceil(pass).max(1);
|
||||
let mut made = 0;
|
||||
for k in 0..passes {
|
||||
let chunk: Vec<Vec<f32>> =
|
||||
take.channels.iter().map(|c| c.get(k * pass..((k + 1) * pass).min(c.len())).map(<[f32]>::to_vec).unwrap_or_default()).collect();
|
||||
if chunk.first().is_none_or(|c| c.len() < pass / 8) {
|
||||
continue;
|
||||
}
|
||||
if made > 0 {
|
||||
let armed: Vec<u64> = self.engine.session().tracks.iter().filter(|t| t.mixer.record_arm).map(|t| t.id.0).collect();
|
||||
let _ = self.engine.execute("track.playlist_new", &serde_json::json!({"tracks": armed}));
|
||||
}
|
||||
if soundcraft_engine::io::add_recording(&mut self.engine, sel.start, chunk, rate).is_ok() {
|
||||
made += 1;
|
||||
}
|
||||
}
|
||||
self.ui.status = format!("Loop-recorded {made} take(s)");
|
||||
return;
|
||||
}
|
||||
self.ui.status = if looped { format!("Loop-recorded {} take(s)", ids.len() / tracks) } else { format!("Recorded {} clip(s)", ids.len()) };
|
||||
if let Some(p) = problem {
|
||||
log::warn!("recording: {p}");
|
||||
self.ui.status = format!("{} ({p})", self.ui.status);
|
||||
match soundcraft_engine::io::add_recording(&mut self.engine, self.record_start, take.channels, rate) {
|
||||
Ok(ids) => {
|
||||
if let Some(p) = self.punch.take() {
|
||||
// Keep only the punch range of each new take (merged into the Record undo step).
|
||||
let clip_ids: Vec<u64> = ids.iter().map(|c| c.0).collect();
|
||||
let _ = self.engine.execute_merged(
|
||||
"edit.trim_to_fill_selection",
|
||||
&serde_json::json!({"clips": clip_ids, "start": p.start, "end": p.end}),
|
||||
"record",
|
||||
);
|
||||
}
|
||||
self.ui.status = format!("Recorded {} clip(s)", ids.len());
|
||||
}
|
||||
Err(e) => self.ui.status = e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,13 +736,7 @@ impl SoundApp {
|
||||
if self.engine.transport.playing {
|
||||
self.engine.transport.position = p.position();
|
||||
}
|
||||
let now = self.last_frame.unwrap_or_default();
|
||||
let meter_interval = if self.ui.window == MainWindow::Mix { 1.0 / 60.0 } else { 1.0 / 20.0 };
|
||||
if now - self.last_meter_poll >= meter_interval {
|
||||
p.copy_meters(&mut self.meter_snapshot);
|
||||
self.last_meter_poll = now;
|
||||
}
|
||||
let snap = &self.meter_snapshot;
|
||||
let snap = p.meters();
|
||||
for (id, m) in &snap.tracks {
|
||||
let d = self.meters.entry(*id).or_default();
|
||||
feed_meter(d, m.peak, m.gain_reduction_db, dt);
|
||||
@@ -775,7 +760,7 @@ impl SoundApp {
|
||||
self.stop_play();
|
||||
}
|
||||
}
|
||||
if !self.meters_live() {
|
||||
if !self.engine.transport.playing {
|
||||
for d in self.meters.values_mut() {
|
||||
feed_meter(d, [0.0, 0.0], 0.0, dt);
|
||||
}
|
||||
@@ -785,9 +770,6 @@ impl SoundApp {
|
||||
|
||||
/// Per-frame logic: control channel, transport, document sync.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
if ctx.current_pass_index() > 0 {
|
||||
return;
|
||||
}
|
||||
// Fonts set now take effect next frame, so draw only from the frame after.
|
||||
if !self.fonts_ready {
|
||||
if self.fonts_installed {
|
||||
@@ -798,13 +780,14 @@ impl SoundApp {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
let light = theme::wants_light(ctx, self.ui.theme);
|
||||
if self.theme_applied != Some(light) || theme::is_light() != light {
|
||||
theme::apply(ctx, light);
|
||||
self.theme_applied = Some(light);
|
||||
let mode = theme::resolve(ctx, self.ui.theme);
|
||||
if self.theme_applied != Some(mode) || theme::active_mode() != mode {
|
||||
theme::apply(ctx, mode);
|
||||
self.theme_applied = Some(mode);
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
let dt = self.last_frame.replace(now).map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25);
|
||||
let dt = self.last_frame.map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25);
|
||||
self.last_frame = Some(now);
|
||||
self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1;
|
||||
control::drain(self, ctx);
|
||||
self.apply_editor_edits();
|
||||
@@ -826,17 +809,16 @@ impl SoundApp {
|
||||
}
|
||||
}
|
||||
self.write_automation(ctx);
|
||||
self.autosave();
|
||||
self.autosave(now);
|
||||
if self.recorder.is_none()
|
||||
&& !self.recorder_failed
|
||||
&& self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm)
|
||||
{
|
||||
self.ensure_input();
|
||||
}
|
||||
// Autosave and the macOS menu bar only run on frames, and plugin editors are native windows egui never hears from.
|
||||
// egui takes its predicted frame time off every delay, so add it back.
|
||||
let editors = self.player.as_ref().is_some_and(Player::any_editor_open);
|
||||
ctx.request_repaint_after_secs(if editors { 0.03 } else { 1.0 } + ctx.input(|i| i.predicted_dt));
|
||||
if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
control::collect_screenshots(self, ctx);
|
||||
}
|
||||
|
||||
@@ -902,14 +884,8 @@ impl SoundApp {
|
||||
score_editor::show(self, &ctx);
|
||||
palette::show(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
// Here, not in logic(): a covered or minimized window runs only logic(), with no vsync to pace it.
|
||||
if self.meters_live() {
|
||||
let hz = if self.ui.window == MainWindow::Mix { 60 } else { 30 };
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(1000 / hz));
|
||||
} else if self.meters.values().any(|m| m.level[0] > 0.0001) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(80));
|
||||
} else if !self.pending_shots.is_empty() || !self.synthetic.is_empty() {
|
||||
ctx.request_repaint();
|
||||
if self.custom_titlebar {
|
||||
titlebar::resize_zones(ui);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -998,20 +974,6 @@ mod tests {
|
||||
assert_eq!(app.engine.transport.position, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_window_still_autosaves() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-hidden-autosave-{}", std::process::id()));
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.autosave_dir = Some(dir.clone());
|
||||
app.run("track.new", json!({"count": 1, "format": "mono"})).unwrap();
|
||||
app.last_autosave = std::time::Instant::now().checked_sub(std::time::Duration::from_secs(121));
|
||||
let hidden = egui::ViewportInfo { minimized: Some(true), ..Default::default() };
|
||||
let raw = egui::RawInput { viewports: [(egui::ViewportId::ROOT, hidden)].into_iter().collect(), ..Default::default() };
|
||||
let _ = egui::Context::default().run_logic(&raw, |ctx| app.logic(ctx));
|
||||
assert!(app.latest_autosave().is_some());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_reset_does_not_apply_old_stop_edits_to_the_new_session() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
@@ -1031,16 +993,6 @@ mod tests {
|
||||
assert!(!app.engine.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_all_clip_indicators_clears_every_meter() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
|
||||
app.meters.entry(kick).or_default().clip = true;
|
||||
app.main_meter.clip = true;
|
||||
app.run("track.clear_clip_indicators", json!({})).unwrap();
|
||||
assert!(!app.meters[&kick].clip && !app.main_meter.clip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_first_pass_after_start_is_due_without_overflow() {
|
||||
// `last_write_at` starts (and resets) at `i64::MIN`: the previous
|
||||
@@ -1059,72 +1011,4 @@ mod tests {
|
||||
fn automation_write_fires_after_loop_wrap() {
|
||||
assert!(automation_step_due(0, 1_000_000, 960));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_saved_before_the_window_was_remembered_keep_full_scale() {
|
||||
let ui: super::UiState = serde_json::from_value(json!({"show_transport": true})).unwrap();
|
||||
assert_eq!((ui.window_size, ui.window_maximized, ui.ui_scale), ([1600.0, 1000.0], false, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_bigger_than_its_screen_opens_maximized() {
|
||||
let maximizes = |w: f32, frames: usize| {
|
||||
let ctx = egui::Context::default();
|
||||
let info = egui::ViewportInfo {
|
||||
inner_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, 1000.0))),
|
||||
monitor_size: Some(egui::vec2(1728.0, 1117.0)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut maximized = false;
|
||||
for _ in 0..frames {
|
||||
let raw = egui::RawInput { viewports: [(egui::ViewportId::ROOT, info.clone())].into_iter().collect(), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| super::fit_window_to_screen(ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
maximized = out.viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Maximized(true));
|
||||
}
|
||||
maximized
|
||||
};
|
||||
assert!(maximizes(2400.0, 1));
|
||||
assert!(!maximizes(1600.0, 1));
|
||||
assert!(!maximizes(2400.0, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_play_after_stop_is_full_speed() {
|
||||
use serde_json::json;
|
||||
let engine = soundcraft_engine::Engine::default();
|
||||
let player = soundcraft_playback::Player::new(engine.session_arc());
|
||||
let mut app = super::SoundApp::new(engine, Some(player), super::Services::default());
|
||||
app.engine.execute("edit.select", &json!({"start": 0, "end": 48000})).unwrap();
|
||||
app.engine.execute("options.loop_playback", &json!({"value": true})).unwrap();
|
||||
app.engine.execute("options.pre_post_roll", &json!({"value": false})).unwrap();
|
||||
let speed = |app: &super::SoundApp| app.player.as_ref().map(|p| p.speed()).unwrap_or(0.0);
|
||||
assert!((speed(&app) - 1.0).abs() < 1e-3);
|
||||
|
||||
app.engine.execute("transport.half_speed", &json!({})).unwrap();
|
||||
app.handle_transport(1.0 / 60.0);
|
||||
assert!(app.is_playing());
|
||||
assert!((speed(&app) - 0.5).abs() < 1e-3, "half-speed playback");
|
||||
|
||||
app.engine.execute("transport.stop", &json!({})).unwrap();
|
||||
app.handle_transport(1.0 / 60.0);
|
||||
assert!(!app.is_playing());
|
||||
assert!((speed(&app) - 1.0).abs() < 1e-3, "stop restores full speed");
|
||||
|
||||
app.engine.execute("transport.play", &json!({})).unwrap();
|
||||
app.handle_transport(1.0 / 60.0);
|
||||
assert!(app.is_playing());
|
||||
assert!((speed(&app) - 1.0).abs() < 1e-3, "ordinary play stays at full speed");
|
||||
|
||||
app.engine.execute("transport.stop", &json!({})).unwrap();
|
||||
app.handle_transport(1.0 / 60.0);
|
||||
app.engine.execute("options.loop_playback", &json!({"value": false})).unwrap();
|
||||
app.engine.execute("transport.half_speed", &json!({})).unwrap();
|
||||
app.handle_transport(1.0 / 60.0);
|
||||
assert!((speed(&app) - 0.5).abs() < 1e-3);
|
||||
app.player.as_ref().unwrap().stop();
|
||||
app.handle_transport(1.0 / 60.0);
|
||||
assert!(!app.is_playing());
|
||||
assert!((speed(&app) - 1.0).abs() < 1e-3, "a half-speed pass that ends on its own restores full speed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,14 +145,12 @@ pub const AUDIOSUITE: &[(&str, &str)] = &[
|
||||
|
||||
/// Commands that open a dialog when invoked from a menu (programmatic calls never do).
|
||||
const DIALOG_COMMANDS: &[&str] = &[
|
||||
"clip.clear",
|
||||
"track.new",
|
||||
"file.bounce_mix",
|
||||
"session.new",
|
||||
"file.import_audio",
|
||||
"file.import_midi",
|
||||
"session.open",
|
||||
"session.save",
|
||||
"session.save_as",
|
||||
"session.save_copy",
|
||||
"track.group",
|
||||
@@ -163,7 +161,6 @@ const DIALOG_COMMANDS: &[&str] = &[
|
||||
"event.quantize",
|
||||
"event.transpose",
|
||||
"edit.strip_silence",
|
||||
"clip.rename",
|
||||
];
|
||||
|
||||
/// Shared menu hierarchy for egui and native desktop hosts.
|
||||
@@ -208,10 +205,19 @@ pub fn tree() -> Vec<MenuNode> {
|
||||
|
||||
pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
|
||||
let t = crate::theme::Tokens::current();
|
||||
egui::Panel::top("menu_bar").exact_size(24.0).frame(egui::Frame::NONE.fill(t.menu_bar).inner_margin(egui::Margin::symmetric(8, 2))).show(
|
||||
ui,
|
||||
|ui| {
|
||||
let height = if app.custom_titlebar { crate::titlebar::HEIGHT } else { 24.0 };
|
||||
egui::Panel::top("menu_bar")
|
||||
.exact_size(height)
|
||||
.frame(egui::Frame::NONE.fill(t.menu_bar).inner_margin(egui::Margin { left: 8, right: 0, top: 0, bottom: 0 }))
|
||||
.show(ui, |ui| {
|
||||
let bar_rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(ui.ctx().content_rect().width(), height));
|
||||
if app.custom_titlebar || app.integrated_titlebar {
|
||||
crate::titlebar::drag_area(ui, bar_rect);
|
||||
}
|
||||
egui::MenuBar::new().ui(ui, |ui| {
|
||||
if app.integrated_titlebar {
|
||||
ui.add_space(crate::titlebar::MAC_INSET);
|
||||
}
|
||||
ui.menu_button("SoundCraft", |ui| {
|
||||
if ui.button("About SoundCraft").clicked() {
|
||||
app.ui.show_about = true;
|
||||
@@ -224,7 +230,7 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Quit").clicked() {
|
||||
let _ = crate::dialogs::run_or_ask(app, "app.quit", json!({}));
|
||||
let _ = app.run("app.quit", json!({}));
|
||||
}
|
||||
});
|
||||
let extra = ui_aliases();
|
||||
@@ -246,11 +252,16 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
|
||||
if app.ui.window == MainWindow::Edit { "Edit" } else { "Mix" }
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if app.custom_titlebar {
|
||||
ui.add_space(crate::titlebar::WIDTH);
|
||||
}
|
||||
ui.label(egui::RichText::new(title).color(t.text_dim));
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
if app.custom_titlebar {
|
||||
crate::titlebar::caption_buttons(app, ui, bar_rect);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str, &str)]) {
|
||||
@@ -264,9 +275,8 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str
|
||||
}
|
||||
let id = command_for_path(&n.path, extra);
|
||||
let (enabled, checked) = item_state(app, &n.path, id.as_deref());
|
||||
let label = live_label(app, id.as_deref()).unwrap_or_else(|| n.label.clone());
|
||||
let label = if checked { format!("✔ {label}") } else { label };
|
||||
let shortcut = id.as_deref().and_then(shortcut_for).unwrap_or("");
|
||||
let label = if checked { format!("✔ {}", n.label) } else { n.label.clone() };
|
||||
let shortcut = id.as_deref().and_then(soundcraft_engine::find_command).and_then(|c| c.shortcut).unwrap_or("");
|
||||
let btn = egui::Button::new(label).shortcut_text(crate::shortcuts::shortcut_label(shortcut, ui.ctx().os().is_mac()));
|
||||
if ui.add_enabled(enabled, btn).clicked()
|
||||
&& let Some(id) = id
|
||||
@@ -397,21 +407,6 @@ const MENU_WINDOWS: &[(&str, &str)] = &[
|
||||
("Event > MIDI Real-Time Properties", "window.rtp"),
|
||||
];
|
||||
|
||||
/// The shortcut of an engine or window command.
|
||||
pub fn shortcut_for(id: &str) -> Option<&'static str> {
|
||||
soundcraft_engine::find_command(id).map_or_else(|| UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| c.3), |c| c.shortcut)
|
||||
}
|
||||
|
||||
/// Undo and Redo name the step they would undo or redo; other items keep their catalog label.
|
||||
pub fn live_label(app: &SoundApp, id: Option<&str>) -> Option<String> {
|
||||
let e = &app.engine;
|
||||
match id? {
|
||||
"edit.undo" => Some(e.undo_label().map_or_else(|| "Can't Undo".into(), |l| format!("Undo {l}"))),
|
||||
"edit.redo" => Some(e.redo_label().map_or_else(|| "Can't Redo".into(), |l| format!("Redo {l}"))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Menu-style invocation: may open a dialog.
|
||||
pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
|
||||
if let Some((_, w)) = MENU_WINDOWS.iter().find(|(p, _)| *p == path)
|
||||
@@ -438,13 +433,12 @@ pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
|
||||
return;
|
||||
}
|
||||
let params = params_for(path, id);
|
||||
let _ = crate::dialogs::run_or_ask(app, id, params);
|
||||
let _ = app.run(id, params);
|
||||
}
|
||||
|
||||
/// Keyboard shortcut for a dialog command, or one that would close a modified session: open its
|
||||
/// dialog. Returns true when handled.
|
||||
/// Keyboard shortcut for a dialog command: open its dialog. Returns true when handled.
|
||||
pub fn invoke_shortcut_dialog(app: &mut SoundApp, id: &str) -> bool {
|
||||
(wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id)) || crate::dialogs::ask_to_save(app, id, &json!({}))
|
||||
wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id)
|
||||
}
|
||||
|
||||
/// Commands that open a dialog first: the listed ones, Score Setup, and anything that needs a path.
|
||||
@@ -591,7 +585,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
||||
return Some(Ok(json!({"mode": app.ui.theme.id()})));
|
||||
};
|
||||
let Some(mode) = crate::theme::ThemeMode::parse(mode) else {
|
||||
return Some(Err(format!("unknown theme `{mode}` (system, light or dark)")));
|
||||
return Some(Err(format!("unknown theme `{mode}` ({})", crate::theme::ThemeMode::id_list())));
|
||||
};
|
||||
app.ui.theme = mode;
|
||||
json!({"mode": mode.id()})
|
||||
@@ -658,24 +652,4 @@ mod tests {
|
||||
invoke_menu(&mut app, "ui.bounce_dialog", "File > Bounce Mix...");
|
||||
assert_eq!(app.dialogs.open_name(), Some("bounce"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_asks_for_a_path_until_the_session_has_one() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
invoke_menu(&mut app, "session.save", "File > Save Session");
|
||||
assert!(matches!(&app.dialogs.open, Some(crate::dialogs::Dialog::PathPrompt { cmd, .. }) if cmd == "session.save_as"));
|
||||
app.dialogs.open = None;
|
||||
assert!(invoke_shortcut_dialog(&mut app, "session.save"));
|
||||
app.dialogs.open = None;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-save-{}", std::process::id()));
|
||||
let path = dir.join("Song.scraft");
|
||||
app.run("session.save_as", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
app.run("track.new", json!({})).unwrap();
|
||||
assert!(!invoke_shortcut_dialog(&mut app, "session.save"));
|
||||
invoke_menu(&mut app, "session.save", "File > Save Session");
|
||||
assert!(app.dialogs.open.is_none());
|
||||
assert!(!app.engine.is_dirty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const VEL_H: f32 = 64.0;
|
||||
pub struct MidiEditorState {
|
||||
pub selected: Vec<usize>,
|
||||
pub drag: Option<NoteDrag>,
|
||||
pub scroll: f32,
|
||||
pub top_pitch: i32,
|
||||
pub clip: Option<ClipId>,
|
||||
/// Rubber-band selection start (screen position).
|
||||
pub band: Option<egui::Pos2>,
|
||||
@@ -127,27 +127,28 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + 4.0), pos2(roll.max.x, full.max.y - 2.0));
|
||||
// Vertical range: centre on the notes the first time.
|
||||
let rows = (roll.height() / ROW_H).floor() as i32;
|
||||
if app.midi.clip != Some(cid) {
|
||||
if app.midi.top_pitch == 0 || app.midi.clip != Some(cid) {
|
||||
let hi = sequence.notes.iter().map(|n| i32::from(n.pitch)).max().unwrap_or(72);
|
||||
let lo = sequence.notes.iter().map(|n| i32::from(n.pitch)).min().unwrap_or(48);
|
||||
app.midi.scroll = (127 - (hi + lo) / 2 - rows / 2) as f32 * ROW_H;
|
||||
let mid = (hi + lo) / 2;
|
||||
app.midi.top_pitch = (mid + rows / 2).clamp(rows.min(127), 127);
|
||||
app.midi.clip = Some(cid);
|
||||
}
|
||||
if ui.rect_contains_pointer(roll) {
|
||||
app.midi.scroll -= ui.input(|i| i.smooth_scroll_delta.y);
|
||||
let dy = ui.input(|i| i.smooth_scroll_delta.y);
|
||||
if dy.abs() > 0.5 {
|
||||
app.midi.top_pitch = (app.midi.top_pitch + (dy / ROW_H).round() as i32).clamp(rows.min(127), 127);
|
||||
}
|
||||
}
|
||||
app.midi.scroll = app.midi.scroll.clamp(0.0, (128.0 * ROW_H - roll.height()).max(0.0));
|
||||
let scroll = app.midi.scroll;
|
||||
let top = app.midi.top_pitch;
|
||||
let px_per_tick = roll.width() / len_ticks as f32;
|
||||
let x_of = |tick: i64| roll.min.x + tick as f32 * px_per_tick;
|
||||
let y_of = |pitch: i32| roll.min.y + (127 - pitch) as f32 * ROW_H - scroll;
|
||||
let pitch_at = |y: f32| 127 - ((y - roll.min.y + scroll) / ROW_H).floor() as i32;
|
||||
let y_of = |pitch: i32| roll.min.y + (top - pitch) as f32 * ROW_H;
|
||||
let painter = ui.painter().with_clip_rect(roll.union(keys));
|
||||
// Background rows and keys.
|
||||
let top = pitch_at(roll.min.y);
|
||||
for r in 0..=rows + 1 {
|
||||
for r in 0..=rows {
|
||||
let pitch = top - r;
|
||||
let y = y_of(pitch);
|
||||
let y = roll.min.y + r as f32 * ROW_H;
|
||||
let row = Rect::from_min_size(pos2(roll.min.x, y), vec2(roll.width(), ROW_H));
|
||||
painter.rect_filled(row, 0.0, if is_black(pitch) { Color32::from_rgb(30, 30, 32) } else { Color32::from_rgb(40, 40, 42) });
|
||||
let key = Rect::from_min_size(pos2(keys.min.x, y), vec2(KEY_W - 2.0, ROW_H - 1.0));
|
||||
@@ -287,7 +288,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
||||
// Audition on the instrument would go here (needs a live MIDI input path).
|
||||
}
|
||||
None => {
|
||||
let pitch = pitch_at(p.y).clamp(0, 127);
|
||||
let pitch = (top - ((p.y - roll.min.y) / ROW_H).floor() as i32).clamp(0, 127);
|
||||
let start = (((p.x - roll.min.x) / px_per_tick) as i64 / grid_ticks) * grid_ticks;
|
||||
let _ = app.run(
|
||||
"midi.note_add",
|
||||
|
||||
@@ -22,7 +22,11 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
||||
pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let ids: Vec<TrackId> = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect();
|
||||
// Per-channel peaks for multichannel meters (read once per frame).
|
||||
let snap = app.player.as_ref().filter(|_| app.meters_live()).map(|p| p.meters());
|
||||
let snap = app
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|_| app.engine.transport.playing || app.engine.session().tracks.iter().any(|t| t.mixer.input_monitor))
|
||||
.map(|p| p.meters());
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 1.0;
|
||||
@@ -221,12 +225,12 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
|
||||
let out_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 33.0), vec2(inner_w - 4.0, 16.0));
|
||||
let input =
|
||||
if track.kind.has_playlist() && track.mixer.input == Route::None { "In 1".to_string() } else { route_name(app, &track.mixer.input) };
|
||||
let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-input", id.0)).max_rect(in_r));
|
||||
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(in_r));
|
||||
let resp = selector_box(&mut c, in_r.width(), in_r.height(), &input, t.text);
|
||||
egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, true));
|
||||
let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-output", id.0)).max_rect(out_r));
|
||||
egui::Popup::menu(&resp).show(|ui| route_menu(app, ui, id, true));
|
||||
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(out_r));
|
||||
let resp = selector_box(&mut c, out_r.width(), out_r.height(), &route_name(app, &track.mixer.output), t.text);
|
||||
egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, false));
|
||||
egui::Popup::menu(&resp).show(|ui| route_menu(app, ui, id, false));
|
||||
// Automation mode.
|
||||
let am_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 51.0), vec2(inner_w - 4.0, 16.0));
|
||||
ui.painter().text(pos2(am_r.center().x, am_r.min.y - 1.0), Align2::CENTER_BOTTOM, "", regular(9.0), t.text_dim);
|
||||
@@ -303,10 +307,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
|
||||
let _ = resp;
|
||||
let pr = Rect::from_center_size(pos2(cx, y + ks + 8.0), vec2(ks + 8.0, 13.0));
|
||||
ui.painter().rect_filled(pr, 1.0, t.counter_bg);
|
||||
let (_, entered) = editable_readout(ui, pr, ui.id().with(("pan-value", id.0, i)), &pan_text(v), &format!("{:.0}", v * 100.0), parse_pan);
|
||||
if let Some(v) = entered {
|
||||
let _ = app.engine.execute_merged("mix.pan", &json!({"track": id.0, "pan": v, "index": i}), &format!("pan:{}:{i}", id.0));
|
||||
}
|
||||
ui.painter().text(pr.center(), Align2::CENTER_CENTER, pan_text(v), regular(10.0), t.counter_text);
|
||||
}
|
||||
y += ks + 20.0;
|
||||
}
|
||||
@@ -371,17 +372,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
|
||||
// Volume readout and name.
|
||||
let vr = Rect::from_min_size(pos2(x0 + 4.0, r.max.y - bottom_h + 4.0), vec2(inner_w - 8.0, 15.0));
|
||||
ui.painter().rect_filled(vr, 1.0, t.counter_bg);
|
||||
let (_, entered) = editable_readout(
|
||||
ui,
|
||||
vr,
|
||||
ui.id().with(("volume-value", id.0)),
|
||||
&db_text(track.mixer.volume_db),
|
||||
&format!("{:.1}", track.mixer.volume_db),
|
||||
parse_db,
|
||||
);
|
||||
if let Some(db) = entered {
|
||||
let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &format!("fader:{}", id.0));
|
||||
}
|
||||
ui.painter().text(vr.center(), Align2::CENTER_CENTER, db_text(track.mixer.volume_db), regular(11.0), t.counter_text);
|
||||
let kind = match track.kind {
|
||||
TrackKind::Audio => "audio",
|
||||
TrackKind::Aux => "aux",
|
||||
@@ -404,7 +395,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
|
||||
ui.painter().with_clip_rect(nr).text(nr.center(), Align2::CENTER_CENTER, &track.name, bold(12.0), t.text_dark);
|
||||
let nresp = ui.interact(nr, ui.id().with(("strip_name", id.0)), Sense::click());
|
||||
if nresp.clicked() {
|
||||
crate::edit_window::click_track(app, id, ui.input(|i| i.modifiers), |_, t| !t.hidden && t.kind != TrackKind::Video);
|
||||
let _ = app.run("edit.select", json!({"tracks": [id.0]}));
|
||||
}
|
||||
if nresp.double_clicked() {
|
||||
app.dialogs.open_rename_track(id, &track.name);
|
||||
@@ -423,12 +414,12 @@ fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {
|
||||
let ins = track.instrument.as_ref();
|
||||
let info = ins.and_then(|i| plugin_info(&i.plugin));
|
||||
let resp = ui.interact(r, ui.id().with(("instrument", id.0)), Sense::click());
|
||||
let fill = if info.is_some() { t.insert_on } else { t.slot_bg };
|
||||
let fill = if info.is_some() { Color32::from_rgb(52, 62, 80) } else { t.slot_bg };
|
||||
ui.painter().rect(
|
||||
r,
|
||||
CornerRadius::same(2),
|
||||
if resp.hovered() { fill.gamma_multiply(1.3) } else { fill },
|
||||
Stroke::new(1.0, t.slot_border),
|
||||
Stroke::new(1.0, Color32::from_rgb(16, 16, 16)),
|
||||
StrokeKind::Inside,
|
||||
);
|
||||
ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, info.map_or("no instrument", |p| p.short_name), regular(10.5), t.text);
|
||||
@@ -620,11 +611,9 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec
|
||||
let t = Tokens::current();
|
||||
let id = track.id;
|
||||
let snd = track.mixer.sends.get(slot).cloned().flatten();
|
||||
let resp = ui.interact(r, ui.id().with(("snd", id.0, slot)), Sense::click_and_drag());
|
||||
let fill = if snd.is_some() { t.send_on } else { t.slot_bg };
|
||||
ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.slot_border), StrokeKind::Inside);
|
||||
let level_r = Rect::from_min_max(pos2((r.max.x - 42.0).max(r.min.x), r.min.y), r.max);
|
||||
let route_r = Rect::from_min_max(r.min, pos2((level_r.min.x - 1.0).max(r.min.x), r.max.y));
|
||||
let route_resp = ui.interact(if snd.is_some() { route_r } else { r }, ui.id().with(("snd-route", id.0, slot)), Sense::click());
|
||||
match &snd {
|
||||
Some(s) => {
|
||||
let name = route_name(app, &s.target);
|
||||
@@ -635,257 +624,44 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec
|
||||
0.0,
|
||||
t.counter_text,
|
||||
);
|
||||
ui.painter().with_clip_rect(route_r).text(
|
||||
pos2(r.min.x + 4.0, r.center().y - 1.0),
|
||||
Align2::LEFT_CENTER,
|
||||
ui.painter().with_clip_rect(r).text(
|
||||
r.center() - vec2(0.0, 1.0),
|
||||
Align2::CENTER_CENTER,
|
||||
name,
|
||||
regular(9.0),
|
||||
regular(10.0),
|
||||
if s.mute { t.mute } else { t.text },
|
||||
);
|
||||
let (level_resp, entered) = editable_readout(
|
||||
ui,
|
||||
level_r,
|
||||
ui.id().with(("send-value", id.0, slot)),
|
||||
&format!("{} dB", db_text(s.level_db)),
|
||||
&format!("{:.1}", s.level_db),
|
||||
parse_db,
|
||||
);
|
||||
if let Some(db) = entered {
|
||||
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db}));
|
||||
}
|
||||
if level_resp.as_ref().is_some_and(egui::Response::dragged) {
|
||||
// Use this frame's pointer movement. `Response::drag_delta()` is cumulative, so
|
||||
// applying it to the already-updated value made trackpad adjustment accelerate.
|
||||
let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift));
|
||||
let db_per_point = if fine { 0.02 } else { 0.15 };
|
||||
let db = (s.level_db.max(-60.0) - dy * db_per_point).clamp(-60.0, 12.0);
|
||||
if resp.dragged() {
|
||||
let db = (s.level_db.max(-60.0) - resp.drag_delta().y * 0.3).clamp(-144.0, 12.0);
|
||||
let _ =
|
||||
app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0));
|
||||
}
|
||||
if level_resp.as_ref().is_some_and(egui::Response::double_clicked) {
|
||||
if resp.double_clicked() {
|
||||
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0}));
|
||||
}
|
||||
if let Some(resp) = level_resp {
|
||||
let _ = resp.clone().on_hover_text(format!(
|
||||
"Send {}: {} dB\nClick to type · drag vertically to adjust · Shift-drag for fine control · double-click for 0 dB · right-click for options",
|
||||
(b'A' + slot as u8) as char,
|
||||
db_text(s.level_db)
|
||||
));
|
||||
resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s));
|
||||
}
|
||||
let _ = resp.clone().on_hover_text(format!("Send {}: {} dB (drag to change)", (b'A' + slot as u8) as char, db_text(s.level_db)));
|
||||
}
|
||||
None => {
|
||||
ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim);
|
||||
}
|
||||
}
|
||||
if let Some(send) = &snd {
|
||||
egui::Popup::menu(&route_resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
ui.set_min_width(250.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.strong(format!("Send {}", (b'A' + slot as u8) as char));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(route_name(app, &send.target)).color(Tokens::current().counter_text));
|
||||
});
|
||||
});
|
||||
ui.label(egui::RichText::new("DESTINATION").size(8.0).color(Tokens::current().text_dim));
|
||||
send_route_menu(app, ui, id, slot, Some(&send.target));
|
||||
ui.separator();
|
||||
if ui.button("Remove send").clicked() {
|
||||
let _ = app.run("mix.send_remove", json!({"track": id.0, "slot": slot}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
egui::Popup::menu(&route_resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
ui.set_min_width(250.0);
|
||||
ui.strong(format!("Send {} · Choose destination", (b'A' + slot as u8) as char));
|
||||
send_route_menu(app, ui, id, slot, None);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Secondary controls live behind the amount readout, separate from the bus selector like Logic.
|
||||
fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, send: &soundcraft_model::SendSlot) {
|
||||
ui.set_min_width(230.0);
|
||||
ui.strong(format!("Send {} · {}", (b'A' + slot as u8) as char, route_name(app, &send.target)));
|
||||
let mut db = send.level_db.max(-60.0);
|
||||
if ui.add(egui::Slider::new(&mut db, -60.0..=12.0).text("Level").suffix(" dB")).changed() {
|
||||
let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0));
|
||||
}
|
||||
send_level_scale(ui, send.level_db);
|
||||
ui.horizontal(|ui| {
|
||||
for (label, delta) in [("−3", -3.0), ("−1", -1.0), ("+1", 1.0), ("+3", 3.0)] {
|
||||
if ui.small_button(label).clicked() {
|
||||
let db = (send.level_db.max(-60.0) + delta).clamp(-60.0, 12.0);
|
||||
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db}));
|
||||
let popup = if snd.is_none() { egui::Popup::menu(&resp) } else { egui::Popup::context_menu(&resp) };
|
||||
popup.show(|ui| {
|
||||
if snd.is_some() && ui.button("no send").clicked() {
|
||||
let _ = app.run("mix.send_remove", json!({"track": id.0, "slot": slot}));
|
||||
}
|
||||
let busses: Vec<String> = app.engine.session().busses.iter().map(|b| b.name.clone()).collect();
|
||||
for b in busses {
|
||||
if ui.button(&b).clicked() {
|
||||
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": b}));
|
||||
}
|
||||
}
|
||||
if ui.small_button("0 dB").clicked() {
|
||||
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0}));
|
||||
}
|
||||
if ui.small_button("−∞").clicked() {
|
||||
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": -144.0}));
|
||||
ui.separator();
|
||||
if ui.button("new bus...").clicked() {
|
||||
let n = app.engine.session().busses.len() + 1;
|
||||
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")}));
|
||||
}
|
||||
});
|
||||
let mut pan = send.pan;
|
||||
if ui.add(egui::Slider::new(&mut pan, -1.0..=1.0).text("Pan")).changed() {
|
||||
let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "pan": pan}), &format!("send-pan:{}:{slot}", id.0));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let mut mute = send.mute;
|
||||
if ui.checkbox(&mut mute, "Mute").changed() {
|
||||
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "mute": mute}));
|
||||
}
|
||||
let mut pre = send.pre_fader;
|
||||
if ui.checkbox(&mut pre, "Pre-fader").changed() {
|
||||
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "pre_fader": pre}));
|
||||
}
|
||||
});
|
||||
if let Route::Bus(bus) = send.target
|
||||
&& let Some(aux) =
|
||||
app.engine.session().tracks.iter().find(|t| t.kind == TrackKind::Aux && t.mixer.input == Route::Bus(bus)).map(|t| (t.id, t.name.clone()))
|
||||
&& ui.button("Rename bus…").clicked()
|
||||
{
|
||||
app.dialogs.open_rename_track(aux.0, &aux.1);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint a value until clicked, then replace it with a focused text field. Enter or clicking away
|
||||
/// commits a valid value; Escape cancels. The interaction response remains available for dragging.
|
||||
fn editable_readout(
|
||||
ui: &mut Ui,
|
||||
r: Rect,
|
||||
id: egui::Id,
|
||||
display: &str,
|
||||
edit_initial: &str,
|
||||
parse: fn(&str) -> Option<f32>,
|
||||
) -> (Option<egui::Response>, Option<f32>) {
|
||||
let t = Tokens::current();
|
||||
let active_id = id.with("active");
|
||||
let text_id = id.with("text");
|
||||
let mut active: bool = ui.data(|d| d.get_temp(active_id)).unwrap_or(false);
|
||||
let mut started = false;
|
||||
let mut response = None;
|
||||
if !active {
|
||||
let resp = ui.interact(r, id, Sense::click_and_drag());
|
||||
if resp.clicked() && !resp.double_clicked() {
|
||||
active = true;
|
||||
started = true;
|
||||
ui.data_mut(|d| d.insert_temp(text_id, edit_initial.to_string()));
|
||||
} else {
|
||||
ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, display, regular(9.0), t.counter_text);
|
||||
}
|
||||
response = Some(resp);
|
||||
}
|
||||
let mut value = None;
|
||||
if active {
|
||||
let mut text: String = ui.data(|d| d.get_temp(text_id)).unwrap_or_else(|| edit_initial.to_string());
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(r));
|
||||
child.visuals_mut().override_text_color = Some(t.counter_text);
|
||||
let edit = egui::TextEdit::singleline(&mut text)
|
||||
.id(id.with("editor"))
|
||||
.frame(egui::Frame::NONE)
|
||||
.horizontal_align(egui::Align::Center)
|
||||
.font(regular(9.0))
|
||||
.desired_width(r.width());
|
||||
let edit_resp = child.add_sized(r.size(), edit);
|
||||
if started {
|
||||
edit_resp.request_focus();
|
||||
}
|
||||
let (enter, escape) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
|
||||
if escape {
|
||||
active = false;
|
||||
} else if enter || edit_resp.lost_focus() {
|
||||
if let Some(parsed) = parse(&text) {
|
||||
value = Some(parsed);
|
||||
active = false;
|
||||
} else if edit_resp.lost_focus() {
|
||||
active = false;
|
||||
}
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(text_id, text));
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(active_id, active));
|
||||
(response, value)
|
||||
}
|
||||
|
||||
fn parse_db(text: &str) -> Option<f32> {
|
||||
let cleaned = text.trim().to_ascii_lowercase().replace("db", "").replace(',', ".");
|
||||
if matches!(cleaned.trim(), "-inf" | "-infinity" | "off") {
|
||||
return Some(-144.0);
|
||||
}
|
||||
cleaned.trim().parse::<f32>().ok().filter(|v| v.is_finite()).map(|v| v.clamp(-144.0, 12.0))
|
||||
}
|
||||
|
||||
fn parse_pan(text: &str) -> Option<f32> {
|
||||
let cleaned = text.trim().to_ascii_lowercase().replace(',', ".");
|
||||
let (sign, number) = if let Some(v) = cleaned.strip_prefix('<').or_else(|| cleaned.strip_prefix('l')) {
|
||||
(-1.0, v)
|
||||
} else if let Some(v) = cleaned.strip_suffix('>').or_else(|| cleaned.strip_prefix('r')) {
|
||||
(1.0, v)
|
||||
} else {
|
||||
(1.0, cleaned.as_str())
|
||||
};
|
||||
let raw = number.trim().parse::<f32>().ok()? * sign;
|
||||
let value = if number.contains('.') && raw.abs() <= 1.0 { raw } else { raw / 100.0 };
|
||||
value.is_finite().then_some(value.clamp(-1.0, 1.0))
|
||||
}
|
||||
|
||||
/// A compact calibrated scale for a send. The marks use the same taper as the strip's level bar.
|
||||
fn send_level_scale(ui: &mut Ui, db: f32) {
|
||||
let t = Tokens::current();
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 25.0), Sense::hover());
|
||||
let line_y = r.min.y + 4.0;
|
||||
ui.painter().line_segment([pos2(r.min.x, line_y), pos2(r.max.x, line_y)], Stroke::new(1.0, t.slot_border));
|
||||
for (i, mark) in [-60.0f32, -24.0, -10.0, 0.0, 6.0, 12.0].into_iter().enumerate() {
|
||||
let x = r.min.x + r.width() * soundcraft_model::fader_db_to_pos(mark);
|
||||
ui.painter().line_segment([pos2(x, line_y - 3.0), pos2(x, line_y + 3.0)], Stroke::new(1.0, t.text_dim));
|
||||
let align = if i == 0 {
|
||||
Align2::LEFT_TOP
|
||||
} else if i == 5 {
|
||||
Align2::RIGHT_TOP
|
||||
} else {
|
||||
Align2::CENTER_TOP
|
||||
};
|
||||
let label = if mark > 0.0 { format!("+{mark:.0}") } else { format!("{mark:.0}") };
|
||||
ui.painter().text(pos2(x, line_y + 5.0), align, label, regular(8.0), t.text_dim);
|
||||
}
|
||||
let x = r.min.x + r.width() * soundcraft_model::fader_db_to_pos(db);
|
||||
ui.painter().line_segment([pos2(x, r.min.y), pos2(x, line_y + 3.0)], Stroke::new(2.0, t.counter_text));
|
||||
}
|
||||
|
||||
fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, current: Option<&Route>) {
|
||||
let busses = app.engine.session().busses.clone();
|
||||
let search_id = egui::Id::new(("send-bus-search", id.0, slot));
|
||||
let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default();
|
||||
ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search buses…"));
|
||||
ui.data_mut(|d| d.insert_temp(search_id, search.clone()));
|
||||
let needle = search.trim().to_ascii_lowercase();
|
||||
let visible: Vec<_> = busses.iter().filter(|bus| needle.is_empty() || bus.name.to_ascii_lowercase().contains(&needle)).collect();
|
||||
egui::ScrollArea::vertical().id_salt(("send-route-busses", id.0, slot)).max_height(260.0).auto_shrink([false, true]).show_rows(
|
||||
ui,
|
||||
22.0,
|
||||
visible.len(),
|
||||
|ui, rows| {
|
||||
for bus in &visible[rows] {
|
||||
let selected = matches!(current, Some(Route::Bus(bus_id)) if *bus_id == bus.id);
|
||||
if ui.selectable_label(selected, &bus.name).clicked() {
|
||||
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": bus.name}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.separator();
|
||||
if ui.button("new bus...").clicked() {
|
||||
let mut n = busses.len() + 1;
|
||||
while busses.iter().any(|bus| bus.name == format!("Bus {n}")) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() {
|
||||
n += 1;
|
||||
}
|
||||
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
|
||||
@@ -893,67 +669,29 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
|
||||
let cmd = if input { "track.input" } else { "track.output" };
|
||||
if ui.button(if input { "no input" } else { "no output" }).clicked() {
|
||||
let _ = app.run(cmd, json!({"track": id.0, key: "none"}));
|
||||
ui.close();
|
||||
}
|
||||
let main_name = app.engine.session().outputs.first().map_or_else(|| "Out 1-2".to_string(), |o| o.name.clone());
|
||||
if !input && ui.button(main_name).clicked() {
|
||||
let _ = app.run(cmd, json!({"track": id.0, key: "main"}));
|
||||
ui.close();
|
||||
}
|
||||
if input {
|
||||
for i in 1..=8 {
|
||||
if ui.button(format!("In {i}")).clicked() {
|
||||
let _ = app.run(cmd, json!({"track": id.0, key: format!("In {i}")}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let busses: Vec<String> = app.engine.session().busses.iter().map(|b| b.name.clone()).collect();
|
||||
ui.strong("Bus");
|
||||
let search_id = egui::Id::new(("route-bus-search", id.0, input));
|
||||
let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default();
|
||||
ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search buses…"));
|
||||
ui.data_mut(|d| d.insert_temp(search_id, search.clone()));
|
||||
let needle = search.trim().to_ascii_lowercase();
|
||||
egui::ScrollArea::vertical().id_salt(("track-route-busses", id.0, input)).max_height(420.0).show(ui, |ui| {
|
||||
for b in busses.iter().filter(|b| needle.is_empty() || b.to_ascii_lowercase().contains(&needle)) {
|
||||
ui.menu_button("bus", |ui| {
|
||||
for b in &busses {
|
||||
if ui.button(b).clicked() {
|
||||
let _ = app.run(cmd, json!({"track": id.0, key: b}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
if ui.button("new bus...").clicked() {
|
||||
let mut n = busses.len() + 1;
|
||||
while busses.contains(&format!("Bus {n}")) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() {
|
||||
n += 1;
|
||||
if ui.button("new bus...").clicked() {
|
||||
let n = busses.len() + 1;
|
||||
let _ = app.run(cmd, json!({"track": id.0, key: format!("Bus {n}")}));
|
||||
}
|
||||
let _ = app.run(cmd, json!({"track": id.0, key: format!("Bus {n}")}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_db, parse_pan};
|
||||
|
||||
#[test]
|
||||
fn typed_db_accepts_console_notation_and_clamps() {
|
||||
assert_eq!(parse_db("-6 dB"), Some(-6.0));
|
||||
assert_eq!(parse_db("+3,5"), Some(3.5));
|
||||
assert_eq!(parse_db("off"), Some(-144.0));
|
||||
assert_eq!(parse_db("99"), Some(12.0));
|
||||
assert_eq!(parse_db("loud"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_pan_accepts_percent_console_and_fractional_values() {
|
||||
assert_eq!(parse_pan("-50"), Some(-0.5));
|
||||
assert_eq!(parse_pan("<35"), Some(-0.35));
|
||||
assert_eq!(parse_pan("35>"), Some(0.35));
|
||||
assert_eq!(parse_pan("R25"), Some(0.25));
|
||||
assert_eq!(parse_pan("0.5"), Some(0.5));
|
||||
assert_eq!(parse_pan("center"), None);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ use crate::SoundApp;
|
||||
use crate::theme::{Tokens, bold, mono, regular, rgb};
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{ClipId, SourceId};
|
||||
use soundcraft_time::format_position;
|
||||
|
||||
fn panel_header(ui: &mut Ui, title: &str) {
|
||||
@@ -38,7 +37,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
|
||||
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 20.0, r.min.y + 3.0), vec2(4.0, 11.0)), 0.0, rgb(color));
|
||||
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), if shown { t.text } else { t.text_dim });
|
||||
if resp.clicked() {
|
||||
crate::edit_window::click_track(app, soundcraft_model::TrackId(id), ui.input(|i| i.modifiers), |_, _| true);
|
||||
let _ = app.run("edit.select", json!({"tracks": [id]}));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -101,58 +100,51 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
|
||||
pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let t = Tokens::current();
|
||||
panel_header(ui, "CLIPS");
|
||||
let items: Vec<(u64, String, bool, [u8; 3], bool)> = {
|
||||
let items: Vec<(Option<u64>, String, bool, [u8; 3])> = {
|
||||
let s = app.engine.session();
|
||||
let mut v = Vec::new();
|
||||
let mut v: Vec<(Option<u64>, String, bool, [u8; 3])> = Vec::new();
|
||||
// Whole files first (bold), then clips on tracks.
|
||||
for src in &s.sources {
|
||||
v.push((src.id.0, format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150], s.edit.selected_sources.contains(&src.id)));
|
||||
v.push((Some(src.id.0), format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150]));
|
||||
}
|
||||
for tr in &s.tracks {
|
||||
for c in tr.clips() {
|
||||
v.push((c.id.0, c.name.clone(), false, c.color.unwrap_or(tr.color), s.edit.selected_clips.contains(&c.id)));
|
||||
v.push((Some(c.id.0), c.name.clone(), false, c.color.unwrap_or(tr.color)));
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
let selected: Vec<u64> = app.engine.session().edit.selected_clips.iter().map(|c| c.0).collect();
|
||||
let fkey = egui::Id::new("clip_filter");
|
||||
let mut filter: String = ui.ctx().memory(|m| m.data.get_temp(fkey)).unwrap_or_default();
|
||||
ui.add(egui::TextEdit::singleline(&mut filter).hint_text("🔍 Find clips").desired_width(f32::INFINITY));
|
||||
ui.ctx().memory_mut(|m| m.data.insert_temp(fkey, filter.clone()));
|
||||
let needle = filter.to_lowercase();
|
||||
let items: Vec<_> = items.into_iter().filter(|(_, n, _, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect();
|
||||
let items: Vec<_> = items.into_iter().filter(|(_, n, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect();
|
||||
egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show(ui, |ui| {
|
||||
for (id, name, whole, color, selected) in items {
|
||||
for (id, name, whole, color) in items {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), if whole { Sense::click_and_drag() } else { Sense::click() });
|
||||
resp.context_menu(|ui| {
|
||||
if whole {
|
||||
if whole && let Some(src) = id {
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button("Place on New Track").clicked() {
|
||||
let _ = app.run("clip.place_source", json!({"source": id}));
|
||||
let _ = app.run("clip.place_source", json!({"source": src}));
|
||||
ui.close();
|
||||
}
|
||||
let sel_track = app.engine.session().edit.selected_tracks.first().map(|t| t.0);
|
||||
if let Some(t) = sel_track
|
||||
&& ui.button("Place on Selected Track").clicked()
|
||||
{
|
||||
let _ = app.run("clip.place_source", json!({"source": id, "track": t}));
|
||||
let _ = app.run("clip.place_source", json!({"source": src, "track": t}));
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
}
|
||||
if ui.button("Clear…").clicked() {
|
||||
let (clips, sources) = if whole { (vec![], vec![SourceId(id)]) } else { (vec![ClipId(id)], vec![]) };
|
||||
app.dialogs.open_clear(&app.engine, clips, sources);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if whole {
|
||||
});
|
||||
// Whole files drag onto tracks.
|
||||
resp.dnd_set_drag_payload(crate::DragSource(id));
|
||||
resp.dnd_set_drag_payload(crate::DragSource(src));
|
||||
if resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
}
|
||||
}
|
||||
if selected {
|
||||
if !whole && id.is_some_and(|i| selected.contains(&i)) {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
}
|
||||
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(9.0, 9.0)), 1.0, rgb(color));
|
||||
@@ -163,8 +155,11 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
|
||||
if whole { bold(11.0) } else { regular(11.0) },
|
||||
t.text,
|
||||
);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("edit.select", if whole { json!({"sources": [id]}) } else { json!({"clips": [id]}) });
|
||||
if resp.clicked()
|
||||
&& !whole
|
||||
&& let Some(i) = id
|
||||
{
|
||||
let _ = app.run("edit.select", json!({"clips": [i]}));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -412,46 +407,44 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
if info.id == "eq_7band" || info.id == "eq_1band" {
|
||||
eq_curve(ui, info.id, &ins);
|
||||
}
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
egui::Grid::new(("pg", tid.0, slot)).num_columns(3).show(ui, |ui| {
|
||||
for p in info.params {
|
||||
let v = ins.params.get(p.id).copied().unwrap_or(p.default);
|
||||
ui.label(p.name);
|
||||
let mut x = v;
|
||||
let resp = if !p.choices.is_empty() {
|
||||
let mut idx = x.round().max(0.0) as usize;
|
||||
let r = egui::ComboBox::from_id_salt(("pc", tid.0, slot, p.id))
|
||||
.selected_text(p.choices.get(idx).copied().unwrap_or("?"))
|
||||
.show_ui(ui, |ui| {
|
||||
for (i, c) in p.choices.iter().enumerate() {
|
||||
ui.selectable_value(&mut idx, i, *c);
|
||||
}
|
||||
});
|
||||
x = idx as f32;
|
||||
r.response
|
||||
} else {
|
||||
let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false);
|
||||
if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 {
|
||||
sl = sl.logarithmic(true);
|
||||
}
|
||||
ui.add(sl)
|
||||
};
|
||||
if (x - v).abs() > f32::EPSILON {
|
||||
let _ = app.engine.execute_merged(
|
||||
"mix.insert_param",
|
||||
&json!({"track": tid.0, "slot": slot, "param": p.id, "value": x}),
|
||||
&format!("param:{}:{slot}:{}", tid.0, p.id),
|
||||
);
|
||||
if let Some(pl) = &app.player {
|
||||
// Keep the plugin's own editor in step.
|
||||
pl.editor_set_param(tid, slot, p.id, x);
|
||||
}
|
||||
egui::Grid::new(("pg", tid.0, slot)).num_columns(3).show(ui, |ui| {
|
||||
for p in info.params {
|
||||
let v = ins.params.get(p.id).copied().unwrap_or(p.default);
|
||||
ui.label(p.name);
|
||||
let mut x = v;
|
||||
let resp = if !p.choices.is_empty() {
|
||||
let mut idx = x.round().max(0.0) as usize;
|
||||
let r = egui::ComboBox::from_id_salt(("pc", tid.0, slot, p.id))
|
||||
.selected_text(p.choices.get(idx).copied().unwrap_or("?"))
|
||||
.show_ui(ui, |ui| {
|
||||
for (i, c) in p.choices.iter().enumerate() {
|
||||
ui.selectable_value(&mut idx, i, *c);
|
||||
}
|
||||
});
|
||||
x = idx as f32;
|
||||
r.response
|
||||
} else {
|
||||
let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false);
|
||||
if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 {
|
||||
sl = sl.logarithmic(true);
|
||||
}
|
||||
ui.add(sl)
|
||||
};
|
||||
if (x - v).abs() > f32::EPSILON {
|
||||
let _ = app.engine.execute_merged(
|
||||
"mix.insert_param",
|
||||
&json!({"track": tid.0, "slot": slot, "param": p.id, "value": x}),
|
||||
&format!("param:{}:{slot}:{}", tid.0, p.id),
|
||||
);
|
||||
if let Some(pl) = &app.player {
|
||||
// Keep the plugin's own editor in step.
|
||||
pl.editor_set_param(tid, slot, p.id, x);
|
||||
}
|
||||
let _ = resp;
|
||||
ui.label(egui::RichText::new(p.format(x)).font(mono(11.0)));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
let _ = resp;
|
||||
ui.label(egui::RichText::new(p.format(x)).font(mono(11.0)));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
if open {
|
||||
@@ -508,10 +501,9 @@ fn about(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
_ => {
|
||||
ui.label(egui::RichText::new("SoundCraft").font(bold(22.0)));
|
||||
ui.label(format!("Version {}", env!("CARGO_PKG_VERSION")));
|
||||
ui.label("An open-source digital audio workstation from the ArtCraft team.");
|
||||
ui.label("An open-source digital audio workstation from the storytold team.");
|
||||
ui.label("Dual-licensed MIT OR Apache-2.0. Made with Rust and egui.");
|
||||
ui.hyperlink_to("getartcraft.com/apps/soundcraft", "https://getartcraft.com/apps/soundcraft");
|
||||
ui.hyperlink_to("Join us on Discord", "https://discord.gg/artcraft");
|
||||
ui.hyperlink_to("github.com/storytold/soundcraft", "https://github.com/storytold/soundcraft");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -55,8 +55,6 @@ fn search_chord(m: Modifiers, mac: bool) -> bool {
|
||||
}
|
||||
|
||||
pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
// Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom.
|
||||
ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
||||
if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() {
|
||||
return;
|
||||
}
|
||||
@@ -75,26 +73,18 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
if fixed(app, key, mods, mac) {
|
||||
continue;
|
||||
}
|
||||
if let Some(id) = command_for(key, mods, mac) {
|
||||
let hit =
|
||||
soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac)));
|
||||
if let Some(c) = hit {
|
||||
let id = c.id;
|
||||
if crate::menus::invoke_shortcut_dialog(app, id) {
|
||||
continue;
|
||||
}
|
||||
let _ = app.run(id, json!({}));
|
||||
} else if let Some(c) =
|
||||
crate::menus::UI_COMMANDS.iter().find(|c| c.3.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac)))
|
||||
{
|
||||
let _ = app.run(c.0, json!({}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn command_for(key: Key, mods: Modifiers, mac: bool) -> Option<&'static str> {
|
||||
soundcraft_engine::command_specs()
|
||||
.iter()
|
||||
.find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac)))
|
||||
.map(|c| c.id)
|
||||
}
|
||||
|
||||
/// Keys with context-dependent meaning. Returns true when handled.
|
||||
fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
|
||||
let plain = !m.command && !m.alt && !m.ctrl && !m.shift;
|
||||
@@ -112,7 +102,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
|
||||
let _ = app.run("window.search", json!({}));
|
||||
true
|
||||
}
|
||||
Key::Equals if m.command && !(mac && m.ctrl) => {
|
||||
Key::Equals if m.command => {
|
||||
let _ = app.run("window.toggle_mix_edit", json!({}));
|
||||
true
|
||||
}
|
||||
@@ -292,8 +282,8 @@ mod label_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{command_for, mods_match, parse, search_chord};
|
||||
use egui::{Key, Modifiers};
|
||||
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 };
|
||||
@@ -323,11 +313,4 @@ mod tests {
|
||||
assert!(!search_chord(MAC_CMD, true));
|
||||
assert!(search_chord(Modifiers { ctrl: true, ..MAC_CMD }, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_cmd_k_prompts_for_a_folder_to_export_clips() {
|
||||
assert_eq!(command_for(Key::K, Modifiers { shift: true, ..MAC_CMD }, true), Some("file.export_clips"));
|
||||
assert_eq!(command_for(Key::K, Modifiers { shift: true, ..PC_CTRL }, false), Some("file.export_clips"));
|
||||
assert!(crate::dialogs::takes_path("file.export_clips"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Design tokens. Colours were chosen by eye to give a dark, studio-style look; they are ours.
|
||||
|
||||
use craft_ui::theme::catppuccin::Flavor;
|
||||
use egui::{Color32, FontFamily, FontId};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
/// Whether the Light palette is active. Dark is the default (the incumbent is dark-only).
|
||||
static LIGHT_THEME: AtomicBool = AtomicBool::new(false);
|
||||
/// Index into `ThemeMode::ALL` of the active palette. Dark (0) is the default (the incumbent is dark-only).
|
||||
static ACTIVE_THEME: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
/// Unit tests that change the process-wide palette must own it until their assertions finish.
|
||||
#[cfg(test)]
|
||||
@@ -13,7 +14,7 @@ pub(crate) fn lock_test_palette() -> std::sync::MutexGuard<'static, ()> {
|
||||
PALETTE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The Appearance setting (`ui.theme {mode}`). Dark by default; System and Light are opt-in.
|
||||
/// The Appearance setting (`ui.theme {mode}`). Dark by default; System, Light and the Catppuccin flavours are opt-in.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ThemeMode {
|
||||
@@ -21,16 +22,45 @@ pub enum ThemeMode {
|
||||
Light,
|
||||
#[default]
|
||||
Dark,
|
||||
#[serde(rename = "catppuccinLatte")]
|
||||
CatppuccinLatte,
|
||||
#[serde(rename = "catppuccinFrappe")]
|
||||
CatppuccinFrappe,
|
||||
#[serde(rename = "catppuccinMacchiato")]
|
||||
CatppuccinMacchiato,
|
||||
#[serde(rename = "catppuccinMocha")]
|
||||
CatppuccinMocha,
|
||||
}
|
||||
|
||||
impl ThemeMode {
|
||||
pub const ALL: [Self; 3] = [Self::Dark, Self::Light, Self::System];
|
||||
pub const ALL: [Self; 7] =
|
||||
[Self::Dark, Self::Light, Self::System, Self::CatppuccinLatte, Self::CatppuccinFrappe, Self::CatppuccinMacchiato, Self::CatppuccinMocha];
|
||||
|
||||
/// The Catppuccin flavour behind this mode, if it is one.
|
||||
pub const fn flavor(self) -> Option<Flavor> {
|
||||
match self {
|
||||
Self::CatppuccinLatte => Some(Flavor::Latte),
|
||||
Self::CatppuccinFrappe => Some(Flavor::Frappe),
|
||||
Self::CatppuccinMacchiato => Some(Flavor::Macchiato),
|
||||
Self::CatppuccinMocha => Some(Flavor::Mocha),
|
||||
Self::System | Self::Light | Self::Dark => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every id, `|`-separated: for error messages.
|
||||
pub fn id_list() -> String {
|
||||
Self::ALL.map(Self::id).join("|")
|
||||
}
|
||||
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "System",
|
||||
Self::Light => "Light",
|
||||
Self::Dark => "Dark",
|
||||
Self::CatppuccinLatte => "Catppuccin Latte",
|
||||
Self::CatppuccinFrappe => "Catppuccin Frappé",
|
||||
Self::CatppuccinMacchiato => "Catppuccin Macchiato",
|
||||
Self::CatppuccinMocha => "Catppuccin Mocha",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +70,10 @@ impl ThemeMode {
|
||||
Self::System => "system",
|
||||
Self::Light => "light",
|
||||
Self::Dark => "dark",
|
||||
Self::CatppuccinLatte => "catppuccinLatte",
|
||||
Self::CatppuccinFrappe => "catppuccinFrappe",
|
||||
Self::CatppuccinMacchiato => "catppuccinMacchiato",
|
||||
Self::CatppuccinMocha => "catppuccinMocha",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,21 +360,126 @@ impl Tokens {
|
||||
};
|
||||
|
||||
pub fn current() -> Tokens {
|
||||
if is_light() { Self::LIGHT } else { Self::DARK }
|
||||
Self::for_mode(active_mode())
|
||||
}
|
||||
|
||||
/// The tokens of a resolved mode (`System` reads as Dark: resolve it with `resolve` first).
|
||||
pub fn for_mode(mode: ThemeMode) -> Tokens {
|
||||
match mode.flavor() {
|
||||
Some(f) => catppuccin(f),
|
||||
None if mode == ThemeMode::Light => Self::LIGHT,
|
||||
None => Self::DARK,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the Light palette is the active one.
|
||||
/// The palette in use: never `System`.
|
||||
pub fn active_mode() -> ThemeMode {
|
||||
ThemeMode::ALL.get(usize::from(ACTIVE_THEME.load(Ordering::Relaxed))).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether the active palette is a light one (Light or Catppuccin Latte).
|
||||
pub fn is_light() -> bool {
|
||||
LIGHT_THEME.load(Ordering::Relaxed)
|
||||
let mode = active_mode();
|
||||
mode.flavor().map_or(mode == ThemeMode::Light, |f| !f.is_dark())
|
||||
}
|
||||
|
||||
/// Resolve each frame from the integration's current OS appearance. Unknown uses Dark.
|
||||
pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool {
|
||||
pub fn resolve(ctx: &egui::Context, mode: ThemeMode) -> ThemeMode {
|
||||
match mode {
|
||||
ThemeMode::Light => true,
|
||||
ThemeMode::Dark => false,
|
||||
ThemeMode::System => ctx.system_theme() == Some(egui::Theme::Light),
|
||||
ThemeMode::System if ctx.system_theme() == Some(egui::Theme::Light) => ThemeMode::Light,
|
||||
ThemeMode::System => ThemeMode::Dark,
|
||||
_ => mode,
|
||||
}
|
||||
}
|
||||
|
||||
/// A Catppuccin flavour on SoundCraft's token set (accent Mauve, selection Blue).
|
||||
fn catppuccin(f: Flavor) -> Tokens {
|
||||
let p = f.palette();
|
||||
let dark = f.is_dark();
|
||||
let a = |c: Color32, alpha: u8| Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), alpha);
|
||||
let tint = |c: Color32, t: f32| p.base.lerp_to_gamma(c, t);
|
||||
Tokens {
|
||||
window_bg: p.base,
|
||||
panel_bg: p.base,
|
||||
panel_bg2: p.surface0,
|
||||
toolbar_bg: p.mantle,
|
||||
toolbar_group: p.crust,
|
||||
border: if dark { p.crust } else { p.surface1 },
|
||||
border_light: p.surface1,
|
||||
text: p.text,
|
||||
text_dim: p.subtext0,
|
||||
text_dark: if dark { p.crust } else { p.base },
|
||||
header_text: p.subtext1,
|
||||
counter_bg: p.crust,
|
||||
counter_text: p.green,
|
||||
counter_label: p.subtext1,
|
||||
accent: p.mauve,
|
||||
accent_dark: tint(p.mauve, 0.6),
|
||||
mode_on: p.green,
|
||||
mode_on_text: p.crust,
|
||||
mode_off_text: p.green,
|
||||
button: p.surface0,
|
||||
button_hi: p.surface1,
|
||||
button_border: p.surface2,
|
||||
name_field: if dark { p.text } else { p.base },
|
||||
name_field_sel: p.rosewater,
|
||||
ruler_bg: p.mantle,
|
||||
ruler_label_bg: p.mantle,
|
||||
ruler_text: p.subtext0,
|
||||
ruler_tick: p.overlay0,
|
||||
tempo_ruler: tint(p.green, 0.5),
|
||||
meter_ruler: tint(p.sapphire, 0.5),
|
||||
marker_ruler: p.surface0,
|
||||
playlist_bg: p.base,
|
||||
playlist_alt: p.mantle,
|
||||
grid_line: p.surface0,
|
||||
bar_line: p.surface1,
|
||||
selection: a(p.blue, 110),
|
||||
playhead: p.red,
|
||||
insertion: p.text,
|
||||
solo: p.yellow,
|
||||
mute: p.peach,
|
||||
rec: p.red,
|
||||
input: p.green,
|
||||
auto_read: p.green,
|
||||
auto_write: p.red,
|
||||
auto_touch: p.peach,
|
||||
auto_latch: p.mauve,
|
||||
meter_green: p.green,
|
||||
meter_yellow: p.yellow,
|
||||
meter_red: p.red,
|
||||
fader_track: p.crust,
|
||||
strip_bg: p.mantle,
|
||||
strip_section: p.base,
|
||||
slot_bg: p.crust,
|
||||
automation_line: p.text,
|
||||
menu_bar: p.mantle,
|
||||
group_border: if dark { p.crust } else { p.surface1 },
|
||||
mode_off: p.surface0,
|
||||
counter_off: p.surface1,
|
||||
counter_border: p.surface1,
|
||||
counter_rule: p.surface0,
|
||||
tool_bracket_off: p.surface1,
|
||||
row_selected: tint(p.blue, 0.35),
|
||||
visible_dot: p.overlay1,
|
||||
big_counter_bg: p.crust,
|
||||
header_selected: p.surface0,
|
||||
strip_selected: p.surface0,
|
||||
marker_text: p.text,
|
||||
playlist_lane: p.mantle,
|
||||
universe_bg: p.crust,
|
||||
universe_view: p.text,
|
||||
meter_bg: p.crust,
|
||||
meter_clip_off: tint(p.red, 0.3),
|
||||
knob_bg: p.surface0,
|
||||
knob_ring: p.overlay0,
|
||||
knob_pointer: p.text,
|
||||
insert_on: tint(p.blue, 0.3),
|
||||
insert_bypass: tint(p.yellow, 0.3),
|
||||
send_on: tint(p.green, 0.3),
|
||||
slot_border: p.surface1,
|
||||
section_label: p.subtext0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,11 +518,12 @@ pub fn mono(size: f32) -> FontId {
|
||||
FontId::new(size, FontFamily::Monospace)
|
||||
}
|
||||
|
||||
/// Make `light` (or dark) the active palette and apply its visuals. Call only when it changes.
|
||||
pub fn apply(ctx: &egui::Context, light: bool) {
|
||||
LIGHT_THEME.store(light, Ordering::Relaxed);
|
||||
/// Make `mode` (already resolved, not `System`) the active palette and apply its visuals. Call only when it changes.
|
||||
pub fn apply(ctx: &egui::Context, mode: ThemeMode) {
|
||||
let index = ThemeMode::ALL.iter().position(|m| *m == mode).unwrap_or_default();
|
||||
ACTIVE_THEME.store(u8::try_from(index).unwrap_or_default(), Ordering::Relaxed);
|
||||
let t = Tokens::current();
|
||||
let mut v = if light { egui::Visuals::light() } else { egui::Visuals::dark() };
|
||||
let mut v = if is_light() { egui::Visuals::light() } else { egui::Visuals::dark() };
|
||||
v.panel_fill = t.panel_bg;
|
||||
v.window_fill = t.panel_bg2;
|
||||
v.extreme_bg_color = t.slot_bg;
|
||||
@@ -436,6 +576,18 @@ mod tests {
|
||||
assert_eq!(ThemeMode::parse("sepia"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catppuccin_themes_use_the_flavour_palette() {
|
||||
for mode in ThemeMode::ALL {
|
||||
let Some(f) = mode.flavor() else { continue };
|
||||
let (t, p) = (Tokens::for_mode(mode), f.palette());
|
||||
assert_eq!((t.window_bg, t.panel_bg, t.text, t.accent), (p.base, p.base, p.text, p.mauve), "{}", mode.id());
|
||||
}
|
||||
assert_eq!(ThemeMode::ALL.iter().filter(|m| m.flavor().is_some()).count(), Flavor::ALL.len());
|
||||
assert_eq!(ThemeMode::parse("catppuccinMocha"), Some(ThemeMode::CatppuccinMocha));
|
||||
assert_eq!(ThemeMode::id_list().split('|').count(), ThemeMode::ALL.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_command_sets_and_reports_the_mode() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
//! The window's own title bar on Windows and Linux, where the window has no OS decorations
|
||||
//! ([`SoundApp::custom_titlebar`]): the menu bar row is the title bar. Empty space in it
|
||||
//! drags the window and a double-click there maximizes it, caption buttons at the far right
|
||||
//! minimize, maximize/restore and close, and thin invisible zones along the window's edges resize
|
||||
//! it. On macOS the traffic lights sit over the same row ([`MAC_INSET`]) and only the drag works.
|
||||
|
||||
use egui::{CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SoundApp;
|
||||
|
||||
/// One caption button: as wide as the native Windows ones.
|
||||
pub const BUTTON_WIDTH: f32 = 46.0;
|
||||
/// The three caption buttons together.
|
||||
pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH;
|
||||
/// Height of the title row (menu bar) when it carries the caption buttons.
|
||||
pub const HEIGHT: f32 = 32.0;
|
||||
/// Room for the macOS traffic lights at the row's left end.
|
||||
pub const MAC_INSET: f32 = 80.0;
|
||||
/// Resize zone thickness along an edge, and the side of a corner zone.
|
||||
const EDGE: f32 = 6.0;
|
||||
const CORNER: f32 = 12.0;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
enum Caption {
|
||||
Minimize,
|
||||
Maximize,
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Left to right.
|
||||
const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close];
|
||||
|
||||
/// Caption button ids (stable, so tests and agents can find them).
|
||||
fn caption_id(c: Caption) -> Id {
|
||||
Id::new(("titlebar-caption", c as u8))
|
||||
}
|
||||
|
||||
fn maximized(ctx: &egui::Context) -> bool {
|
||||
ctx.input(|i| i.viewport().maximized.unwrap_or(false))
|
||||
}
|
||||
|
||||
fn toggle_maximize(ctx: &egui::Context) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized(ctx)));
|
||||
}
|
||||
|
||||
/// The bar's background: a press-and-drag on empty space moves the window, a double-click toggles
|
||||
/// maximize. Call it before laying out the bar so its widgets, registered later, take the clicks.
|
||||
pub fn drag_area(ui: &Ui, rect: Rect) {
|
||||
let ctx = ui.ctx();
|
||||
let resp = ui.interact(rect, Id::new("titlebar-drag"), Sense::click_and_drag());
|
||||
// egui hands a drag that starts on a click-only widget (a menu title) to the drag-sensing
|
||||
// background below it, so remember whether the press itself landed on bare background.
|
||||
if ctx.input(|i| i.pointer.any_pressed()) {
|
||||
let bare = ctx.viewport(|v| v.hits.click.is_some_and(|w| w.id == resp.id));
|
||||
ctx.data_mut(|d| d.insert_temp(resp.id, bare));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
toggle_maximize(ctx);
|
||||
} else if resp.drag_started_by(PointerButton::Primary) && ctx.data(|d| d.get_temp(resp.id).unwrap_or(false)) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimize, Maximize/Restore and Close at the right end of `bar` (the title row's outer rect),
|
||||
/// full row height, flush with the window's edge so a maximized window's corner hits Close. Close
|
||||
/// runs `app.quit`, which asks about unsaved changes first.
|
||||
pub fn caption_buttons(app: &mut SoundApp, ui: &mut Ui, bar: Rect) {
|
||||
let t = crate::theme::Tokens::current();
|
||||
let max = maximized(ui.ctx());
|
||||
let mut clicked = None;
|
||||
let danger = egui::Color32::from_rgb(218, 54, 51);
|
||||
for (i, c) in CAPTIONS.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(bar.right() - WIDTH + i as f32 * BUTTON_WIDTH, bar.top()), vec2(BUTTON_WIDTH, bar.height() - 1.0));
|
||||
let resp = ui.interact(r, caption_id(c), Sense::click());
|
||||
let close = c == Caption::Close;
|
||||
let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) {
|
||||
(true, _) if close => danger.gamma_multiply(0.8),
|
||||
(_, true) if close => danger,
|
||||
(true, _) => t.mode_on,
|
||||
(_, true) => t.button_hi,
|
||||
_ => egui::Color32::TRANSPARENT,
|
||||
};
|
||||
ui.painter().rect_filled(r, 0.0, fill);
|
||||
let ink = if close && resp.hovered() { egui::Color32::WHITE } else { t.text_dim };
|
||||
paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink));
|
||||
let tip = match c {
|
||||
Caption::Minimize => "Minimize",
|
||||
Caption::Maximize if max => "Restore",
|
||||
Caption::Maximize => "Maximize",
|
||||
Caption::Close => "Close",
|
||||
};
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
clicked = Some(c);
|
||||
}
|
||||
}
|
||||
match clicked {
|
||||
Some(Caption::Minimize) => ui.ctx().send_viewport_cmd(ViewportCommand::Minimized(true)),
|
||||
Some(Caption::Maximize) => toggle_maximize(ui.ctx()),
|
||||
Some(Caption::Close) => {
|
||||
let _ = app.run("app.quit", json!({}));
|
||||
ui.ctx().send_viewport_cmd(ViewportCommand::Close);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 10 pt line glyphs of the caption buttons.
|
||||
fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) {
|
||||
let p = ui.painter();
|
||||
let g = Rect::from_center_size(at, vec2(10.0, 10.0));
|
||||
match c {
|
||||
Caption::Minimize => {
|
||||
p.line_segment([g.left_center(), g.right_center()], s);
|
||||
}
|
||||
// Restore: a front square with the corner of the one behind it.
|
||||
Caption::Maximize if maximized => {
|
||||
let front = Rect::from_min_max(g.min + vec2(0.0, 2.0), g.max - vec2(2.0, 0.0));
|
||||
p.rect_stroke(front, 0.0, s, egui::StrokeKind::Middle);
|
||||
p.line(vec![g.min + vec2(2.0, 2.0), g.min + vec2(2.0, 0.0), g.right_top(), g.max - vec2(0.0, 2.0), g.max - vec2(2.0, 2.0)], s);
|
||||
}
|
||||
Caption::Maximize => {
|
||||
p.rect_stroke(g, 0.0, s, egui::StrokeKind::Middle);
|
||||
}
|
||||
Caption::Close => {
|
||||
p.line_segment([g.left_top(), g.right_bottom()], s);
|
||||
p.line_segment([g.right_top(), g.left_bottom()], s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The edge and corner zones of a window with content rect `w`: (zone, direction, cursor). Corners
|
||||
/// come last so they win where they overlap the edges.
|
||||
fn zones(w: Rect) -> [(Rect, ResizeDirection, CursorIcon); 8] {
|
||||
use ResizeDirection::*;
|
||||
let (e, c) = (EDGE, CORNER);
|
||||
[
|
||||
(Rect::from_min_max(w.left_top(), pos2(w.right(), w.top() + e)), North, CursorIcon::ResizeVertical),
|
||||
(Rect::from_min_max(pos2(w.left(), w.bottom() - e), w.right_bottom()), South, CursorIcon::ResizeVertical),
|
||||
(Rect::from_min_max(w.left_top(), pos2(w.left() + e, w.bottom())), West, CursorIcon::ResizeHorizontal),
|
||||
(Rect::from_min_max(pos2(w.right() - e, w.top()), w.right_bottom()), East, CursorIcon::ResizeHorizontal),
|
||||
(Rect::from_min_size(w.left_top(), vec2(c, c)), NorthWest, CursorIcon::ResizeNwSe),
|
||||
(Rect::from_min_size(w.right_top() - vec2(c, 0.0), vec2(c, c)), NorthEast, CursorIcon::ResizeNeSw),
|
||||
(Rect::from_min_size(w.left_bottom() - vec2(0.0, c), vec2(c, c)), SouthWest, CursorIcon::ResizeNeSw),
|
||||
(Rect::from_min_size(w.right_bottom() - vec2(c, c), vec2(c, c)), SouthEast, CursorIcon::ResizeNwSe),
|
||||
]
|
||||
}
|
||||
|
||||
/// Invisible resize zones along the window's edges, above everything else; a press in one starts
|
||||
/// an OS resize in that direction. None while the window is maximized or fullscreen.
|
||||
pub fn resize_zones(ui: &mut Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
if maximized(&ctx) || ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)) {
|
||||
return;
|
||||
}
|
||||
let w = ctx.content_rect();
|
||||
let layer = LayerId::new(Order::Foreground, Id::new("titlebar-resize"));
|
||||
let zui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(w));
|
||||
for (i, (r, dir, cursor)) in zones(w).into_iter().enumerate() {
|
||||
let resp = zui.interact(r, Id::new(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor);
|
||||
if resp.drag_started_by(PointerButton::Primary) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::{Event, Pos2, RawInput, ViewportId};
|
||||
|
||||
/// A headless window `width` pt wide with a title row: the drag area, the caption buttons and
|
||||
/// the resize zones.
|
||||
struct Win {
|
||||
ctx: egui::Context,
|
||||
app: SoundApp,
|
||||
width: f32,
|
||||
time: f64,
|
||||
maximized: bool,
|
||||
}
|
||||
|
||||
impl Win {
|
||||
fn new(width: f32) -> Self {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, crate::Services::default());
|
||||
app.custom_titlebar = true;
|
||||
Self { ctx, app, width, time: 0.0, maximized: false }
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>) -> egui::FullOutput {
|
||||
self.time += 0.05;
|
||||
let mut input =
|
||||
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 600.0))), time: Some(self.time), events, ..Default::default() };
|
||||
if let Some(v) = input.viewports.get_mut(&ViewportId::ROOT) {
|
||||
v.maximized = Some(self.maximized);
|
||||
}
|
||||
let app = &mut self.app;
|
||||
let width = self.width;
|
||||
let mut out = self.ctx.run_ui(input, |ui| {
|
||||
let bar = Rect::from_min_size(Pos2::ZERO, vec2(width, HEIGHT));
|
||||
drag_area(ui, bar);
|
||||
caption_buttons(app, ui, bar);
|
||||
resize_zones(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
fn gesture(&mut self, path: &[Pos2]) -> Vec<ViewportCommand> {
|
||||
let button = |p: Pos2, pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true)]];
|
||||
events.extend(path[1..].iter().map(|p| vec![Event::PointerMoved(*p)]));
|
||||
events.push(vec![button(*path.last().unwrap_or(&path[0]), false)]);
|
||||
events.into_iter().flat_map(|e| self.frame(e).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
fn click(&mut self, p: Pos2) -> Vec<ViewportCommand> {
|
||||
self.gesture(&[p])
|
||||
}
|
||||
|
||||
fn caption(&self, c: Caption) -> Rect {
|
||||
self.ctx.read_response(caption_id(c)).map(|r| r.rect).unwrap_or(Rect::NOTHING)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_buttons_take_the_right_end_of_the_row() {
|
||||
for width in [800.0, 1440.0] {
|
||||
let mut w = Win::new(width);
|
||||
w.frame(vec![]);
|
||||
w.frame(vec![]);
|
||||
let [min, max, close] = CAPTIONS.map(|c| w.caption(c));
|
||||
assert_eq!((close.right(), close.top()), (width, 0.0), "Close sits in the window's corner at {width}");
|
||||
assert!(close.height() >= 30.0 && [min, max, close].iter().all(|r| r.width() == BUTTON_WIDTH));
|
||||
assert_eq!((min.right(), max.right()), (max.left(), close.left()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimize_maximize_and_close_follow_the_window_state() {
|
||||
let mut w = Win::new(1000.0);
|
||||
w.frame(vec![]);
|
||||
let (min, max, close) = (w.caption(Caption::Minimize).center(), w.caption(Caption::Maximize).center(), w.caption(Caption::Close).center());
|
||||
assert!(w.click(min).contains(&ViewportCommand::Minimized(true)));
|
||||
assert!(w.click(max).contains(&ViewportCommand::Maximized(true)));
|
||||
w.maximized = true;
|
||||
assert!(w.click(max).contains(&ViewportCommand::Maximized(false)), "Restore");
|
||||
assert!(w.click(close).contains(&ViewportCommand::Close));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_row_space_drags_and_double_click_maximizes() {
|
||||
let mut w = Win::new(1440.0);
|
||||
w.frame(vec![]);
|
||||
let empty = pos2(700.0, 16.0);
|
||||
let cmds = w.gesture(&[empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)]);
|
||||
assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}");
|
||||
let mut cmds = w.click(empty);
|
||||
cmds.extend(w.click(empty));
|
||||
assert!(cmds.contains(&ViewportCommand::Maximized(true)), "{cmds:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_zones_resize_unless_maximized() {
|
||||
use ResizeDirection::*;
|
||||
let mut w = Win::new(1000.0);
|
||||
w.frame(vec![]);
|
||||
for (p, dir) in [
|
||||
(pos2(1.0, 300.0), West),
|
||||
(pos2(998.0, 300.0), East),
|
||||
(pos2(500.0, 1.0), North),
|
||||
(pos2(500.0, 598.0), South),
|
||||
(pos2(2.0, 2.0), NorthWest),
|
||||
(pos2(997.0, 3.0), NorthEast),
|
||||
(pos2(3.0, 597.0), SouthWest),
|
||||
(pos2(998.0, 598.0), SouthEast),
|
||||
] {
|
||||
let cmds = w.gesture(&[p, p + vec2(6.0, 6.0)]);
|
||||
assert_eq!(cmds, vec![ViewportCommand::BeginResize(dir)], "at {p:?}");
|
||||
}
|
||||
w.maximized = true;
|
||||
let cmds = w.gesture(&[pos2(1.0, 300.0), pos2(8.0, 300.0)]);
|
||||
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::BeginResize(_))), "{cmds:?}");
|
||||
}
|
||||
}
|
||||
@@ -405,7 +405,7 @@ mod selector_visual_regressions {
|
||||
move |ui, regions: &mut serde_json::Map<String, serde_json::Value>| {
|
||||
if !ready {
|
||||
crate::fonts::install(ui.ctx());
|
||||
crate::theme::apply(ui.ctx(), false);
|
||||
crate::theme::apply(ui.ctx(), crate::theme::ThemeMode::Dark);
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
@@ -458,7 +458,7 @@ mod selector_visual_regressions {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let ctx = egui::Context::default();
|
||||
crate::fonts::install(&ctx);
|
||||
crate::theme::apply(&ctx, false);
|
||||
crate::theme::apply(&ctx, crate::theme::ThemeMode::Dark);
|
||||
let mut app = mixer_app(narrow, mode);
|
||||
let mut output = ctx.run_ui(scaled_input(vec2(320.0, 640.0), scale), |ui| crate::mix_window::show(&mut app, ui));
|
||||
output.textures_delta.clear();
|
||||
@@ -505,7 +505,7 @@ mod selector_visual_regressions {
|
||||
move |ui, app: &mut crate::SoundApp| {
|
||||
if !ready {
|
||||
crate::fonts::install(ui.ctx());
|
||||
crate::theme::apply(ui.ctx(), false);
|
||||
crate::theme::apply(ui.ctx(), crate::theme::ThemeMode::Dark);
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
@@ -553,7 +553,7 @@ mod selector_visual_regressions {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let ctx = egui::Context::default();
|
||||
crate::fonts::install(&ctx);
|
||||
crate::theme::apply(&ctx, false);
|
||||
crate::theme::apply(&ctx, crate::theme::ThemeMode::Dark);
|
||||
let mut rect = Rect::NOTHING;
|
||||
let mut output = ctx.run_ui(scaled_input(vec2(320.0, 120.0), scale), |ui| {
|
||||
rect = selector_box(ui, width, 16.0, label, Tokens::current().text).rect;
|
||||
|
||||
@@ -350,7 +350,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
if is_dir {
|
||||
app.ui.workspace_dir = path;
|
||||
} else if name.ends_with(".scraft") {
|
||||
let _ = crate::dialogs::run_or_ask(app, "session.open", json!({"path": path}));
|
||||
let _ = app.run("session.open", json!({"path": path}));
|
||||
} else if name.ends_with(".mid") || name.ends_with(".midi") {
|
||||
let _ = app.run("file.import_midi", json!({"path": path}));
|
||||
} else {
|
||||
@@ -493,13 +493,10 @@ pub fn shortcuts_window(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
win(ctx, &mut open, "Keyboard Shortcuts", vec2(520.0, 480.0), |ui| {
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
egui::Grid::new("sc").num_columns(3).striped(true).show(ui, |ui| {
|
||||
let engine = soundcraft_engine::command_specs().iter().map(|c| (c.shortcut, c.label, c.menu.join(" › ")));
|
||||
let windows = crate::menus::UI_COMMANDS.iter().map(|c| (c.3, c.1, c.2.replace(" > ", " › ")));
|
||||
for (shortcut, label, menu) in engine.chain(windows) {
|
||||
let Some(shortcut) = shortcut else { continue };
|
||||
ui.label(egui::RichText::new(crate::shortcuts::shortcut_label(shortcut, mac)).font(mono(11.0)));
|
||||
ui.label(label);
|
||||
ui.label(egui::RichText::new(menu).small());
|
||||
for c in soundcraft_engine::command_specs().iter().filter(|c| c.shortcut.is_some()) {
|
||||
ui.label(egui::RichText::new(crate::shortcuts::shortcut_label(c.shortcut.unwrap_or(""), mac)).font(mono(11.0)));
|
||||
ui.label(c.label);
|
||||
ui.label(egui::RichText::new(c.menu.join(" › ")).small());
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, Vec2, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
|
||||
const WINDOW_PX: Vec2 = vec2(1600.0, 1000.0);
|
||||
|
||||
fn frame(h: &mut Harness<'_, SoundApp>, px: Pos2, pressed: Option<bool>) {
|
||||
let zoom = h.ctx.zoom_factor();
|
||||
h.set_size(WINDOW_PX / zoom);
|
||||
let pos = (px.to_vec2() / zoom).to_pos2();
|
||||
h.input_mut().events.push(Event::PointerMoved(pos));
|
||||
if let Some(pressed) = pressed {
|
||||
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
}
|
||||
h.step();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_scale_slider_follows_the_pointer() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.extra.show_ui_customization = true;
|
||||
let mut h = Harness::builder().with_size(WINDOW_PX).with_pixels_per_point(1.0).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(6);
|
||||
let rail = h.get_by_role(Role::Slider).rect();
|
||||
let start = pos2(rail.left() + rail.width() / 3.0, rail.center().y);
|
||||
let end = pos2(rail.right() + 40.0, rail.center().y);
|
||||
frame(&mut h, start, Some(true));
|
||||
let mut scales = vec![];
|
||||
for i in 1..=20 {
|
||||
frame(&mut h, start.lerp(end, i as f32 / 20.0), None);
|
||||
scales.push(h.state().extra.ui_scale);
|
||||
}
|
||||
assert!(scales.windows(2).all(|w| w[0] <= w[1]), "{scales:?}");
|
||||
frame(&mut h, end, Some(false));
|
||||
frame(&mut h, end, None);
|
||||
assert_eq!(h.state().extra.ui_scale, 1.5);
|
||||
assert_eq!(h.ctx.zoom_factor(), 1.5);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, menus};
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_name_the_step_they_would_take() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let label = |app: &SoundApp, id| menus::live_label(app, Some(id));
|
||||
assert_eq!(label(&app, "edit.undo").as_deref(), Some("Can't Undo"));
|
||||
app.run("mix.mute", json!({"track": "Kick"})).unwrap();
|
||||
assert_eq!(label(&app, "edit.undo").as_deref(), Some("Undo Mute"));
|
||||
assert_eq!(label(&app, "edit.redo").as_deref(), Some("Can't Redo"));
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(label(&app, "edit.redo").as_deref(), Some("Redo Mute"));
|
||||
assert_eq!(label(&app, "edit.copy"), None);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
use std::time::Duration;
|
||||
|
||||
fn pass(ctx: &egui::Context, app: &mut SoundApp, shown: bool) -> Duration {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
app.logic(ui.ctx());
|
||||
if shown {
|
||||
app.ui(ui);
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.viewport_output[&egui::ViewportId::ROOT].repaint_delay
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_shown_window_animates_and_a_hidden_one_wakes_once_a_second() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
for _ in 0..3 {
|
||||
pass(&ctx, &mut app, true);
|
||||
}
|
||||
assert_ne!(pass(&ctx, &mut app, true), Duration::ZERO);
|
||||
app.run("transport.play", serde_json::json!({})).unwrap();
|
||||
// Playing repaints at the meter rate (30 Hz in the Edit window, #207).
|
||||
assert!(pass(&ctx, &mut app, true) <= Duration::from_millis(34));
|
||||
pass(&ctx, &mut app, false);
|
||||
assert!((pass(&ctx, &mut app, false).as_secs_f64() - 1.0).abs() < 1e-3);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
use egui::{Color32, Event, FullOutput, Rect, Shape, pos2, vec2};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, theme::ThemeMode};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Slot {
|
||||
rect: Rect,
|
||||
fill: Color32,
|
||||
text: Color32,
|
||||
border: Color32,
|
||||
}
|
||||
|
||||
// Read the rectangle and foreground emitted by the real Mix drawing path, rather than
|
||||
// testing a palette token that a custom-painted control might never use.
|
||||
fn slot(output: &FullOutput, label: &str) -> Slot {
|
||||
let text = output
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|shape| match &shape.shape {
|
||||
Shape::Text(text) if text.galley.job.text == label => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("missing painted label {label}"));
|
||||
let center = text.visual_bounding_rect().center();
|
||||
let rect = output
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|shape| match &shape.shape {
|
||||
Shape::Rect(rect) if rect.rect.height() == 15.0 && rect.rect.contains(center) => Some(rect),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("missing painted slot for {label}"));
|
||||
let foreground = text.override_text_color.unwrap_or(text.galley.job.sections[0].format.color);
|
||||
assert_eq!(text.opacity_factor, 1.0);
|
||||
assert_eq!(foreground.a(), 255);
|
||||
assert_eq!(rect.fill.a(), 255);
|
||||
Slot { rect: rect.rect, fill: rect.fill, text: foreground, border: rect.stroke.color }
|
||||
}
|
||||
|
||||
fn contrast(a: Color32, b: Color32) -> f64 {
|
||||
let luminance = |color: Color32| {
|
||||
let linear = |channel: u8| {
|
||||
let s = f64::from(channel) / 255.0;
|
||||
if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
|
||||
};
|
||||
0.2126 * linear(color.r()) + 0.7152 * linear(color.g()) + 0.0722 * linear(color.b())
|
||||
};
|
||||
let (a, b) = (luminance(a), luminance(b));
|
||||
(a.max(b) + 0.05) / (a.min(b) + 0.05)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populated_mix_instrument_uses_readable_palette_in_real_frames() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
assert_eq!(app.ui.theme, ThemeMode::Dark);
|
||||
// Keep demo audio loaded for the preservation check while isolating the new strips.
|
||||
for track in &mut app.engine.session_mut().tracks {
|
||||
track.hidden = true;
|
||||
}
|
||||
app.run("track.new", json!({"kind": "instrument", "name": "Built-in instrument"})).unwrap();
|
||||
let instrument = app.engine.session().edit.selected_tracks[0];
|
||||
assert_eq!(app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap().plugin, "subtractive_synth");
|
||||
app.run("mix.insert", json!({"track": instrument.0, "slot": 0, "plugin": "eq_7band"})).unwrap();
|
||||
app.run("track.new", json!({"kind": "instrument", "name": "Empty instrument"})).unwrap();
|
||||
// An unassigned instrument is a supported model state; track.new normally supplies a synth.
|
||||
let empty = app.engine.session().edit.selected_tracks[0];
|
||||
app.engine.session_mut().track_mut(empty).unwrap().instrument = None;
|
||||
app.run("window.mix", json!({})).unwrap();
|
||||
app.run("view.mix_section", json!({"section": "minimal"})).unwrap();
|
||||
for section in ["instruments", "inserts_ae"] {
|
||||
app.run("view.mix_section", json!({"section": section})).unwrap();
|
||||
}
|
||||
let session = app.engine.session().clone();
|
||||
assert!(!session.pool.is_empty(), "the fixture must include decoded audio");
|
||||
let proof_dir = std::env::var_os("SOUNDCRAFT_MIX_PROOF_DIR").map(std::path::PathBuf::from);
|
||||
let mut builder = egui_kittest::Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0);
|
||||
if proof_dir.is_some() {
|
||||
builder = builder.wgpu();
|
||||
}
|
||||
let mut harness = builder.build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.input_mut().max_texture_side = Some(8192);
|
||||
let mut failures = Vec::new();
|
||||
for (name, mode, system, light) in [
|
||||
("light", ThemeMode::Light, Some(egui::Theme::Dark), true),
|
||||
("dark", ThemeMode::Dark, Some(egui::Theme::Light), false),
|
||||
("system-light", ThemeMode::System, Some(egui::Theme::Light), true),
|
||||
("system-dark", ThemeMode::System, Some(egui::Theme::Dark), false),
|
||||
] {
|
||||
harness.state_mut().run("ui.theme", json!({"mode": mode.id()})).unwrap();
|
||||
harness.input_mut().system_theme = system;
|
||||
harness.input_mut().events.push(Event::PointerMoved(pos2(1550.0, 950.0)));
|
||||
for _ in 0..4 {
|
||||
harness.step();
|
||||
}
|
||||
let ordinary = slot(harness.output(), "SubSynth");
|
||||
let insert = slot(harness.output(), "EQ7");
|
||||
let empty = slot(harness.output(), "no instrument");
|
||||
let palette = if light { soundcraft_ui_egui::theme::Tokens::LIGHT } else { soundcraft_ui_egui::theme::Tokens::DARK };
|
||||
assert_eq!(insert.fill, palette.insert_on);
|
||||
assert_eq!(empty.fill, palette.slot_bg);
|
||||
assert_eq!(ordinary.text, palette.text);
|
||||
if ordinary.fill != insert.fill || ordinary.border != insert.border {
|
||||
failures.push(format!("{name}: instrument {ordinary:?} differs from populated insert {insert:?}"));
|
||||
}
|
||||
for hovered in [false, true] {
|
||||
if hovered {
|
||||
harness.input_mut().events.push(Event::PointerMoved(ordinary.rect.center()));
|
||||
harness.step();
|
||||
harness.step();
|
||||
}
|
||||
let painted = slot(harness.output(), "SubSynth");
|
||||
let ratio = contrast(painted.text, painted.fill);
|
||||
println!(
|
||||
"{name} hover={hovered}: foreground={:?}, fill={:?}, contrast={ratio:.6}:1, rect={:?}",
|
||||
painted.text.to_array(),
|
||||
painted.fill.to_array(),
|
||||
painted.rect
|
||||
);
|
||||
if ratio < 4.5 {
|
||||
failures.push(format!("{name} hover={hovered}: contrast {ratio:.6}:1 is below 4.5:1"));
|
||||
}
|
||||
if !light {
|
||||
let original = Color32::from_rgb(52, 62, 80);
|
||||
assert_eq!(painted.fill, if hovered { original.gamma_multiply(1.3) } else { original });
|
||||
assert_eq!(painted.border, Color32::from_rgb(16, 16, 16));
|
||||
}
|
||||
if let Some(dir) = &proof_dir {
|
||||
harness.render().unwrap().save(dir.join(format!("{name}-{}.png", if hovered { "hover" } else { "ordinary" }))).unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(harness.state().ui.theme, mode);
|
||||
assert_eq!(harness.state().engine.session(), &session, "appearance and hover must preserve the full session and decoded audio pool");
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
|
||||
// Exercise the same painted control's ordinary click/picker path after palette changes.
|
||||
let at = slot(harness.output(), "SubSynth").rect.center();
|
||||
for pressed in [true, false] {
|
||||
harness.input_mut().events.push(Event::PointerButton {
|
||||
pos: at,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed,
|
||||
modifiers: Default::default(),
|
||||
});
|
||||
harness.step();
|
||||
}
|
||||
harness.step(); // Let the popup finish its initial sizing pass.
|
||||
harness.get_by_label_contains("Built-in").hover();
|
||||
harness.step();
|
||||
harness.step();
|
||||
harness.get_by_label("Drum Synth").click();
|
||||
harness.step();
|
||||
harness.step();
|
||||
let app = harness.state();
|
||||
let replacement = app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap();
|
||||
assert_eq!(replacement.plugin, "drum_synth");
|
||||
for param in soundcraft_dsp::plugin_info("drum_synth").unwrap().params {
|
||||
assert_eq!(replacement.params.get(param.id), Some(¶m.default));
|
||||
}
|
||||
assert_eq!(app.engine.session().edit.selected_tracks, session.edit.selected_tracks);
|
||||
assert_eq!(app.engine.session().pool, session.pool);
|
||||
assert!(app.ui.plugin_windows.is_empty(), "a built-in instrument opens its picker");
|
||||
harness.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(harness.state().engine.session(), &session);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
use egui_kittest::Harness;
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
|
||||
#[test]
|
||||
fn a_long_plugin_window_stays_on_a_short_screen() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
|
||||
app.ui.plugin_windows = vec![(kick, 0)];
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 500.0)).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("plugin", kick.0, 0usize)))).unwrap();
|
||||
assert!(window.bottom() <= 500.0, "{window:?}");
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
use egui::{Event, Key, Modifiers, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, menus};
|
||||
|
||||
#[test]
|
||||
fn typing_in_the_rename_clip_dialog_replaces_the_old_name() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let clip = app.engine.session().track_by_name("Kick").unwrap().clips()[0].clone();
|
||||
app.run("edit.select", json!({"clips": [clip.id.0]})).unwrap();
|
||||
menus::invoke_menu(&mut app, "clip.rename", "Clip > Rename...");
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::Text("Intro".into()));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
let session = h.state().engine.session();
|
||||
let renamed = session.tracks.iter().flat_map(|t| t.clips()).find(|c| c.id == clip.id).map(|c| c.name.clone());
|
||||
assert_eq!(renamed.as_deref(), Some("Intro"));
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
//! Save As over another session asks first, and Enter alone never replaces it.
|
||||
use egui::vec2;
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, dialogs::Dialog};
|
||||
|
||||
#[test]
|
||||
fn saving_over_another_session_takes_the_replace_button() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-ui-replace-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Song.scraft");
|
||||
soundcraft_engine::Engine::default().execute("session.save_as", &json!({"path": path})).unwrap();
|
||||
let other = std::fs::read(&path).unwrap();
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.dialogs.open = Some(Dialog::PathPrompt {
|
||||
cmd: "session.save_as".into(),
|
||||
title: "Save Session As".into(),
|
||||
path: path.to_string_lossy().into(),
|
||||
key: "path".into(),
|
||||
});
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), other);
|
||||
h.get_by_label("Replace").click();
|
||||
h.run_steps(3);
|
||||
assert_ne!(std::fs::read(&path).unwrap(), other);
|
||||
assert_eq!(h.state().engine.path, Some(path.to_string_lossy().into_owned()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::dialogs::Dialog;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, menus};
|
||||
|
||||
fn harness() -> Harness<'static, SoundApp> {
|
||||
let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
fn steps(h: &mut Harness<'_, SoundApp>) {
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, SoundApp>, label: &str) {
|
||||
h.get_by_label(label).click();
|
||||
steps(h);
|
||||
}
|
||||
|
||||
fn close_session(h: &mut Harness<'_, SoundApp>) {
|
||||
menus::invoke_menu(h.state_mut(), "session.close", "File > Close Session");
|
||||
steps(h);
|
||||
}
|
||||
|
||||
fn mute_kick(h: &mut Harness<'_, SoundApp>) {
|
||||
h.state_mut().run("mix.mute", json!({"track": "Kick"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_modified_session_asks_first() {
|
||||
let mut h = harness();
|
||||
let tracks = h.state().engine.session().tracks.len();
|
||||
h.state_mut().run("edit.select", json!({"tracks": ["Kick"], "start": 0, "end": 48_000})).unwrap();
|
||||
close_session(&mut h);
|
||||
assert!(h.state().dialogs.open.is_none(), "a selection alone is not a change");
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
|
||||
let mut h = harness();
|
||||
mute_kick(&mut h);
|
||||
close_session(&mut h);
|
||||
assert_eq!(h.state().dialogs.open_name(), Some("save_changes"));
|
||||
click(&mut h, "Cancel");
|
||||
assert!(h.state().dialogs.open.is_none());
|
||||
assert_eq!(h.state().engine.session().tracks.len(), tracks);
|
||||
|
||||
close_session(&mut h);
|
||||
click(&mut h, "Don't Save");
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
|
||||
let mut h = harness();
|
||||
mute_kick(&mut h);
|
||||
menus::invoke_menu(h.state_mut(), "app.quit", "");
|
||||
steps(&mut h);
|
||||
assert!(!h.state().quit_requested);
|
||||
click(&mut h, "Don't Save");
|
||||
assert!(h.state().quit_requested);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_in_the_question_saves_before_closing() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-save-changes-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
|
||||
|
||||
let mut h = harness();
|
||||
mute_kick(&mut h);
|
||||
close_session(&mut h);
|
||||
click(&mut h, "Save");
|
||||
match &mut h.state_mut().dialogs.open {
|
||||
Some(Dialog::PathPrompt { cmd, path: p, .. }) if cmd == "session.save_as" => p.clone_from(&path),
|
||||
other => panic!("expected Save As, got {other:?}"),
|
||||
}
|
||||
steps(&mut h);
|
||||
click(&mut h, "OK");
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
|
||||
h.state_mut().run("session.open", json!({"path": path})).unwrap();
|
||||
assert!(h.state().engine.session().track_by_name("Kick").unwrap().mixer.mute);
|
||||
h.state_mut().run("mix.mute", json!({"track": "Kick"})).unwrap();
|
||||
close_session(&mut h);
|
||||
click(&mut h, "Save");
|
||||
assert!(h.state().dialogs.open.is_none());
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
h.state_mut().run("session.open", json!({"path": path})).unwrap();
|
||||
assert!(!h.state().engine.session().track_by_name("Kick").unwrap().mixer.mute);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -180,7 +180,7 @@ fn shared_controls_visuals() {
|
||||
}
|
||||
let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||||
|ui, rects: &mut Vec<egui::Rect>| {
|
||||
theme::apply(ui.ctx(), false);
|
||||
theme::apply(ui.ctx(), theme::ThemeMode::Dark);
|
||||
rects.clear();
|
||||
for enabled in [true, false] {
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use egui::os::OperatingSystem;
|
||||
use egui::{Key, Modifiers};
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{MainWindow, Services, SoundApp, shortcuts};
|
||||
use soundcraft_ui_egui::{Services, SoundApp, shortcuts};
|
||||
|
||||
fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) {
|
||||
let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec();
|
||||
@@ -24,23 +24,3 @@ fn ctrl_shift_arrows_nudge_the_selected_clip_gain() {
|
||||
assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0, "{os:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_equals_toggles_mix_and_edit_without_zooming() {
|
||||
let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
|
||||
let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
|
||||
for (os, cmd) in [(OperatingSystem::Mac, mac_cmd), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_os(os);
|
||||
ctx.set_zoom_factor(1.25);
|
||||
press(&ctx, &mut app, Key::Equals, cmd);
|
||||
assert_eq!(app.ui.window, MainWindow::Mix, "{os:?}");
|
||||
press(&ctx, &mut app, Key::Equals, cmd);
|
||||
press(&ctx, &mut app, Key::Num0, cmd);
|
||||
press(&ctx, &mut app, Key::Minus, cmd);
|
||||
ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
|
||||
assert_eq!(app.ui.window, MainWindow::Edit, "{os:?}");
|
||||
assert_eq!(ctx.zoom_factor(), 1.25, "{os:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ fn shared_panels_and_controls_follow_manual_and_live_system_appearance() {
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
theme::apply(&ctx, light);
|
||||
theme::apply(&ctx, if light { theme::ThemeMode::Light } else { theme::ThemeMode::Dark });
|
||||
let tokens = theme::Tokens::current();
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
widgets::icon_button(ui, vec2(38.0, 28.0), "loop", true, "Loop playback");
|
||||
@@ -58,7 +58,7 @@ fn shared_panels_and_controls_follow_manual_and_live_system_appearance() {
|
||||
if let Some(directory) = std::env::var_os("CRAFT_UI_THEME_SHOTS").map(std::path::PathBuf::from) {
|
||||
let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||||
move |ui, _: &mut ()| {
|
||||
theme::apply(ui.ctx(), light);
|
||||
theme::apply(ui.ctx(), if light { theme::ThemeMode::Light } else { theme::ThemeMode::Dark });
|
||||
for enabled in [true, false] {
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
use egui::{Pos2, RawInput, Rect, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_time::to_samples;
|
||||
use soundcraft_ui_egui::{ControlRequest, Services, SoundApp};
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
|
||||
struct Rig {
|
||||
ctx: egui::Context,
|
||||
app: SoundApp,
|
||||
tx: Sender<ControlRequest>,
|
||||
width: f32,
|
||||
}
|
||||
|
||||
impl Rig {
|
||||
fn new(width: f32) -> Rig {
|
||||
let (tx, rx) = channel();
|
||||
let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()).with_control(rx);
|
||||
let mut r = Rig { ctx: egui::Context::default(), app, tx, width };
|
||||
r.frames(6);
|
||||
r
|
||||
}
|
||||
|
||||
fn frames(&mut self, n: usize) {
|
||||
for _ in 0..n {
|
||||
let mut raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 900.0))), ..Default::default() };
|
||||
self.app.raw_input_hook(&mut raw);
|
||||
self.ctx
|
||||
.run_ui(raw, |ui| {
|
||||
self.app.logic(ui.ctx());
|
||||
self.app.ui(ui);
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn send(&mut self, method: &str, params: Value) {
|
||||
let (req, reply) = ControlRequest::new(method, params);
|
||||
self.tx.send(req).unwrap();
|
||||
self.frames(40);
|
||||
assert_eq!(reply.try_recv().unwrap()["ok"], true, "{method}");
|
||||
}
|
||||
|
||||
fn view(&self) -> (f64, i64) {
|
||||
let z = self.app.engine.session().edit.zoom;
|
||||
(z.samples_per_px, z.scroll)
|
||||
}
|
||||
|
||||
fn timeline(&self) -> Rect {
|
||||
let [x0, y0, x1, y1] = self.app.edit_layout.timeline;
|
||||
Rect::from_min_max(pos2(x0, y0), pos2(x1, y1))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swipes_and_wheels_scroll_with_the_fingers_on_one_axis_even_slowly() {
|
||||
let mut r = Rig::new(1400.0);
|
||||
let (tl, (spp, scroll)) = (r.timeline(), r.view());
|
||||
let (x, y) = (tl.center().x, tl.center().y);
|
||||
assert!(r.app.edit_layout.content_h - tl.height() > 60.0);
|
||||
r.send("ui.scroll", json!({"x": x, "y": y, "dx": -240.0, "dy": -60.0}));
|
||||
assert_eq!(r.view(), (spp, scroll + to_samples(240.0 * spp)));
|
||||
assert_eq!(r.app.edit_layout.scroll_y, 0.0);
|
||||
r.send("ui.scroll", json!({"x": x, "y": y, "dx": -30.0, "dy": -60.0}));
|
||||
assert_eq!(r.view(), (spp, scroll + to_samples(240.0 * spp)));
|
||||
assert_eq!(r.app.edit_layout.scroll_y, 60.0);
|
||||
r.send("ui.scroll", json!({"x": x, "y": y, "dy": 1.0, "unit": "line", "steps": 1}));
|
||||
assert!((r.app.edit_layout.scroll_y - 20.0).abs() < 1.0, "{}", r.app.edit_layout.scroll_y);
|
||||
r.send("ui.scroll", json!({"x": x, "y": tl.min.y - 20.0, "dx": 100_000.0}));
|
||||
assert_eq!(r.view(), (spp, 0));
|
||||
r.app.engine.execute("view.zoom_set", &json!({"samples_per_px": 0.25})).unwrap();
|
||||
let scroll_y = r.app.edit_layout.scroll_y;
|
||||
r.send("ui.scroll", json!({"x": x, "y": y, "dx": -12.0, "dy": -6.0, "steps": 24}));
|
||||
assert_eq!(r.view().1, 3);
|
||||
assert_eq!(r.app.edit_layout.scroll_y, scroll_y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinch_and_cmd_scroll_zoom_around_the_pointer_and_shift_scroll_pans() {
|
||||
let mut r = Rig::new(1400.0);
|
||||
let tl = r.timeline();
|
||||
let (x, y) = (tl.min.x + 300.0, tl.center().y);
|
||||
let under = |r: &Rig| r.view().1 as f64 + 300.0 * r.view().0;
|
||||
let (spp, at) = (r.view().0, under(&r));
|
||||
r.send("ui.zoom", json!({"x": x, "y": y, "factor": 4.0}));
|
||||
assert!((r.view().0 * 4.0 / spp - 1.0).abs() < 1e-4, "{spp} -> {}", r.view().0);
|
||||
assert!((under(&r) - at).abs() < 8.0);
|
||||
r.send("ui.scroll", json!({"x": x, "y": y, "dy": 100.0, "cmd": true}));
|
||||
assert!(r.view().0 < spp / 4.0);
|
||||
assert!((under(&r) - at).abs() < 8.0);
|
||||
let ((spp, scroll), scroll_y) = (r.view(), r.app.edit_layout.scroll_y);
|
||||
r.send("ui.scroll", json!({"x": x, "y": y, "dy": -100.0, "shift": true}));
|
||||
assert_eq!(r.view(), (spp, scroll + to_samples(100.0 * spp)));
|
||||
assert_eq!(r.app.edit_layout.scroll_y, scroll_y);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
use egui::os::OperatingSystem;
|
||||
use egui::{Key, Modifiers};
|
||||
use soundcraft_ui_egui::{MainWindow, Services, SoundApp, shortcuts};
|
||||
|
||||
const MAC_CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
|
||||
|
||||
fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) {
|
||||
let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec();
|
||||
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())).textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_shortcuts_open_and_close_windows() {
|
||||
let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
|
||||
for (os, cmd) in [(OperatingSystem::Mac, MAC_CMD), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_os(os);
|
||||
for key in [Key::Num1, Key::Num3, Key::Num4, Key::Num5, Key::Num9] {
|
||||
press(&ctx, &mut app, key, cmd);
|
||||
}
|
||||
press(&ctx, &mut app, Key::M, Modifiers { alt: true, ..cmd });
|
||||
let ui = &app.ui;
|
||||
assert!(
|
||||
ui.show_transport && ui.show_big_counter && ui.show_automation && ui.show_memory_locations && ui.show_video && ui.narrow_mix,
|
||||
"{os:?}"
|
||||
);
|
||||
press(&ctx, &mut app, Key::W, cmd);
|
||||
assert!(!app.ui.show_transport && !app.ui.show_big_counter && !app.ui.show_memory_locations, "{os:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_ctrl_equals_opens_the_score_editor_on_a_mac() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_os(OperatingSystem::Mac);
|
||||
press(&ctx, &mut app, Key::Equals, Modifiers { ctrl: true, ..MAC_CMD });
|
||||
assert!(app.ui.show_score);
|
||||
assert_eq!(app.ui.window, MainWindow::Edit);
|
||||
press(&ctx, &mut app, Key::Equals, MAC_CMD);
|
||||
assert_eq!(app.ui.window, MainWindow::Mix);
|
||||
}
|
||||
@@ -1749,7 +1749,7 @@ impl EditorLink {
|
||||
mod window {
|
||||
use objc2::rc::Retained;
|
||||
use objc2::{MainThreadMarker, MainThreadOnly};
|
||||
use objc2_app_kit::{NSApplication, NSBackingStoreType, NSFloatingWindowLevel, NSWindow, NSWindowCollectionBehavior, NSWindowStyleMask};
|
||||
use objc2_app_kit::{NSApplication, NSBackingStoreType, NSFloatingWindowLevel, NSWindow, NSWindowStyleMask};
|
||||
use objc2_foundation::{NSPoint, NSRect, NSSize, NSString};
|
||||
use std::ffi::{c_char, c_void};
|
||||
|
||||
@@ -1780,10 +1780,7 @@ mod window {
|
||||
let mtm = MainThreadMarker::new().ok_or_else(|| "not on the main thread".to_string())?;
|
||||
let _app = NSApplication::sharedApplication(mtm);
|
||||
let rect = NSRect::new(NSPoint::new(200.0, 200.0), NSSize::new(w, h));
|
||||
// Plugin editors are transient windows. In particular they must not be
|
||||
// minimizable: minimized NSWindows become persistent thumbnails in the macOS Dock,
|
||||
// which made every editor visited in a session look like another running app.
|
||||
let style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable;
|
||||
let style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable | NSWindowStyleMask::Miniaturizable;
|
||||
// SAFETY: a freshly allocated window on the main thread; valid rect and style.
|
||||
let window = unsafe {
|
||||
NSWindow::initWithContentRect_styleMask_backing_defer(NSWindow::alloc(mtm), rect, style, NSBackingStoreType::Buffered, false)
|
||||
@@ -1794,8 +1791,6 @@ mod window {
|
||||
window.setTitle(&NSString::from_str(title));
|
||||
window.setLevel(NSFloatingWindowLevel);
|
||||
window.setHidesOnDeactivate(true);
|
||||
window.setExcludedFromWindowsMenu(true);
|
||||
window.setCollectionBehavior(NSWindowCollectionBehavior::Transient | NSWindowCollectionBehavior::IgnoresCycle);
|
||||
window.center();
|
||||
Ok(HostWindow { window: Retained::into_raw(window) })
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
ArtCraft brand assets: license
|
||||
|
||||
The ArtCraft name, wordmark and mark (the "ArtCraft Marks"), including every file in this
|
||||
directory in every color, size and format, are owned by the ArtCraft Team. They are trademarks
|
||||
and are NOT open source. They are not covered by SoundCraft's MIT OR Apache-2.0 license, and nothing in
|
||||
that license, in NOTICE or in any other file of this repository grants any right to them.
|
||||
|
||||
Permission
|
||||
|
||||
The ArtCraft Team grants a limited, non-exclusive, non-transferable, revocable, royalty-free
|
||||
permission to use the ArtCraft Marks only:
|
||||
|
||||
1. as part of copies of this repository; and
|
||||
2. within SoundCraft itself, its documentation and its official builds, to identify SoundCraft as made
|
||||
by the ArtCraft team and community and to link to getartcraft.com and its community.
|
||||
|
||||
Conditions
|
||||
|
||||
The permission holds only while every one of these is true:
|
||||
|
||||
- The marks are used exactly as supplied in this directory. Do not redraw, recolor, crop,
|
||||
stretch, rotate, animate, outline or add effects to them, extract parts of them, or combine
|
||||
them with any other name, mark or artwork.
|
||||
- They are used only in the context of SoundCraft, and only to say that the ArtCraft team makes it.
|
||||
They must never suggest that the ArtCraft Team made, sponsors, endorses, certifies or supports
|
||||
any other product, service, fork, modified version, plug-in, organization or person.
|
||||
- Neither the marks nor the ArtCraft name appear in the name, logo, icon, domain, social media
|
||||
handle, app-store listing, advertising or marketing of anything other than SoundCraft.
|
||||
|
||||
Forks and modified versions
|
||||
|
||||
If you distribute or publish a modified version of SoundCraft, or any work derived from it, you must
|
||||
remove the ArtCraft Marks from it (or replace them with your own) and must not call it ArtCraft or
|
||||
present it as an ArtCraft product. You may say, in plain text, that your work is based on SoundCraft by
|
||||
the ArtCraft team. A fork kept only to propose changes back to this repository may keep the marks
|
||||
unchanged.
|
||||
|
||||
Everything else
|
||||
|
||||
Any use not expressly permitted here needs prior written permission from the ArtCraft Team
|
||||
(https://getartcraft.com/). The permission ends automatically for anyone who breaks these terms.
|
||||
All other rights are reserved.
|
||||
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 26 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
|
||||
<title>ArtCraft</title>
|
||||
<path fill="#0b0b0c" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 341 B |
|
Before Width: | Height: | Size: 24 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
|
||||
<title>ArtCraft</title>
|
||||
<path fill="#4e7bfb" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 341 B |
@@ -42,15 +42,13 @@ Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no tr
|
||||
| `engine.execute` (alias `command`) | `{command, params}` | Run a command programmatically. **Never opens a dialog**; empty params use defaults or the current selection. |
|
||||
| `engine.commands` | `{filter?}` | Every command: id, label, menu path, shortcut, params doc, enabled + reason. Includes UI-layer commands (`window.*`). |
|
||||
| `engine.parity` | `{}` | Parity report against the incumbent's menu catalog. |
|
||||
| `session.inspect` | `{detail?: "summary"|"full"}` | The document: tracks, clips (with times), inserts (+params when full), sends, routing, automation, markers, groups, busses, sources (`loaded` true when decoded audio is in the pool), selection, zoom (`samples_per_px`, `scroll`), transport, undo label. |
|
||||
| `session.inspect` | `{detail?: "summary"|"full"}` | The document: tracks, clips (with times), inserts (+params when full), sends, routing, automation, markers, groups, busses, sources (`loaded` true when decoded audio is in the pool), selection, transport, undo label. |
|
||||
| `ui.inspect` | `{}` | UI state (`window`, panels, dialogs), window size, playing/position, audio device, and `edit_layout` (timeline rect and every track row's rect, for clicking). |
|
||||
| `ui.set` | any `UiState` fields | e.g. `{"window": "Mix", "narrow_mix": true}`. |
|
||||
| `ui.menu.list` | `{}` | Every catalog menu path and the command that implements it (or null). |
|
||||
| `ui.menu.invoke` | `{path}` or `{id}` | Like clicking the menu item: may open its dialog (e.g. `"Track > New..."`). |
|
||||
| `ui.click` | `{x, y, button?, count?, cmd?, shift?, alt?, ctrl?}` | Synthetic click (window points). |
|
||||
| `ui.drag` | `{x, y, to_x, to_y, steps?}` | Synthetic drag with the primary button. |
|
||||
| `ui.scroll` | `{x, y, dx?, dy?, unit?: "point"\|"line", steps?, cmd?, shift?, alt?, ctrl?}` | A two-finger trackpad swipe over `(x, y)`: `dx`/`dy` points spread over `steps` frames (default 8). Positive values move the content right/down (natural scrolling). `unit: "line"` sends mouse-wheel notches instead. With `cmd` it zooms; with `shift` it scrolls sideways. |
|
||||
| `ui.zoom` | `{x, y, factor, steps?}` | A trackpad pinch around `(x, y)`: `factor` > 1 spreads the fingers (zooms in). |
|
||||
| `ui.move` | `{x, y}` | Move the pointer. |
|
||||
| `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press (`Space`, `Enter`, `A`, `F7`, `=`…). |
|
||||
| `ui.text` | `{text}` | Type text into the focused field. |
|
||||
|
||||
@@ -22,9 +22,9 @@ claude mcp add soundcraft -- soundcraft-cli mcp --demo
|
||||
|---|---|
|
||||
| `list_commands` | Discover every command (id, label, menu path, shortcut, params doc, enabled). |
|
||||
| `execute` | Run one command with JSON params. |
|
||||
| `batch` | Run several commands in order (`keep_going` optional). Any failed call makes the tool result `isError: true`; with `keep_going` the remaining calls still run and every result is listed. |
|
||||
| `batch` | Run several commands in order (`keep_going` optional). |
|
||||
| `inspect_session` | Verify work: tracks, clips, mixer, plugins, sends, automation, markers, selection; each `sources[]` entry includes `loaded` (decoded audio in the pool). |
|
||||
| `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). `open_session` returns `missing`: the media files that could not be loaded (empty when all are found). `save_session` won't save over another existing file unless `replace` is true. |
|
||||
| `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). `open_session` returns `missing`: the media files that could not be loaded (empty when all are found). |
|
||||
| `import_audio` | WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF onto a new or existing track. |
|
||||
| `bounce_mix` | Render to WAV/AIFF/FLAC; returns peak dBFS and integrated LUFS. |
|
||||
| `parity` | Feature-parity report. |
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
<launchable type="desktop-id">ai.storyteller.soundcraft.desktop</launchable>
|
||||
<icon type="stock">ai.storyteller.soundcraft</icon>
|
||||
<!-- Only url types known to appstream 0.15 (Ubuntu 22.04, where CI validates): no vcs-browser. -->
|
||||
<url type="homepage">https://getartcraft.com/apps/soundcraft</url>
|
||||
<url type="homepage">https://github.com/storytold/soundcraft</url>
|
||||
<url type="bugtracker">https://github.com/storytold/soundcraft/issues</url>
|
||||
<url type="help">https://github.com/storytold/soundcraft</url>
|
||||
<url type="contact">https://discord.gg/artcraft</url>
|
||||
<url type="contact">https://github.com/storytold/soundcraft/issues</url>
|
||||
<categories>
|
||||
<category>AudioVideo</category>
|
||||
<category>Audio</category>
|
||||
|
||||
@@ -14,7 +14,7 @@ section: sound
|
||||
priority: optional
|
||||
maintainer: ${SOUNDCRAFT_MAINTAINER}
|
||||
vendor: Learning Machines LLC
|
||||
homepage: https://getartcraft.com/apps/soundcraft
|
||||
homepage: https://github.com/storytold/soundcraft
|
||||
license: MIT OR Apache-2.0
|
||||
description: |-
|
||||
Open-source, native digital audio workstation. Record, edit and mix
|
||||
|
||||