mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-08 19:30:15 -06:00
Compare commits
218
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01b39850ad | ||
|
|
b48df2a8b9 | ||
|
|
106678471f | ||
|
|
541096d2cb | ||
|
|
353ea145aa | ||
|
|
35905fb90b | ||
|
|
d7b1562cc1 | ||
|
|
9048e04d60 | ||
|
|
e954639900 | ||
|
|
bbebfa1a6e | ||
|
|
9821168bc4 | ||
|
|
e46339606a | ||
|
|
835de9f038 | ||
|
|
e910e72940 | ||
|
|
54467f544b | ||
|
|
4311742624 | ||
|
|
a8ca533b9b | ||
|
|
3a139d4bed | ||
|
|
092122b49c | ||
|
|
be75be896a | ||
|
|
f77c360f94 | ||
|
|
a6cec46275 | ||
|
|
10e26f6fe2 | ||
|
|
c0dc2c3ec6 | ||
|
|
06f30628bb | ||
|
|
746a825ed5 | ||
|
|
fe4cd8095d | ||
|
|
a41f254aa0 | ||
|
|
698507cf30 | ||
|
|
1e9a0e9c88 | ||
|
|
ee050db59c | ||
|
|
0ca492e0ab | ||
|
|
3e56738290 | ||
|
|
d5be1e6de3 | ||
|
|
7b19c3068a | ||
|
|
9b60e84fc3 | ||
|
|
02b153797d | ||
|
|
4a0e4caa3f | ||
|
|
dc9e84978f | ||
|
|
aa74bffac1 | ||
|
|
40ced8132b | ||
|
|
b322ff2f13 | ||
|
|
c5ddbbafd1 | ||
|
|
f0f9addd16 | ||
|
|
e884d6eb37 | ||
|
|
4ab3bde39b | ||
|
|
824cf5cecb | ||
|
|
f6a27c2ccf | ||
|
|
08679d5a65 | ||
|
|
93800e0764 | ||
|
|
33c979cd34 | ||
|
|
a05b1e7dc1 | ||
|
|
c29510af86 | ||
|
|
1cc7b40e6a | ||
|
|
42f22369fb | ||
|
|
b47f544c7a | ||
|
|
7bf20dbbac | ||
|
|
de206f46b6 | ||
|
|
4f959bee40 | ||
|
|
0ca5dd74ab | ||
|
|
848978a4af | ||
|
|
f3b897f532 | ||
|
|
c13e55c7c3 | ||
|
|
419e7726bb | ||
|
|
bc530b172a | ||
|
|
fb9d4ac78b | ||
|
|
76bd7bc3af | ||
|
|
c4fee770ca | ||
|
|
c0f72e5fe3 | ||
|
|
7ec4216ba2 | ||
|
|
d33563ebe2 | ||
|
|
95243c7698 | ||
|
|
4240873586 | ||
|
|
9eb2077dd1 | ||
|
|
3fe53ee834 | ||
|
|
9e7583d99b | ||
|
|
9f03eeb354 | ||
|
|
ec7cfbb8ac | ||
|
|
d2d6518a5f | ||
|
|
045938728f | ||
|
|
2e17315d5d | ||
|
|
678cb0f57d | ||
|
|
a1eafbd1c5 | ||
|
|
c8f44fbbef | ||
|
|
fd07262983 | ||
|
|
9719f955a4 | ||
|
|
98aa5dd1b5 | ||
|
|
9a5ab203d2 | ||
|
|
0cb3c692ac | ||
|
|
5bd755f604 | ||
|
|
b0e35aa988 | ||
|
|
bb990d9bfa | ||
|
|
2adec7f128 | ||
|
|
038c4c0ca7 | ||
|
|
fc87f5a5c6 | ||
|
|
a851761270 | ||
|
|
c84bdda2f1 | ||
|
|
886969bd19 | ||
|
|
6c03d7e8de | ||
|
|
ef646cf626 | ||
|
|
aa06d87410 | ||
|
|
c422084a4d | ||
|
|
1779610626 | ||
|
|
7cec02403e | ||
|
|
7ae583895d | ||
|
|
b7fe88a72e | ||
|
|
16c3e222b7 | ||
|
|
04d014b662 | ||
|
|
ffd88ee73d | ||
|
|
279d32080e | ||
|
|
6db7d29218 | ||
|
|
11aa5b99b3 | ||
|
|
567172f530 | ||
|
|
6a4bcadf1e | ||
|
|
cdc07eef35 | ||
|
|
0a1b1b185d | ||
|
|
a0f78c0a1f | ||
|
|
03183ae308 | ||
|
|
58b4208e1f | ||
|
|
2ac0200c11 | ||
|
|
a6963aaeea | ||
|
|
9a9da473c6 | ||
|
|
b7dda1433a | ||
|
|
a19b67eb58 | ||
|
|
3903e0a894 | ||
|
|
0982bdedee | ||
|
|
62d2a47878 | ||
|
|
8e8157f550 | ||
|
|
c2da4cf912 | ||
|
|
0888ca8ac3 | ||
|
|
77d96affc3 | ||
|
|
a18f86cc89 | ||
|
|
ad7b9bcac6 | ||
|
|
19f1069109 | ||
|
|
5088846d45 | ||
|
|
b0f07331a2 | ||
|
|
1053073302 | ||
|
|
a50fdf9cc9 | ||
|
|
99579a5421 | ||
|
|
0000566f5e | ||
|
|
b8b9a90c6f | ||
|
|
07b85277ee | ||
|
|
1f72dc30be | ||
|
|
525a4e02fe | ||
|
|
8b34dc533f | ||
|
|
4b54d72c23 | ||
|
|
e82de5cb97 | ||
|
|
cc493b2d8b | ||
|
|
9ecf197aa7 | ||
|
|
072b4ff3da | ||
|
|
0235d11d4c | ||
|
|
767feacf72 | ||
|
|
d01abd0e4e | ||
|
|
7c690b19e1 | ||
|
|
24fa4668ef | ||
|
|
ce235c6f80 | ||
|
|
811d78729d | ||
|
|
9049eb53c5 | ||
|
|
d9f2b070cb | ||
|
|
38d62661ce | ||
|
|
8063aa7173 | ||
|
|
ce42466dd6 | ||
|
|
79adead5bf | ||
|
|
a966917c76 | ||
|
|
d49e114d84 | ||
|
|
da26cf832a | ||
|
|
5029626f9e | ||
|
|
590ac97e93 | ||
|
|
8198d6e35a | ||
|
|
1c1df8728d | ||
|
|
52253e89c5 | ||
|
|
63eacf115d | ||
|
|
76bac6495e | ||
|
|
16371a8502 | ||
|
|
717ae97af5 | ||
|
|
187ee51ae0 | ||
|
|
797aaac058 | ||
|
|
09c2e71141 | ||
|
|
dfde8a6724 | ||
|
|
0d8a92f53b | ||
|
|
b5bcf577b2 | ||
|
|
4eee6b17a8 | ||
|
|
82616dcfb8 | ||
|
|
5ddd4b115f | ||
|
|
c8f8fc45eb | ||
|
|
3c04a3c36f | ||
|
|
278b41b8ff | ||
|
|
c09dab9fef | ||
|
|
4110f5ff10 | ||
|
|
a72e46e7b6 | ||
|
|
6f3591ae11 | ||
|
|
a4b6bd0179 | ||
|
|
3ea890496e | ||
|
|
7a086baa7b | ||
|
|
5f03a5991e | ||
|
|
adab577f9a | ||
|
|
05fe56ec1a | ||
|
|
fb087c879b | ||
|
|
075104f448 | ||
|
|
ea0e4719fc | ||
|
|
2cba9ed4cd | ||
|
|
315745b022 | ||
|
|
952ca5e4ed | ||
|
|
800b27013d | ||
|
|
7bf80663dc | ||
|
|
b50373d6db | ||
|
|
1e1458caf1 | ||
|
|
76696826cd | ||
|
|
f8b2f8ce2e | ||
|
|
d4096db1f0 | ||
|
|
28c936a256 | ||
|
|
2dd8f7da55 | ||
|
|
d13f6657f8 | ||
|
|
bddc113f46 | ||
|
|
b7329ee904 | ||
|
|
9a398d79ff | ||
|
|
f9d04ffc98 | ||
|
|
245015fd89 |
@@ -16,6 +16,9 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# The shared workflow creates the draft release, which needs write access to contents.
|
||||
permissions:
|
||||
contents: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
|
||||
Generated
+26
-21
@@ -904,12 +904,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3"
|
||||
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -917,9 +917,13 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -3605,7 +3609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"eframe",
|
||||
@@ -3624,7 +3628,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3634,7 +3638,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -3646,7 +3650,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -3656,7 +3660,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading 0.9.0",
|
||||
@@ -3669,7 +3673,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
@@ -3680,7 +3684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -3691,7 +3695,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"log",
|
||||
@@ -3714,7 +3718,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3724,7 +3728,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -3740,7 +3744,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3753,7 +3757,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
@@ -3768,7 +3772,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3778,7 +3782,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -3800,11 +3804,12 @@ dependencies = [
|
||||
"soundcraft-video",
|
||||
"soundcraft-vst3-host",
|
||||
"toml",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"image",
|
||||
@@ -3816,7 +3821,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
@@ -3832,7 +3837,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5296,7 +5301,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+4
-4
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -45,13 +45,13 @@ zip = { version = "9", default-features = false }
|
||||
cpal = "0.18"
|
||||
|
||||
# Which file paths stored in a session may be opened.
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
|
||||
|
||||
# Video containers and bitstreams, shared with the other craft apps.
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4" }
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
|
||||
|
||||
# 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://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", features = ["serde"] }
|
||||
# egui 0.37 needs craft-libs#199 (craft-ui) first; pin 0.36 until it lands.
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
|
||||
@@ -122,11 +122,11 @@ where things are.
|
||||
- **Automation:** volume, pan, mute, send and every plugin parameter; Write, Touch, Latch and
|
||||
Trim passes recorded live from the faders; thin, glide, coalesce and convert to clip gain.
|
||||
- **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
|
||||
runs offline as AudioSuite, and user presets save per plugin.
|
||||
channel strip, room and plate reverbs, mod delay, auto pan, chorus, flanger, phaser, ring
|
||||
modulator, saturator, lo-fi, rectifier, subharmonic, 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 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,
|
||||
and Audio-to-MIDI from a sung or played melody.
|
||||
@@ -138,7 +138,7 @@ where things are.
|
||||
pitch-preserving Elastic warping.
|
||||
- **Audio files:** WAV/BWF/RF64, AIFF/AIFC and FLAC in and out; MP3, Ogg Vorbis, AAC, ALAC, CAF
|
||||
and the audio of MP4/MOV movies in. Any sample rate and bit depth.
|
||||
- **Bounce:** mix or stems to WAV, AIFF or FLAC at 16/24/32-bit float with dither and
|
||||
- **Bounce:** mix or stems to WAV, AIFF or FLAC at 16/24/32-bit float with optional dither and
|
||||
normalisation, reporting peak, true peak and integrated loudness (LUFS).
|
||||
- **Fast:** a parallel mix engine, copy-on-write undo, and a UI that holds 120 frames per second
|
||||
with seventy-odd tracks playing.
|
||||
@@ -387,4 +387,4 @@ Forks and modified versions must remove them.
|
||||
<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.
|
||||
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 Panel to Group, and Close Panel; the tab group's menu adds Float Tab Group and Close Tab Group. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
|
||||
|
||||
+25
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
|
||||
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** trivial (Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
|
||||
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
|
||||
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
|
||||
@@ -122,12 +122,25 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change.
|
||||
- **2026-10-24:** While the Strip Silence window is open, the Edit window draws a rectangle around each range that Strip would keep on the selected tracks, padded by Clip Start Pad and Clip End Pad, and the rectangles follow the sliders as they move. They are removed when the window closes. `edit.strip_silence_preview` returns the same ranges without changing the session. No readiness percentage change.
|
||||
- **2026-10-24:** The Strip Silence window has a Rename button. It opens Rename Selected Clips, where a name, a starting number, a count of zeros and a suffix set the names of the clips that Strip, Extract and Separate leave in the selection (for example SFX023.Reel1, SFX024.Reel1). The app keeps the scheme for later uses of Strip Silence until the app quits, or until Clear and then OK empty it. No readiness percentage change.
|
||||
- **2026-10-24:** The Strip Silence window has Extract and Separate buttons. Extract removes the audio and keeps the silent parts of the selection, and Separate separates the clips at the edges of the silence without removing anything. Minimum Strip Duration goes up to 4,000 ms. No readiness percentage change.
|
||||
- **2026-10-24:** Clip › Loop… (Cmd+Alt+L) opens the Clip Looping dialog: Number of Loops, Loop Length in the main counter's time format, or Loop Until End of Session or Next Clip. A loop length that is not a whole number of loops cuts the last loop short, and Unloop removes that short loop too. No readiness percentage change.
|
||||
- **2026-10-24:** AudioSuite ▸ Pitch Shift ▸ Vari-Fi slows the selected clips down to a complete stop or speeds them up from one, and the pitch follows the speed. Fit To keeps each clip's length and plays two-thirds of it; Extend plays all of it and makes the clip 150 % long. Fades On fades the audio out with the slowdown or in with the speedup. Before, the menu item resampled the clip at a constant speed. No readiness percentage change.
|
||||
- **2026-10-24:** A built-in Auto Pan plugin pans the mono sum of a stereo track between left and right. An LFO with seven waveforms moves it at a rate in BPM or at the session tempo, running free, once, in step with the transport, or when the level crosses a threshold or a manual trigger changes; an envelope follower can move it by level instead. Width, Manual and Path set the field, and Output sets the level. No readiness percentage change.
|
||||
- **2026-10-24:** A built-in Subharmonic (`subharmonic`) adds the octave below a lower and an upper band of the input, chosen from four frequency ranges, with a direct bass band, a gain and a solo for each band, Drive with six characters, HP and LP filters, and a Mix that keeps one path at full level. No readiness percentage change.
|
||||
- **2026-10-23:** A built-in Ring Modulator (`ring_mod`) applies ring modulation, frequency modulation or a positive or negative resonator, and an LFO, an envelope follower or sample and hold moves its frequency. AudioSuite › Modulation › Sci-Fi now runs it instead of the Flanger. No readiness percentage change.
|
||||
- **2026-10-19:** Float32 WAV and AIFF-C encoding preserve finite samples above full scale. Integer PCM still clips at full scale. Tests cover whole-file and streaming encoders, non-finite samples, and bounce output with normalization on and off. No readiness percentage change.
|
||||
- **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change.
|
||||
- **2026-10-16:** Master fader volume automation uses each render block's actual last sample, matching track faders. Breakpoints in the next block no longer pull the preceding block's level toward them, and ramps reach their last-sample value. Regression tests cover nonzero starts, one-sample callbacks and several block sizes. No readiness percentage change.
|
||||
- **2026-10-16:** Aux, folder and master latency calculations exclude inserts already taken out for producing NaN or infinity, matching audio tracks. Subsequent blocks no longer compensate for an insert that is bypassed after failure. Regression tests check impulse timing, healthy insert latency and reset/retry at 64- and 512-sample blocks. No readiness percentage change.
|
||||
- **2026-10-16:** Split into Mono extracts the correct source channel for each mono track instead of playing channel zero on every track. Clip offsets, gain, fades and stretch, source timestamps, Undo/Redo and saved audio are preserved; repeated clips share each extracted mono source. Unavailable media returns an error without partial edits. No readiness percentage change.
|
||||
- **2026-10-14:** MCP `batch` rejects more than 1000 calls before executing any command, instead of silently running only the first 1000 (#460). No readiness percentage change.
|
||||
- **2026-10-14:** `soundcraft-cli convert` dithers only when the output loses resolution (fewer bits than the source, a float or lossy source, or resampled/normalized samples); a same-depth, wider or 32-bit float conversion copies the samples exactly and digital silence stays silent. `--dither` and `--no-dither` override the choice (#548)
|
||||
- **2026-10-13:** WAV: an empty data chunk followed by metadata (LIST, bext) reads as no audio; a recording that was never finalised (sizes left at 0) still gives what was written (#466)
|
||||
- **2026-10-13:** Sampler: a ping-pong loop keeps its phase and direction when a step is wider than the loop (a narrow loop with high transposition), instead of reflecting once and dropping the rest of the travel (#463)
|
||||
- **2026-10-13 (VST3 host messages):** The VST3 host creates `IMessage` and `IAttributeList` objects for plugins (`IHostApplication::createInstance`), so plugins whose controller and processor talk through `IConnectionPoint::notify` work: Line 6 Helix Stadium Native now loads its content database (it reported "Failed to get content database recovery info (Out of memory condition)") and exposes its parameters. No readiness percentage change.
|
||||
- **2026-10-13:** `mix.instrument` takes the built-in instrument's parameters in `params` (validated, clamped) and refuses any other field, so a synth setting written beside `plugin` is an error instead of being accepted and ignored (#265)
|
||||
- **2026-10-13:** A bounce of a bus that an aux returns onto lines up with the timeline: the aux insert's lookahead is compensated like the main mix's (it left the bus late and cut the end off), and tracks routed to that bus wait for it too (#246)
|
||||
- **2026-10-13:** Delay compensation covers the inserts of the master faders: a bounce of the main mix with a Maximizer on the master lines up with the same mix without it (it came out 71 samples late) (#351)
|
||||
@@ -161,9 +174,20 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Strip Silence rectangles; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Strip Silence Rename; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Strip Silence Extract and Separate; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Clip Looping dialog; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: AudioSuite Vari-Fi speed ramps; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Auto Pan plugin; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Subharmonic plugin; readiness estimates unchanged |
|
||||
| 2026-10-23 | trivial | Progress log: Ring Modulator plugin; readiness estimates unchanged |
|
||||
| 2026-10-19 | trivial | Float32 file encoding preserves finite headroom (#791); readiness estimates unchanged |
|
||||
| 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged |
|
||||
| 2026-10-16 | trivial | Master volume automation block boundaries corrected; readiness estimates unchanged |
|
||||
| 2026-10-16 | trivial | Failed inserts no longer contribute aux, folder or master latency after containment; readiness estimates unchanged |
|
||||
| 2026-10-16 | trivial | Split into Mono preserves each source channel, clip edits and saved audio; readiness estimates unchanged |
|
||||
| 2026-10-14 | trivial | Progress log: oversized MCP batches rejected before execution (#460) |
|
||||
| 2026-10-13 | trivial | Progress log: empty WAV data chunk followed by metadata (#466) |
|
||||
| 2026-10-13 | trivial | Progress log: sampler ping-pong phase for wide steps (#463) |
|
||||
|
||||
@@ -3,30 +3,22 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AutomationArgs {
|
||||
pub read_root: Option<PathBuf>,
|
||||
pub write_root: Option<PathBuf>,
|
||||
read_given: bool,
|
||||
write_given: bool,
|
||||
pub read_roots: Vec<PathBuf>,
|
||||
pub write_roots: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||
Self { read_root, write_root, read_given: false, write_given: false }
|
||||
}
|
||||
|
||||
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
|
||||
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
|
||||
/// Returns whether this argument is a root flag. Each flag may be given more than once (#874),
|
||||
/// in either spelling (`--flag DIR`, `--flag=DIR`): every use adds a folder.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let (root, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
||||
let roots = match flag {
|
||||
"--automation-read-root" => &mut self.read_roots,
|
||||
"--automation-write-root" => &mut self.write_roots,
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *given {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
@@ -40,8 +32,7 @@ impl AutomationArgs {
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
*root = Some(value.into());
|
||||
*given = true;
|
||||
roots.push(value.into());
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -51,40 +42,43 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_are_taken_once_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
fn roots_add_up_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::default();
|
||||
let mut args = ["a folder".to_owned(), "song.scraft".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--demo", &mut args).unwrap());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
assert_eq!(roots.write_root, Some("out".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("a folder")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("out")]);
|
||||
assert_eq!(args.next().as_deref(), Some("song.scraft"));
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
// #874: a flag given again adds a folder, in either spelling.
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_operands_leave_roots_unchanged() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
|
||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||
let mut roots = AutomationArgs { read_roots: vec!["read".into()], write_roots: vec!["write".into()] };
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_root, Some("read".into()));
|
||||
assert_eq!(roots.write_root, Some("write".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("read")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("write")]);
|
||||
}
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
let mut roots = AutomationArgs::default();
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
let mut roots = AutomationArgs::default();
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
|
||||
}
|
||||
}
|
||||
|
||||
+171
-29
@@ -2,18 +2,22 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! soundcraft-cli info FILE audio file or .scraft summary
|
||||
//! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize]
|
||||
//! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize] [--dither | --no-dither]
|
||||
//! dithers only when the output loses resolution, unless told
|
||||
//! soundcraft-cli run [--in S.scraft | --demo] [--cmd ID[=JSON]]… [--bounce OUT] [--start T --end T] [--save S.scraft] [--inspect]
|
||||
//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT] (one `ID JSON` per line)
|
||||
//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT [--start T --end T]] (one `ID JSON` per line;
|
||||
//! without --start/--end, --bounce covers the whole session)
|
||||
//! soundcraft-cli commands [FILTER] list commands
|
||||
//! soundcraft-cli describe ID one command's details
|
||||
//! soundcraft-cli app [--port P] METHOD [JSON] call a running app's control channel
|
||||
//! soundcraft-cli mcp [--connect PORT] [--demo] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
//! MCP server on stdio. The two roots confine every file the
|
||||
//! soundcraft-cli mcp [--connect PORT] [--demo] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
//! MCP server on stdio. The roots confine every file the
|
||||
//! agent's commands read (open, import, presets) and write
|
||||
//! (save, bounce, export) to those folders, links followed;
|
||||
//! a root left out grants none of its access. They run the
|
||||
//! in-process server, so they conflict with --connect.
|
||||
//! each flag may be given more than once, a path inside any
|
||||
//! of its folders being allowed, and a kind left out grants
|
||||
//! none of its access. They run the in-process server, so
|
||||
//! they conflict with --connect.
|
||||
//! soundcraft-cli parity [--write PATH] parity report (markdown)
|
||||
//! soundcraft-cli plugins list built-in plugins
|
||||
//! ```
|
||||
@@ -38,9 +42,57 @@ macro_rules! outln {
|
||||
}
|
||||
|
||||
fn arg_value(args: &[String], flag: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned()
|
||||
args.iter().enumerate().find_map(|(i, a)| {
|
||||
if a == flag { args.get(i + 1).cloned() } else { a.strip_prefix(flag).and_then(|rest| rest.strip_prefix('=')).map(str::to_owned) }
|
||||
})
|
||||
}
|
||||
|
||||
/// Every option a subcommand accepts, with whether it carries a value.
|
||||
type Options = [(&'static str, bool)];
|
||||
|
||||
/// A misspelled option, or one whose value is missing, reads as absent through `arg_value`, so the
|
||||
/// subcommand would run without what was asked for and still report success. Reject both instead.
|
||||
fn check_options(args: &[String], allowed: &Options) -> Result<(), String> {
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let Some(name) = args[i].strip_prefix("--") else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let (name, inline) = name.split_once('=').map_or((name, None), |(n, v)| (n, Some(v)));
|
||||
let flag = format!("--{name}");
|
||||
let Some(&(_, takes_value)) = allowed.iter().find(|(f, _)| f == &flag) else {
|
||||
return Err(format!("unknown option {flag}"));
|
||||
};
|
||||
match (takes_value, inline) {
|
||||
(false, Some(_)) => return Err(format!("{flag} takes no value")),
|
||||
(true, None) => {
|
||||
// A following option is the next flag, not this one's value.
|
||||
if args.get(i + 1).is_none_or(|v| v.starts_with("--")) {
|
||||
return Err(format!("{flag} needs a value"));
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `--in`, `--demo`/`--sample`, `--bounce`, `--start`, `--end`, `--save` and `--inspect`, read by
|
||||
/// `load_engine` and `finish` and so shared by `run` and `script`.
|
||||
const ENGINE_OPTIONS: &Options = &[
|
||||
("--in", true),
|
||||
("--demo", false),
|
||||
("--sample", false),
|
||||
("--bounce", true),
|
||||
("--start", true),
|
||||
("--end", true),
|
||||
("--save", true),
|
||||
("--inspect", false),
|
||||
];
|
||||
|
||||
fn fail(msg: impl std::fmt::Display) -> ExitCode {
|
||||
eprintln!("soundcraft-cli: {msg}");
|
||||
ExitCode::FAILURE
|
||||
@@ -122,6 +174,10 @@ fn info(args: &[String]) -> ExitCode {
|
||||
|
||||
fn convert(args: &[String]) -> ExitCode {
|
||||
let (Some(input), Some(output)) = (args.first(), args.get(1)) else { return fail("convert IN OUT") };
|
||||
const OPTIONS: &Options = &[("--rate", true), ("--bit-depth", true), ("--normalize", false), ("--dither", false), ("--no-dither", false)];
|
||||
if let Err(e) = check_options(&args[2..], OPTIONS) {
|
||||
return fail(e);
|
||||
}
|
||||
let rate = match parse_rate(arg_value(args, "--rate").as_deref()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return fail(e),
|
||||
@@ -130,12 +186,16 @@ fn convert(args: &[String]) -> ExitCode {
|
||||
Ok(b) => b,
|
||||
Err(e) => return fail(e),
|
||||
};
|
||||
let dither_flag = match parse_dither(args) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return fail(e),
|
||||
};
|
||||
let bytes = match std::fs::read(input) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return fail(format!("{input}: {e}")),
|
||||
};
|
||||
let ext = std::path::Path::new(input).extension().and_then(std::ffi::OsStr::to_str);
|
||||
let (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
|
||||
let (info, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
|
||||
Ok(x) => x,
|
||||
Err(e) => return fail(format!("{input}: {e}")),
|
||||
};
|
||||
@@ -143,14 +203,17 @@ fn convert(args: &[String]) -> ExitCode {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
|
||||
buf.sample_rate = rate;
|
||||
}
|
||||
if args.iter().any(|a| a == "--normalize") {
|
||||
let normalize = args.iter().any(|a| a == "--normalize");
|
||||
if normalize {
|
||||
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
|
||||
}
|
||||
let processed = rate.is_some_and(|r| r != info.sample_rate) || normalize;
|
||||
let dither = dither_flag.unwrap_or_else(|| auto_dither(info.sample_format, bit_depth, processed));
|
||||
let out_ext = std::path::Path::new(output).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
|
||||
let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else {
|
||||
return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS));
|
||||
};
|
||||
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None };
|
||||
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf: None };
|
||||
match soundcraft_audio_io::encode(&buf, &opts).map_err(|e| e.to_string()).and_then(|b| std::fs::write(output, b).map_err(|e| e.to_string())) {
|
||||
Ok(()) => {
|
||||
outln!("wrote {output} ({} ch, {} Hz, {:.2} s)", buf.num_channels(), buf.sample_rate, buf.duration_secs());
|
||||
@@ -179,6 +242,41 @@ fn parse_bit_depth(v: Option<&str>) -> Result<soundcraft_audio_io::BitDepth, Str
|
||||
}
|
||||
}
|
||||
|
||||
/// `--dither` / `--no-dither`: `Some` when the caller chose, `None` to decide from the depths.
|
||||
fn parse_dither(args: &[String]) -> Result<Option<bool>, String> {
|
||||
match (args.iter().any(|a| a == "--dither"), args.iter().any(|a| a == "--no-dither")) {
|
||||
(true, true) => Err("--dither and --no-dither: choose one".into()),
|
||||
(true, false) => Ok(Some(true)),
|
||||
(false, true) => Ok(Some(false)),
|
||||
(false, false) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `convert` dithers when not told: only when writing integer samples that cannot hold the
|
||||
/// source exactly, i.e. the output has fewer bits than the source, the source is float or lossy, or
|
||||
/// resampling/normalizing produced new sample values. A 16-bit file written as 16 or 24-bit, or
|
||||
/// anything written as 32-bit float, is copied sample for sample (digital silence stays silent).
|
||||
fn auto_dither(source: soundcraft_audio_io::SampleFormat, target: soundcraft_audio_io::BitDepth, processed: bool) -> bool {
|
||||
use soundcraft_audio_io::{BitDepth, SampleFormat};
|
||||
let target_bits = match target {
|
||||
BitDepth::Float32 => return false,
|
||||
BitDepth::Int16 => 16,
|
||||
BitDepth::Int24 => 24,
|
||||
BitDepth::Int32 => 32,
|
||||
};
|
||||
if processed {
|
||||
return true;
|
||||
}
|
||||
match source {
|
||||
SampleFormat::Int8 => target_bits < 8,
|
||||
SampleFormat::Int16 => target_bits < 16,
|
||||
SampleFormat::Int24 => target_bits < 24,
|
||||
// Decoded through f32, which holds 24 bits: a 32-bit integer source has already lost
|
||||
// resolution, so treat it like float.
|
||||
SampleFormat::Int32 | SampleFormat::Float32 | SampleFormat::Float64 | SampleFormat::Compressed => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// One stderr line per media file a session refers to but could not load (the exit code is unchanged).
|
||||
fn missing_warnings(missing: &[String]) -> Vec<String> {
|
||||
missing.iter().map(|m| format!("warning: missing media: {m}")).collect()
|
||||
@@ -212,6 +310,11 @@ fn finish(e: &mut Engine, args: &[String]) -> ExitCode {
|
||||
if let Some(s) = arg_value(args, "--end") {
|
||||
p["end"] = time_arg(&s);
|
||||
}
|
||||
// No range given: bounce the whole session, not whatever edit selection the commands left.
|
||||
if p.get("start").is_none() && p.get("end").is_none() {
|
||||
p["start"] = json!(0);
|
||||
p["end"] = json!(e.session().content_end().max(1));
|
||||
}
|
||||
match e.execute("file.bounce_mix", &p) {
|
||||
Ok(v) => outln!("{v}"),
|
||||
Err(err) => return fail(err),
|
||||
@@ -243,32 +346,42 @@ fn parse_cmd_spec(spec: &str) -> Result<(String, Value), String> {
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> ExitCode {
|
||||
let options: Vec<(&str, bool)> = ENGINE_OPTIONS.iter().copied().chain([("--cmd", true)]).collect();
|
||||
if let Err(e) = check_options(args, &options) {
|
||||
return fail(e);
|
||||
}
|
||||
let mut e = match load_engine(args) {
|
||||
Ok(e) => e,
|
||||
Err(err) => return fail(err),
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
if args.get(i).is_some_and(|a| a == "--cmd")
|
||||
&& let Some(spec) = args.get(i + 1)
|
||||
{
|
||||
let (id, params) = match parse_cmd_spec(spec) {
|
||||
Ok(v) => v,
|
||||
Err(err) => return fail(err),
|
||||
};
|
||||
match e.execute(&id, ¶ms) {
|
||||
Ok(v) => outln!("{id}: {v}"),
|
||||
Err(err) => return fail(format!("{id}: {err}")),
|
||||
}
|
||||
i += 1;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
let spec = match a.strip_prefix("--cmd=") {
|
||||
Some(inline) => inline,
|
||||
None if a == "--cmd" => match it.next() {
|
||||
Some(next) => next,
|
||||
None => continue,
|
||||
},
|
||||
None => continue,
|
||||
};
|
||||
let (id, params) = match parse_cmd_spec(spec) {
|
||||
Ok(v) => v,
|
||||
Err(err) => return fail(err),
|
||||
};
|
||||
match e.execute(&id, ¶ms) {
|
||||
Ok(v) => outln!("{id}: {v}"),
|
||||
Err(err) => return fail(format!("{id}: {err}")),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
finish(&mut e, args)
|
||||
}
|
||||
|
||||
fn script(args: &[String]) -> ExitCode {
|
||||
let Some(path) = args.first() else { return fail("script FILE") };
|
||||
let options: Vec<(&str, bool)> = ENGINE_OPTIONS.iter().copied().chain([("--keep-going", false)]).collect();
|
||||
if let Err(e) = check_options(&args[1..], &options) {
|
||||
return fail(e);
|
||||
}
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return fail(format!("{path}: {e}")),
|
||||
@@ -398,7 +511,7 @@ struct McpArgs {
|
||||
fn parse_mcp_args(args: &[String]) -> Result<McpArgs, String> {
|
||||
let mut connect = None;
|
||||
let mut demo = false;
|
||||
let mut roots_args = automation_args::AutomationArgs::new(None, None);
|
||||
let mut roots_args = automation_args::AutomationArgs::default();
|
||||
let mut it = args.iter().cloned();
|
||||
while let Some(a) = it.next() {
|
||||
if roots_args.consume(&a, &mut it)? {
|
||||
@@ -411,7 +524,9 @@ fn parse_mcp_args(args: &[String]) -> Result<McpArgs, String> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
|
||||
let read: Vec<&std::path::Path> = roots_args.read_roots.iter().map(std::path::PathBuf::as_path).collect();
|
||||
let write: Vec<&std::path::Path> = roots_args.write_roots.iter().map(std::path::PathBuf::as_path).collect();
|
||||
let roots = AutomationRoots::new(&read, &write)?;
|
||||
if roots.is_some() && connect.is_some() {
|
||||
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
|
||||
so drop --connect (or use --connect without the roots)"
|
||||
@@ -437,8 +552,7 @@ fn mcp(args: &[String]) -> ExitCode {
|
||||
if let Some(roots) = roots {
|
||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||
file_access::confine_process(roots.clone());
|
||||
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
eprintln!("soundcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||||
eprintln!("soundcraft-cli: files confined: {}", roots.describe());
|
||||
h = h.with_automation_roots(Some(roots));
|
||||
}
|
||||
let mut s = Server::new(h);
|
||||
@@ -511,6 +625,34 @@ mod tests {
|
||||
assert!(parse_bit_depth(Some("8")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_dither_flags() {
|
||||
let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(parse_dither(&args(&["in.wav", "out.wav"])), Ok(None));
|
||||
assert_eq!(parse_dither(&args(&["in.wav", "out.wav", "--dither"])), Ok(Some(true)));
|
||||
assert_eq!(parse_dither(&args(&["in.wav", "out.wav", "--no-dither"])), Ok(Some(false)));
|
||||
assert!(parse_dither(&args(&["in.wav", "out.wav", "--dither", "--no-dither"])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_dithers_only_when_resolution_is_lost() {
|
||||
use soundcraft_audio_io::{BitDepth, SampleFormat};
|
||||
// Same depth or wider: the samples fit exactly.
|
||||
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int16, false));
|
||||
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int24, false));
|
||||
assert!(!auto_dither(SampleFormat::Int24, BitDepth::Int24, false));
|
||||
assert!(!auto_dither(SampleFormat::Int8, BitDepth::Int16, false));
|
||||
// Float output never dithers.
|
||||
assert!(!auto_dither(SampleFormat::Float32, BitDepth::Float32, true));
|
||||
assert!(!auto_dither(SampleFormat::Int24, BitDepth::Float32, false));
|
||||
// Fewer bits, a float or lossy source, or processed samples: dither.
|
||||
assert!(auto_dither(SampleFormat::Int24, BitDepth::Int16, false));
|
||||
assert!(auto_dither(SampleFormat::Float32, BitDepth::Int24, false));
|
||||
assert!(auto_dither(SampleFormat::Compressed, BitDepth::Int16, false));
|
||||
assert!(auto_dither(SampleFormat::Int32, BitDepth::Int24, false));
|
||||
assert!(auto_dither(SampleFormat::Int16, BitDepth::Int16, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_media_gets_one_warning_each() {
|
||||
assert!(missing_warnings(&[]).is_empty());
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! `soundcraft-cli convert` dithers only when the output loses resolution (#548), run as the real binary.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("sc-cli-convert-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A 16-bit mono WAV of `samples`, written without dither so the file holds exactly these values.
|
||||
fn write_wav16(path: &Path, samples: &[i16]) {
|
||||
let mut buf = AudioBuffer::new(48_000, 1, samples.len());
|
||||
for (d, s) in buf.channels[0].iter_mut().zip(samples) {
|
||||
*d = f32::from(*s) / 32768.0;
|
||||
}
|
||||
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 convert(input: &Path, output: &Path, extra: &[&str]) {
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"))
|
||||
.env("SOUNDCRAFT_NO_PREFS", "1")
|
||||
.arg("convert")
|
||||
.arg(input)
|
||||
.arg(output)
|
||||
.args(extra)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
}
|
||||
|
||||
/// The decoded samples of `path`, scaled to 16-bit steps.
|
||||
fn steps16(path: &Path) -> Vec<f64> {
|
||||
let (_, buf) = soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap();
|
||||
buf.channels[0].iter().map(|s| f64::from(*s) * 32768.0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_copies_samples_when_nothing_is_lost() {
|
||||
let dir = tmpdir("exact");
|
||||
let (silence, tone) = (dir.join("silence.wav"), dir.join("tone.wav"));
|
||||
let tone_samples: Vec<i16> = (0..4800).map(|i| (8000.0 * (f64::from(i) * 0.05).sin()) as i16).collect();
|
||||
write_wav16(&silence, &[0; 4800]);
|
||||
write_wav16(&tone, &tone_samples);
|
||||
let want: Vec<f64> = tone_samples.iter().map(|s| f64::from(*s)).collect();
|
||||
|
||||
// Same depth, wider and float: every sample comes back unchanged, silence stays silent.
|
||||
for (depth, name) in [("16", "s16.wav"), ("24", "s24.wav"), ("32f", "s32.wav")] {
|
||||
let out = dir.join(name);
|
||||
convert(&silence, &out, &["--bit-depth", depth]);
|
||||
assert!(steps16(&out).iter().all(|s| *s == 0.0), "silence at {depth} is not silent");
|
||||
let out = dir.join(format!("t{name}"));
|
||||
convert(&tone, &out, &["--bit-depth", depth]);
|
||||
assert_eq!(steps16(&out), want, "tone at {depth} changed");
|
||||
}
|
||||
// The default output depth (24-bit) too.
|
||||
let out = dir.join("default.wav");
|
||||
convert(&tone, &out, &[]);
|
||||
assert_eq!(steps16(&out), want);
|
||||
|
||||
// Asked for, dither is still applied.
|
||||
let out = dir.join("forced.wav");
|
||||
convert(&silence, &out, &["--bit-depth", "16", "--dither"]);
|
||||
assert!(steps16(&out).iter().any(|s| *s != 0.0), "--dither did not dither");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_dithers_when_reducing_depth_unless_told_not_to() {
|
||||
let dir = tmpdir("reduce");
|
||||
let src = dir.join("src24.wav");
|
||||
// A 24-bit file with detail below one 16-bit step.
|
||||
let mut buf = AudioBuffer::new(48_000, 1, 4800);
|
||||
for (i, s) in buf.channels[0].iter_mut().enumerate() {
|
||||
*s = 0.3 / 32768.0 * (i as f32 * 0.01).sin();
|
||||
}
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None };
|
||||
std::fs::write(&src, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
|
||||
|
||||
let dithered = dir.join("d16.wav");
|
||||
convert(&src, &dithered, &["--bit-depth", "16"]);
|
||||
assert!(steps16(&dithered).iter().any(|s| *s != 0.0), "24 to 16-bit was not dithered");
|
||||
// Plain rounding: everything is below half a step, so it all rounds to zero.
|
||||
let plain = dir.join("p16.wav");
|
||||
convert(&src, &plain, &["--bit-depth", "16", "--no-dither"]);
|
||||
assert!(steps16(&plain).iter().all(|s| *s == 0.0), "--no-dither still dithered");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
use serde_json::Value;
|
||||
@@ -24,10 +24,33 @@ fn tmpdir(tag: &str) -> PathBuf {
|
||||
dir
|
||||
}
|
||||
|
||||
/// `path` made absolute with links followed (`/var` becomes `/private/var` on macOS), as the server
|
||||
/// prints a root: without the `\\?\` prefix that `canonicalize` adds on Windows.
|
||||
fn resolved(path: &Path) -> PathBuf {
|
||||
let path = std::fs::canonicalize(path).unwrap();
|
||||
if !cfg!(windows) {
|
||||
return path;
|
||||
}
|
||||
let text = path.to_str().unwrap();
|
||||
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
|
||||
return PathBuf::from(format!(r"\\{rest}"));
|
||||
}
|
||||
text.strip_prefix(r"\\?\").map_or(path.clone(), PathBuf::from)
|
||||
}
|
||||
|
||||
/// Send `calls` (tool name, arguments) to `soundcraft-cli mcp <args>`; returns whether it exited
|
||||
/// cleanly, its stderr, and the stdout lines after `initialize`, one per call.
|
||||
fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) {
|
||||
let mut child = bin().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
mcp_session_in(None, args, calls)
|
||||
}
|
||||
|
||||
/// [`mcp_session`] with the server started in the working directory `cwd`.
|
||||
fn mcp_session_in(cwd: Option<&Path>, args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) {
|
||||
let mut cmd = bin();
|
||||
if let Some(cwd) = cwd {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
let mut child = cmd.arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
|
||||
@@ -101,6 +124,73 @@ fn mcp_automation_roots_confine_the_server() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// #874: each root flag may be given more than once. A path inside any root of its kind passes;
|
||||
/// the two roots' common parent, `..` out of a root and a link out of one stay refused.
|
||||
#[test]
|
||||
fn mcp_automation_roots_take_several_folders() {
|
||||
let base = resolved(&tmpdir("several"));
|
||||
for d in ["in", "in2", "out", "out2", "outside"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
for f in ["in/a.wav", "in2/b.wav", "outside/secret.wav", "parent.wav"] {
|
||||
make_wav(&base.join(f));
|
||||
}
|
||||
let p = |f: &str| base.join(f).to_str().unwrap().to_string();
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let calls = [
|
||||
("import_audio", serde_json::json!({"path": p("in/a.wav")})),
|
||||
("import_audio", serde_json::json!({"path": p("in2/b.wav")})),
|
||||
// The roots' common parent is not a root.
|
||||
("import_audio", serde_json::json!({"path": p("parent.wav")})),
|
||||
("import_audio", serde_json::json!({"path": format!("{}{sep}..{sep}outside{sep}secret.wav", p("in2"))})),
|
||||
("bounce_mix", serde_json::json!({"path": p("out/a.wav")})),
|
||||
("bounce_mix", serde_json::json!({"path": p("out2/b.wav")})),
|
||||
("bounce_mix", serde_json::json!({"path": p("x.wav")})),
|
||||
// Read and write stay separate: a read root is not a write root, nor the other way round.
|
||||
("bounce_mix", serde_json::json!({"path": p("in2/c.wav")})),
|
||||
("import_audio", serde_json::json!({"path": p("out2/b.wav")})),
|
||||
];
|
||||
let read2 = format!("--automation-read-root={}", p("in2"));
|
||||
let args = ["--automation-read-root", &p("in"), &read2, "--automation-write-root", &p("out"), "--automation-write-root", &p("out2")];
|
||||
let (success, stderr, replies) = mcp_session(&args, &calls);
|
||||
assert!(success, "{stderr}");
|
||||
assert!(stderr.contains(&format!("read {}, {}", p("in"), p("in2"))), "{stderr}");
|
||||
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
|
||||
assert_eq!(errors, [false, false, true, true, false, false, true, true, true], "{replies:?}");
|
||||
for (i, kind) in [(2, "read"), (3, "read"), (6, "write"), (7, "write"), (8, "read")] {
|
||||
assert!(text(&replies[i]).contains(&format!("outside the {kind} roots ")), "{}", text(&replies[i]));
|
||||
}
|
||||
assert!(base.join("out/a.wav").is_file() && base.join("out2/b.wav").is_file());
|
||||
assert!(!base.join("x.wav").exists() && !base.join("in2/c.wav").exists());
|
||||
|
||||
// A link in one root that leads out of all of them is outside.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(base.join("outside/secret.wav"), base.join("in2/leak.wav")).unwrap();
|
||||
let (_, _, replies) = mcp_session(&args, &[("import_audio", serde_json::json!({"path": p("in2/leak.wav")}))]);
|
||||
assert!(text(&replies[0]).contains("outside the read roots"), "{}", text(&replies[0]));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_save_session_name_lands_in_the_working_directory_inside_the_roots() {
|
||||
let work = tmpdir("bare-name");
|
||||
let work_s = work.to_str().unwrap();
|
||||
let calls = [
|
||||
("new_session", serde_json::json!({"name": "t", "sample_rate": 48000})),
|
||||
("execute", serde_json::json!({"command": "track.new", "params": {"kind": "audio", "name": "Tone", "format": "mono"}})),
|
||||
("save_session", serde_json::json!({"path": "t.scraft"})),
|
||||
];
|
||||
let (success, stderr, replies) = mcp_session_in(Some(&work), &["--automation-read-root", work_s, "--automation-write-root", work_s], &calls);
|
||||
assert!(success, "{stderr}");
|
||||
for reply in &replies {
|
||||
assert_eq!(reply["result"]["isError"], false, "{reply}");
|
||||
}
|
||||
assert!(work.join("t.scraft").is_file());
|
||||
let _ = std::fs::remove_dir_all(work);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
let base = tmpdir("roots-args");
|
||||
@@ -117,8 +207,9 @@ fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
// The flags confine the in-process server, so they cannot be combined with --connect.
|
||||
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
|
||||
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
|
||||
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
|
||||
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
|
||||
// #874: a flag may be given again, but every folder it names must exist.
|
||||
fails(&["--automation-read-root", dir, "--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
fails(&["--automation-write-root", dir, "--automation-write-root"], "needs a folder");
|
||||
fails(&["--automation-read-root"], "needs a folder");
|
||||
fails(&["--automation-write-root", "--demo"], "needs a folder");
|
||||
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
@@ -137,3 +228,28 @@ fn usage_lists_the_root_flags() {
|
||||
let help = String::from_utf8_lossy(&o.stdout).into_owned();
|
||||
assert!(help.contains("--automation-read-root DIR") && help.contains("--automation-write-root DIR"), "{help}");
|
||||
}
|
||||
|
||||
/// Without `--start`/`--end`, `--bounce` covers the whole session, not a stray edit selection
|
||||
/// left by the script (a bare `19.2` in command params is 19 samples).
|
||||
#[test]
|
||||
fn script_bounce_without_range_covers_the_session() {
|
||||
let base = tmpdir("bounce-range");
|
||||
let script = base.join("cmds.txt");
|
||||
std::fs::write(
|
||||
&script,
|
||||
concat!(
|
||||
"track.new {\"count\":1,\"kind\":\"instrument\",\"name\":\"Keys\"}\n",
|
||||
"midi.new_clip {\"track\":\"Keys\",\"start\":0,\"end\":{\"seconds\":19.2}}\n",
|
||||
"edit.select {\"start\":0,\"end\":19.2}\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let out = base.join("out.wav");
|
||||
let o = run(&["script", script.to_str().unwrap(), "--bounce", out.to_str().unwrap()]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let stdout = String::from_utf8_lossy(&o.stdout).into_owned();
|
||||
let last: Value = serde_json::from_str(stdout.lines().last().unwrap()).unwrap();
|
||||
let secs = last["seconds"].as_f64().unwrap();
|
||||
assert!((secs - 19.2).abs() < 0.01, "bounced {secs} s: {stdout}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Unknown options and missing option values must be reported, not silently skipped (#549).
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn bin() -> Command {
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
|
||||
cmd.env("SOUNDCRAFT_NO_PREFS", "1");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn run(args: &[&str]) -> Output {
|
||||
bin().args(args).output().unwrap()
|
||||
}
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("sc-cli-options-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A 0.1 s mono 48 kHz 16-bit WAV, written by hand so the test needs no fixture.
|
||||
fn tone(dir: &Path) -> PathBuf {
|
||||
let frames: u32 = 4800;
|
||||
let data: Vec<u8> = (0..frames).flat_map(|_| [0x00, 0x10]).collect();
|
||||
let (bytes, rate, ch, bits) = (data.len() as u32, 48_000u32, 1u16, 16u16);
|
||||
let mut wav = Vec::new();
|
||||
wav.extend_from_slice(b"RIFF");
|
||||
wav.extend_from_slice(&(36 + bytes).to_le_bytes());
|
||||
wav.extend_from_slice(b"WAVEfmt ");
|
||||
wav.extend_from_slice(&16u32.to_le_bytes());
|
||||
wav.extend_from_slice(&1u16.to_le_bytes());
|
||||
wav.extend_from_slice(&ch.to_le_bytes());
|
||||
wav.extend_from_slice(&rate.to_le_bytes());
|
||||
wav.extend_from_slice(&(rate * u32::from(ch) * u32::from(bits) / 8).to_le_bytes());
|
||||
wav.extend_from_slice(&(ch * bits / 8).to_le_bytes());
|
||||
wav.extend_from_slice(&bits.to_le_bytes());
|
||||
wav.extend_from_slice(b"data");
|
||||
wav.extend_from_slice(&bytes.to_le_bytes());
|
||||
wav.extend_from_slice(&data);
|
||||
let path = dir.join("tone.wav");
|
||||
std::fs::write(&path, wav).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn sample_format(cli_info: &str) -> String {
|
||||
serde_json::from_str::<serde_json::Value>(cli_info).unwrap()["sample_format"].as_str().unwrap().to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rejects_an_unknown_option_instead_of_writing_the_file() {
|
||||
let dir = tmpdir("convert-unknown");
|
||||
let input = tone(&dir);
|
||||
let out = dir.join("out.wav");
|
||||
for bad in ["--bitdepth", "--normalise", "--bogus"] {
|
||||
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), bad]);
|
||||
assert!(!o.status.success(), "{bad} should fail");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("unknown option {bad}")), "{bad}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(!out.exists(), "{bad} should not write the output");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rejects_an_option_whose_value_is_missing() {
|
||||
let dir = tmpdir("convert-novalue");
|
||||
let input = tone(&dir);
|
||||
let out = dir.join("out.wav");
|
||||
for bad in ["--rate", "--bit-depth"] {
|
||||
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), bad]);
|
||||
assert!(!o.status.success(), "{bad} should fail");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("{bad} needs a value")), "{bad}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(!out.exists(), "{bad} should not write the output");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_accepts_the_inline_value_form() {
|
||||
let dir = tmpdir("convert-inline");
|
||||
let input = tone(&dir);
|
||||
let out = dir.join("out.wav");
|
||||
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), "--bit-depth=32f"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let info = run(&["info", out.to_str().unwrap()]);
|
||||
assert_eq!(sample_format(&String::from_utf8_lossy(&info.stdout)), "Float32");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_still_accepts_the_documented_space_separated_form() {
|
||||
let dir = tmpdir("convert-space");
|
||||
let input = tone(&dir);
|
||||
let out = dir.join("out.wav");
|
||||
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), "--bit-depth", "16"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let info = run(&["info", out.to_str().unwrap()]);
|
||||
assert_eq!(sample_format(&String::from_utf8_lossy(&info.stdout)), "Int16");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_rejects_a_misspelled_or_value_less_bounce() {
|
||||
let dir = tmpdir("run-bounce");
|
||||
let out = dir.join("x.wav");
|
||||
let o = run(&["run", "--cmd", "session.new", "--bounce"]);
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("--bounce needs a value"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
|
||||
let o = run(&["run", "--cmd", "session.new", "--bounces", out.to_str().unwrap()]);
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown option --bounces"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(!out.exists(), "nothing should be bounced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_bounces_with_the_inline_value_form() {
|
||||
let dir = tmpdir("run-inline");
|
||||
let out = dir.join("y.wav");
|
||||
let o = run(&["run", "--cmd", "session.new", &format!("--bounce={}", out.display())]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(out.exists(), "--bounce=PATH should write the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_executes_the_inline_cmd_form() {
|
||||
let o = run(&["run", "--cmd=track.new", "--cmd", "track.new"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
assert_eq!(stdout.lines().filter(|l| l.starts_with("track.new: ")).count(), 2, "both --cmd forms should run: {stdout}");
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use soundcraft_ui_egui::docking;
|
||||
|
||||
/// Portable mode (#268): this file beside the executable keeps settings, autosaves, presets and
|
||||
/// logs in [`PORTABLE_DATA_DIR`] next to it instead of the user profile.
|
||||
const PORTABLE_MARKER: &str = "portable.txt";
|
||||
@@ -88,19 +90,18 @@ fn read_ui(path: &std::path::Path, recover_docking: bool) -> Result<Option<sound
|
||||
let mut saved: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| format!("Settings could not be restored: {error}"))?;
|
||||
if recover_docking && let Some(fields) = saved.as_object_mut() {
|
||||
for (field, mix) in [("docking", false), ("mix_docking", true)] {
|
||||
let unreadable =
|
||||
fields.get(field).is_some_and(|value| match serde_json::from_value::<soundcraft_ui_egui::docking::Workspace>(value.clone()) {
|
||||
Ok(workspace) => if mix { workspace.validate_mix() } else { workspace.validate() }.is_err(),
|
||||
Err(_) => true,
|
||||
});
|
||||
let unreadable = fields.get(field).is_some_and(|value| match serde_json::from_value::<docking::Workspace>(value.clone()) {
|
||||
Ok(workspace) => if mix { docking::validate_mix(&workspace) } else { docking::validate(&workspace) }.is_err(),
|
||||
Err(_) => true,
|
||||
});
|
||||
if unreadable {
|
||||
fields.remove(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
let state: soundcraft_ui_egui::UiState = serde_json::from_value(saved).map_err(|error| format!("Settings could not be restored: {error}"))?;
|
||||
state.docking.validate()?;
|
||||
state.mix_docking.validate_mix()?;
|
||||
docking::validate(&state.docking)?;
|
||||
docking::validate_mix(&state.mix_docking)?;
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
@@ -111,8 +112,8 @@ pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Resu
|
||||
use std::io::Write as _;
|
||||
// Loading can recover a future layout for this session, but saving must preserve its source.
|
||||
read_ui(path, false)?;
|
||||
ui.docking.validate()?;
|
||||
ui.mix_docking.validate_mix()?;
|
||||
docking::validate(&ui.docking)?;
|
||||
docking::validate_mix(&ui.mix_docking)?;
|
||||
let mut saved = ui.clone();
|
||||
saved.plugin_windows.clear();
|
||||
saved.status.clear();
|
||||
@@ -160,18 +161,18 @@ mod config_tests {
|
||||
app.ui = load_ui(&path).unwrap().unwrap();
|
||||
assert_eq!(app.ui.theme, soundcraft_ui_egui::theme::ThemeMode::Light);
|
||||
assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), edit);
|
||||
assert_eq!(app.ui.mix_docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]);
|
||||
assert_eq!(app.ui.mix_docking.layout.floating.iter().find(|g| g.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]);
|
||||
app.ui.window = MainWindow::Edit;
|
||||
app.run("ui.dock", serde_json::json!({"operation":"open","panel":"tracks"})).unwrap();
|
||||
assert_eq!(app.ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]);
|
||||
assert_eq!(app.ui.docking.layout.floating.iter().find(|g| g.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]);
|
||||
let mut invalid_mix = app.ui.clone();
|
||||
invalid_mix.mix_docking = soundcraft_ui_egui::docking::Workspace::default();
|
||||
invalid_mix.mix_docking = soundcraft_ui_egui::docking::edit_default();
|
||||
let invalid_bytes = serde_json::to_vec(&invalid_mix).unwrap();
|
||||
std::fs::write(&path, &invalid_bytes).unwrap();
|
||||
let recovered = load_ui(&path).unwrap().unwrap();
|
||||
assert_eq!(recovered.theme, app.ui.theme);
|
||||
assert_eq!(serde_json::to_value(recovered.docking).unwrap(), serde_json::to_value(&app.ui.docking).unwrap());
|
||||
recovered.mix_docking.validate_mix().unwrap();
|
||||
soundcraft_ui_egui::docking::validate_mix(&recovered.mix_docking).unwrap();
|
||||
assert!(save_ui(&path, &app.ui).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), invalid_bytes);
|
||||
std::fs::write(&path, b"damaged settings").unwrap();
|
||||
@@ -201,8 +202,8 @@ mod config_tests {
|
||||
let recovered = load_ui(&path).unwrap().unwrap();
|
||||
assert_eq!(recovered.theme, app.ui.theme);
|
||||
assert!(recovered.narrow_mix);
|
||||
recovered.docking.validate().unwrap();
|
||||
recovered.mix_docking.validate_mix().unwrap();
|
||||
soundcraft_ui_egui::docking::validate(&recovered.docking).unwrap();
|
||||
soundcraft_ui_egui::docking::validate_mix(&recovered.mix_docking).unwrap();
|
||||
let other = if field == "docking" { "mix_docking" } else { "docking" };
|
||||
assert_eq!(serde_json::to_value(&recovered).unwrap()[other], saved[other]);
|
||||
assert!(save_ui(&path, &recovered).is_err());
|
||||
|
||||
@@ -112,8 +112,10 @@ fn open_path(app: &mut SoundApp, p: &str) {
|
||||
} 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 {
|
||||
// Decoded in the background so a long file doesn't freeze the window (#597).
|
||||
let at = app.engine.session().edit.selection.start;
|
||||
app.run("file.import_audio", serde_json::json!({"path": p, "at": at}))
|
||||
soundcraft_ui_egui::import::start(app, serde_json::json!({"path": p, "at": at}));
|
||||
Ok(serde_json::Value::Null)
|
||||
};
|
||||
if let Err(e) = r {
|
||||
log::warn!("{p}: {e}");
|
||||
|
||||
@@ -55,3 +55,9 @@ sync_github_name = true
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.mattburlage]
|
||||
display_name = "Matt Smith-Burlage"
|
||||
|
||||
[people.ProdByBuddha]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -192,7 +192,7 @@ pub enum AuError {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod api {
|
||||
use super::{AuDescriptor, AuError, Plugin, PluginInfo};
|
||||
use super::{AuDescriptor, AuError, Plugin, PluginInfo, registry};
|
||||
|
||||
pub fn scan() -> Vec<AuDescriptor> {
|
||||
registry::scan()
|
||||
|
||||
@@ -115,6 +115,7 @@ pub enum BitDepth {
|
||||
Int16,
|
||||
Int24,
|
||||
Int32,
|
||||
/// Preserve finite samples, including values above full scale; map NaN to 0 and infinities to ±1.
|
||||
Float32,
|
||||
}
|
||||
|
||||
|
||||
@@ -193,8 +193,8 @@ pub(crate) fn write_interleaved(
|
||||
for (c, q) in quantizers.iter_mut().enumerate() {
|
||||
let s = block.get(c).and_then(|ch| ch.get(f)).copied().unwrap_or(0.0);
|
||||
match (is_float, big_endian) {
|
||||
(true, false) => scratch.extend_from_slice(&clamp_float(s).to_le_bytes()),
|
||||
(true, true) => scratch.extend_from_slice(&clamp_float(s).to_be_bytes()),
|
||||
(true, false) => scratch.extend_from_slice(&sanitize_float(s).to_le_bytes()),
|
||||
(true, true) => scratch.extend_from_slice(&sanitize_float(s).to_be_bytes()),
|
||||
(false, false) => scratch.extend_from_slice(q.quantize(s).to_le_bytes().get(..size).unwrap_or_default()),
|
||||
(false, true) => scratch.extend_from_slice(q.quantize(s).to_be_bytes().get(4 - size..).unwrap_or_default()),
|
||||
}
|
||||
@@ -206,9 +206,15 @@ pub(crate) fn write_interleaved(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clamp a float sample to [-1, 1], mapping NaN to 0.
|
||||
pub(crate) fn clamp_float(x: f32) -> f32 {
|
||||
if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) }
|
||||
/// Preserve finite float samples, mapping NaN to 0 and infinities to their sign (±1).
|
||||
fn sanitize_float(x: f32) -> f32 {
|
||||
if x.is_finite() {
|
||||
x
|
||||
} else if x.is_nan() {
|
||||
0.0
|
||||
} else {
|
||||
x.signum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that a buffer is encodable: at least one channel and a non-zero sample rate.
|
||||
|
||||
@@ -10,6 +10,8 @@ const WAVE_FORMAT_IEEE_FLOAT: u16 = 3;
|
||||
const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE;
|
||||
/// Tail of the KSDATAFORMAT_SUBTYPE_* GUIDs (bytes 2..16); bytes 0..2 hold the format tag.
|
||||
const GUID_TAIL: [u8; 14] = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71];
|
||||
/// Tail of the Ambisonic subtype GUIDs {0000000x-0721-11D3-8644-C8C1CA000000}: PCM (x = 1) and float (x = 3) as well.
|
||||
const AMBISONIC_TAIL: [u8; 14] = [0x00, 0x00, 0x21, 0x07, 0xD3, 0x11, 0x86, 0x44, 0xC8, 0xC1, 0xCA, 0x00, 0x00, 0x00];
|
||||
const BEXT_MIN_LEN: usize = 602;
|
||||
|
||||
/// Parsed WAV layout (no sample data decoded yet).
|
||||
@@ -50,13 +52,25 @@ fn parse_fmt(c: &[u8]) -> Result<Fmt> {
|
||||
fmt.valid_bits = fmt.bits;
|
||||
if fmt.tag == WAVE_FORMAT_EXTENSIBLE {
|
||||
// cbSize(2) validBits(2) channelMask(4) subFormat GUID(16)
|
||||
if let (Some(valid), Some(sub)) = (le_u16(c, 18), le_u16(c, 24)) {
|
||||
if valid != 0 {
|
||||
fmt.valid_bits = valid;
|
||||
}
|
||||
fmt.tag = sub;
|
||||
} else {
|
||||
let (Some(cb_size), Some(valid), Some(sub)) = (le_u16(c, 16), le_u16(c, 18), le_u16(c, 24)) else {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into()));
|
||||
};
|
||||
let Some(tail) = c.get(26..40) else {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into()));
|
||||
};
|
||||
if cb_size < 22 {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE cbSize is below 22".into()));
|
||||
}
|
||||
if valid > fmt.bits {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE valid bits exceed the container size".into()));
|
||||
}
|
||||
if valid != 0 {
|
||||
fmt.valid_bits = valid;
|
||||
}
|
||||
// A subtype outside the KSDATAFORMAT_SUBTYPE_* family is not PCM/float, whatever its first two bytes say:
|
||||
// keep the extensible tag so the file goes to the foreign decoder.
|
||||
if tail == GUID_TAIL || tail == AMBISONIC_TAIL {
|
||||
fmt.tag = sub;
|
||||
}
|
||||
}
|
||||
Ok(fmt)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileEncoder, FileFormat, SampleFormat, decode, encode};
|
||||
use std::io::Cursor;
|
||||
|
||||
fn encoded(audio: &AudioBuffer, options: &EncodeOptions, streaming: bool) -> Vec<u8> {
|
||||
if !streaming {
|
||||
return encode(audio, options).unwrap();
|
||||
}
|
||||
let mut writer = FileEncoder::new(Cursor::new(Vec::new()), options, audio.num_channels(), audio.sample_rate, None).unwrap();
|
||||
// Cross the writer's 4096-frame scratch boundary, then write a short final block.
|
||||
for start in (0..audio.frames()).step_by(4097) {
|
||||
let end = (start + 4097).min(audio.frames());
|
||||
let block: Vec<Vec<f32>> = audio.channels.iter().map(|channel| channel[start..end].to_vec()).collect();
|
||||
writer.write(&block, end - start).unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn assert_float_headroom(streaming: bool) {
|
||||
let samples = [
|
||||
-f32::MAX,
|
||||
-2.0,
|
||||
-1.25,
|
||||
-1.0,
|
||||
-0.5,
|
||||
-f32::MIN_POSITIVE,
|
||||
-f32::from_bits(1),
|
||||
-0.0,
|
||||
0.0,
|
||||
f32::from_bits(1),
|
||||
f32::MIN_POSITIVE,
|
||||
0.5,
|
||||
1.0,
|
||||
1.25,
|
||||
2.0,
|
||||
f32::MAX,
|
||||
];
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff] {
|
||||
for dither in [false, true] {
|
||||
for channels in [1, 2] {
|
||||
let audio = AudioBuffer {
|
||||
sample_rate: 48_000,
|
||||
channels: (0..channels).map(|c| samples.iter().copied().cycle().skip(c).take(4103).collect()).collect(),
|
||||
};
|
||||
let options = EncodeOptions { format, bit_depth: BitDepth::Float32, dither, bwf: None };
|
||||
let bytes = encoded(&audio, &options, streaming);
|
||||
let pcm: Vec<u8> =
|
||||
audio.interleaved().iter().flat_map(|v| if format == FileFormat::Wav { v.to_le_bytes() } else { v.to_be_bytes() }).collect();
|
||||
assert!(bytes.ends_with(&pcm), "{format:?}, dither={dither}: stored sample bytes changed");
|
||||
let (info, decoded) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(info.sample_format, SampleFormat::Float32);
|
||||
assert_eq!((decoded.sample_rate, decoded.num_channels(), decoded.frames()), (48_000, channels, 4103));
|
||||
for (channel, (actual, expected)) in decoded.channels.iter().zip(&audio.channels).enumerate() {
|
||||
for (frame, (actual, expected)) in actual.iter().zip(expected).enumerate() {
|
||||
assert_eq!(actual.to_bits(), expected.to_bits(), "{format:?}, dither={dither}, channel={channel}, frame={frame}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_file_float32_preserves_finite_headroom() {
|
||||
assert_float_headroom(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_float32_preserves_finite_headroom() {
|
||||
assert_float_headroom(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float32_keeps_non_finite_sample_handling() {
|
||||
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![f32::NAN, f32::NEG_INFINITY, f32::INFINITY]] };
|
||||
let expected = [0.0_f32, -1.0, 1.0];
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff] {
|
||||
for streaming in [false, true] {
|
||||
let options = EncodeOptions { format, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = encoded(&audio, &options, streaming);
|
||||
// Inspect stored samples too: the decoder itself replaces non-finite values.
|
||||
let pcm: Vec<u8> = expected.iter().flat_map(|v| if format == FileFormat::Wav { v.to_le_bytes() } else { v.to_be_bytes() }).collect();
|
||||
assert!(bytes.ends_with(&pcm), "{format:?}, streaming={streaming}");
|
||||
assert_eq!(decode(&bytes, None).unwrap().1.channels[0], expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_pcm_still_clips_at_full_scale() {
|
||||
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![-2.0, -1.25, -1.0, -0.5, 0.0, 0.5, 1.0, 1.25, 2.0]] };
|
||||
for (depth, max) in [(BitDepth::Int16, 32767.0 / 32768.0), (BitDepth::Int24, 8_388_607.0 / 8_388_608.0), (BitDepth::Int32, 1.0)] {
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||
for streaming in [false, true] {
|
||||
let options = EncodeOptions { format, bit_depth: depth, dither: false, bwf: None };
|
||||
let bytes = encoded(&audio, &options, streaming);
|
||||
let (_, decoded) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(decoded.channels[0], [-1.0, -1.0, -1.0, -0.5, 0.0, 0.5, max, max, max], "{format:?} {depth:?}, streaming={streaming}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
mod encode_format;
|
||||
mod float_headroom;
|
||||
mod malformed;
|
||||
mod roundtrip;
|
||||
mod stream;
|
||||
|
||||
@@ -511,3 +511,41 @@ proptest! {
|
||||
prop_assert_eq!(flac, wav);
|
||||
}
|
||||
}
|
||||
|
||||
/// An extensible `fmt ` chunk: cbSize, valid bits, mask, then the 16-byte subtype GUID.
|
||||
fn extensible_fmt(cb_size: u16, valid_bits: u16, guid: &[u8]) -> Vec<u8> {
|
||||
let mut f = fmt(0xFFFE, 1, 44_100, 16);
|
||||
f.extend_from_slice(&cb_size.to_le_bytes());
|
||||
f.extend_from_slice(&valid_bits.to_le_bytes());
|
||||
f.extend_from_slice(&4u32.to_le_bytes());
|
||||
f.extend_from_slice(guid);
|
||||
f
|
||||
}
|
||||
|
||||
const PCM_GUID: [u8; 16] = [1, 0, 0, 0, 0, 0, 0x10, 0, 0x80, 0, 0, 0xAA, 0, 0x38, 0x9B, 0x71];
|
||||
|
||||
#[test]
|
||||
fn extensible_fmt_is_checked_in_full() {
|
||||
let data = vec![0u8; 8];
|
||||
let good = riff(&[(b"fmt ", extensible_fmt(22, 16, &PCM_GUID)), (b"data", data.clone())]);
|
||||
assert_eq!(probe(&good, None).unwrap().sample_format, SampleFormat::Int16);
|
||||
// Subtype GUID cut short after the format code.
|
||||
let cut = riff(&[(b"fmt ", extensible_fmt(22, 16, &PCM_GUID[..4])), (b"data", data.clone())]);
|
||||
assert!(matches!(probe(&cut, None), Err(AudioError::Malformed(_))));
|
||||
assert!(matches!(decode(&cut, None), Err(AudioError::Malformed(_))));
|
||||
// cbSize too small for the extension.
|
||||
let small_cb = riff(&[(b"fmt ", extensible_fmt(0, 16, &PCM_GUID)), (b"data", data.clone())]);
|
||||
assert!(matches!(probe(&small_cb, None), Err(AudioError::Malformed(_))));
|
||||
// More valid bits than the 16-bit container holds.
|
||||
let wide = riff(&[(b"fmt ", extensible_fmt(22, 24, &PCM_GUID)), (b"data", data.clone())]);
|
||||
assert!(matches!(probe(&wide, None), Err(AudioError::Malformed(_))));
|
||||
// PCM-like prefix but a foreign GUID tail is not read as native PCM.
|
||||
let mut foreign = PCM_GUID;
|
||||
foreign[15] ^= 0xFF;
|
||||
let foreign = riff(&[(b"fmt ", extensible_fmt(22, 16, &foreign)), (b"data", data)]);
|
||||
assert!(!matches!(probe(&foreign, None), Ok(i) if i.sample_format == SampleFormat::Int16));
|
||||
// The Ambisonic PCM subtype is PCM.
|
||||
let ambi = [1, 0, 0, 0, 0x21, 0x07, 0xD3, 0x11, 0x86, 0x44, 0xC8, 0xC1, 0xCA, 0, 0, 0];
|
||||
let ambi = riff(&[(b"fmt ", extensible_fmt(22, 16, &ambi)), (b"data", vec![0u8; 8])]);
|
||||
assert_eq!(probe(&ambi, None).unwrap().sample_format, SampleFormat::Int16);
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ fn encode_rejects_bad_buffers_and_formats() {
|
||||
for f in [FileFormat::Mp3, FileFormat::Ogg, FileFormat::Aac, FileFormat::Alac, FileFormat::Caf, FileFormat::Other] {
|
||||
assert!(encode(&sine(48_000, 1, 10), &EncodeOptions { format: f, ..EncodeOptions::default() }).is_err());
|
||||
}
|
||||
// Non-finite and out-of-range samples are clamped, not errors.
|
||||
// Non-finite samples are sanitized; only integer output clips finite samples.
|
||||
let mut b = AudioBuffer::new(48_000, 1, 4);
|
||||
b.channels[0] = vec![f32::NAN, f32::INFINITY, -5.0, 0.5];
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||
@@ -217,7 +217,7 @@ fn encode_rejects_bad_buffers_and_formats() {
|
||||
let (_, out) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(out.channels[0][0], 0.0);
|
||||
assert!(out.channels[0][1] > 0.99 && out.channels[0][1] <= 1.0);
|
||||
assert_eq!(out.channels[0][2], -1.0);
|
||||
assert_eq!(out.channels[0][2], if depth == BitDepth::Float32 { -5.0 } else { -1.0 });
|
||||
}
|
||||
}
|
||||
// Ragged buffers encode the common length.
|
||||
|
||||
@@ -13,7 +13,7 @@ use serde_json::{Value, json};
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_engine::file_access::{self, AutomationRoots};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -97,9 +97,19 @@ impl Remote {
|
||||
if self.conn.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let sock = self.addr.parse().map_err(|e| format!("{}: {e}", self.addr))?;
|
||||
let s =
|
||||
TcpStream::connect_timeout(&sock, Duration::from_millis(800)).map_err(|e| format!("cannot reach SoundCraft at {}: {e}", self.addr))?;
|
||||
let socks: Vec<_> = self.addr.to_socket_addrs().map_err(|e| format!("{}: {e}", self.addr))?.collect();
|
||||
let mut last = "no addresses found".to_string();
|
||||
let mut stream = None;
|
||||
for sock in &socks {
|
||||
match TcpStream::connect_timeout(sock, Duration::from_millis(800)) {
|
||||
Ok(s) => {
|
||||
stream = Some(s);
|
||||
break;
|
||||
}
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
}
|
||||
let s = stream.ok_or_else(|| format!("cannot reach SoundCraft at {}: {last}", self.addr))?;
|
||||
s.set_read_timeout(Some(Duration::from_secs(120))).map_err(|e| e.to_string())?;
|
||||
let r = s.try_clone().map_err(|e| e.to_string())?;
|
||||
self.conn = Some((s, BufReader::new(r)));
|
||||
@@ -179,6 +189,14 @@ mod tests {
|
||||
assert!(r.call("session.inspect", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_resolves_hostnames() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let mut r = Remote::new(&format!("localhost:{port}"));
|
||||
assert_eq!(r.connect(), Ok(()));
|
||||
}
|
||||
|
||||
/// Fake app: accepts one connection per entry in `replies`, counts the request line it reads,
|
||||
/// then either drops the connection (`false`) or answers ok (`true`).
|
||||
fn fake_app(replies: Vec<bool>) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
|
||||
|
||||
@@ -57,7 +57,7 @@ fn fixture(name: &str) -> Fixture {
|
||||
}
|
||||
|
||||
fn server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> {
|
||||
let roots = AutomationRoots::new(read, write).unwrap();
|
||||
let roots = AutomationRoots::new(read.as_slice(), write.as_slice()).unwrap();
|
||||
Server::new(Headless::new(Engine::default()).with_automation_roots(roots))
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ fn writes(dir: &Path) -> Vec<(&'static str, Value)> {
|
||||
}
|
||||
|
||||
fn demo_server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> {
|
||||
let roots = AutomationRoots::new(read, write).unwrap();
|
||||
let roots = AutomationRoots::new(read.as_slice(), write.as_slice()).unwrap();
|
||||
Server::new(Headless::new(soundcraft_engine::demo::demo_engine()).with_automation_roots(roots))
|
||||
}
|
||||
|
||||
|
||||
@@ -340,7 +340,6 @@ impl PluginEditor for ClapEditor {
|
||||
|
||||
fn idle(&mut self) -> Vec<(String, f32)> {
|
||||
let raw = self.link.idle();
|
||||
let _ = self.link.host().dirty.swap(false, Ordering::Relaxed);
|
||||
let mut out: Vec<(String, f32)> = Vec::new();
|
||||
for (cid, v) in raw {
|
||||
let Some(i) = self.ids.iter().position(|&x| x == cid) else { continue };
|
||||
@@ -354,4 +353,8 @@ impl PluginEditor for ClapEditor {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn state_changed(&mut self) -> bool {
|
||||
self.link.host().dirty.swap(false, Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::OnceLock;
|
||||
pub fn fixture_dir() -> &'static Path {
|
||||
static DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixture/Cargo.toml");
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../clap-host/tests/fixture/Cargo.toml");
|
||||
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("clap-fixture-target");
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let st = Command::new(cargo)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# A tiny CLAP plugin used only by soundcraft-clap-host's integration tests. Not part of the
|
||||
# A tiny CLAP plugin used only by SoundCraft's integration tests. Not part of the
|
||||
# SoundCraft workspace (the empty [workspace] table makes it its own root); the test builds it
|
||||
# with `cargo build` and loads the resulting library. Never shipped.
|
||||
[package]
|
||||
|
||||
@@ -272,6 +272,11 @@ pub trait PluginEditor: Send {
|
||||
/// user closed, and returns parameter edits made in the editor as `(param id, value)` in
|
||||
/// SoundCraft units (ids and ranges of [`PluginInfo::params`]).
|
||||
fn idle(&mut self) -> Vec<(String, f32)>;
|
||||
/// True once after the plugin said its state changed in a way its parameters don't show
|
||||
/// (CLAP `state.mark_dirty`), so the session stores the new state.
|
||||
fn state_changed(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Tells the editor that a parameter changed elsewhere (generic editor, automation), so the
|
||||
/// GUI shows it. Values are in SoundCraft units.
|
||||
fn set_param(&mut self, _id: &str, _value: f32) {}
|
||||
|
||||
+182
-1
@@ -226,16 +226,95 @@ fn resample_ratio(ch: &[Vec<f32>], ratio: f64, out_len: usize) -> Vec<Vec<f32>>
|
||||
ch.iter().map(|c| resample_channel(c, ratio, out_len, &table)).collect()
|
||||
}
|
||||
|
||||
/// Most kernel weights a [`Polyphase`] bank may hold (16 MiB of f32).
|
||||
const MAX_POLYPHASE_WEIGHTS: usize = 1 << 22;
|
||||
|
||||
/// The windowed-sinc kernel of [`resample_channel`] precomputed for every phase of a rational
|
||||
/// rate change `from` → `to` (reduced to `step` / `phases`). Output sample `n` reads input time
|
||||
/// `n * step / phases`: whole part `i`, phase `p`. Its taps are input samples `i + first ..`, one
|
||||
/// weight each, so every output is one dot product over contiguous memory instead of a table
|
||||
/// lookup and interpolation per tap. The weights are the ones `resample_channel` computes, taps
|
||||
/// beyond the kernel's half width weigh 0, and taps are summed in the same order.
|
||||
struct Polyphase {
|
||||
phases: u64,
|
||||
step: u64,
|
||||
/// Offset of the first tap from `i`.
|
||||
first: i64,
|
||||
taps: usize,
|
||||
/// `phases * taps` weights, phase-major.
|
||||
weights: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Polyphase {
|
||||
/// `None` when the bank would be too large (rates with no small common ratio); the caller
|
||||
/// then falls back to [`resample_channel`].
|
||||
fn new(from: u32, to: u32, table: &SincTable) -> Option<Polyphase> {
|
||||
let g = gcd(from, to);
|
||||
let (step, phases) = (u64::from(from / g), u64::from(to / g));
|
||||
let hw = table.half_width;
|
||||
let first = (-hw).floor() as i64;
|
||||
let last = hw.ceil() as i64 + 1;
|
||||
let taps = usize::try_from(last - first + 1).ok()?;
|
||||
if usize::try_from(phases).ok()?.checked_mul(taps)? > MAX_POLYPHASE_WEIGHTS {
|
||||
return None;
|
||||
}
|
||||
let mut weights = Vec::with_capacity(taps * phases as usize);
|
||||
for p in 0..phases {
|
||||
let frac = p as f64 / phases as f64;
|
||||
for k in first..first + taps as i64 {
|
||||
let d = frac - k as f64;
|
||||
weights.push(if d.abs() <= hw { table.at(d) } else { 0.0 });
|
||||
}
|
||||
}
|
||||
Some(Polyphase { phases, step, first, taps, weights })
|
||||
}
|
||||
|
||||
fn run(&self, x: &[f32], out_len: usize) -> Vec<f32> {
|
||||
let len = x.len() as i64;
|
||||
let mut out = Vec::with_capacity(out_len);
|
||||
for n in 0..out_len as u64 {
|
||||
let Some(pos) = n.checked_mul(self.step) else { break };
|
||||
let (i, p) = ((pos / self.phases) as i64, (pos % self.phases) as usize);
|
||||
let w = self.weights.get(p * self.taps..(p + 1) * self.taps).unwrap_or_default();
|
||||
let start = i + self.first;
|
||||
let acc = match usize::try_from(start).ok().and_then(|s| x.get(s..s + self.taps)) {
|
||||
// Inside the signal: a plain dot product.
|
||||
Some(xs) => xs.iter().zip(w).fold(0.0f32, |a, (v, k)| a + v * k),
|
||||
// At the edges, taps outside the signal are skipped, as `resample_channel` does.
|
||||
None => (0..self.taps as i64)
|
||||
.filter(|k| (0..len).contains(&(start + k)))
|
||||
.fold(0.0f32, |a, k| a + x.get((start + k) as usize).copied().unwrap_or(0.0) * w.get(k as usize).copied().unwrap_or(0.0)),
|
||||
};
|
||||
out.push(acc);
|
||||
}
|
||||
out.resize(out_len, 0.0);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn gcd(mut a: u32, mut b: u32) -> u32 {
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
a.max(1)
|
||||
}
|
||||
|
||||
/// High-quality windowed-sinc sample-rate conversion from `from` Hz to `to` Hz.
|
||||
pub fn resample(ch: &[Vec<f32>], from: u32, to: u32) -> Vec<Vec<f32>> {
|
||||
if from == 0 || to == 0 || from == to {
|
||||
return ch.to_vec();
|
||||
}
|
||||
let ratio = f64::from(to) / f64::from(from);
|
||||
let cutoff = if ratio < 1.0 { ratio * 0.94 } else { 0.97 };
|
||||
let table = SincTable::new(cutoff);
|
||||
let bank = Polyphase::new(from, to, &table);
|
||||
ch.iter()
|
||||
.map(|c| {
|
||||
let out_len = ((c.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES);
|
||||
resample_ratio(std::slice::from_ref(c), ratio, out_len).into_iter().next().unwrap_or_default()
|
||||
match &bank {
|
||||
Some(b) => b.run(c, out_len),
|
||||
None => resample_channel(c, ratio, out_len, &table),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -373,6 +452,78 @@ pub fn pitch_shift(ch: &[Vec<f32>], semitones: f32, sample_rate: f32) -> Vec<Vec
|
||||
out
|
||||
}
|
||||
|
||||
/// Which way [`speed_ramp`] ramps the playback speed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SpeedRampChange {
|
||||
/// From normal speed down to a complete stop.
|
||||
SlowDown,
|
||||
/// From a complete stop up to normal speed.
|
||||
SpeedUp,
|
||||
}
|
||||
|
||||
/// How much of the input [`speed_ramp`] plays, and how long the result is.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SpeedRampSelection {
|
||||
/// The result is as long as the input. Two thirds of the input are played: the first two
|
||||
/// thirds for Slow Down, the last two thirds for Speed Up, so each joins its neighbour
|
||||
/// without a jump.
|
||||
FitTo,
|
||||
/// All of the input is played, and the result is 150 % of its length.
|
||||
Extend,
|
||||
}
|
||||
|
||||
/// Plays `ch` with the speed ramping between normal speed and a complete stop, so the pitch follows
|
||||
/// the speed. The speed is `1 - u²` for Slow Down and `1 - (1 - u)²` for Speed Up, where `u`
|
||||
/// runs from 0 to 1 over the result; both curves average 2/3 of normal speed. With `fades`, the
|
||||
/// gain follows the same curve: a fade-out for Slow Down and a fade-in for Speed Up. Every channel
|
||||
/// is read at the same positions, and a channel shorter than the others is silent past its end.
|
||||
pub fn speed_ramp(ch: &[Vec<f32>], change: SpeedRampChange, selection: SpeedRampSelection, fades: bool) -> Vec<Vec<f32>> {
|
||||
let len = max_len(ch) as f64;
|
||||
let (total, first, played) = match selection {
|
||||
SpeedRampSelection::Extend => ((len * 1.5).round(), 0.0, len),
|
||||
SpeedRampSelection::FitTo => {
|
||||
let played = len * 2.0 / 3.0;
|
||||
(len, if change == SpeedRampChange::SlowDown { 0.0 } else { len - played }, played)
|
||||
}
|
||||
};
|
||||
let out_len = (total as usize).min(MAX_OFFLINE_SAMPLES);
|
||||
let table = SincTable::new(0.97);
|
||||
let hw = table.half_width;
|
||||
// The fraction of the played input reached at `u`, and the speed there (0 to 1).
|
||||
let at = |u: f64| -> (f64, f64) {
|
||||
match change {
|
||||
SpeedRampChange::SlowDown => ((3.0 * u - u * u * u) / 2.0, 1.0 - u * u),
|
||||
SpeedRampChange::SpeedUp => {
|
||||
let r = 1.0 - u;
|
||||
(1.0 - (3.0 * r - r * r * r) / 2.0, 1.0 - r * r)
|
||||
}
|
||||
}
|
||||
};
|
||||
// The interpolation reads the first or last sample of the input for taps before or after it.
|
||||
let last = (len as i64 - 1).max(0);
|
||||
ch.iter()
|
||||
.map(|x| {
|
||||
(0..out_len)
|
||||
.map(|n| {
|
||||
let (pos, speed) = at(n as f64 / total);
|
||||
let t = first + played * pos;
|
||||
let lo = (t - hw).ceil() as i64;
|
||||
let hi = (t + hw).floor() as i64;
|
||||
let mut acc = 0.0f32;
|
||||
let mut j = lo;
|
||||
while j <= hi {
|
||||
if let Some(&v) = x.get(j.clamp(0, last) as usize) {
|
||||
acc += v * table.at(t - j as f64);
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if fades { acc * speed as f32 } else { acc }
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Generators and analysis
|
||||
|
||||
@@ -662,6 +813,36 @@ pub fn peak_db(ch: &[Vec<f32>]) -> f32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The polyphase bank gives what the per-tap kernel gives (it only moves where the weights
|
||||
/// are computed), at common rate pairs, both directions, and at the signal's edges.
|
||||
#[test]
|
||||
fn polyphase_resample_matches_the_per_tap_kernel() {
|
||||
let x: Vec<f32> = (0..3_000).map(|i| (i as f32 * 0.071).sin() * 0.6 + (i as f32 * 0.53).sin() * 0.3).collect();
|
||||
let pairs = [(44_100, 48_000), (48_000, 44_100), (48_000, 96_000), (96_000, 48_000), (22_050, 48_000), (8_000, 44_100), (48_000, 22_050)];
|
||||
for (from, to) in pairs {
|
||||
let ratio = f64::from(to) / f64::from(from);
|
||||
let out_len = ((x.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES);
|
||||
let cutoff = if ratio < 1.0 { ratio * 0.94 } else { 0.97 };
|
||||
let table = SincTable::new(cutoff);
|
||||
let reference = resample_channel(&x, ratio, out_len, &table);
|
||||
let bank = Polyphase::new(from, to, &table).expect("small common ratio");
|
||||
let fast = bank.run(&x, out_len);
|
||||
assert_eq!(fast.len(), reference.len(), "{from}->{to}");
|
||||
let worst = fast.iter().zip(&reference).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(worst < 1e-5, "{from}->{to}: worst difference {worst}");
|
||||
assert_eq!(resample(std::slice::from_ref(&x), from, to)[0], fast, "resample uses the bank");
|
||||
}
|
||||
// Signals shorter than the kernel are all edge.
|
||||
let short = [0.5f32, -0.25, 0.75];
|
||||
let table = SincTable::new(0.97);
|
||||
let reference = resample_channel(&short, 48_000.0 / 44_100.0, 3, &table);
|
||||
let fast = Polyphase::new(44_100, 48_000, &table).expect("bank").run(&short, 3);
|
||||
assert!(fast.iter().zip(&reference).all(|(a, b)| (a - b).abs() < 1e-6), "{fast:?} {reference:?}");
|
||||
// Rates with no small common ratio fall back to the per-tap kernel.
|
||||
assert!(Polyphase::new(44_100, 96_001, &table).is_none());
|
||||
assert_eq!(resample(&[short.to_vec()], 44_100, 96_001)[0].len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fade_shapes_endpoints() {
|
||||
for s in FadeShape::ALL {
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
//! Auto Pan: moves a signal between the left and right channels with an LFO or an envelope.
|
||||
|
||||
use std::f32::consts::{FRAC_PI_4, PI, SQRT_2, TAU};
|
||||
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
|
||||
use crate::util::{Smoother, frames_in, ms_to_samples, sane_channels, sane_sr, time_coef};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Transport, Unit, db_to_gain, gain_to_db};
|
||||
|
||||
const SOURCE_CHOICES: &[&str] = &["LFO", "Envelope"];
|
||||
const PATH_CHOICES: &[&str] = &["Left to Right", "Right to Left"];
|
||||
const WAVEFORM_CHOICES: &[&str] = &["Sine", "Triangle", "Square", "Saw Up", "Saw Down", "Half Sine", "4 Step Triangle"];
|
||||
const TRIGGER_CHOICES: &[&str] = &["Free", "Single", "Beat Clock", "Envelope", "Manual"];
|
||||
const DURATION_CHOICES: &[&str] = &["16th Note", "8th Note", "1 Beat", "2 Beats", "1 Bar", "2 Bars", "4 Bars"];
|
||||
|
||||
const SOURCE_ENVELOPE: usize = 1;
|
||||
const PATH_RIGHT_TO_LEFT: usize = 1;
|
||||
const TRIGGER_FREE: usize = 0;
|
||||
const TRIGGER_SINGLE: usize = 1;
|
||||
const TRIGGER_BEAT_CLOCK: usize = 2;
|
||||
const TRIGGER_ENVELOPE: usize = 3;
|
||||
const TRIGGER_MANUAL: usize = 4;
|
||||
|
||||
static AUTOPAN_PARAMS: [ParamInfo; 14] = [
|
||||
lin("output", "Output", -24.0, 12.0, 0.0, Unit::Db),
|
||||
lin("width", "Width", 0.0, 100.0, 100.0, Unit::Percent),
|
||||
lin("manual", "Manual", -100.0, 100.0, 0.0, Unit::Percent),
|
||||
choice("source", "Source", SOURCE_CHOICES, 0),
|
||||
choice("path", "Path", PATH_CHOICES, 0),
|
||||
log("rate", "Rate (BPM)", 1.0, 960.0, 120.0, Unit::None),
|
||||
choice("waveform", "Waveform", WAVEFORM_CHOICES, 0),
|
||||
choice("trigger", "Trigger", TRIGGER_CHOICES, 0),
|
||||
toggle("retrigger", "Manual Trigger", false),
|
||||
toggle("link_tempo", "Link to Tempo", false),
|
||||
choice("duration", "Duration", DURATION_CHOICES, 4),
|
||||
lin("threshold", "Threshold", -60.0, -1.0, -30.0, Unit::Db),
|
||||
log("attack", "Attack", 0.1, 100.0, 5.0, Unit::Ms),
|
||||
log("release", "Release", 5.0, 2000.0, 150.0, Unit::Ms),
|
||||
];
|
||||
|
||||
pub static AUTOPAN_INFO: PluginInfo = PluginInfo {
|
||||
id: "auto_pan",
|
||||
name: "Auto Pan",
|
||||
short_name: "Auto Pan",
|
||||
category: Category::Modulation,
|
||||
params: &AUTOPAN_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// The LFO shape at `t` in 0..=1 of a cycle, from -1 (the start of the path) to 1 (its end).
|
||||
fn wave_at(waveform: usize, t: f32) -> f32 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let tri = if t < 0.5 { 4.0 * t - 1.0 } else { 3.0 - 4.0 * t };
|
||||
match waveform {
|
||||
1 => tri,
|
||||
2 => {
|
||||
if t < 0.5 {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
3 => 2.0 * t - 1.0,
|
||||
4 => 1.0 - 2.0 * t,
|
||||
5 => 2.0 * (PI * t).sin() - 1.0,
|
||||
6 => {
|
||||
let step = ((tri + 1.0) * 2.0).floor().clamp(0.0, 3.0);
|
||||
step * 2.0 / 3.0 - 1.0
|
||||
}
|
||||
_ => -(TAU * t).cos(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Length of one Duration choice in quarter notes, for a bar of `bar` quarter notes.
|
||||
fn duration_quarters(duration: usize, bar: f64) -> f64 {
|
||||
match duration {
|
||||
0 => 0.25,
|
||||
1 => 0.5,
|
||||
2 => 1.0,
|
||||
3 => 2.0,
|
||||
5 => 2.0 * bar,
|
||||
6 => 4.0 * bar,
|
||||
_ => bar,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of bars in a bar-based Duration choice.
|
||||
fn duration_bars(duration: usize) -> Option<i64> {
|
||||
match duration {
|
||||
4 => Some(1),
|
||||
5 => Some(2),
|
||||
6 => Some(4),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pans the mono sum of the first two channels between them. The pan position is
|
||||
/// `manual · (1 - width) + modulator · width`, where the modulator is the LFO or the envelope
|
||||
/// follower in -1..=1. A signal that is the same on both channels keeps its level at the center.
|
||||
pub struct AutoPan {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
ch: usize,
|
||||
transport: Transport,
|
||||
/// Samples processed since the last `set_transport`, for hosts that call it less than once a block.
|
||||
since_transport: u64,
|
||||
was_playing: bool,
|
||||
/// LFO phase in cycles, in `f64`: at the slowest rates an `f32` step is below half an ULP.
|
||||
phase: f64,
|
||||
/// A Single, Envelope or Manual trigger has finished its cycle and holds the last position.
|
||||
holding: bool,
|
||||
env: f32,
|
||||
armed: bool,
|
||||
/// Samples the envelope has spent below the threshold since the last Envelope trigger. The
|
||||
/// trigger re-arms once this reaches the Release time, so a longer Release spaces triggers out.
|
||||
below: u64,
|
||||
last_retrigger: bool,
|
||||
last_trigger: usize,
|
||||
output: Smoother,
|
||||
width: Smoother,
|
||||
manual: Smoother,
|
||||
/// Short declicking smoother on the pan position.
|
||||
position: Smoother,
|
||||
/// The next sample starts the position at its target instead of gliding to it.
|
||||
snap_position: bool,
|
||||
}
|
||||
|
||||
impl Default for AutoPan {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AutoPan {
|
||||
pub fn new() -> Self {
|
||||
let mut a = Self {
|
||||
p: Params::new(&AUTOPAN_PARAMS),
|
||||
sr: 48_000.0,
|
||||
ch: 0,
|
||||
transport: Transport::default(),
|
||||
since_transport: 0,
|
||||
was_playing: false,
|
||||
phase: 0.0,
|
||||
holding: false,
|
||||
env: 0.0,
|
||||
armed: true,
|
||||
below: 0,
|
||||
last_retrigger: false,
|
||||
last_trigger: TRIGGER_FREE,
|
||||
output: Smoother::new(1.0),
|
||||
width: Smoother::new(1.0),
|
||||
manual: Smoother::new(0.0),
|
||||
position: Smoother::new(0.0),
|
||||
snap_position: true,
|
||||
};
|
||||
a.update();
|
||||
a.restart();
|
||||
a
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.output.set(db_to_gain(self.p.v(0)));
|
||||
self.width.set(self.p.v(1) / 100.0);
|
||||
self.manual.set(self.p.v(2) / 100.0);
|
||||
let trigger = self.p.choice(7);
|
||||
if trigger != self.last_trigger {
|
||||
self.last_trigger = trigger;
|
||||
self.restart();
|
||||
}
|
||||
let retrigger = self.p.on(8);
|
||||
if retrigger != self.last_retrigger {
|
||||
self.last_retrigger = retrigger;
|
||||
if trigger == TRIGGER_MANUAL {
|
||||
self.phase = 0.0;
|
||||
self.holding = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the LFO back at the start of the path. Single runs one cycle from there; Envelope and
|
||||
/// Manual wait at the start for their first trigger.
|
||||
fn restart(&mut self) {
|
||||
self.phase = 0.0;
|
||||
self.holding = matches!(self.p.choice(7), TRIGGER_ENVELOPE | TRIGGER_MANUAL);
|
||||
self.armed = true;
|
||||
self.below = 0;
|
||||
}
|
||||
|
||||
/// LFO frequency in cycles per second.
|
||||
fn lfo_hz(&self) -> f64 {
|
||||
let tempo = if self.p.on(9) && self.transport.tempo.is_finite() && self.transport.tempo > 0.0 {
|
||||
self.transport.tempo
|
||||
} else {
|
||||
f64::from(self.p.v(5))
|
||||
};
|
||||
tempo / 60.0 / duration_quarters(self.p.choice(10), self.bar_quarters())
|
||||
}
|
||||
|
||||
fn bar_quarters(&self) -> f64 {
|
||||
let (num, den) = (self.transport.numerator, self.transport.denominator);
|
||||
if num == 0 || den == 0 { 4.0 } else { (f64::from(num) * 4.0 / f64::from(den)).clamp(0.25, 64.0) }
|
||||
}
|
||||
|
||||
/// The LFO phase at `n` samples into the block when Beat Clock follows a playing transport.
|
||||
/// Beat Clock restarts the LFO at the start of every Duration and runs it at Rate in between,
|
||||
/// so a Rate slower than the session tempo plays only part of each cycle before the restart.
|
||||
fn beat_clock_phase(&self, n: usize, hz: f64) -> Option<f64> {
|
||||
let t = &self.transport;
|
||||
if !(t.playing && t.tempo.is_finite() && t.tempo > 0.0 && t.quarters.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let duration = self.p.choice(10);
|
||||
let bar = self.bar_quarters();
|
||||
let dur = duration_quarters(duration, bar);
|
||||
let elapsed = self.since_transport.saturating_add(n as u64) as f64;
|
||||
let quarters = t.quarters + elapsed * t.tempo / 60.0 / f64::from(self.sr);
|
||||
// Bar-based cycles start on the bar line, so a change of time signature does not shift them;
|
||||
// note and beat cycles follow the song position in quarter notes.
|
||||
let into = match duration_bars(duration) {
|
||||
Some(bars) => {
|
||||
let in_bar = if t.bar_start.is_finite() { quarters - t.bar_start } else { quarters };
|
||||
((t.bar.rem_euclid(bars) as f64 + in_bar / bar) / bars as f64).rem_euclid(1.0)
|
||||
}
|
||||
None => (quarters / dur).rem_euclid(1.0),
|
||||
};
|
||||
// Cycles of the LFO per Duration: 1 when its rate matches the session tempo.
|
||||
let ratio = hz * dur * 60.0 / t.tempo;
|
||||
Some((into * ratio).rem_euclid(1.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for AutoPan {
|
||||
param_plumbing!(AUTOPAN_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.ch = sane_channels(channels);
|
||||
for s in [&mut self.output, &mut self.width, &mut self.manual] {
|
||||
s.set_time(20.0, self.sr);
|
||||
}
|
||||
self.position.set_time(2.0, self.sr);
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.restart();
|
||||
self.env = 0.0;
|
||||
for s in [&mut self.output, &mut self.width, &mut self.manual] {
|
||||
s.snap();
|
||||
}
|
||||
self.snap_position = true;
|
||||
}
|
||||
|
||||
fn set_transport(&mut self, transport: &Transport) {
|
||||
self.transport = *transport;
|
||||
self.since_transport = 0;
|
||||
if transport.playing && !self.was_playing && self.p.choice(7) == TRIGGER_SINGLE {
|
||||
self.restart();
|
||||
}
|
||||
self.was_playing = transport.playing;
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.ch.min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let hz = self.lfo_hz();
|
||||
let inc = if hz.is_finite() { (hz / f64::from(self.sr)).clamp(0.0, 0.5) } else { 0.0 };
|
||||
let waveform = self.p.choice(6);
|
||||
let trigger = self.p.choice(7);
|
||||
let envelope_source = self.p.choice(3) == SOURCE_ENVELOPE;
|
||||
let sign = if self.p.choice(4) == PATH_RIGHT_TO_LEFT { -1.0 } else { 1.0 };
|
||||
let threshold_db = self.p.v(11);
|
||||
let threshold = db_to_gain(threshold_db);
|
||||
let attack = time_coef(self.p.v(12), self.sr);
|
||||
let release = time_coef(self.p.v(13), self.sr);
|
||||
let release_samples = ms_to_samples(self.p.v(13), self.sr) as u64;
|
||||
for n in 0..frames {
|
||||
let sample = |io: &[Vec<f32>], c: usize| io.get(c).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let mono = if ch >= 2 { 0.5 * (sample(io, 0) + sample(io, 1)) } else { sample(io, 0) };
|
||||
let level = mono.abs();
|
||||
let k = if level > self.env { attack } else { release };
|
||||
self.env = level + (self.env - level) * k;
|
||||
if self.env < 1.0e-12 {
|
||||
self.env = 0.0;
|
||||
}
|
||||
if trigger == TRIGGER_ENVELOPE {
|
||||
if self.armed && self.env >= threshold {
|
||||
self.armed = false;
|
||||
self.phase = 0.0;
|
||||
self.holding = false;
|
||||
self.below = 0;
|
||||
} else if !self.armed {
|
||||
if self.env < threshold {
|
||||
self.below = self.below.saturating_add(1);
|
||||
self.armed = self.below >= release_samples;
|
||||
} else {
|
||||
self.below = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let lfo_phase = match (trigger == TRIGGER_BEAT_CLOCK).then(|| self.beat_clock_phase(n, hz)).flatten() {
|
||||
Some(phase) => phase,
|
||||
None if self.holding => self.phase,
|
||||
None => {
|
||||
let current = self.phase;
|
||||
self.phase += inc;
|
||||
if self.phase >= 1.0 {
|
||||
if matches!(trigger, TRIGGER_FREE | TRIGGER_BEAT_CLOCK) {
|
||||
self.phase -= 1.0;
|
||||
} else {
|
||||
self.phase = 1.0;
|
||||
self.holding = true;
|
||||
}
|
||||
}
|
||||
current
|
||||
}
|
||||
};
|
||||
let modulator = if envelope_source {
|
||||
let above = (gain_to_db(self.env) - threshold_db) / -threshold_db;
|
||||
2.0 * above.clamp(0.0, 1.0) - 1.0
|
||||
} else {
|
||||
wave_at(waveform, lfo_phase as f32)
|
||||
};
|
||||
let width = self.width.next();
|
||||
let manual = self.manual.next();
|
||||
self.position.set(manual * (1.0 - width) + sign * modulator * width);
|
||||
if self.snap_position {
|
||||
self.snap_position = false;
|
||||
self.position.snap();
|
||||
}
|
||||
let position = self.position.next();
|
||||
let out = self.output.next();
|
||||
if ch >= 2 {
|
||||
// Constant power: sin² + cos² = 1 across the field.
|
||||
let theta = (position.clamp(-1.0, 1.0) + 1.0) * FRAC_PI_4;
|
||||
let (l, r) = (theta.cos().max(0.0), theta.sin().max(0.0));
|
||||
if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
|
||||
*x = mono * l * SQRT_2 * out;
|
||||
}
|
||||
if let Some(x) = io.get_mut(1).and_then(|b| b.get_mut(n)) {
|
||||
*x = mono * r * SQRT_2 * out;
|
||||
}
|
||||
for c in 2..ch {
|
||||
if let Some(x) = io.get_mut(c).and_then(|b| b.get_mut(n)) {
|
||||
*x = if x.is_finite() { *x * out } else { 0.0 };
|
||||
}
|
||||
}
|
||||
} else if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
|
||||
*x = mono * out;
|
||||
}
|
||||
}
|
||||
self.since_transport = self.since_transport.saturating_add(frames as u64);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction) and Rectifier.
|
||||
//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction), Rectifier and
|
||||
//! Subharmonic (octave-down bass synthesis).
|
||||
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing};
|
||||
use crate::util::{DcBlocker, Rng, Smoother, flush, frames_in, sane_channels, sane_sr};
|
||||
use crate::biquad::{Biquad, FilterType};
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
|
||||
use crate::util::{DcBlocker, Rng, Smoother, flush, frames_in, sane_channels, sane_sr, time_coef};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain};
|
||||
|
||||
static SAT_PARAMS: [ParamInfo; 5] = [
|
||||
@@ -307,3 +309,289 @@ impl Plugin for Rectifier {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
const SUB_RANGES: &[&str] = &["120-180 Hz", "80-120 Hz", "60-90 Hz", "40-60 Hz"];
|
||||
/// Lower and upper band frequency of each range.
|
||||
const SUB_RANGE_HZ: [(f32, f32); 4] = [(120.0, 180.0), (80.0, 120.0), (60.0, 90.0), (40.0, 60.0)];
|
||||
const SUB_CHARACTERS: &[&str] = &["Clean I", "Clean II", "Resonant I", "Resonant II", "Distort I", "Distort II"];
|
||||
/// Band gains at or below this are off.
|
||||
const SUB_OFF_DB: f32 = -120.0;
|
||||
/// Q of the band-pass that picks each band out of the input.
|
||||
const SUB_BAND_Q: f32 = 2.0;
|
||||
/// Cutoff of the low-pass after each divider, as a fraction of the band frequency.
|
||||
const SUB_SMOOTH: f32 = 0.75;
|
||||
/// Zero-crossing hysteresis, as a fraction of the band's envelope.
|
||||
const SUB_HYSTERESIS: f32 = 0.1;
|
||||
/// Release of the band envelope that sets the hysteresis.
|
||||
const SUB_ENV_RELEASE_MS: f32 = 50.0;
|
||||
/// Bias of the asymmetric Distort I curve.
|
||||
const SUB_ASYM: f32 = 0.25;
|
||||
|
||||
static SUB_PARAMS: [ParamInfo; 17] = [
|
||||
lin("input", "Input", -18.0, 18.0, 0.0, Unit::Db),
|
||||
choice("range", "Range", SUB_RANGES, 2),
|
||||
lin("lower_gain", "Lower Gain", SUB_OFF_DB, 24.0, 0.0, Unit::Db),
|
||||
lin("upper_gain", "Upper Gain", SUB_OFF_DB, 24.0, -6.0, Unit::Db),
|
||||
lin("direct_gain", "Direct Gain", SUB_OFF_DB, 24.0, SUB_OFF_DB, Unit::Db),
|
||||
toggle("lower_solo", "Lower Solo", false),
|
||||
toggle("upper_solo", "Upper Solo", false),
|
||||
toggle("direct_solo", "Direct Solo", false),
|
||||
lin("drive", "Drive", -18.0, 18.0, 0.0, Unit::Db),
|
||||
choice("character", "Character", SUB_CHARACTERS, 0),
|
||||
log("hp_freq", "HP Freq", 5.0, 1000.0, 20.0, Unit::Hz),
|
||||
log("hp_q", "HP Q", 0.1, 5.0, 0.707, Unit::None),
|
||||
log("lp_freq", "LP Freq", 5.0, 1000.0, 200.0, Unit::Hz),
|
||||
log("lp_q", "LP Q", 0.1, 5.0, 0.707, Unit::None),
|
||||
lin("mix", "Mix", 0.0, 100.0, 50.0, Unit::Percent),
|
||||
toggle("wet_solo", "Wet Solo", false),
|
||||
lin("output", "Output", -18.0, 18.0, 0.0, Unit::Db),
|
||||
];
|
||||
|
||||
pub static SUB_INFO: PluginInfo = PluginInfo {
|
||||
id: "subharmonic",
|
||||
name: "Subharmonic",
|
||||
short_name: "SubHarm",
|
||||
category: Category::Harmonic,
|
||||
params: &SUB_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// One synthesized band: a band-pass, a divide-by-two flip-flop and a smoothing low-pass.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct SubBand {
|
||||
band: Biquad,
|
||||
smooth: Biquad,
|
||||
/// Divider output: ±1, flipped on every rising zero crossing of the band.
|
||||
flip: f32,
|
||||
/// The band has gone below the negative threshold since the last flip.
|
||||
armed: bool,
|
||||
env: f32,
|
||||
}
|
||||
|
||||
impl SubBand {
|
||||
fn new() -> Self {
|
||||
Self { band: Biquad::default(), smooth: Biquad::default(), flip: 1.0, armed: false, env: 0.0 }
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.band.reset();
|
||||
self.smooth.reset();
|
||||
self.flip = 1.0;
|
||||
self.armed = false;
|
||||
self.env = 0.0;
|
||||
}
|
||||
|
||||
/// The octave below the band's part of `x`; `rel` is the envelope's release coefficient.
|
||||
#[inline]
|
||||
fn process(&mut self, x: f32, rel: f32) -> f32 {
|
||||
let b = self.band.process_sample(x);
|
||||
let a = b.abs();
|
||||
self.env = flush(if a > self.env { a } else { self.env * rel });
|
||||
let th = self.env * SUB_HYSTERESIS;
|
||||
if b < -th {
|
||||
self.armed = true;
|
||||
} else if self.armed && b > th {
|
||||
self.armed = false;
|
||||
self.flip = -self.flip;
|
||||
}
|
||||
self.smooth.process_sample(b * self.flip)
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter and divider state of one channel.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct SubChannel {
|
||||
/// The lower and the upper band.
|
||||
bands: [SubBand; 2],
|
||||
/// Low-pass that takes the direct bass out of the input.
|
||||
direct: Biquad,
|
||||
hp: Biquad,
|
||||
lp: Biquad,
|
||||
dc: DcBlocker,
|
||||
}
|
||||
|
||||
impl SubChannel {
|
||||
fn new(sr: f32) -> Self {
|
||||
Self { bands: [SubBand::new(); 2], direct: Biquad::default(), hp: Biquad::default(), lp: Biquad::default(), dc: DcBlocker::new(5.0, sr) }
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.bands.iter_mut().for_each(SubBand::reset);
|
||||
self.direct.reset();
|
||||
self.hp.reset();
|
||||
self.lp.reset();
|
||||
self.dc.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// Subharmonic synthesizer. The Range sets a lower and an upper band frequency. Each band
|
||||
/// band-passes the input at its frequency, and a divide-by-two flip-flop on the band's rising zero
|
||||
/// crossings multiplies the band, which moves it an octave down at a level that follows the band.
|
||||
/// The direct band is the input low-passed at the upper band frequency.
|
||||
///
|
||||
/// - The three bands are summed with their gains, raised by Drive and saturated, then
|
||||
/// shaped by the HP and LP filters. A soloed band plays alone and mutes the dry signal.
|
||||
/// - Clean I saturates with tanh and Clean II with a softer algebraic curve. Resonant I and II use
|
||||
/// tanh and raise the Q of the low-pass after each divider to 2 and 4. Distort I bends the curve
|
||||
/// asymmetrically (even harmonics, DC removed) and Distort II clips hard at full scale.
|
||||
/// - Below 50 % Mix the dry signal stays at full level and the wet level falls; above 50 % the wet
|
||||
/// signal stays at full level and the dry level falls. Wet Solo mutes the dry signal.
|
||||
pub struct SubHarmonic {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
ch: Vec<SubChannel>,
|
||||
input: Smoother,
|
||||
bands: [Smoother; 3],
|
||||
drive: Smoother,
|
||||
dry: Smoother,
|
||||
wet: Smoother,
|
||||
out: Smoother,
|
||||
env_rel: f32,
|
||||
}
|
||||
|
||||
impl Default for SubHarmonic {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SubHarmonic {
|
||||
pub fn new() -> Self {
|
||||
let mut s = Self {
|
||||
p: Params::new(&SUB_PARAMS),
|
||||
sr: 48_000.0,
|
||||
ch: Vec::new(),
|
||||
input: Smoother::new(1.0),
|
||||
bands: [Smoother::new(0.0); 3],
|
||||
drive: Smoother::new(1.0),
|
||||
dry: Smoother::new(1.0),
|
||||
wet: Smoother::new(1.0),
|
||||
out: Smoother::new(1.0),
|
||||
env_rel: 0.0,
|
||||
};
|
||||
s.update();
|
||||
s
|
||||
}
|
||||
|
||||
/// The lower and upper band frequencies of the current range.
|
||||
fn range_hz(&self) -> (f32, f32) {
|
||||
SUB_RANGE_HZ.get(self.p.choice(1)).copied().unwrap_or((60.0, 90.0))
|
||||
}
|
||||
|
||||
/// Q of the low-pass after each divider for the current Drive Character.
|
||||
fn smooth_q(&self) -> f32 {
|
||||
match self.p.choice(9) {
|
||||
2 => 2.0,
|
||||
3 => 4.0,
|
||||
_ => std::f32::consts::FRAC_1_SQRT_2,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
let gain = |db: f32| if db <= SUB_OFF_DB { 0.0 } else { db_to_gain(db) };
|
||||
let solo = [self.p.on(5), self.p.on(6), self.p.on(7)];
|
||||
let any_solo = solo.contains(&true);
|
||||
for (k, s) in self.bands.iter_mut().enumerate() {
|
||||
let heard = !any_solo || solo.get(k).copied().unwrap_or(false);
|
||||
s.set(if heard { gain(self.p.v(2 + k)) } else { 0.0 });
|
||||
}
|
||||
self.input.set(db_to_gain(self.p.v(0)));
|
||||
self.drive.set(db_to_gain(self.p.v(8)));
|
||||
self.out.set(db_to_gain(self.p.v(16)));
|
||||
let m = self.p.v(14) / 100.0;
|
||||
self.wet.set((2.0 * m).min(1.0));
|
||||
self.dry.set(if any_solo || self.p.on(15) { 0.0 } else { (2.0 * (1.0 - m)).min(1.0) });
|
||||
self.env_rel = time_coef(SUB_ENV_RELEASE_MS, self.sr);
|
||||
|
||||
let (lo, hi) = self.range_hz();
|
||||
let sq = self.smooth_q();
|
||||
let sr = self.sr;
|
||||
let (hp_f, hp_q, lp_f, lp_q) = (self.p.v(10), self.p.v(11), self.p.v(12), self.p.v(13));
|
||||
for c in &mut self.ch {
|
||||
for (b, f) in c.bands.iter_mut().zip([lo, hi]) {
|
||||
b.band.set(FilterType::BandPass, f, SUB_BAND_Q, 0.0, sr);
|
||||
b.smooth.set(FilterType::LowPass, f * SUB_SMOOTH, sq, 0.0, sr);
|
||||
}
|
||||
c.direct.set(FilterType::LowPass, hi, std::f32::consts::FRAC_1_SQRT_2, 0.0, sr);
|
||||
c.hp.set(FilterType::HighPass, hp_f, hp_q, 0.0, sr);
|
||||
c.lp.set(FilterType::LowPass, lp_f, lp_q, 0.0, sr);
|
||||
}
|
||||
}
|
||||
|
||||
fn smoothers(&mut self) -> impl Iterator<Item = &mut Smoother> {
|
||||
[&mut self.input, &mut self.drive, &mut self.dry, &mut self.wet, &mut self.out].into_iter().chain(self.bands.iter_mut())
|
||||
}
|
||||
}
|
||||
|
||||
/// Saturation curve of each Drive Character (index into `SUB_CHARACTERS`).
|
||||
fn sub_saturate(character: usize, x: f32) -> f32 {
|
||||
match character {
|
||||
1 => x / (1.0 + x * x).sqrt(),
|
||||
4 => ((x + SUB_ASYM).tanh() - SUB_ASYM.tanh()) / (1.0 + SUB_ASYM.tanh()),
|
||||
5 => x.clamp(-1.0, 1.0),
|
||||
_ => x.tanh(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for SubHarmonic {
|
||||
param_plumbing!(SUB_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.ch = vec![SubChannel::new(self.sr); sane_channels(channels)];
|
||||
let sr = self.sr;
|
||||
for s in self.smoothers() {
|
||||
s.set_time(20.0, sr);
|
||||
}
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ch.iter_mut().for_each(SubChannel::reset);
|
||||
for s in self.smoothers() {
|
||||
s.snap();
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.ch.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let character = self.p.choice(9);
|
||||
let rel = self.env_rel;
|
||||
for n in 0..frames {
|
||||
let gin = self.input.next();
|
||||
let g = [self.bands[0].next(), self.bands[1].next(), self.bands[2].next()];
|
||||
let drive = self.drive.next();
|
||||
let dg = self.dry.next();
|
||||
let wg = self.wet.next();
|
||||
let o = self.out.next();
|
||||
for c in 0..ch {
|
||||
let (Some(buf), Some(st)) = (io.get_mut(c), self.ch.get_mut(c)) else { continue };
|
||||
let Some(x) = buf.get_mut(n) else { continue };
|
||||
let dry = if x.is_finite() { *x * gin } else { 0.0 };
|
||||
let mut sum = st.direct.process_sample(dry) * g[2];
|
||||
for (band, gain) in st.bands.iter_mut().zip(g) {
|
||||
sum += band.process(dry, rel) * gain;
|
||||
}
|
||||
let mut y = sub_saturate(character, sum * drive);
|
||||
if character == 4 {
|
||||
y = st.dc.process(y);
|
||||
}
|
||||
let wet = st.lp.process_sample(st.hp.process_sample(y));
|
||||
*x = (dry * dg + wet * wg) * o;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
// Time for the slowest, most resonant filter to ring down by 60 dB (6.9 time constants).
|
||||
let (lo, _) = self.range_hz();
|
||||
let f = self.p.v(10).min(self.p.v(12)).min(lo * SUB_SMOOTH).max(1.0);
|
||||
let q = self.p.v(11).max(self.p.v(13)).max(self.smooth_q()).max(SUB_BAND_Q);
|
||||
(6.9 * q / (std::f32::consts::PI * f) * self.sr) as usize
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The built-in plugins and their registry.
|
||||
|
||||
pub mod auto_pan;
|
||||
pub mod drums;
|
||||
pub mod dynamics;
|
||||
pub mod eq;
|
||||
@@ -26,12 +27,15 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[
|
||||
&reverb::ROOM_INFO,
|
||||
&reverb::PLATE_INFO,
|
||||
&modulation::DELAY_INFO,
|
||||
&auto_pan::AUTOPAN_INFO,
|
||||
&modulation::CHORUS_INFO,
|
||||
&modulation::FLANGER_INFO,
|
||||
&modulation::PHASER_INFO,
|
||||
&modulation::RING_INFO,
|
||||
&harmonic::SAT_INFO,
|
||||
&harmonic::LOFI_INFO,
|
||||
&harmonic::RECT_INFO,
|
||||
&harmonic::SUB_INFO,
|
||||
&pitch::PITCH_INFO,
|
||||
&utility::SHIFT_INFO,
|
||||
&utility::GAIN_INFO,
|
||||
@@ -58,12 +62,15 @@ pub(crate) fn instantiate(id: &str) -> Option<Box<dyn Plugin>> {
|
||||
"room_reverb" => Box::new(reverb::Reverb::room()),
|
||||
"plate_reverb" => Box::new(reverb::Reverb::plate()),
|
||||
"mod_delay" => Box::new(modulation::ModDelay::new()),
|
||||
"auto_pan" => Box::new(auto_pan::AutoPan::new()),
|
||||
"chorus" => Box::new(modulation::ModFx::chorus()),
|
||||
"flanger" => Box::new(modulation::ModFx::flanger()),
|
||||
"phaser" => Box::new(modulation::Phaser::new()),
|
||||
"ring_mod" => Box::new(modulation::RingMod::new()),
|
||||
"saturator" => Box::new(harmonic::Saturator::new()),
|
||||
"lofi" => Box::new(harmonic::LoFi::new()),
|
||||
"rectifier" => Box::new(harmonic::Rectifier::new()),
|
||||
"subharmonic" => Box::new(harmonic::SubHarmonic::new()),
|
||||
"pitch_shifter" => Box::new(pitch::PitchShifter::new()),
|
||||
"time_shift" => Box::new(utility::TimeShift::new()),
|
||||
"gain" => Box::new(utility::Gain::new()),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Delay and modulation effects: Mod Delay, Chorus, Flanger, Phaser.
|
||||
//! Delay and modulation effects: Mod Delay, Chorus, Flanger, Phaser, Ring Modulator.
|
||||
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing};
|
||||
use crate::util::{DelayLine, Lfo, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr};
|
||||
use crate::util::{DelayLine, Lfo, Rng, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr, time_coef};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit};
|
||||
|
||||
/// Per-channel LFO phase offset for stereo movement (channel 1 is offset by `spread` cycles).
|
||||
@@ -459,3 +459,221 @@ impl Plugin for Phaser {
|
||||
ms_to_samples(100.0, self.sr) as usize
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
const RING_EFFECTS: &[&str] = &["Ring", "Frequency Mod", "Resonator +", "Resonator -"];
|
||||
const RING_SOURCES: &[&str] = &["LFO", "Envelope", "Sample & Hold"];
|
||||
const RING_MIN_HZ: f32 = 20.0;
|
||||
/// Octaves the frequency moves at full modulation depth.
|
||||
const RING_MOD_OCTAVES: f32 = 2.0;
|
||||
/// Peak delay swing of Frequency Mod.
|
||||
const RING_FM_SWING_MS: f32 = 1.0;
|
||||
/// Sample-and-hold noise seed (the same sequence after every reset).
|
||||
const RING_SEED: u32 = 0x5EED_1234;
|
||||
|
||||
static RING_PARAMS: [ParamInfo; 7] = [
|
||||
choice("effect", "Effect", RING_EFFECTS, 0),
|
||||
log("frequency", "Frequency", RING_MIN_HZ, 5000.0, 440.0, Unit::Hz),
|
||||
lin("resonance", "Resonance", 0.0, 95.0, 50.0, Unit::Percent),
|
||||
choice("mod_source", "Mod Source", RING_SOURCES, 0),
|
||||
log("mod_rate", "Mod Rate", 0.05, 20.0, 1.0, Unit::Hz),
|
||||
lin("mod_depth", "Mod Depth", 0.0, 100.0, 0.0, Unit::Percent),
|
||||
lin("mix", "Mix", 0.0, 100.0, 100.0, Unit::Percent),
|
||||
];
|
||||
|
||||
pub static RING_INFO: PluginInfo = PluginInfo {
|
||||
id: "ring_mod",
|
||||
name: "Ring Modulator",
|
||||
short_name: "RingMod",
|
||||
category: Category::Modulation,
|
||||
params: &RING_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// Ring modulation, frequency modulation and positive or negative comb resonators, with the
|
||||
/// frequency moved by an LFO, an envelope follower or a sample-and-hold source.
|
||||
///
|
||||
/// - Ring multiplies the input by a sine carrier at the frequency.
|
||||
/// - Frequency Mod reads the input through a delay that the carrier swings by ±1 ms.
|
||||
/// - Resonator + and Resonator - feed the output back after one period of the frequency, with
|
||||
/// a positive or a negative sign. The output is scaled by `1 - resonance`, so the peak gain at
|
||||
/// resonance is unity.
|
||||
///
|
||||
/// At 100 % depth the modulation moves the frequency by up to two octaves either way. The
|
||||
/// envelope follower moves it up only.
|
||||
pub struct RingMod {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
lines: Vec<DelayLine>,
|
||||
lfo: Lfo,
|
||||
rng: Rng,
|
||||
carrier: f32,
|
||||
env: f32,
|
||||
env_attack: f32,
|
||||
env_release: f32,
|
||||
held: f32,
|
||||
hold_left: usize,
|
||||
last_effect: usize,
|
||||
freq: Smoother,
|
||||
res: Smoother,
|
||||
depth: Smoother,
|
||||
mix: Smoother,
|
||||
}
|
||||
|
||||
impl Default for RingMod {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl RingMod {
|
||||
pub fn new() -> Self {
|
||||
let mut r = Self {
|
||||
p: Params::new(&RING_PARAMS),
|
||||
sr: 48_000.0,
|
||||
lines: Vec::new(),
|
||||
lfo: Lfo::new(),
|
||||
rng: Rng::new(RING_SEED),
|
||||
carrier: 0.0,
|
||||
env: 0.0,
|
||||
env_attack: 0.0,
|
||||
env_release: 0.0,
|
||||
held: 0.0,
|
||||
hold_left: 0,
|
||||
last_effect: 0,
|
||||
freq: Smoother::new(440.0),
|
||||
res: Smoother::new(0.0),
|
||||
depth: Smoother::new(0.0),
|
||||
mix: Smoother::new(1.0),
|
||||
};
|
||||
r.update();
|
||||
r
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.freq.set(self.p.v(1));
|
||||
self.res.set(self.p.v(2) / 100.0);
|
||||
self.depth.set(self.p.v(5) / 100.0);
|
||||
self.mix.set(self.p.v(6) / 100.0);
|
||||
}
|
||||
|
||||
/// Samples in one period of the lowest frequency the modulation can reach.
|
||||
fn longest_period(&self) -> f32 {
|
||||
self.sr / (self.p.v(1) * 2f32.powf(-RING_MOD_OCTAVES * self.p.v(5) / 100.0)).max(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for RingMod {
|
||||
param_plumbing!(RING_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
let comb = self.sr / (RING_MIN_HZ * 2f32.powf(-RING_MOD_OCTAVES));
|
||||
let max = comb.max(ms_to_samples(2.0 * RING_FM_SWING_MS, self.sr)) as usize + 8;
|
||||
self.lines = (0..sane_channels(channels))
|
||||
.map(|_| {
|
||||
let mut d = DelayLine::default();
|
||||
d.allocate(max);
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
self.env_attack = time_coef(5.0, self.sr);
|
||||
self.env_release = time_coef(100.0, self.sr);
|
||||
for s in [&mut self.freq, &mut self.res, &mut self.depth, &mut self.mix] {
|
||||
s.set_time(20.0, self.sr);
|
||||
}
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.lines.iter_mut().for_each(DelayLine::clear);
|
||||
self.lfo.reset();
|
||||
self.rng = Rng::new(RING_SEED);
|
||||
self.carrier = 0.0;
|
||||
self.env = 0.0;
|
||||
self.held = 0.0;
|
||||
self.hold_left = 0;
|
||||
self.last_effect = self.p.choice(0);
|
||||
for s in [&mut self.freq, &mut self.res, &mut self.depth, &mut self.mix] {
|
||||
s.snap();
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.lines.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let effect = self.p.choice(0);
|
||||
if effect != self.last_effect {
|
||||
// The lines hold the previous effect's state.
|
||||
self.lines.iter_mut().for_each(DelayLine::clear);
|
||||
self.last_effect = effect;
|
||||
}
|
||||
let source = self.p.choice(3);
|
||||
let rate = self.p.v(4);
|
||||
let inc = rate / self.sr;
|
||||
let hold = ((self.sr / rate.max(0.05)) as usize).max(1);
|
||||
let nyq = self.sr * 0.45;
|
||||
let swing = ms_to_samples(RING_FM_SWING_MS, self.sr);
|
||||
for n in 0..frames {
|
||||
let level = io.iter().take(ch).filter_map(|b| b.get(n)).filter(|x| x.is_finite()).fold(0.0f32, |m, x| m.max(x.abs()));
|
||||
let k = if level > self.env { self.env_attack } else { self.env_release };
|
||||
self.env = flush(level + (self.env - level) * k);
|
||||
let lfo = Lfo::sin_at(self.lfo.tick(inc));
|
||||
let m = match source {
|
||||
1 => self.env.min(1.0),
|
||||
2 => {
|
||||
if self.hold_left == 0 {
|
||||
self.held = self.rng.bipolar();
|
||||
self.hold_left = hold;
|
||||
}
|
||||
self.hold_left -= 1;
|
||||
self.held
|
||||
}
|
||||
_ => lfo,
|
||||
};
|
||||
let f = (self.freq.next() * 2f32.powf(RING_MOD_OCTAVES * self.depth.next() * m)).clamp(1.0, nyq);
|
||||
let fb = self.res.next();
|
||||
let mix = self.mix.next();
|
||||
self.carrier += f / self.sr;
|
||||
if self.carrier >= 1.0 {
|
||||
self.carrier -= 1.0;
|
||||
}
|
||||
let carrier = Lfo::sin_at(self.carrier);
|
||||
for c in 0..ch {
|
||||
let (Some(buf), Some(line)) = (io.get_mut(c), self.lines.get_mut(c)) else { continue };
|
||||
let Some(x) = buf.get_mut(n) else { continue };
|
||||
let dry = if x.is_finite() { *x } else { 0.0 };
|
||||
let wet = match effect {
|
||||
1 => {
|
||||
line.push(dry);
|
||||
line.read_cubic(2.0 + swing * (1.0 + carrier))
|
||||
}
|
||||
2 | 3 => {
|
||||
let sign = if effect == 2 { 1.0 } else { -1.0 };
|
||||
let y = flush(dry + sign * fb * line.read_cubic(self.sr / f));
|
||||
line.push(y);
|
||||
y * (1.0 - fb)
|
||||
}
|
||||
_ => dry * carrier,
|
||||
};
|
||||
*x = dry * (1.0 - mix) + wet * mix;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
match self.p.choice(0) {
|
||||
1 => ms_to_samples(2.0 * RING_FM_SWING_MS, self.sr) as usize + 4,
|
||||
2 | 3 => {
|
||||
// Time for the feedback to fall by 60 dB at the longest period the modulation reaches.
|
||||
let fb = (self.p.v(2) / 100.0).clamp(0.0, 0.99);
|
||||
let repeats = if fb < 1e-3 { 1.0 } else { (-60.0 / (20.0 * fb.log10())).min(500.0) + 1.0 };
|
||||
(self.longest_period() * repeats) as usize
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use soundcraft_dsp::{Plugin, Transport, create, db_to_gain};
|
||||
|
||||
const SR: f32 = 48_000.0;
|
||||
const BLOCK: usize = 512;
|
||||
|
||||
fn auto_pan(params: &[(&str, f32)]) -> Box<dyn Plugin> {
|
||||
let mut p = create("auto_pan").unwrap();
|
||||
p.prepare(SR, BLOCK, 2);
|
||||
for (id, v) in params {
|
||||
assert!(p.set_param(id, *v), "{id}");
|
||||
}
|
||||
p.reset();
|
||||
p
|
||||
}
|
||||
|
||||
/// Runs `seconds` of a constant `level` on both channels in host-sized blocks.
|
||||
fn run_level(p: &mut dyn Plugin, level: f32, seconds: f32) -> Vec<Vec<f32>> {
|
||||
let len = (seconds * SR) as usize;
|
||||
let mut out = vec![Vec::new(), Vec::new()];
|
||||
let mut pos = 0;
|
||||
while pos < len {
|
||||
let n = BLOCK.min(len - pos);
|
||||
let mut buf = vec![vec![level; n]; 2];
|
||||
p.process(&mut buf, n);
|
||||
for c in 0..2 {
|
||||
out[c].extend_from_slice(&buf[c]);
|
||||
}
|
||||
pos += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Pan position (-1 left ..= 1 right) of frame `i`, from the constant-power gains.
|
||||
fn position(out: &[Vec<f32>], i: usize) -> f32 {
|
||||
4.0 / PI * out[1][i].atan2(out[0][i]) - 1.0
|
||||
}
|
||||
|
||||
fn at(seconds: f32) -> usize {
|
||||
(seconds * SR) as usize
|
||||
}
|
||||
|
||||
fn assert_near(got: f32, want: f32, tol: f32, what: &str) {
|
||||
assert!((got - want).abs() <= tol, "{what}: got {got}, want {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_manual_places_the_signal_when_width_is_zero() {
|
||||
let mut p = auto_pan(&[("width", 0.0), ("manual", -100.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 0.1);
|
||||
assert_near(out[0][at(0.05)], 0.5 * 2f32.sqrt(), 1e-4, "hard left, left channel");
|
||||
assert_near(out[1][at(0.05)], 0.0, 1e-4, "hard left, right channel");
|
||||
|
||||
let mut p = auto_pan(&[("width", 0.0), ("manual", 50.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 0.1);
|
||||
assert_near(position(&out, at(0.05)), 0.5, 1e-3, "manual 50 %");
|
||||
|
||||
let mut p = auto_pan(&[("width", 0.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 0.1);
|
||||
assert_near(out[0][at(0.05)], 0.5, 1e-4, "center keeps the level on the left");
|
||||
assert_near(out[1][at(0.05)], 0.5, 1e-4, "center keeps the level on the right");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_lfo_moves_from_left_to_right_and_back() {
|
||||
// 60 BPM with a Duration of 1 beat is one cycle per second.
|
||||
let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
for (t, want) in [(0.0, -1.0), (0.25, 0.0), (0.5, 1.0), (0.75, 0.0)] {
|
||||
assert_near(position(&out, at(t)), want, 0.05, &format!("left to right at {t} s"));
|
||||
}
|
||||
|
||||
let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0), ("path", 1.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
assert_near(position(&out, at(0.01)), 1.0, 0.05, "right to left starts on the right");
|
||||
assert_near(position(&out, at(0.5)), -1.0, 0.05, "right to left at 0.5 s");
|
||||
|
||||
// Width 50 % sweeps half the field, and Manual moves that half to the right.
|
||||
let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0), ("width", 50.0), ("manual", 50.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
assert_near(position(&out, at(0.0)), -0.25, 0.05, "width 50 % at the start");
|
||||
assert_near(position(&out, at(0.5)), 0.75, 0.05, "width 50 % at the far end");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_keeps_moving_at_the_slowest_rate() {
|
||||
// Rate 1 BPM over 4 bars of 4/4 is one cycle in 960 s; 600 s in, a saw is 62.5 % across.
|
||||
let mut p = auto_pan(&[("rate", 1.0), ("duration", 6.0), ("waveform", 3.0)]);
|
||||
let mut buf = vec![vec![0.5f32; BLOCK]; 2];
|
||||
for _ in 0..(600.0 * SR) as usize / BLOCK {
|
||||
buf.iter_mut().for_each(|c| c.fill(0.5));
|
||||
p.process(&mut buf, BLOCK);
|
||||
}
|
||||
assert_near(position(&buf, BLOCK - 1), 0.25, 0.01, "600 s into a 960 s cycle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_waveforms_take_their_shapes() {
|
||||
let shape = |waveform: f32, t: f32| {
|
||||
let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0), ("waveform", waveform)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
position(&out, at(t))
|
||||
};
|
||||
assert_near(shape(1.0, 0.25), 0.0, 0.05, "triangle at a quarter cycle");
|
||||
assert_near(shape(1.0, 0.125), -0.5, 0.05, "triangle at an eighth of a cycle");
|
||||
assert_near(shape(2.0, 0.2), -1.0, 0.02, "square in its first half");
|
||||
assert_near(shape(2.0, 0.7), 1.0, 0.02, "square in its second half");
|
||||
assert_near(shape(3.0, 0.25), -0.5, 0.05, "saw up");
|
||||
assert_near(shape(4.0, 0.25), 0.5, 0.05, "saw down");
|
||||
assert_near(shape(5.0, 1.0 / 6.0), 0.0, 0.05, "half sine reaches the center at a sixth of a cycle");
|
||||
assert_near(shape(6.0, 0.1), -1.0, 0.02, "4 step triangle, first step");
|
||||
assert_near(shape(6.0, 0.2), -1.0 / 3.0, 0.02, "4 step triangle, second step");
|
||||
assert_near(shape(6.0, 0.45), 1.0, 0.02, "4 step triangle, top step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_rate_follows_the_session_tempo_and_duration() {
|
||||
// Linked to a 120 BPM session with a Duration of 1 beat: two cycles per second.
|
||||
let mut p = auto_pan(&[("link_tempo", 1.0), ("rate", 30.0), ("duration", 2.0)]);
|
||||
p.set_transport(&Transport { tempo: 120.0, numerator: 4, denominator: 4, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 0.5);
|
||||
assert_near(position(&out, at(0.25)), 1.0, 0.05, "linked to 120 BPM, 1 beat");
|
||||
|
||||
// Not linked: Rate 120 BPM and the default Duration of 1 bar (4/4) is one cycle in 2 s.
|
||||
let mut p = auto_pan(&[]);
|
||||
p.set_transport(&Transport { tempo: 60.0, numerator: 4, denominator: 4, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 1.5);
|
||||
assert_near(position(&out, at(1.0)), 1.0, 0.05, "Rate 120 BPM, 1 bar");
|
||||
|
||||
// A 3/4 bar at 120 BPM lasts 1.5 s.
|
||||
let mut p = auto_pan(&[("link_tempo", 1.0)]);
|
||||
p.set_transport(&Transport { tempo: 120.0, numerator: 3, denominator: 4, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
assert_near(position(&out, at(0.75)), 1.0, 0.05, "1 bar of 3/4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_beat_clock_follows_the_transport_position() {
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("duration", 2.0)]);
|
||||
// Half a beat into the song, the LFO is half way through its cycle.
|
||||
p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, quarters: 7.5, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 0.01);
|
||||
assert_near(position(&out, 0), 1.0, 0.05, "half a beat in");
|
||||
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("duration", 2.0)]);
|
||||
p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, quarters: 3.0, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 0.2);
|
||||
assert_near(position(&out, 0), -1.0, 0.05, "on the beat");
|
||||
assert_near(position(&out, at(0.125)), 0.0, 0.05, "a quarter of a beat later");
|
||||
|
||||
// Not linked, Rate 60 BPM in a 120 BPM session: the LFO restarts on every beat (0.5 s) and
|
||||
// runs at 1 Hz in between, so it reaches the right and then starts again from the left.
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("rate", 60.0), ("duration", 2.0)]);
|
||||
p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, quarters: 3.0, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
assert_near(position(&out, at(0.25)), 0.0, 0.05, "a quarter cycle at 1 Hz");
|
||||
assert_near(position(&out, at(0.49)), 1.0, 0.05, "on the right at the end of the beat");
|
||||
assert_near(position(&out, at(0.51)), -1.0, 0.05, "restarted on the next beat");
|
||||
assert_near(position(&out, at(0.75)), 0.0, 0.05, "a quarter cycle after the restart");
|
||||
|
||||
// 2 Beats in 3/4, with the host updating the transport every block: the cycle runs on across
|
||||
// the bar line at quarter 3 instead of restarting there.
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("waveform", 3.0), ("duration", 3.0)]);
|
||||
let mut buf = vec![vec![0.5f32; 100]; 2];
|
||||
let mut positions = Vec::new();
|
||||
for block in 0..480 {
|
||||
let quarters = 2.0 + block as f64 * 100.0 * 2.0 / f64::from(SR);
|
||||
let bar = (quarters / 3.0).floor();
|
||||
p.set_transport(&Transport {
|
||||
playing: true,
|
||||
tempo: 120.0,
|
||||
numerator: 3,
|
||||
denominator: 4,
|
||||
quarters,
|
||||
bar_start: bar * 3.0,
|
||||
bar: bar as i64,
|
||||
..Transport::default()
|
||||
});
|
||||
buf.iter_mut().for_each(|c| c.fill(0.5));
|
||||
p.process(&mut buf, 100);
|
||||
positions.push((quarters, position(&buf, 0)));
|
||||
}
|
||||
for (q, want) in [(2.5, -0.5), (3.0, 0.0), (3.5, 0.5)] {
|
||||
let got = positions.iter().find(|(quarters, _)| (quarters - q).abs() < 1e-9).map(|(_, pos)| *pos).unwrap();
|
||||
assert_near(got, want, 0.02, &format!("2 beats in 3/4 at quarter {q}"));
|
||||
}
|
||||
|
||||
// Bar-based cycles start on the bar line after a change from 4/4 to 3/4 at quarter 4.
|
||||
let three_four = |quarters: f64| Transport {
|
||||
playing: true,
|
||||
tempo: 120.0,
|
||||
numerator: 3,
|
||||
denominator: 4,
|
||||
quarters,
|
||||
bar_start: 4.0,
|
||||
bar: 1,
|
||||
..Transport::default()
|
||||
};
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("waveform", 3.0)]);
|
||||
p.set_transport(&three_four(4.0));
|
||||
let out = run_level(p.as_mut(), 0.5, 0.01);
|
||||
assert_near(position(&out, 0), -1.0, 0.02, "1 bar starts on the 3/4 bar line");
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("waveform", 3.0), ("duration", 5.0)]);
|
||||
p.set_transport(&three_four(5.5));
|
||||
let out = run_level(p.as_mut(), 0.5, 0.01);
|
||||
assert_near(position(&out, 0), 0.5, 0.02, "half way through the second bar of 2 bars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_single_runs_one_cycle_and_holds() {
|
||||
let mut p = auto_pan(&[("trigger", 1.0), ("rate", 60.0), ("duration", 2.0), ("waveform", 3.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 2.0);
|
||||
assert_near(position(&out, at(0.5)), 0.0, 0.05, "half way through the one cycle");
|
||||
assert_near(position(&out, at(1.5)), 1.0, 0.02, "holds at the end of the path");
|
||||
assert_near(position(&out, at(1.99)), 1.0, 0.02, "still holding");
|
||||
|
||||
// Starting the transport runs the cycle again.
|
||||
p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 0.5);
|
||||
assert_near(position(&out, at(0.25)), -0.5, 0.05, "restarted when playback starts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_envelope_source_pans_by_level() {
|
||||
let settled = |level: f32| {
|
||||
let mut p = auto_pan(&[("source", 1.0), ("threshold", -30.0)]);
|
||||
let out = run_level(p.as_mut(), level, 0.5);
|
||||
position(&out, at(0.45))
|
||||
};
|
||||
assert_near(settled(db_to_gain(-40.0)), -1.0, 0.02, "below the threshold");
|
||||
assert_near(settled(db_to_gain(-15.0)), 0.0, 0.05, "half way to full scale");
|
||||
assert_near(settled(1.0), 1.0, 0.02, "full scale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_envelope_and_manual_triggers_start_a_cycle() {
|
||||
let mut p = auto_pan(&[("trigger", 3.0), ("rate", 60.0), ("duration", 2.0), ("waveform", 3.0), ("threshold", -30.0)]);
|
||||
let quiet = run_level(p.as_mut(), db_to_gain(-40.0), 0.5);
|
||||
assert_near(position(&quiet, at(0.4)), -1.0, 0.02, "waits at the start below the threshold");
|
||||
let loud = run_level(p.as_mut(), 1.0, 1.5);
|
||||
assert_near(position(&loud, at(0.25)), -0.5, 0.05, "a quarter cycle after the level crosses the threshold");
|
||||
assert_near(position(&loud, at(1.4)), 1.0, 0.02, "holds after one cycle while the level stays up");
|
||||
|
||||
let mut p = auto_pan(&[("trigger", 4.0), ("rate", 60.0), ("duration", 2.0), ("waveform", 3.0)]);
|
||||
let before = run_level(p.as_mut(), 0.5, 0.5);
|
||||
assert_near(position(&before, at(0.4)), -1.0, 0.02, "waits for the manual trigger");
|
||||
assert!(p.set_param("retrigger", 1.0));
|
||||
let after = run_level(p.as_mut(), 0.5, 0.5);
|
||||
assert_near(position(&after, at(0.25)), -0.5, 0.05, "a quarter cycle after switching the trigger on");
|
||||
let held = run_level(p.as_mut(), 0.5, 1.0);
|
||||
assert!(p.set_param("retrigger", 0.0));
|
||||
let again = run_level(p.as_mut(), 0.5, 0.5);
|
||||
assert_near(position(&held, at(0.9)), 1.0, 0.02, "holds after one cycle");
|
||||
assert_near(position(&again, at(0.25)), -0.5, 0.05, "switching the trigger off starts another cycle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_envelope_trigger_waits_for_the_release() {
|
||||
let mut p = auto_pan(&[
|
||||
("trigger", 3.0),
|
||||
("rate", 60.0),
|
||||
("duration", 2.0),
|
||||
("waveform", 3.0),
|
||||
("threshold", -30.0),
|
||||
("attack", 0.1),
|
||||
("release", 100.0),
|
||||
]);
|
||||
let quiet = db_to_gain(-40.0);
|
||||
let first = run_level(p.as_mut(), 1.0, 1.5);
|
||||
assert_near(position(&first, at(1.4)), 1.0, 0.02, "the first cycle ends and holds");
|
||||
// The envelope falls below the threshold about 0.38 s into the quiet part, so a 0.42 s dip
|
||||
// leaves it below for less than the 100 ms Release.
|
||||
run_level(p.as_mut(), quiet, 0.42);
|
||||
let short = run_level(p.as_mut(), 1.0, 0.5);
|
||||
assert_near(position(&short, at(0.25)), 1.0, 0.02, "no trigger before the release is over");
|
||||
run_level(p.as_mut(), quiet, 1.0);
|
||||
let long = run_level(p.as_mut(), 1.0, 0.5);
|
||||
assert_near(position(&long, at(0.25)), -0.5, 0.05, "a trigger once the release is over");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_output_gain_and_mono_tracks() {
|
||||
let mut p = auto_pan(&[("width", 0.0), ("output", 6.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 0.1);
|
||||
assert_near(out[0][at(0.05)], 0.5 * db_to_gain(6.0), 1e-4, "+6 dB at the center");
|
||||
|
||||
// A mono insert has nothing to pan to; Output still applies.
|
||||
let mut p = create("auto_pan").unwrap();
|
||||
p.prepare(SR, BLOCK, 1);
|
||||
assert!(p.set_param("output", -6.0));
|
||||
p.reset();
|
||||
let mut buf = vec![vec![0.5f32; BLOCK]];
|
||||
p.process(&mut buf, BLOCK);
|
||||
assert_near(buf[0][BLOCK - 1], 0.5 * db_to_gain(-6.0), 1e-4, "mono output");
|
||||
}
|
||||
@@ -46,6 +46,9 @@ proptest! {
|
||||
let _ = offline::detect_transients(&ch, a, a);
|
||||
let _ = offline::time_stretch(&ch, r, a);
|
||||
let _ = offline::pitch_shift(&ch, a, a);
|
||||
let change = if n % 2 == 0 { offline::SpeedRampChange::SpeedUp } else { offline::SpeedRampChange::SlowDown };
|
||||
let selection = if n % 3 == 0 { offline::SpeedRampSelection::Extend } else { offline::SpeedRampSelection::FitTo };
|
||||
let _ = offline::speed_ramp(&ch, change, selection, n % 5 == 0);
|
||||
let _ = offline::resample(&ch, n as u32, (n as u32).wrapping_mul(3) % 200_000);
|
||||
let mut m = ch.clone();
|
||||
offline::normalize(&mut m, a, n % 2 == 0);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
mod auto_pan;
|
||||
mod fuzz;
|
||||
mod offline_meters;
|
||||
mod offline_normalize;
|
||||
mod plugins;
|
||||
mod speed_ramp;
|
||||
mod transients;
|
||||
|
||||
@@ -513,6 +513,103 @@ fn saturator_adds_harmonics_and_stays_bounded() {
|
||||
assert!(spec[third - 1..=third + 1].iter().cloned().fold(f32::MIN, f32::max) > -30.0);
|
||||
}
|
||||
|
||||
/// Amplitude of the `freq` component of `x` (a whole number of cycles keeps it exact).
|
||||
fn tone_level(x: &[f32], freq: f32) -> f32 {
|
||||
let (mut re, mut im) = (0.0f64, 0.0f64);
|
||||
for (i, v) in x.iter().enumerate() {
|
||||
let w = f64::from(TAU * freq / SR) * i as f64;
|
||||
re += f64::from(*v) * w.cos();
|
||||
im += f64::from(*v) * w.sin();
|
||||
}
|
||||
(2.0 * (re * re + im * im).sqrt() / x.len() as f64) as f32
|
||||
}
|
||||
|
||||
/// A Subharmonic with only the wet path and wide filters, so a band's octave reaches the output.
|
||||
fn subharmonic_wet() -> Box<dyn Plugin> {
|
||||
let mut p = prepared("subharmonic");
|
||||
p.set_param("mix", 100.0);
|
||||
p.set_param("hp_freq", 5.0);
|
||||
p.set_param("lp_freq", 1000.0);
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subharmonic_adds_the_octave_below_each_range_band() {
|
||||
for (range, lower, upper) in [(0.0, 120.0, 180.0), (1.0, 80.0, 120.0), (2.0, 60.0, 90.0), (3.0, 40.0, 60.0)] {
|
||||
for (band, freq) in [("lower", lower), ("upper", upper)] {
|
||||
let mut p = subharmonic_wet();
|
||||
p.set_param("range", range);
|
||||
p.set_param("lower_gain", if band == "lower" { 0.0 } else { -120.0 });
|
||||
p.set_param("upper_gain", if band == "upper" { 0.0 } else { -120.0 });
|
||||
let out = run(p.as_mut(), &[sine(freq, 0.5, 96_000)]);
|
||||
// One second after the filters settle: a whole number of cycles of both tones.
|
||||
let tail = &out[0][48_000..];
|
||||
let sub = tone_level(tail, freq / 2.0);
|
||||
assert!(sub > 0.2, "range {range} {band} band: {sub} at {} Hz", freq / 2.0);
|
||||
assert!(sub > 4.0 * tone_level(tail, freq), "range {range} {band} band keeps its input tone");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subharmonic_mix_keeps_one_path_at_full_level() {
|
||||
// A 1 kHz tone is far above every band, so the output is the dry path alone.
|
||||
for (mix, dry) in [(0.0, 1.0), (25.0, 1.0), (50.0, 1.0), (75.0, 0.5), (100.0, 0.0)] {
|
||||
let mut p = prepared("subharmonic");
|
||||
p.set_param("mix", mix);
|
||||
let out = run(p.as_mut(), &[sine(1000.0, 0.5, 48_000)]);
|
||||
let level = tone_level(&out[0][24_000..], 1000.0);
|
||||
assert!((level - 0.5 * dry).abs() < 0.01, "mix {mix}: dry level {level}");
|
||||
}
|
||||
// The wet path is at full level from 50 % up and at half level at 25 %.
|
||||
let wet = |mix: f32| {
|
||||
let mut p = prepared("subharmonic");
|
||||
p.set_param("mix", mix);
|
||||
p.set_param("wet_solo", 1.0);
|
||||
let out = run(p.as_mut(), &[sine(60.0, 0.5, 96_000)]);
|
||||
tone_level(&out[0][48_000..], 30.0)
|
||||
};
|
||||
let full = wet(50.0);
|
||||
assert!(full > 0.1, "wet level {full}");
|
||||
assert!((wet(100.0) - full).abs() < 0.01 * full);
|
||||
assert!((wet(25.0) - 0.5 * full).abs() < 0.01 * full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subharmonic_solo_plays_one_band_and_mutes_the_dry_signal() {
|
||||
let level = |solo: &str, gain: f32| {
|
||||
let mut p = prepared("subharmonic");
|
||||
p.set_param("lower_gain", gain);
|
||||
p.set_param(solo, 1.0);
|
||||
let x = sine(60.0, 0.5, 96_000).iter().zip(sine(1000.0, 0.5, 96_000)).map(|(a, b)| a + b).collect();
|
||||
let out = run(p.as_mut(), &[x]);
|
||||
let tail = &out[0][48_000..];
|
||||
(tone_level(tail, 30.0), tone_level(tail, 1000.0))
|
||||
};
|
||||
let (sub, dry) = level("lower_solo", 0.0);
|
||||
assert!(sub > 0.1 && dry < 0.01, "lower solo: sub {sub}, dry {dry}");
|
||||
// With the soloed band turned off nothing else plays, though the upper band is on.
|
||||
let (sub, dry) = level("lower_solo", -120.0);
|
||||
assert!(sub < 0.01 && dry < 0.01, "silent lower solo: sub {sub}, dry {dry}");
|
||||
let (sub, dry) = level("upper_solo", -120.0);
|
||||
assert!(sub > 0.05 && dry < 0.01, "upper solo: sub {sub}, dry {dry}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subharmonic_drive_keeps_every_character_bounded() {
|
||||
for character in 0..6 {
|
||||
let mut p = subharmonic_wet();
|
||||
p.set_param("input", 18.0);
|
||||
p.set_param("lower_gain", 24.0);
|
||||
p.set_param("upper_gain", 24.0);
|
||||
p.set_param("drive", 18.0);
|
||||
p.set_param("character", character as f32);
|
||||
let out = run(p.as_mut(), &[sine(60.0, 1.0, 48_000)]);
|
||||
let top = peak(&out[0][24_000..]);
|
||||
assert!(top > 0.5 && top < 1.5, "character {character}: peak {top}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lofi_quantizes() {
|
||||
let mut p = prepared("lofi");
|
||||
@@ -524,3 +621,95 @@ fn lofi_quantizes() {
|
||||
levels.dedup();
|
||||
assert!(levels.len() <= 5, "{levels:?}");
|
||||
}
|
||||
|
||||
fn ring_mod(params: &[(&str, f32)]) -> Box<dyn Plugin> {
|
||||
let mut p = prepared("ring_mod");
|
||||
for &(id, v) in params {
|
||||
assert!(p.set_param(id, v), "ring_mod.{id}");
|
||||
}
|
||||
p.reset();
|
||||
p
|
||||
}
|
||||
|
||||
/// Loudest bin within two bins of `hz` in an 8192-point spectrum.
|
||||
fn band_db(spec: &[f32], hz: f32) -> f32 {
|
||||
let bin = (hz / (SR / 8192.0)).round() as usize;
|
||||
spec[bin - 2..=bin + 2].iter().cloned().fold(f32::MIN, f32::max)
|
||||
}
|
||||
|
||||
fn zero_crossings(x: &[f32]) -> usize {
|
||||
x.windows(2).filter(|w| (w[0] < 0.0) != (w[1] < 0.0)).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_mod_makes_sum_and_difference_tones() {
|
||||
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 300.0), ("mod_depth", 0.0), ("mix", 100.0)]);
|
||||
let out = run(p.as_mut(), &[sine(1000.0, 0.5, 48_000)]);
|
||||
let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][8192..], 8192);
|
||||
let (low, high, input) = (band_db(&spec, 700.0), band_db(&spec, 1300.0), band_db(&spec, 1000.0));
|
||||
// The two tones fall at different places between bins, so their bin peaks differ a little.
|
||||
assert!((low - high).abs() < 2.0, "700 Hz {low} dB, 1300 Hz {high} dB");
|
||||
assert!(low - input > 40.0, "the 1000 Hz input is left at {input} dB against {low} dB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_mod_frequency_mod_adds_sidebands() {
|
||||
let x = sine(1000.0, 0.5, 48_000);
|
||||
let dry = soundcraft_dsp::spectrum::magnitude_db(&x[8192..], 8192);
|
||||
assert!(band_db(&dry, 1000.0) - band_db(&dry, 1200.0) > 40.0);
|
||||
let mut p = ring_mod(&[("effect", 1.0), ("frequency", 200.0), ("mix", 100.0)]);
|
||||
let out = run(p.as_mut(), &[x]);
|
||||
let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][8192..], 8192);
|
||||
for hz in [800.0, 1200.0] {
|
||||
assert!(band_db(&spec, 1000.0) - band_db(&spec, hz) < 20.0, "no sideband at {hz} Hz");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_mod_resonators_ring_at_the_frequency_with_their_sign() {
|
||||
// 480 Hz at 48 kHz is a 100-sample period.
|
||||
for (effect, sign) in [(2.0, 1.0), (3.0, -1.0)] {
|
||||
let mut p = ring_mod(&[("effect", effect), ("frequency", 480.0), ("resonance", 90.0), ("mix", 100.0)]);
|
||||
let mut x = vec![0.0f32; 9600];
|
||||
x[0] = 1.0;
|
||||
let out = run(p.as_mut(), &[x]);
|
||||
let y = &out[0];
|
||||
assert!((y[0] - 0.1).abs() < 1e-4, "effect {effect}: direct {}", y[0]);
|
||||
assert!((y[100] - sign * 0.09).abs() < 1e-4, "effect {effect}: first echo {}", y[100]);
|
||||
assert!((y[200] - 0.081).abs() < 1e-4, "effect {effect}: second echo {}", y[200]);
|
||||
assert!(y[50].abs() < 1e-6 && y[150].abs() < 1e-6, "effect {effect}: sound between the echoes");
|
||||
let last = y.iter().rposition(|v| v.abs() > 0.1e-3).unwrap();
|
||||
assert!(last < p.tail_samples(), "effect {effect}: ringing at {last}, tail {}", p.tail_samples());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_mod_modulation_sources_move_the_frequency() {
|
||||
// A constant input through Ring gives the carrier itself.
|
||||
let dc = vec![1.0f32; 48_000];
|
||||
|
||||
// The envelope of a full-scale input lifts 100 Hz by one octave at 50 % depth.
|
||||
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 1.0), ("mod_depth", 50.0), ("mix", 100.0)]);
|
||||
let out = run(p.as_mut(), std::slice::from_ref(&dc));
|
||||
let n = zero_crossings(&out[0][24_000..]);
|
||||
assert!((195..=205).contains(&n), "envelope: {n} crossings in 0.5 s, 200 expected for 200 Hz");
|
||||
|
||||
// A 1 Hz LFO raises the frequency for its first half cycle and lowers it for the second.
|
||||
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 0.0), ("mod_rate", 1.0), ("mod_depth", 100.0), ("mix", 100.0)]);
|
||||
let out = run(p.as_mut(), std::slice::from_ref(&dc));
|
||||
let (up, down) = (zero_crossings(&out[0][..12_000]), zero_crossings(&out[0][24_000..36_000]));
|
||||
assert!(up > 3 * down, "LFO: {up} crossings rising, {down} falling");
|
||||
|
||||
// Sample and hold at 4 Hz keeps one frequency for each 12,000-sample step.
|
||||
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 2.0), ("mod_rate", 4.0), ("mod_depth", 100.0), ("mix", 100.0)]);
|
||||
let out = run(p.as_mut(), std::slice::from_ref(&dc));
|
||||
let mut steps = Vec::new();
|
||||
for s in out[0].chunks(12_000) {
|
||||
let (a, b) = (zero_crossings(&s[..6000]), zero_crossings(&s[6000..]));
|
||||
assert!(a.abs_diff(b) <= 2, "sample and hold changed within a step: {a} then {b}");
|
||||
steps.push(a);
|
||||
}
|
||||
steps.sort_unstable();
|
||||
steps.dedup_by(|a, b| a.abs_diff(*b) <= 2);
|
||||
assert!(steps.len() >= 3, "sample and hold steps: {steps:?}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
use soundcraft_dsp::offline::{self, SpeedRampChange, SpeedRampSelection};
|
||||
|
||||
/// A channel whose samples rise from 0 to 1, so each output sample shows where it was read.
|
||||
fn ramp(len: usize) -> Vec<f32> {
|
||||
(0..len).map(|i| i as f32 / len as f32).collect()
|
||||
}
|
||||
|
||||
/// The fraction of the played input reached at `u` (0 to 1) of the result.
|
||||
fn slow_down_pos(u: f64) -> f64 {
|
||||
(3.0 * u - u * u * u) / 2.0
|
||||
}
|
||||
|
||||
fn speed_up_pos(u: f64) -> f64 {
|
||||
1.0 - slow_down_pos(1.0 - u)
|
||||
}
|
||||
|
||||
fn near(got: f32, want: f64, what: &str) {
|
||||
assert!((f64::from(got) - want).abs() < 2e-3, "{what}: got {got}, want {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_fit_to_keeps_the_length_and_plays_two_thirds() {
|
||||
let len = 30_000;
|
||||
let x = vec![ramp(len)];
|
||||
let slow = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::FitTo, false);
|
||||
assert_eq!(slow[0].len(), len);
|
||||
// Slow Down plays the first two thirds: it starts at normal speed at the start and stops at the end.
|
||||
for u in [0.0, 0.25, 0.5, 0.75, 0.9] {
|
||||
let n = (u * len as f64) as usize;
|
||||
near(slow[0][n], 2.0 / 3.0 * slow_down_pos(u), &format!("slow down at {u}"));
|
||||
}
|
||||
// The speed over 50 samples: a one-sample step inside the input also shows the interpolation's ripple.
|
||||
let step = |a: &[f32], n: usize| f64::from(a[n + 50] - a[n]) * len as f64 / 50.0;
|
||||
assert!((step(&slow[0], 100) - 1.0).abs() < 0.01, "slow down starts at normal speed: {}", step(&slow[0], 100));
|
||||
assert!(step(&slow[0], len - 200) < 0.02, "slow down ends near a stop: {}", step(&slow[0], len - 200));
|
||||
|
||||
// Speed Up plays the last two thirds: it starts from a stop and reaches normal speed at the end.
|
||||
let fast = offline::speed_ramp(&x, SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, false);
|
||||
assert_eq!(fast[0].len(), len);
|
||||
for u in [0.1, 0.25, 0.5, 0.75, 0.99] {
|
||||
let n = (u * len as f64) as usize;
|
||||
near(fast[0][n], 1.0 / 3.0 + 2.0 / 3.0 * speed_up_pos(u), &format!("speed up at {u}"));
|
||||
}
|
||||
assert!(step(&fast[0], 200) < 0.02, "speed up starts near a stop: {}", step(&fast[0], 200));
|
||||
assert!((step(&fast[0], len - 100) - 1.0).abs() < 0.01, "speed up ends at normal speed: {}", step(&fast[0], len - 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_extend_plays_everything_over_150_percent() {
|
||||
let len = 30_000;
|
||||
let x = vec![ramp(len), ramp(len)];
|
||||
for change in [SpeedRampChange::SlowDown, SpeedRampChange::SpeedUp] {
|
||||
let out = offline::speed_ramp(&x, change, SpeedRampSelection::Extend, false);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(out[0].len(), 45_000, "{change:?}");
|
||||
assert_eq!(out[0], out[1], "{change:?}: both channels are read at the same positions");
|
||||
for u in [0.1, 0.25, 0.5, 0.75, 0.97] {
|
||||
let n = (u * 45_000.0) as usize;
|
||||
let pos = if change == SpeedRampChange::SlowDown { slow_down_pos(u) } else { speed_up_pos(u) };
|
||||
near(out[0][n], pos, &format!("{change:?} at {u}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_fades_follow_the_speed() {
|
||||
let len = 30_000;
|
||||
let x = vec![vec![0.5; len]];
|
||||
let slow = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::FitTo, true);
|
||||
let fast = offline::speed_ramp(&x, SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, true);
|
||||
for u in [0.25, 0.5, 0.75] {
|
||||
let n = (u * len as f64) as usize;
|
||||
near(slow[0][n], 0.5 * (1.0 - u * u), &format!("slow down fades out at {u}"));
|
||||
near(fast[0][n], 0.5 * (1.0 - (1.0 - u) * (1.0 - u)), &format!("speed up fades in at {u}"));
|
||||
}
|
||||
assert!(slow[0][len - 1].abs() < 1e-3 && fast[0][0].abs() < 1e-3, "the fades reach silence at the stop");
|
||||
// Fades Off keeps the level.
|
||||
let off = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::FitTo, false);
|
||||
for u in [0.25, 0.5, 0.75, 0.9] {
|
||||
near(off[0][(u * len as f64) as usize], 0.5, &format!("slow down without fades at {u}"));
|
||||
}
|
||||
// ... at the stop too, where the reads reach either end of the input.
|
||||
let up = offline::speed_ramp(&x, SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, false);
|
||||
near(up[0][0], 0.5, "speed up without fades from the stop");
|
||||
near(off[0][len - 1], 0.5, "slow down without fades at the stop");
|
||||
let off = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::Extend, false);
|
||||
near(off[0][off[0].len() - 1], 0.5, "slow down with Extend and without fades at the stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_pitch_falls_as_the_tape_slows() {
|
||||
let sr = 48_000.0;
|
||||
let len = 96_000;
|
||||
let x = vec![(0..len).map(|i| (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / sr).sin() * 0.5).collect::<Vec<f32>>()];
|
||||
let out = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::Extend, false);
|
||||
// Upward zero crossings per second in a 0.1 s window starting at `u`.
|
||||
let rate = |u: f64| {
|
||||
let a = (u * out[0].len() as f64) as usize;
|
||||
let w = &out[0][a..a + 4800];
|
||||
w.windows(2).filter(|p| p[0] < 0.0 && p[1] >= 0.0).count() as f64 * 10.0
|
||||
};
|
||||
assert!((rate(0.0) - 1000.0).abs() < 20.0, "starts at the original pitch: {}", rate(0.0));
|
||||
let half = 1000.0 * 0.75;
|
||||
assert!((rate(0.5) - half).abs() < 30.0, "three quarters speed halfway: {}", rate(0.5));
|
||||
assert!(rate(0.9) < 250.0, "low near the stop: {}", rate(0.9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_handles_empty_and_uneven_channels() {
|
||||
assert!(offline::speed_ramp(&[], SpeedRampChange::SlowDown, SpeedRampSelection::Extend, true).is_empty());
|
||||
let out = offline::speed_ramp(&[vec![], vec![]], SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, true);
|
||||
assert!(out.iter().all(Vec::is_empty));
|
||||
// A shorter channel is read as silence past its end, and every channel gets the same length.
|
||||
let out = offline::speed_ramp(&[vec![0.5; 300], vec![0.5; 30]], SpeedRampChange::SlowDown, SpeedRampSelection::Extend, false);
|
||||
assert_eq!((out[0].len(), out[1].len()), (450, 450));
|
||||
assert!(out.iter().flatten().all(|v| v.is_finite()));
|
||||
near(out[0][449], 0.5, "the longer channel at the stop");
|
||||
assert!(out[1][200..].iter().all(|v| v.abs() < 1e-3), "the shorter channel is silent past its end");
|
||||
}
|
||||
@@ -181,11 +181,16 @@ impl Autosave {
|
||||
fn create(root: &Path, name: &str) -> Result<Autosave> {
|
||||
let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis();
|
||||
let base = format!("{} {stamp}-{}", sanitize_name(name), std::process::id());
|
||||
std::fs::create_dir_all(root).map_err(|err| io_error(root, err))?;
|
||||
create_folder(root).map_err(|err| io_error(root, err))?;
|
||||
let mut dir = root.join(&base);
|
||||
let mut n = 1;
|
||||
loop {
|
||||
match std::fs::create_dir(&dir) {
|
||||
// Not recursive, so a folder that already exists is reported and the next name tried.
|
||||
#[cfg_attr(not(unix), allow(unused_mut))]
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||
match builder.create(&dir) {
|
||||
Ok(()) => break,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
n += 1;
|
||||
@@ -224,9 +229,7 @@ impl Autosave {
|
||||
self.next += 1;
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
std::fs::create_dir_all(&audio_dir)
|
||||
.and_then(|()| write_synced(&audio_dir.join(&name), &bytes))
|
||||
.map_err(|err| io_error(&audio_dir, err))?;
|
||||
create_folder(&audio_dir).and_then(|()| write_synced(&audio_dir.join(&name), &bytes)).map_err(|err| io_error(&audio_dir, err))?;
|
||||
name
|
||||
}
|
||||
};
|
||||
@@ -409,6 +412,18 @@ pub fn discard(left: &Leftover) {
|
||||
let _ = std::fs::remove_dir_all(&left.dir);
|
||||
}
|
||||
|
||||
/// Create `dir` and its missing parents under the autosave root. On Unix every folder made here is
|
||||
/// owner-only (`0o700`): autosaves and the takes of a session never saved are unsaved work, and
|
||||
/// with the default mode other accounts on the machine could read them. A folder that already
|
||||
/// exists keeps its mode.
|
||||
pub fn create_folder(dir: &Path) -> std::io::Result<()> {
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||
builder.create(dir)
|
||||
}
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let mut f = std::fs::File::create(path)?;
|
||||
f.write_all(bytes)?;
|
||||
@@ -534,6 +549,50 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Autosaves are unsaved work: on Unix the folders the autosaver makes are owner-only, and an
|
||||
/// autosave root that already exists keeps its mode.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_autosaver_makes_owner_only_folders() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
let base = folder("autosave-modes");
|
||||
let mut e = Engine::default();
|
||||
record(&mut e, 0.5);
|
||||
// A root that doesn't exist yet is made with the first autosave, owner-only like the
|
||||
// session's folder and the audio copied into it.
|
||||
let new = base.join("Autosave");
|
||||
let w = Autosaver::new(new.clone());
|
||||
w.write(e.session_arc(), None, None);
|
||||
w.flush().unwrap();
|
||||
let session = std::fs::read_dir(&new).unwrap().flatten().map(|d| d.path()).next().unwrap();
|
||||
assert_eq!((mode(&new), mode(&session), mode(&session.join("Audio Files"))), (0o700, 0o700, 0o700));
|
||||
w.close(false);
|
||||
w.flush().unwrap();
|
||||
// A root that already exists keeps its mode; the folders made inside it are owner-only.
|
||||
let old = base.join("old");
|
||||
std::fs::create_dir_all(&old).unwrap();
|
||||
std::fs::set_permissions(&old, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let w = Autosaver::new(old.clone());
|
||||
w.write(e.session_arc(), None, None);
|
||||
w.flush().unwrap();
|
||||
let session = std::fs::read_dir(&old).unwrap().flatten().map(|d| d.path()).next().unwrap();
|
||||
assert_eq!((mode(&old), mode(&session)), (0o755, 0o700));
|
||||
// A session never saved records its takes into `<name>/Audio Files` under the root. Both
|
||||
// are made owner-only, and so is `Audio Files` in a take folder an earlier version left.
|
||||
let takes = old.join("Untitled").join("Audio Files");
|
||||
create_folder(&takes).unwrap();
|
||||
assert_eq!((mode(&old.join("Untitled")), mode(&takes)), (0o700, 0o700));
|
||||
let earlier = old.join("Earlier");
|
||||
std::fs::create_dir_all(&earlier).unwrap();
|
||||
std::fs::set_permissions(&earlier, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
create_folder(&earlier.join("Audio Files")).unwrap();
|
||||
assert_eq!((mode(&earlier), mode(&earlier.join("Audio Files"))), (0o755, 0o700));
|
||||
w.close(false);
|
||||
w.flush().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_take_still_recording_at_a_crash_is_placed_on_recovery() {
|
||||
use crate::io::Take;
|
||||
|
||||
@@ -176,6 +176,8 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
|
||||
c.sync_point = scale(c.sync_point);
|
||||
c.gain_env.iter_mut().for_each(|(at, _)| *at = scale(*at));
|
||||
c.group = Some(group_id);
|
||||
// The file does not hold the Auto Rename schemes of the session it came from.
|
||||
c.auto_name = None;
|
||||
c.sanitize();
|
||||
placed.push(c.id);
|
||||
prepared.push(c);
|
||||
@@ -231,6 +233,21 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_clips_carry_no_auto_rename_scheme() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-names-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("named.scgrp").to_string_lossy().to_string();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let kick = e.session().track_by_name("Kick").unwrap().clips()[0].id.0;
|
||||
e.execute("clip.auto_rename", &json!({"clips": [kick], "name": "Hit"})).unwrap();
|
||||
e.execute("file.export_clip_groups", &json!({"path": path, "clips": [kick]})).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 30_000_000, "track": "Bass"})).unwrap();
|
||||
let placed = soundcraft_model::ClipId(r["clips"][0].as_u64().unwrap());
|
||||
assert_eq!(e.session().find_clip(placed).unwrap().1.auto_name, None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_in_a_group_file_are_kept_within_reach() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-far-{}", std::process::id()));
|
||||
|
||||
@@ -5,6 +5,7 @@ use soundcraft_time::Range;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_audio_io::{AudioBuffer, FileFormat};
|
||||
use soundcraft_dsp::offline::{SpeedRampChange, SpeedRampSelection};
|
||||
use soundcraft_model::{Clip, ClipContent, TrackKind};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -13,7 +14,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"AudioSuite Process",
|
||||
[],
|
||||
None,
|
||||
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks)",
|
||||
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | speed_ramp | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)",
|
||||
has_selection,
|
||||
process
|
||||
)]
|
||||
@@ -31,7 +32,16 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let known = soundcraft_dsp::plugin_info(&name).is_some()
|
||||
|| matches!(
|
||||
name.as_str(),
|
||||
"normalize" | "reverse" | "invert" | "gain" | "duplicate" | "time_stretch" | "pitch_shift" | "varispeed" | "dc_offset_removal"
|
||||
"normalize"
|
||||
| "reverse"
|
||||
| "invert"
|
||||
| "gain"
|
||||
| "duplicate"
|
||||
| "time_stretch"
|
||||
| "pitch_shift"
|
||||
| "varispeed"
|
||||
| "speed_ramp"
|
||||
| "dc_offset_removal"
|
||||
);
|
||||
if !known {
|
||||
return Err(bad("audiosuite.process", format!("unknown process `{name}`")));
|
||||
@@ -50,12 +60,16 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ratio = f64::from(get("ratio", 1.0).clamp(0.25, 4.0));
|
||||
let semitones = get("semitones", 0.0).clamp(-24.0, 24.0);
|
||||
let to = (sr.as_f64() / f64::from(get("speed", 1.0).clamp(0.25, 4.0))).round().max(1.0) as u32;
|
||||
let change = if get("change", 0.0) >= 0.5 { SpeedRampChange::SpeedUp } else { SpeedRampChange::SlowDown };
|
||||
let selection = if get("selection", 0.0) >= 0.5 { SpeedRampSelection::Extend } else { SpeedRampSelection::FitTo };
|
||||
let fades = get("fades", 1.0) >= 0.5;
|
||||
// These processes cut their output at MAX_OFFLINE_SAMPLES frames.
|
||||
let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize;
|
||||
let most = match name.as_str() {
|
||||
"time_stretch" => longest(ratio),
|
||||
"pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)),
|
||||
"varispeed" => longest(f64::from(to) / sr.as_f64()),
|
||||
"speed_ramp" => longest(if selection == SpeedRampSelection::Extend { 1.5 } else { 1.0 }),
|
||||
_ => usize::MAX,
|
||||
};
|
||||
let mut done = 0;
|
||||
@@ -101,6 +115,7 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
"time_stretch" => soundcraft_dsp::offline::time_stretch(&audio, ratio, srf),
|
||||
"pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, semitones, srf),
|
||||
"varispeed" => soundcraft_dsp::offline::resample(&audio, sr.hz(), to),
|
||||
"speed_ramp" => soundcraft_dsp::offline::speed_ramp(&audio, change, selection, fades),
|
||||
plugin => {
|
||||
apply(plugin, ¶ms, &mut audio, srf)?;
|
||||
audio
|
||||
@@ -140,9 +155,17 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn generate(e: &mut Engine, p: &Value, name: &str, params: &[(String, f32)], r: Range) -> Result<Value> {
|
||||
let sr = e.session().sample_rate;
|
||||
let tracks = tracks_param(e, "audiosuite.process", p)?;
|
||||
let mut budget = soundcraft_mix::MAX_RENDER_SAMPLES;
|
||||
// Refuse before generating anything, rather than cut the clip short.
|
||||
for t in tracks.iter().filter_map(|t| e.session().track(*t)).filter(|t| t.kind == TrackKind::Audio) {
|
||||
crate::io::refuse_longer(e.session(), "audiosuite.process", &t.name, "generate on", r.len(), crate::io::render_limit(t.channels()))?;
|
||||
let channels = t.channels().max(1);
|
||||
crate::io::refuse_longer(e.session(), "audiosuite.process", &t.name, "generate on", r.len(), crate::io::render_limit(channels))?;
|
||||
// Each track keeps its generated buffer for the life of the command (and undo holds it), so
|
||||
// cap the total samples generated across tracks, not just each one.
|
||||
let frames = usize::try_from(r.len()).unwrap_or(0).min(crate::io::render_limit(channels));
|
||||
budget = budget
|
||||
.checked_sub(frames.saturating_mul(channels))
|
||||
.ok_or_else(|| bad("audiosuite.process", "too much audio to generate in one command"))?;
|
||||
}
|
||||
let mut clips = Vec::new();
|
||||
for track in tracks {
|
||||
@@ -234,6 +257,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_ramps_the_speed_and_extend_lengthens_the_clip() {
|
||||
// A mono clip at 480 whose samples rise from 0 to 1, so each output sample shows where it was read.
|
||||
let run = |params: Value| {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Ramp"));
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![(0..4800).map(|i| i as f32 / 4800.0).collect()] };
|
||||
let src = crate::io::add_source(&mut s, "ramp", buf, None, FileFormat::Wav);
|
||||
let id = s.new_clip_id();
|
||||
crate::edit::place_clip(&mut s, t, Clip::audio(id, "ramp", src, 0, 480, 4800));
|
||||
let mut e = crate::Engine::new(s);
|
||||
e.execute("audiosuite.process", &json!({"process": "speed_ramp", "params": params, "clips": [id.0]})).unwrap();
|
||||
let c = e.session().tracks[0].clips()[0].clone();
|
||||
let audio = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0].clone();
|
||||
(c.start, c.length, c.name, audio)
|
||||
};
|
||||
// The defaults: Slow Down, Fit To, Fades On. Halfway through, the read position is 11/16 of the
|
||||
// way through the first two thirds, and the gain is the speed there, 3/4.
|
||||
let (start, length, name, audio) = run(json!({}));
|
||||
assert_eq!((start, length, name.as_str()), (480, 4800, "ramp-speeramp"));
|
||||
let want = (2.0 / 3.0 * 11.0 / 16.0) * 0.75;
|
||||
assert!((audio[2400] - want).abs() < 2e-3, "slow down halfway: got {}, want {want}", audio[2400]);
|
||||
assert!(audio[4790].abs() < 0.01, "the fade-out ends near silence, got {}", audio[4790]);
|
||||
// Speed Up with Extend and no fades: all of the clip is played over 150 % of its length, and the
|
||||
// clip still starts at 480.
|
||||
let (start, length, _, audio) = run(json!({"change": 1, "selection": 1, "fades": 0}));
|
||||
assert_eq!((start, length), (480, 7200));
|
||||
assert!((audio[3600] - 5.0 / 16.0).abs() < 2e-3, "speed up halfway: got {}", audio[3600]);
|
||||
let r = 1.0 - 7100.0 / 7200.0;
|
||||
let want = 1.0 - (3.0 * r - r * r * r) / 2.0;
|
||||
assert!((audio[7100] - want).abs() < 2e-3, "speed up near the end: got {}, want {want}", audio[7100]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_generator_fills_the_selection_with_a_clip() {
|
||||
let (mut e, _) = gained_clip();
|
||||
@@ -257,4 +313,43 @@ mod tests {
|
||||
let ranges: Vec<_> = e.session().tracks[0].clips().iter().map(|c| (c.start, c.length)).collect();
|
||||
assert_eq!(ranges, vec![(0, 32), (32, 64)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_track_ids_generate_once_not_per_listing() {
|
||||
let mut e = Engine::new(Session::default());
|
||||
let t = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
|
||||
e.execute("edit.select", &json!({"tracks": [t.0], "start": 0, "end": 4800})).unwrap();
|
||||
// The same track eight times over: it must generate once, not eight times (#896).
|
||||
let out = e
|
||||
.execute(
|
||||
"audiosuite.process",
|
||||
&json!({"process": "signal_generator", "tracks": [t.0, t.0, t.0, t.0, t.0, t.0, t.0, t.0], "params": {"freq": 440.0}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out["processed"], 1);
|
||||
assert_eq!(e.session().tracks[0].clips().len(), 1, "one clip, not one per duplicate listing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_refuses_a_total_budget_across_tracks() {
|
||||
let mut e = Engine::new(Session::default());
|
||||
// Two tracks each near the per-track limit: the second would push the total past the budget.
|
||||
for n in 0..2 {
|
||||
e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, Some(&format!("T{n}")));
|
||||
}
|
||||
let t: Vec<_> = e.session().tracks.iter().map(|t| t.id.0).collect();
|
||||
// Select both tracks so signal_generator targets them.
|
||||
e.execute("edit.select", &json!({"tracks": [t[0], t[1]], "start": 0, "end": 4800})).unwrap();
|
||||
e.execute("audiosuite.process", &serde_json::json!({"process": "signal_generator", "track": t[0], "params": {"freq": 440.0}})).unwrap();
|
||||
assert_eq!(e.session().tracks[0].clips().len(), 1, "one track generates one clip");
|
||||
// Two distinct tracks over the total budget are refused (MAX_RENDER_SAMPLES is f32 samples
|
||||
// across all channels; a single track over the limit is already refused per-track).
|
||||
let len = (soundcraft_mix::MAX_RENDER_SAMPLES as i64) / 3 * 2;
|
||||
let out = e.execute(
|
||||
"audiosuite.process",
|
||||
&serde_json::json!({"process": "signal_generator", "tracks": [t[0], t[1]], "params": {"freq": 440.0}, "start": 0, "end": len}),
|
||||
);
|
||||
let ok = matches!(out, Err(crate::EngineError::BadParams(_, ref m)) if m.contains("too much audio"));
|
||||
assert!(ok, "two tracks over budget, but got: {out:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Clip List › Auto Rename: the names that editing gives the clips it separates from a clip.
|
||||
use super::{CommandSpec, bad};
|
||||
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::ClipId;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const AUTO_RENAME: &str = "clip.auto_rename";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"clip.auto_rename",
|
||||
"Auto Rename...",
|
||||
[],
|
||||
None,
|
||||
"{clips?: [id], name?, number?: 0..999999999, zeros?: 0..9, suffix?}: sets the names that editing gives the clips it separates from these clips (default: the Clip List's selected clips): the piece after each cut is named `name`, then `zeros` zeros, then a number counting up from `number` (default 1), then `suffix`, in one number sequence for all of them. Without a name, the clips keep their names when they are separated",
|
||||
has_clip_selection,
|
||||
auto_rename
|
||||
)]
|
||||
}
|
||||
|
||||
fn has_clip_selection(e: &Engine) -> std::result::Result<(), String> {
|
||||
if e.session().edit.selected_clips.is_empty() { Err("select a clip in the Clip List".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn auto_rename(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session();
|
||||
let clips: BTreeSet<ClipId> = match p.get("clips") {
|
||||
None => s.edit.selected_clips.iter().copied().collect(),
|
||||
Some(v) => BTreeSet::deserialize(v).map_err(|_| bad(AUTO_RENAME, "`clips` must be an array of ids"))?,
|
||||
};
|
||||
if clips.is_empty() {
|
||||
return Err(bad(AUTO_RENAME, "select a clip in the Clip List, or pass `clips`"));
|
||||
}
|
||||
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(AUTO_RENAME, format!("no clip with id {c}")));
|
||||
}
|
||||
let names = super::edit::strip_names(AUTO_RENAME, p)?;
|
||||
let s = e.session_mut();
|
||||
let key = names.map(|n| {
|
||||
let (key, id) = (s.alloc(), crate::new_scheme_id(s));
|
||||
s.auto_names.insert(key, soundcraft_model::AutoName { id, ..n.into() });
|
||||
key
|
||||
});
|
||||
for c in s.tracks.iter_mut().flat_map(|t| t.playlists.iter_mut()).flat_map(|pl| pl.clips.iter_mut()) {
|
||||
if clips.contains(&c.id) {
|
||||
c.auto_name = key;
|
||||
}
|
||||
}
|
||||
// Earlier schemes stay while the session is open: copies on the clipboard can still use them.
|
||||
Ok(json!({"clips": clips.len(), "named": key.is_some()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The first Kick clip: its track, id, start and length.
|
||||
fn first_kick(e: &Engine) -> (soundcraft_model::TrackId, ClipId, i64, i64) {
|
||||
let s = e.session();
|
||||
let t = s.track_by_name("Kick").unwrap();
|
||||
let c = &t.clips()[0];
|
||||
(t.id, c.id, c.start, c.length)
|
||||
}
|
||||
|
||||
/// The names of the clips on `t` that lie inside `[start, end)`, in time order.
|
||||
fn names_in(e: &Engine, t: soundcraft_model::TrackId, start: i64, end: i64) -> Vec<String> {
|
||||
e.session().track(t).unwrap().clips().iter().filter(|c| c.start >= start && c.end() <= end).map(|c| c.name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separating_an_auto_renamed_clip_names_each_new_piece() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (t, id, start, len) = first_kick(&e);
|
||||
let old = e.session().find_clip(id).unwrap().1.name.clone();
|
||||
let r = e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX", "number": 23, "zeros": 1, "suffix": ".Reel1"})).unwrap();
|
||||
assert_eq!(r, json!({"clips": 1, "named": true}));
|
||||
assert_eq!(e.undo_label(), Some("Auto Rename..."));
|
||||
assert_eq!(names_in(&e, t, start, start + len), [old.as_str()], "setting the scheme leaves the clip's name");
|
||||
// Cut the middle, then each half: every piece after a cut is a new clip with the next name.
|
||||
for at in [start + len / 2, start + len / 4, start + len * 3 / 4] {
|
||||
e.execute("edit.separate", &json!({"tracks": [t], "at": at})).unwrap();
|
||||
}
|
||||
assert_eq!(names_in(&e, t, start, start + len), [old.as_str(), "SFX024.Reel1", "SFX023.Reel1", "SFX025.Reel1"]);
|
||||
assert!(e.undo());
|
||||
assert_eq!(names_in(&e, t, start, start + len).len(), 3);
|
||||
assert_eq!(e.session().auto_names.values().next().map(|a| a.number), Some(25), "undo takes the number back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_rename_uses_the_clip_list_selection_and_one_number_sequence() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let s = e.session();
|
||||
let t = s.track_by_name("Kick").unwrap();
|
||||
let (track, a, b) = (t.id, t.clips()[0].clone(), t.clips()[1].clone());
|
||||
e.execute("edit.select", &json!({"clips": [a.id, b.id]})).unwrap();
|
||||
e.execute("clip.auto_rename", &json!({"name": "Hit"})).unwrap();
|
||||
e.execute("edit.separate", &json!({"tracks": [track], "at": b.start + b.length / 2})).unwrap();
|
||||
e.execute("edit.separate", &json!({"tracks": [track], "at": a.start + a.length / 2})).unwrap();
|
||||
assert_eq!(names_in(&e, track, b.start, b.end())[1], "Hit1");
|
||||
assert_eq!(names_in(&e, track, a.start, a.end())[1], "Hit2", "both clips count up one sequence");
|
||||
assert_eq!(e.session().auto_names.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_rename_without_a_name_removes_the_scheme() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (t, id, start, len) = first_kick(&e);
|
||||
let old = e.session().find_clip(id).unwrap().1.name.clone();
|
||||
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap();
|
||||
let r = e.execute("clip.auto_rename", &json!({"clips": [id], "name": " "})).unwrap();
|
||||
assert_eq!(r["named"], false);
|
||||
assert_eq!(e.session().find_clip(id).unwrap().1.auto_name, None);
|
||||
e.execute("edit.separate", &json!({"tracks": [t], "at": start + len / 2})).unwrap();
|
||||
assert_eq!(names_in(&e, t, start, start + len), [old.clone(), old]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_copy_keeps_the_scheme_it_was_copied_with() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (t, id, start, len) = first_kick(&e);
|
||||
let old = e.session().find_clip(id).unwrap().1.name.clone();
|
||||
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap();
|
||||
e.execute("edit.copy", &json!({"tracks": [t], "start": start, "end": start + len})).unwrap();
|
||||
e.execute("clip.auto_rename", &json!({"clips": [id]})).unwrap();
|
||||
let at = e.session().content_end() + 48_000;
|
||||
e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap();
|
||||
e.execute("edit.separate", &json!({"tracks": [t], "at": at + len / 2})).unwrap();
|
||||
assert_eq!(names_in(&e, t, at, at + len)[1], "SFX1", "the copy's scheme outlives the original clip's");
|
||||
// Pasted into another session, the copy brings its scheme along and counts on from it.
|
||||
e.execute("edit.copy", &json!({"tracks": [t], "start": at, "end": at + len})).unwrap();
|
||||
e.replace_session(crate::demo::demo_engine().session().clone());
|
||||
let t = first_kick(&e).0;
|
||||
e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap();
|
||||
e.execute("edit.separate", &json!({"tracks": [t], "at": at + len / 4})).unwrap();
|
||||
assert_eq!(names_in(&e, t, at, at + len), [old.as_str(), "SFX2", "SFX1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_keeps_its_scheme_when_undo_hands_the_key_to_another() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (t, id, start, len) = first_kick(&e);
|
||||
let bass = e.session().track_by_name("Bass").unwrap().clips()[0].id;
|
||||
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap();
|
||||
e.execute("edit.copy", &json!({"tracks": [t], "start": start, "end": start + len})).unwrap();
|
||||
assert!(e.undo());
|
||||
// The same name under the key the undo freed is still another scheme.
|
||||
e.execute("clip.auto_rename", &json!({"clips": [bass], "name": "SFX", "number": 100})).unwrap();
|
||||
let at = e.session().content_end() + 48_000;
|
||||
e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap();
|
||||
e.execute("edit.separate", &json!({"tracks": [t], "at": at + len / 2})).unwrap();
|
||||
assert_eq!(names_in(&e, t, at, at + len)[1], "SFX1");
|
||||
let s = e.session();
|
||||
assert_eq!(s.auto_names.len(), 2);
|
||||
let key = s.find_clip(bass).unwrap().1.auto_name.unwrap();
|
||||
assert_eq!(s.auto_names[&key].number, 100, "the other scheme's count is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_keeps_its_scheme_when_another_session_holds_its_id_with_other_text() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (t, id, start, len) = first_kick(&e);
|
||||
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap();
|
||||
let copied = e.session().auto_names.values().next().unwrap().id;
|
||||
e.execute("edit.copy", &json!({"tracks": [t], "start": start, "end": start + len})).unwrap();
|
||||
// Another session holds a scheme under the same id with another name.
|
||||
e.replace_session(crate::demo::demo_engine().session().clone());
|
||||
let bass = e.session().track_by_name("Bass").unwrap().clips()[0].id;
|
||||
let s = e.session_mut();
|
||||
let key = s.alloc();
|
||||
s.auto_names.insert(key, soundcraft_model::AutoName { name: "Other".into(), number: 100, zeros: 0, suffix: String::new(), id: copied });
|
||||
s.tracks
|
||||
.iter_mut()
|
||||
.flat_map(|t| t.playlists.iter_mut())
|
||||
.flat_map(|pl| pl.clips.iter_mut())
|
||||
.filter(|c| c.id == bass)
|
||||
.for_each(|c| c.auto_name = Some(key));
|
||||
let t = first_kick(&e).0;
|
||||
let at = e.session().content_end() + 48_000;
|
||||
e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap();
|
||||
e.execute("edit.separate", &json!({"tracks": [t], "at": at + len / 2})).unwrap();
|
||||
assert_eq!(names_in(&e, t, at, at + len)[1], "SFX1");
|
||||
let s = e.session();
|
||||
assert_eq!(s.auto_names.len(), 2);
|
||||
assert_eq!(s.auto_names[&key].number, 100, "the other scheme's count is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_auto_rename_params_are_refused() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (_, id, _, _) = first_kick(&e);
|
||||
for p in [
|
||||
json!({"name": "SFX"}),
|
||||
json!({"clips": [], "name": "SFX"}),
|
||||
json!({"clips": [999_999], "name": "SFX"}),
|
||||
json!({"clips": "Kick", "name": "SFX"}),
|
||||
json!({"clips": [id], "name": 5}),
|
||||
json!({"clips": [id], "name": "SFX", "zeros": 10}),
|
||||
json!({"clips": [id], "name": "SFX", "number": 1_000_000_000}),
|
||||
json!({"clips": [id], "number": 3}),
|
||||
] {
|
||||
assert!(e.execute("clip.auto_rename", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(e.session().auto_names.is_empty());
|
||||
assert!(e.session().find_clip(id).unwrap().1.auto_name.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_rename_schemes_are_saved_and_repaired_on_open() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (t, id, start, len) = first_kick(&e);
|
||||
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX", "number": 7})).unwrap();
|
||||
let mut v: Value = serde_json::from_str(&e.session().to_json().unwrap()).unwrap();
|
||||
let key = *e.session().auto_names.keys().next().unwrap();
|
||||
v["auto_names"][key.to_string()]["zeros"] = json!(usize::MAX);
|
||||
v["auto_names"]["999999999"] = json!({"name": "Unused", "number": 1, "zeros": 0, "suffix": ""});
|
||||
let mut s = soundcraft_model::Session::from_json(&v.to_string()).unwrap();
|
||||
assert_eq!(s.auto_names.len(), 1, "a scheme no clip uses is dropped");
|
||||
assert_eq!(s.auto_names[&key].zeros, soundcraft_model::AutoName::MAX_ZEROS);
|
||||
assert!(s.next_id > key);
|
||||
crate::edit::separate_at(&mut s, t, start + len / 2);
|
||||
let names: Vec<String> =
|
||||
s.track(t).unwrap().clips().iter().filter(|c| c.start >= start && c.end() <= start + len).map(|c| c.name.clone()).collect();
|
||||
assert_eq!(names[1], "SFX0000000007");
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("clip.loop", "Loop...", ["Clip"], Some("Cmd+Alt+L"), "{clips?, count?: n, length?: samples}", has_selection, loop_clips),
|
||||
cmd!(
|
||||
"clip.loop",
|
||||
"Loop...",
|
||||
["Clip"],
|
||||
Some("Cmd+Alt+L"),
|
||||
"{clips?, count?: n, length?: samples, fill?: bool}: count is the clip plus its loops; length is the whole looped clip, with the last loop cut short to fit; fill loops up to the next clip on the track, or else to the session end",
|
||||
has_selection,
|
||||
loop_clips
|
||||
),
|
||||
cmd!("clip.rename", "Rename...", ["Clip"], Some("Cmd+Alt+Shift+R"), "{clip?, name}", has_selection, rename),
|
||||
cmd!("clip.gain", "Clip Gain", [], None, "{clips?, db | delta_db}", has_selection, gain),
|
||||
cmd!("clip.gain_nudge_up", "Nudge Clip Gain Up", [], Some("Ctrl+Shift+Up"), "{clips?}", has_selection, |e, p| nudge_gain(e, p, 1.0)),
|
||||
@@ -287,15 +295,46 @@ fn layer(e: &mut Engine, p: &Value, front: bool) -> Result<Value> {
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
/// The most loops `clip.loop` makes of one clip.
|
||||
const MAX_LOOPS: i64 = 1000;
|
||||
|
||||
fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let count = i64_or(p, "count", 4).clamp(1, 512);
|
||||
let total = position_param(e, "clip.loop", p, "length")?;
|
||||
let fill = p.get("fill").and_then(Value::as_bool).unwrap_or(false);
|
||||
let s = e.session_mut();
|
||||
let session_end = s.content_end();
|
||||
let mut made = 0;
|
||||
for id in ids {
|
||||
let Some((tid, mut c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
|
||||
let reps = total.map_or(count - 1, |l| (l / c.length.max(1)).max(1) - 1).clamp(0, 1000);
|
||||
if c.length <= 0 {
|
||||
continue;
|
||||
}
|
||||
let len = c.length;
|
||||
// Where the looped clip ends: the next clip or the session end, or `length` after the start.
|
||||
let end = if fill {
|
||||
let next = s.track(tid).and_then(|t| t.clips().iter().filter(|x| x.id != c.id && x.start >= c.end()).map(|x| x.start).min());
|
||||
Some(next.unwrap_or(session_end))
|
||||
} else {
|
||||
total.map(|l| c.start.saturating_add(l))
|
||||
};
|
||||
// Whole iterations, then one cut short to reach `end` exactly.
|
||||
let (reps, last) = match end {
|
||||
Some(end) => {
|
||||
let span = end.saturating_sub(c.end()).max(0);
|
||||
let (whole, rest) = (span / len, span % len);
|
||||
let reps = whole.saturating_add(i64::from(rest > 0));
|
||||
if reps > MAX_LOOPS {
|
||||
return Err(bad(
|
||||
"clip.loop",
|
||||
format!("“{}” would need {reps} loops to fill that; a clip loops at most {MAX_LOOPS} times", c.name),
|
||||
));
|
||||
}
|
||||
(reps, if rest > 0 { rest } else { len })
|
||||
}
|
||||
None => ((count - 1).clamp(0, MAX_LOOPS), len),
|
||||
};
|
||||
if reps > 0 && c.loop_id.is_none() {
|
||||
let lid = s.alloc();
|
||||
if let Some(src) = s.find_clip_mut(id) {
|
||||
@@ -307,10 +346,15 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
for k in 1..=reps {
|
||||
let mut nc = c.clone();
|
||||
nc.start = 0;
|
||||
if k == reps && last < c.length {
|
||||
// `start` is 0 here, so the end is the length; the trim also shortens the fades to fit and removes a sync point past the cut.
|
||||
nc.trim_end_to(last);
|
||||
}
|
||||
copies.push((c.start + c.length * k, nc));
|
||||
}
|
||||
for (at, nc) in copies {
|
||||
let _ = crate::edit::paste(s, tid, at, &[nc], c.length, false);
|
||||
let length = nc.length;
|
||||
let _ = crate::edit::paste(s, tid, at, &[nc], length, false);
|
||||
made += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,24 +99,40 @@ fn regroup(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"group": g, "clips": n}))
|
||||
}
|
||||
|
||||
fn same_material(a: &Clip, b: &Clip) -> bool {
|
||||
a.length == b.length
|
||||
&& (a.loop_id.is_some() && a.loop_id == b.loop_id
|
||||
|| match (&a.content, &b.content) {
|
||||
(ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
|
||||
(ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2,
|
||||
(ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
|
||||
_ => false,
|
||||
})
|
||||
fn same_content(a: &Clip, b: &Clip) -> bool {
|
||||
match (&a.content, &b.content) {
|
||||
(ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
|
||||
(ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2,
|
||||
(ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The loop a clip belongs to: back-to-back copies of the same material. Origin first.
|
||||
fn same_material(a: &Clip, b: &Clip) -> bool {
|
||||
a.length == b.length && (a.loop_id.is_some() && a.loop_id == b.loop_id || same_content(a, b))
|
||||
}
|
||||
|
||||
/// Whether `short` is the last iteration of `full`'s loop, cut short at its end. It shares the loop,
|
||||
/// it is shorter, and no clip of the loop starts where it ends, as the other half of a split clip
|
||||
/// does. When `full` is a whole copy (an iteration of the same length ends where it starts), that is
|
||||
/// enough. When `full` is the loop's origin alone, `short` also has to hold `full`'s material.
|
||||
fn cut_short(clips: &[Clip], full: &Clip, short: &Clip) -> bool {
|
||||
let whole_copy = || clips.iter().any(|x| x.id != full.id && x.end() == full.start && same_material(x, full));
|
||||
full.loop_id.is_some()
|
||||
&& full.loop_id == short.loop_id
|
||||
&& short.length < full.length
|
||||
&& !clips.iter().any(|x| x.loop_id == short.loop_id && x.start == short.end())
|
||||
&& (whole_copy() || same_content(full, short))
|
||||
}
|
||||
|
||||
/// The loop a clip belongs to: back-to-back copies of the same material, the last of which can be
|
||||
/// cut short. Origin first.
|
||||
fn loop_chain(s: &Session, t: TrackId, id: ClipId) -> Vec<ClipId> {
|
||||
let Some(tr) = s.track(t) else { return Vec::new() };
|
||||
let clips = tr.clips();
|
||||
let Some(mut cur) = clips.iter().find(|c| c.id == id) else { return Vec::new() };
|
||||
let mut guard = 0;
|
||||
while let Some(prev) = clips.iter().find(|x| x.id != cur.id && x.end() == cur.start && same_material(x, cur)) {
|
||||
while let Some(prev) = clips.iter().find(|x| x.id != cur.id && x.end() == cur.start && (same_material(x, cur) || cut_short(clips, x, cur))) {
|
||||
cur = prev;
|
||||
guard += 1;
|
||||
if guard > clips.len() {
|
||||
@@ -128,6 +144,9 @@ fn loop_chain(s: &Session, t: TrackId, id: ClipId) -> Vec<ClipId> {
|
||||
chain.push(next.id);
|
||||
cur = next;
|
||||
}
|
||||
if let Some(last) = clips.iter().find(|x| !chain.contains(&x.id) && x.start == cur.end() && cut_short(clips, cur, x)) {
|
||||
chain.push(last.id);
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
@@ -342,6 +361,101 @@ mod tests {
|
||||
assert!(e.session().track(t).unwrap().clips().iter().all(|c| c.loop_id.is_none() && c.group.is_none()));
|
||||
}
|
||||
|
||||
fn spans(e: &Engine, t: TrackId) -> Vec<(Samples, Samples)> {
|
||||
e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.length)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_length_cuts_the_last_iteration_short() {
|
||||
let (mut e, t, id) = midi_engine();
|
||||
assert_eq!(e.execute("clip.loop", &json!({"clip": id.0, "length": 250})).unwrap()["loops"], 2);
|
||||
assert_eq!(spans(&e, t), vec![(0, 100), (100, 100), (200, 50)]);
|
||||
// A length that is a whole number of loops leaves no short one.
|
||||
let (mut e, t, id) = midi_engine();
|
||||
assert_eq!(e.execute("clip.loop", &json!({"clip": id.0, "length": 300})).unwrap()["loops"], 2);
|
||||
assert_eq!(spans(&e, t), vec![(0, 100), (100, 100), (200, 100)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_last_iteration_fits_its_fades_and_drops_a_sync_point_past_the_cut() {
|
||||
let (mut e, t, id) = midi_engine();
|
||||
if let Some(c) = e.session_mut().find_clip_mut(id) {
|
||||
c.sync_point = 80;
|
||||
c.fade_out.len = 70;
|
||||
}
|
||||
e.execute("clip.loop", &json!({"clip": id.0, "length": 150})).unwrap();
|
||||
let clips = e.session().track(t).unwrap().clips();
|
||||
let last = clips.last().unwrap();
|
||||
assert_eq!((last.start, last.length, last.sync_point, last.fade_out.len), (100, 50, 0, 50));
|
||||
assert_eq!((clips[0].sync_point, clips[0].fade_out.len), (80, 70));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloop_removes_a_short_last_iteration_too() {
|
||||
let (mut e, t, id) = midi_engine();
|
||||
e.execute("clip.loop", &json!({"clip": id.0, "length": 250})).unwrap();
|
||||
e.execute("clip.unloop", &json!({"clip": id.0})).unwrap();
|
||||
assert_eq!(spans(&e, t), vec![(0, 100)]);
|
||||
// An edit to the source note or to the short loop's note.
|
||||
for edited in [0, 2] {
|
||||
let (mut e, t, id) = midi_engine();
|
||||
e.execute("clip.loop", &json!({"clip": id.0, "length": 250})).unwrap();
|
||||
let target = e.session().track(t).unwrap().clips()[edited].id;
|
||||
if let Some(c) = e.session_mut().find_clip_mut(target)
|
||||
&& let ClipContent::Midi { sequence } = &mut c.content
|
||||
{
|
||||
sequence.notes[0].length = 1100;
|
||||
}
|
||||
e.execute("clip.unloop", &json!({"clip": id.0})).unwrap();
|
||||
assert_eq!(spans(&e, t), vec![(0, 100)], "edited loop {edited}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloop_keeps_both_halves_of_a_split_clip() {
|
||||
let (mut e, t, ids) = engine_with(&[(0, 100, 0)]);
|
||||
e.execute("clip.loop", &json!({"clip": ids[0].0, "count": 3})).unwrap();
|
||||
crate::edit::separate_at(e.session_mut(), t, 230);
|
||||
e.execute("clip.unloop", &json!({"clip": ids[0].0})).unwrap();
|
||||
assert_eq!(spans(&e, t), vec![(0, 100), (200, 30), (230, 70)]);
|
||||
// A split loop origin is not a loop.
|
||||
let (mut e, t, ids) = engine_with(&[(0, 100, 0)]);
|
||||
e.execute("clip.loop", &json!({"clip": ids[0].0, "count": 2})).unwrap();
|
||||
crate::edit::separate_at(e.session_mut(), t, 30);
|
||||
assert!(e.execute("clip.unloop", &json!({"clip": ids[0].0})).is_err());
|
||||
assert_eq!(spans(&e, t), vec![(0, 30), (30, 70), (100, 100)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_fill_stops_at_the_next_clip() {
|
||||
let (mut e, t, ids) = engine_with(&[(0, 100, 0), (430, 100, 0)]);
|
||||
assert_eq!(e.execute("clip.loop", &json!({"clip": ids[0].0, "fill": true})).unwrap()["loops"], 4);
|
||||
assert_eq!(spans(&e, t), vec![(0, 100), (100, 100), (200, 100), (300, 100), (400, 30), (430, 100)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_fill_without_a_next_clip_runs_to_the_session_end() {
|
||||
let (mut e, t, ids) = engine_with(&[(0, 100, 0)]);
|
||||
let other = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let far = e.session_mut().new_clip_id();
|
||||
crate::edit::place_clip(e.session_mut(), other, Clip::audio(far, "far", SourceId(9), 0, 200, 150));
|
||||
assert_eq!(e.execute("clip.loop", &json!({"clip": ids[0].0, "fill": true})).unwrap()["loops"], 3);
|
||||
assert_eq!(spans(&e, t), vec![(0, 100), (100, 100), (200, 100), (300, 50)]);
|
||||
// The last clip in the session has nothing to fill up to.
|
||||
assert_eq!(e.execute("clip.loop", &json!({"clip": far.0, "fill": true})).unwrap()["loops"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_or_length_past_the_loop_limit_is_refused_without_an_edit() {
|
||||
let (mut e, t, ids) = engine_with(&[(0, 100, 0), (100_150, 100, 0)]);
|
||||
let err = e.execute("clip.loop", &json!({"clip": ids[0].0, "fill": true})).unwrap_err().to_string();
|
||||
assert!(err.contains("1001 loops"), "{err}");
|
||||
assert!(e.execute("clip.loop", &json!({"clip": ids[0].0, "length": 100_200})).is_err());
|
||||
assert_eq!(spans(&e, t), vec![(0, 100), (100_150, 100)]);
|
||||
// 1000 loops still fit.
|
||||
assert_eq!(e.execute("clip.loop", &json!({"clip": ids[0].0, "length": 100_100})).unwrap()["loops"], 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regroup_reuses_group() {
|
||||
let (mut e, _, ids) = engine_with(&[(0, 100, 0), (500, 100, 0), (900, 100, 0)]);
|
||||
|
||||
+115
-15
@@ -121,10 +121,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Strip Silence",
|
||||
["Edit"],
|
||||
Some("Cmd+U"),
|
||||
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20}",
|
||||
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, mode?: strip|extract|separate, name?, number?: 1, zeros?: 0, suffix?} (strip removes the silence; extract removes the audio and keeps the silence; separate separates the clips at the edges of the silence and does not remove any; with a name, the clips left in the selection are named name, zeros, a number counting up from number, suffix)",
|
||||
has_range,
|
||||
strip_silence
|
||||
),
|
||||
cmd!(
|
||||
query "edit.strip_silence_preview",
|
||||
"Strip Silence Rectangles",
|
||||
[],
|
||||
None,
|
||||
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, tracks?, start?, end?} (the ranges edit.strip_silence finds with the same settings, padded, on each track: what Strip keeps and the Strip Silence window draws as rectangles; changes nothing)",
|
||||
has_range,
|
||||
strip_silence_preview
|
||||
),
|
||||
cmd!(
|
||||
"edit.fades_create",
|
||||
"Create...",
|
||||
@@ -280,6 +289,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
|
||||
.unwrap_or_default();
|
||||
cb.automation.push(lanes);
|
||||
}
|
||||
cb.keep_auto_names(e.session());
|
||||
cb.markers = e
|
||||
.session()
|
||||
.markers
|
||||
@@ -316,10 +326,21 @@ fn cut_track(s: &mut Session, t: TrackId, r: soundcraft_time::Range, sh: bool, f
|
||||
ops::clear_range(s, t, r, sh);
|
||||
}
|
||||
|
||||
/// The clipboard for a paste, with the Auto Rename schemes of its clips put into the session first
|
||||
/// ([`Clipboard::adopt_auto_names`]).
|
||||
pub(super) fn clipboard_for_paste(e: &mut Engine) -> Clipboard {
|
||||
let mut cb = e.clipboard.clone();
|
||||
if cb.tracks.iter().flatten().any(|c| c.auto_name.is_some()) {
|
||||
cb.adopt_auto_names(e.session_mut());
|
||||
e.clipboard = cb.clone();
|
||||
}
|
||||
cb
|
||||
}
|
||||
|
||||
fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.paste", p)?;
|
||||
let given = position_param(e, "edit.paste", p, "at")?;
|
||||
let cb = e.clipboard.clone();
|
||||
let cb = clipboard_for_paste(e);
|
||||
let s = e.session();
|
||||
let at = match given {
|
||||
Some(at) => at,
|
||||
@@ -417,7 +438,7 @@ fn repeat_to_fill(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let range = range_param(e, "edit.paste_repeat_to_fill", p)?;
|
||||
let len = e.clipboard.length.max(1);
|
||||
let tracks = tracks_required(e, "edit.paste_repeat_to_fill", p)?;
|
||||
let cb = e.clipboard.clone();
|
||||
let cb = clipboard_for_paste(e);
|
||||
let s = e.session_mut();
|
||||
let mut at = range.start;
|
||||
let mut n = 0;
|
||||
@@ -789,25 +810,94 @@ fn copy_to_new_playlist(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"tracks": tracks.len()}))
|
||||
}
|
||||
|
||||
fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.strip_silence", p)?;
|
||||
let r = range_param(e, "edit.strip_silence", p)?;
|
||||
/// Strip Silence's tracks, range, threshold, minimum strip duration and clip start and end pads.
|
||||
type StripSettings = (Vec<TrackId>, soundcraft_time::Range, f32, Samples, (Samples, Samples));
|
||||
|
||||
/// The [`StripSettings`] in Strip Silence's params, with their defaults and limits. `cmd` names the
|
||||
/// command in errors.
|
||||
fn strip_settings(e: &Engine, cmd: &str, p: &Value) -> Result<StripSettings> {
|
||||
let tracks = tracks_required(e, cmd, p)?;
|
||||
let r = range_param(e, cmd, p)?;
|
||||
let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0);
|
||||
let sr = e.session().sample_rate;
|
||||
let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0);
|
||||
let (min_len, pre, post) = (ms("min_length_ms", 50.0), ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0));
|
||||
Ok((tracks, r, thr, ms("min_length_ms", 50.0), (ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0))))
|
||||
}
|
||||
|
||||
/// The scan `edit.strip_silence_preview` runs with params `p`, on the session as it is now. The Edit
|
||||
/// window runs it while the Strip Silence window or its Rename Selected Clips dialog is open: on a
|
||||
/// worker thread outside the web build, and on the frame thread in the web build or when no thread
|
||||
/// can be started.
|
||||
pub fn strip_silence_scan(e: &Engine, p: &Value) -> Result<crate::io::StripScan> {
|
||||
let (tracks, range, threshold_db, min_len, pad) = strip_settings(e, "edit.strip_silence_preview", p)?;
|
||||
Ok(crate::io::StripScan { session: e.session_arc(), tracks, range, threshold_db, min_len, pad, stop: Default::default() })
|
||||
}
|
||||
|
||||
fn strip_silence_preview(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let found = strip_silence_scan(e, p)?.run()?;
|
||||
let tracks: Vec<Value> =
|
||||
found.iter().map(|(t, ranges)| json!({"track": t.0, "ranges": ranges.iter().map(|x| [x.start, x.end]).collect::<Vec<_>>()})).collect();
|
||||
Ok(json!({"tracks": tracks}))
|
||||
}
|
||||
|
||||
fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let (tracks, r, thr, min_len, (pre, post)) = strip_settings(e, "edit.strip_silence", p)?;
|
||||
let mode = match p.get("mode") {
|
||||
None => crate::io::StripMode::Strip,
|
||||
Some(Value::String(m)) => crate::io::StripMode::from_id(m).ok_or_else(|| bad("edit.strip_silence", format!("unknown mode `{m}`")))?,
|
||||
Some(_) => return Err(bad("edit.strip_silence", "mode must be strip, extract or separate")),
|
||||
};
|
||||
let names = strip_names("edit.strip_silence", p)?;
|
||||
let s = e.session_mut();
|
||||
let mut kept = 0;
|
||||
let mut named = 0;
|
||||
for t in &tracks {
|
||||
kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post)?;
|
||||
kept += crate::io::strip_silence(s, *t, r, thr, min_len, (pre, post), mode, names.is_some())?;
|
||||
if let Some(names) = &names {
|
||||
named += crate::io::name_stripped_clips(s, *t, r, names, named);
|
||||
}
|
||||
}
|
||||
Ok(json!({"clips": kept}))
|
||||
Ok(json!({"clips": kept, "named": named}))
|
||||
}
|
||||
|
||||
/// The naming scheme in the `name`, `number`, `zeros` and `suffix` params of `cmd`
|
||||
/// (`edit.strip_silence`, `clip.auto_rename`), or `None` when `name` is missing or blank.
|
||||
pub(super) fn strip_names(cmd: &str, p: &Value) -> Result<Option<crate::io::StripNames>> {
|
||||
use crate::io::StripNames;
|
||||
let text = |k: &str| match p.get(k) {
|
||||
None => Ok(None),
|
||||
Some(Value::String(v)) => Ok(Some(v.clone())),
|
||||
Some(_) => Err(bad(cmd, format!("`{k}` must be text"))),
|
||||
};
|
||||
let whole = |k: &str, max: u64| match p.get(k) {
|
||||
None => Ok(None),
|
||||
Some(v) => v.as_u64().filter(|n| *n <= max).map(Some).ok_or_else(|| bad(cmd, format!("`{k}` must be a whole number from 0 to {max}"))),
|
||||
};
|
||||
let (name, suffix) = (text("name")?, text("suffix")?);
|
||||
let (number, zeros) = (whole("number", StripNames::MAX_NUMBER)?, whole("zeros", StripNames::MAX_ZEROS as u64)?);
|
||||
let Some(name) = name.filter(|n| !n.trim().is_empty()) else {
|
||||
if suffix.is_some() || number.is_some() || zeros.is_some() {
|
||||
return Err(bad(cmd, "`number`, `zeros` and `suffix` need a `name`"));
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
let d = StripNames::default();
|
||||
let suffix = suffix.unwrap_or(d.suffix);
|
||||
let max_text = soundcraft_model::AutoName::MAX_TEXT_CHARS;
|
||||
if name.chars().count() + suffix.chars().count() > max_text {
|
||||
return Err(bad(cmd, format!("`name` and `suffix` together must be at most {max_text} characters")));
|
||||
}
|
||||
Ok(Some(StripNames { name, number: number.unwrap_or(d.number), zeros: zeros.and_then(|z| usize::try_from(z).ok()).unwrap_or(d.zeros), suffix }))
|
||||
}
|
||||
|
||||
fn fades_create(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let shape = str_param(p, "shape")
|
||||
.map_or(Some(FadeShape::EqualPower), FadeShape::from_id)
|
||||
.ok_or_else(|| bad("edit.fades_create", "unknown fade shape"))?;
|
||||
let shape = match p.get("shape") {
|
||||
None => FadeShape::EqualPower,
|
||||
Some(v) => v
|
||||
.as_str()
|
||||
.and_then(FadeShape::from_id)
|
||||
.ok_or_else(|| bad("edit.fades_create", "shape must be linear|equal power|s-curve|exponential|logarithmic"))?,
|
||||
};
|
||||
if p.get("clip").is_some() || p.get("side").is_some() {
|
||||
let curvature = match p.get("curvature") {
|
||||
None => 0.0,
|
||||
@@ -835,9 +925,6 @@ fn fades_create(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if available <= 0 {
|
||||
return Err(bad("edit.fades_create", "clip has no length"));
|
||||
}
|
||||
if p.get("shape").is_some_and(|v| !v.is_string()) {
|
||||
return Err(bad("edit.fades_create", "shape must be a fade shape name"));
|
||||
}
|
||||
let fade = Fade { len: length.min(available), shape, curvature };
|
||||
let clip = e.session_mut().find_clip_mut(id).ok_or_else(|| bad("edit.fades_create", "clip not found"))?;
|
||||
if fade_in {
|
||||
@@ -1074,6 +1161,19 @@ mod tests {
|
||||
assert_eq!(e.session(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_fades_refuse_a_shape_that_is_not_a_shape_name() {
|
||||
for shape in [json!("bad"), json!(null), json!(true), json!(1)] {
|
||||
let (mut e, t, _) = trim_session(&[(0, 10_000, 0), (10_000, 10_000, 10_000)]);
|
||||
let before = e.session().clone();
|
||||
let history = e.undo_history();
|
||||
let params = json!({"tracks": [t], "start": 9_500, "end": 10_500, "shape": shape});
|
||||
assert!(e.execute("edit.fades_create", ¶ms).is_err(), "{params}");
|
||||
assert_eq!(e.session(), &before, "{params}");
|
||||
assert_eq!(e.undo_history(), history, "{params}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targeted_fades_support_one_sample_clips() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
||||
@@ -452,7 +452,7 @@ fn paste_merge_midi(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "edit.paste_merge_midi";
|
||||
let tracks = tracks_required(e, id, p)?;
|
||||
let at = position_param(e, id, p, "at")?.unwrap_or(e.session().edit.selection.start).max(0);
|
||||
let cb = e.clipboard.clone();
|
||||
let cb = super::edit::clipboard_for_paste(e);
|
||||
let s = e.session_mut();
|
||||
let tempo = s.tempo.clone();
|
||||
let sr = s.sample_rate;
|
||||
|
||||
@@ -88,7 +88,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!(noundo "file.bounce_mix", "Bounce Mix...", ["File"], Some("Cmd+Alt+B"), "{path, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, source?: main|bus name, normalize?: false, dither?: true, fold_down?: stereo}", always, bounce),
|
||||
cmd!(noundo "file.bounce_stems", "Bounce Stems...", [], None, "{dir, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, fold_down?: stereo}", has_tracks, |e, p| {
|
||||
cmd!(noundo "file.bounce_stems", "Bounce Stems...", [], None, "{dir, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, dither?: true, fold_down?: stereo}", has_tracks, |e, p| {
|
||||
let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string();
|
||||
let r = range_param(e, "file.bounce_stems", p)?;
|
||||
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
|
||||
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
_ => soundcraft_audio_io::BitDepth::Int24,
|
||||
};
|
||||
let fold = fold_down_param(p, "file.bounce_stems")?;
|
||||
let files = crate::io::bounce_stems_with(e, &dir, r, &soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None }, fold)?;
|
||||
let files = crate::io::bounce_stems_with(e, &dir, r, &soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None }, fold)?;
|
||||
Ok(json!({"files": files}))
|
||||
}),
|
||||
cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| {
|
||||
@@ -113,9 +113,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
crate::io::write_file(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}),
|
||||
cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?}", always, |e, p| {
|
||||
cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?, replace?: false}", always, |e, p| {
|
||||
let text = crate::inspect::session_text(e);
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
refuse_replacing(e, "file.export_session_text", std::path::Path::new(path), p)?;
|
||||
crate::io::write_file(path, text.as_bytes())?;
|
||||
}
|
||||
Ok(json!({"text": text}))
|
||||
@@ -240,9 +241,17 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let target = import_audio_target(e, p)?;
|
||||
let mut out = Vec::new();
|
||||
for path in &paths {
|
||||
let bytes = crate::io::read_file(path)?;
|
||||
let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Audio").to_string();
|
||||
let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?;
|
||||
// Audio the app decoded on a worker thread (`Engine::offer_import`) is only placed here;
|
||||
// the path is checked the same way either way.
|
||||
crate::file_access::check_read(std::path::Path::new(path)).map_err(crate::EngineError::Io)?;
|
||||
let r = match e.take_import(std::path::Path::new(path)) {
|
||||
Some(d) => crate::io::place_decoded(e, &name, d, Some(path.as_str()), target, at)?,
|
||||
None => {
|
||||
let bytes = crate::io::read_file(path)?;
|
||||
crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?
|
||||
}
|
||||
};
|
||||
out.push(r);
|
||||
}
|
||||
Ok(json!({"imported": out}))
|
||||
@@ -353,6 +362,64 @@ mod tests {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// Issue #597: the app decodes on a worker thread and hands the audio over; the command then
|
||||
/// only places it. Audio decoded for another rate is ignored and the file is read again.
|
||||
#[test]
|
||||
fn import_audio_places_audio_offered_by_a_worker() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-import-offered-{}", 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();
|
||||
let rate = e.session().sample_rate.hz();
|
||||
let decoded = std::thread::spawn({
|
||||
let wav = wav.clone();
|
||||
move || crate::io::decode_import(&wav, rate)
|
||||
})
|
||||
.join()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(decoded.frames(), 4_800);
|
||||
e.offer_import(decoded);
|
||||
// The file changes after decoding: the offered audio is placed, not the new bytes.
|
||||
std::fs::write(&wav, b"not audio").unwrap();
|
||||
let r = e.execute("file.import_audio", &json!({"path": wav, "at": 960})).unwrap();
|
||||
assert_eq!(r["imported"][0]["frames"], 4_800);
|
||||
assert_eq!(e.session().tracks.len(), 1);
|
||||
assert_eq!(e.session().tracks[0].clips()[0].start, 960);
|
||||
assert_eq!(e.session().sources[0].path, wav);
|
||||
// Taken once: the next import reads the (now broken) file.
|
||||
assert!(e.execute("file.import_audio", &json!({"path": wav})).is_err());
|
||||
|
||||
// Decoded for another rate: ignored, so the broken file is read and refused.
|
||||
let good = tone_wav(&dir, "good.wav");
|
||||
let other = crate::io::decode_import(&good, rate / 2).unwrap();
|
||||
std::fs::write(&good, b"not audio").unwrap();
|
||||
e.offer_import(other);
|
||||
assert!(e.execute("file.import_audio", &json!({"path": good})).is_err());
|
||||
e.drop_offered_imports();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_session_text_needs_replace_to_overwrite() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-export-text-replace-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("info.txt");
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
let mut e = Engine::default();
|
||||
e.execute("file.export_session_text", &json!({"path": path_str})).unwrap();
|
||||
std::fs::write(&path, "keep me").unwrap();
|
||||
let err = e.execute("file.export_session_text", &json!({"path": path_str})).unwrap_err();
|
||||
assert!(err.to_string().contains("replace: true"), "{err}");
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "keep me");
|
||||
e.execute("file.export_session_text", &json!({"path": path_str, "replace": true})).unwrap();
|
||||
assert_ne!(std::fs::read_to_string(&path).unwrap(), "keep me");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_audio_honours_new_tracks() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-import-new-tracks-{}", std::process::id()));
|
||||
|
||||
@@ -130,8 +130,19 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
n.pitch = i64::from(n.pitch).saturating_add(ds).clamp(0, 127) as u8;
|
||||
}
|
||||
}
|
||||
let moved: Vec<Note> = seq.notes.iter().enumerate().filter(|(i, _)| idx.contains(i)).map(|(_, n)| *n).collect();
|
||||
seq.sort();
|
||||
Ok(json!({"moved": idx.len()}))
|
||||
// Where the moved notes ended up after sorting, so the editor can keep them selected.
|
||||
let mut taken = vec![false; seq.notes.len()];
|
||||
let mut indices = Vec::with_capacity(moved.len());
|
||||
for m in &moved {
|
||||
if let Some(j) = seq.notes.iter().enumerate().position(|(j, n)| !taken[j] && n == m) {
|
||||
taken[j] = true;
|
||||
indices.push(j);
|
||||
}
|
||||
}
|
||||
indices.sort_unstable();
|
||||
Ok(json!({"moved": idx.len(), "indices": indices}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -172,6 +183,20 @@ mod tests {
|
||||
assert_eq!(n["notes"][0]["pitch"], 127);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_move_reports_where_the_moved_notes_went() {
|
||||
let mut e = Engine::default();
|
||||
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
|
||||
let clip = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap()["clip"].as_u64().unwrap();
|
||||
for start in [0, 480, 960] {
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": start})).unwrap();
|
||||
}
|
||||
let r = e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "ticks": 720})).unwrap();
|
||||
assert_eq!(r["indices"], json!([1]));
|
||||
let notes = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
|
||||
assert_eq!(notes["notes"][1]["start"], 720);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_audio_tracks() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -697,23 +697,35 @@ fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
for t in &tracks {
|
||||
let Some(tr) = s.track_mut(*t) else { continue };
|
||||
let base = tr.mixer.volume_db;
|
||||
// An existing lane overrides the fader, so the clip gain rides on top of the lane value.
|
||||
let old = tr.lane(&AutoParam::Volume).cloned();
|
||||
let level = |at: i64| old.as_ref().map_or(base, |l| l.value_at(at, base));
|
||||
let mut pins = Vec::new();
|
||||
let mut pts = Vec::new();
|
||||
if let Some(pl) = tr.playlist_mut() {
|
||||
for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) {
|
||||
if c.gain_env.is_empty() {
|
||||
pts.push((c.start, base + c.gain_db));
|
||||
pts.push((c.end() - 1, base + c.gain_db));
|
||||
} else {
|
||||
for (o, db) in &c.gain_env {
|
||||
pts.push((c.start + o, base + c.gain_db + db));
|
||||
}
|
||||
// Pin the unchanged level just outside the clip so the neighbouring segments keep their shape.
|
||||
if c.start > 0 {
|
||||
pins.push((c.start - 1, level(c.start - 1)));
|
||||
}
|
||||
pins.push((c.end(), level(c.end())));
|
||||
// The clip edges always get a point, so the envelope's first and last values hold out to them.
|
||||
let mut ats = vec![c.start, c.end() - 1];
|
||||
ats.extend(c.gain_env.iter().map(|&(o, _)| c.start + o).filter(|&at| at > c.start && at < c.end() - 1));
|
||||
// The lane's own points inside the clip carry the clip gain too.
|
||||
if let Some(l) = &old {
|
||||
ats.extend(l.points.iter().map(|pt| pt.at).filter(|&at| at > c.start && at < c.end() - 1));
|
||||
}
|
||||
for at in ats {
|
||||
pts.push((at, level(at) + c.gain_db + env_db(&c.gain_env, at - c.start)));
|
||||
}
|
||||
c.gain_db = 0.0;
|
||||
c.gain_env.clear();
|
||||
}
|
||||
}
|
||||
let lane = tr.lane_mut(&AutoParam::Volume);
|
||||
for (at, v) in pts {
|
||||
// Pins first, so a neighbouring clip's converted points win where they meet.
|
||||
for (at, v) in pins.into_iter().chain(pts) {
|
||||
lane.set_point(at, v);
|
||||
}
|
||||
}
|
||||
@@ -872,6 +884,77 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_gain_to_volume_keeps_the_existing_lane() {
|
||||
let clip_gain_to_volume = |points: &[(i64, f32)], env: Vec<(i64, f32)>| {
|
||||
let (mut e, t) = tone_session(points);
|
||||
let clip = e.session().track(t).unwrap().clips()[0].id;
|
||||
let c = e.session_mut().find_clip_mut(clip).unwrap();
|
||||
c.gain_db = 6.0;
|
||||
c.gain_env = env;
|
||||
let before = rendered(e.session());
|
||||
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": 8192})).unwrap();
|
||||
let after = rendered(e.session());
|
||||
assert!(e.session().find_clip(clip).unwrap().1.gain_db == 0.0);
|
||||
(before, after)
|
||||
};
|
||||
let (before, after) = clip_gain_to_volume(&[(0, -6.0), (8192, -6.0)], Vec::new());
|
||||
let delta = rms_db(&before, &after);
|
||||
assert!(delta.abs() < 0.02, "level changed by {delta} dB");
|
||||
|
||||
// A moving lane keeps its shape, and its points inside the clip carry the gain.
|
||||
let (before, after) = clip_gain_to_volume(&[(0, -12.0), (4096, -6.0), (8192, -12.0)], Vec::new());
|
||||
for (start, end) in [(0, 2048), (2048, 4096), (4096, 6144), (6144, 8192)] {
|
||||
let delta = rms_db(&before[start..end], &after[start..end]);
|
||||
assert!(delta.abs() < 0.1, "{start}..{end} changed by {delta} dB");
|
||||
}
|
||||
|
||||
// The clip's own gain envelope rides on the lane value as well.
|
||||
let (before, after) = clip_gain_to_volume(&[(0, -6.0), (8192, -6.0)], vec![(2000, 0.0), (6000, -10.0)]);
|
||||
for (start, end) in [(3900, 4100), (5000, 5200)] {
|
||||
let delta = rms_db(&before[start..end], &after[start..end]);
|
||||
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_gain_to_volume_holds_the_envelope_ends_out_to_the_clip_edges() {
|
||||
let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]);
|
||||
let clip = e.session().track(t).unwrap().clips()[0].id;
|
||||
let c = e.session_mut().find_clip_mut(clip).unwrap();
|
||||
c.gain_db = 6.0;
|
||||
c.gain_env = vec![(2000, 0.0), (6000, -10.0)];
|
||||
let before = rendered(e.session());
|
||||
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": 8192})).unwrap();
|
||||
let after = rendered(e.session());
|
||||
for (start, end) in [(0, 1000), (1000, 2000), (6000, 7000), (7000, 8192)] {
|
||||
let delta = rms_db(&before[start..end], &after[start..end]);
|
||||
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_gain_to_volume_leaves_audio_outside_the_clip_alone() {
|
||||
// Three clips in a row; only the middle one is converted, and the lane extends past it on both sides.
|
||||
let (mut e, t) = tone_session(&[(0, -6.0), (16384, -6.0)]);
|
||||
let (middle, last) = (e.session_mut().new_clip_id(), e.session_mut().new_clip_id());
|
||||
let pl = e.session_mut().track_mut(t).unwrap().playlist_mut().unwrap();
|
||||
if let Some(c) = pl.clips.first_mut() {
|
||||
c.length = 2048;
|
||||
}
|
||||
pl.clips.push(Clip::audio(middle, "mid", SourceId(910), 2048, 2048, 4096));
|
||||
pl.clips.push(Clip::audio(last, "end", SourceId(910), 6144, 6144, 2048));
|
||||
e.session_mut().find_clip_mut(middle).unwrap().gain_db = 6.0;
|
||||
let before = rendered(e.session());
|
||||
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 2048, "end": 6144})).unwrap();
|
||||
let after = rendered(e.session());
|
||||
assert!(e.session().find_clip(middle).unwrap().1.gain_db == 0.0);
|
||||
for (start, end) in [(0, 2040), (2056, 6136), (6152, 8192)] {
|
||||
let delta = rms_db(&before[start..end], &after[start..end]);
|
||||
assert!(delta.abs() < 0.1, "{start}..{end} changed by {delta} dB");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_params_rejects_unknown_and_non_numeric_keys() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::sync::OnceLock;
|
||||
|
||||
mod audio_midi;
|
||||
mod audiosuite;
|
||||
mod auto_rename;
|
||||
mod clap;
|
||||
mod clip;
|
||||
mod clip_list;
|
||||
@@ -38,9 +39,31 @@ mod view_more;
|
||||
mod vst3;
|
||||
mod zoom_toggle;
|
||||
|
||||
#[cfg(test)]
|
||||
mod panic_probe {
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(noundo "test.panic_probe", "Panic Probe", [], None, "{}", always, probe)]
|
||||
}
|
||||
|
||||
fn probe(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if p.get("panic").is_none() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
e.clipboard.length = 123;
|
||||
e.message("probe");
|
||||
e.transport_requests.push(crate::TransportRequest::Play);
|
||||
e.last_selection = Some((soundcraft_time::Range::new(0, 10), Vec::new()));
|
||||
panic!("probe")
|
||||
}
|
||||
}
|
||||
|
||||
pub use file::bounce_job;
|
||||
|
||||
pub(crate) use edit::remember_selection;
|
||||
pub use edit::strip_silence_scan;
|
||||
|
||||
pub type Run = fn(&mut Engine, &Value) -> Result<Value>;
|
||||
/// `Err(reason)` = disabled.
|
||||
@@ -126,6 +149,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(track_more::specs());
|
||||
v.extend(clip_more::specs());
|
||||
v.extend(clip_list::specs());
|
||||
v.extend(auto_rename::specs());
|
||||
v.extend(event_more::specs());
|
||||
v.extend(setup_more::specs());
|
||||
v.extend(clap::specs());
|
||||
@@ -136,6 +160,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(sampler::specs());
|
||||
v.extend(video::specs());
|
||||
v.extend(zoom_toggle::specs());
|
||||
#[cfg(test)]
|
||||
v.extend(panic_probe::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
@@ -224,13 +250,19 @@ pub fn tracks_param(e: &Engine, cmd: &str, p: &Value) -> Result<Vec<TrackId>> {
|
||||
if let Some(arr) = p.get("tracks").and_then(Value::as_array) {
|
||||
let s = e.session();
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for v in arr {
|
||||
let id = match (v.as_u64(), v.as_str()) {
|
||||
(Some(n), _) => s.track(TrackId(n)).map(|t| t.id),
|
||||
(_, Some(name)) => s.track_by_name(name).map(|t| t.id),
|
||||
_ => None,
|
||||
};
|
||||
out.push(id.ok_or_else(|| bad(cmd, format!("unknown track {v}")))?);
|
||||
let id = id.ok_or_else(|| bad(cmd, format!("unknown track {v}")))?;
|
||||
// Duplicates multiply the work (each entry renders and keeps its own result), so drop
|
||||
// repeated ids instead of letting them blow the budget.
|
||||
if seen.insert(id) {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,15 @@ fn insert_state(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Some(Value::String(s)) => Some(b64::decode(s).ok_or_else(|| bad(CMD, "`state` is not valid base64 (or is too large)"))?),
|
||||
Some(_) => return Err(bad(CMD, "`state` must be a base64 string or null")),
|
||||
};
|
||||
let n = state.as_ref().map_or(0, Vec::len);
|
||||
// Plugins report changes that leave their state as it was: don't modify the session for those.
|
||||
let stored = e.session().track(t).and_then(|tr| match slot {
|
||||
Some(i) => tr.mixer.inserts.get(i).and_then(Option::as_ref),
|
||||
None => tr.instrument.as_ref(),
|
||||
});
|
||||
if stored.is_some_and(|ins| ins.state_bytes().as_deref() == state.as_deref().filter(|b| !b.is_empty())) {
|
||||
return Ok(json!({"bytes": n}));
|
||||
}
|
||||
let s = e.session_mut();
|
||||
let tr = s.track_mut(t).ok_or_else(|| bad(CMD, "no track"))?;
|
||||
let ins = match slot {
|
||||
@@ -55,7 +64,6 @@ fn insert_state(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
None => tr.instrument.as_mut(),
|
||||
}
|
||||
.ok_or_else(|| bad(CMD, "empty slot"))?;
|
||||
let n = state.as_ref().map_or(0, Vec::len);
|
||||
match state {
|
||||
Some(bytes) => ins.set_state_bytes(&bytes),
|
||||
None => ins.state = None,
|
||||
@@ -97,6 +105,19 @@ mod tests {
|
||||
assert_eq!(e.session().tracks[0].mixer.inserts[2].as_ref().unwrap().state, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storing_the_same_state_again_changes_nothing() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
let t = e.session().tracks[0].id.0;
|
||||
e.execute("mix.insert", &json!({"track": t, "plugin": "gain", "slot": 0})).unwrap();
|
||||
let blob = b64::encode(b"same");
|
||||
e.execute("mix.insert_state", &json!({"track": t, "slot": 0, "state": blob})).unwrap();
|
||||
let rev = e.revision;
|
||||
e.execute("mix.insert_state", &json!({"track": t, "slot": 0, "state": blob})).unwrap();
|
||||
assert_eq!(e.revision, rev);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_are_errors() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -461,6 +461,8 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
s.sources.push(ns);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
// The donor's Auto Rename schemes come along under new keys, shared as they were.
|
||||
let mut names: std::collections::BTreeMap<u64, u64> = Default::default();
|
||||
for mut tr in picked {
|
||||
tr.id = TrackId(s.alloc());
|
||||
tr.name = s.unique_track_name(&tr.name);
|
||||
@@ -474,6 +476,14 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
{
|
||||
*source = *n;
|
||||
}
|
||||
c.auto_name = c.auto_name.and_then(|k| {
|
||||
let scheme = other.auto_names.get(&k)?;
|
||||
Some(*names.entry(k).or_insert_with(|| {
|
||||
let (key, id) = (s.alloc(), crate::new_scheme_id(s));
|
||||
s.auto_names.insert(key, soundcraft_model::AutoName { id, ..scheme.clone() });
|
||||
key
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
// The donor's source ids mean nothing here: remap the sample, or drop it when it didn't come along.
|
||||
@@ -601,6 +611,29 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_session_data_brings_auto_rename_schemes_under_new_keys() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-import-names-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("donor.scraft").to_string_lossy().into_owned();
|
||||
let mut donor = crate::demo::demo_engine();
|
||||
let kick: Vec<_> = donor.session().track_by_name("Kick").unwrap().clips().iter().map(|c| c.id.0).collect();
|
||||
donor.execute("clip.auto_rename", &json!({"clips": kick, "name": "Hit", "number": 5})).unwrap();
|
||||
crate::io::save_session(&mut donor, &path).unwrap();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let bass = e.session().track_by_name("Bass").unwrap().clips()[0].id;
|
||||
e.execute("clip.auto_rename", &json!({"clips": [bass], "name": "Low"})).unwrap();
|
||||
e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Kick"]})).unwrap();
|
||||
let s = e.session();
|
||||
assert_eq!(s.auto_names.len(), 2, "the donor's scheme comes in beside this session's");
|
||||
let imported = s.tracks.iter().rfind(|t| t.name.starts_with("Kick")).unwrap();
|
||||
let keys: std::collections::BTreeSet<_> = imported.clips().iter().map(|c| c.auto_name).collect();
|
||||
assert_eq!(keys.len(), 1, "the imported clips still share one scheme");
|
||||
let key = keys.into_iter().next().flatten().unwrap();
|
||||
assert_eq!((s.auto_names[&key].name.as_str(), s.auto_names[&key].number), ("Hit", 5));
|
||||
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()));
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::{
|
||||
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
|
||||
use soundcraft_model::{ChannelFormat, ClipContent, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -414,6 +414,8 @@ fn split_mono(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.split_into_mono", p)?;
|
||||
let s = e.session_mut();
|
||||
let mut out = Vec::new();
|
||||
let mut mono_sources = std::collections::BTreeMap::new();
|
||||
let mut copied_samples = 0usize;
|
||||
for t in tracks {
|
||||
let Some(src) = s.track(t).cloned() else { continue };
|
||||
if src.channels() < 2 || src.kind != TrackKind::Audio {
|
||||
@@ -429,12 +431,46 @@ fn split_mono(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let mut clips = src.clips().to_vec();
|
||||
for c in &mut clips {
|
||||
c.id = s.new_clip_id();
|
||||
// Mono tracks read channel 0 of the source; record the channel in the name for now.
|
||||
if let ClipContent::Audio { source, .. } = &mut c.content {
|
||||
let audio =
|
||||
s.pool.get(*source).ok_or_else(|| bad("track.split_into_mono", format!("audio unavailable for clip `{}`", c.name)))?;
|
||||
let channels = audio.buffer.channels.len();
|
||||
if channels == 0 {
|
||||
return Err(bad("track.split_into_mono", format!("audio has no channels for clip `{}`", c.name)));
|
||||
}
|
||||
// Match the renderer's channel wrapping when a track is wider than its source.
|
||||
// An already-mono source can be shared without copying it.
|
||||
if channels > 1 {
|
||||
let key = (*source, ch % channels);
|
||||
let mono = if let Some(&id) = mono_sources.get(&key) {
|
||||
id
|
||||
} else {
|
||||
let samples =
|
||||
audio.buffer.channels.get(key.1).ok_or_else(|| bad("track.split_into_mono", "source channel unavailable"))?;
|
||||
copied_samples = copied_samples
|
||||
.checked_add(samples.len())
|
||||
.filter(|&n| n <= crate::io::MAX_IMPORT_SAMPLES)
|
||||
.ok_or_else(|| bad("track.split_into_mono", "split would copy too much audio"))?;
|
||||
// Keep the full channel: the cloned clips retain their offsets, stretch,
|
||||
// gain and fades, and every reader sees genuine mono media (including saves).
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![samples.clone()] };
|
||||
let original = s.source(*source);
|
||||
let name = format!("{}.{suffix}.wav", original.map_or(c.name.as_str(), |source| source.name.as_str()));
|
||||
let time_reference = original.map_or(0, |source| source.time_reference);
|
||||
let id = crate::io::add_source(s, &name, buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
if let Some(source) = s.sources.iter_mut().find(|source| source.id == id) {
|
||||
source.time_reference = time_reference;
|
||||
}
|
||||
mono_sources.insert(key, id);
|
||||
id
|
||||
};
|
||||
*source = mono;
|
||||
}
|
||||
}
|
||||
c.name = format!("{}.{suffix}", c.name);
|
||||
}
|
||||
if let Some(tr) = s.track_mut(id) {
|
||||
tr.mixer.pan = vec![if ch == 0 { -1.0 } else { 1.0 }];
|
||||
tr.comments = format!("source_channel={ch}");
|
||||
if let Some(pl) = tr.playlist_mut() {
|
||||
pl.clips = clips;
|
||||
}
|
||||
@@ -704,15 +740,26 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn rename(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let t = track_param(e, "track.rename", p, "track")?.ok_or_else(|| bad("track.rename", "`track` required"))?;
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("track.rename", "`name` required"))?;
|
||||
let s = e.session_mut();
|
||||
// Validate the stored name, including its existing 64-character limit.
|
||||
let name: String = name.chars().take(64).collect();
|
||||
let s = e.session();
|
||||
if s.tracks.iter().any(|x| x.name == name && x.id != t) {
|
||||
return Err(bad("track.rename", format!("a track named `{name}` already exists")));
|
||||
}
|
||||
let name: String = name.chars().take(64).collect();
|
||||
let bus = s.track(t).and_then(|track| match (track.kind, &track.mixer.input) {
|
||||
(TrackKind::Folder, Route::Bus(bus)) => Some(*bus),
|
||||
_ => None,
|
||||
});
|
||||
let track = s.track(t).ok_or_else(|| bad("track.rename", "unknown track"))?;
|
||||
if track.name == name
|
||||
&& bus.is_none_or(|id| {
|
||||
let expected = unique_folder_bus_name(s, &format!("{name} Bus"), Some(id));
|
||||
s.bus(id).is_none_or(|bus| bus.name == expected)
|
||||
})
|
||||
{
|
||||
return Ok(json!({"name": name}));
|
||||
}
|
||||
let s = e.session_mut();
|
||||
if let Some(track) = s.track_mut(t) {
|
||||
track.name.clone_from(&name);
|
||||
}
|
||||
|
||||
+216
-10
@@ -78,8 +78,16 @@ fn rebase_midi_start(clip: &mut Clip, old_start: Samples, t: TimeCtx<'_>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Split every clip on a track's active playlist that spans `at`.
|
||||
/// Split every clip on a track's active playlist that spans `at`. The piece before `at` keeps the
|
||||
/// clip's name. The piece after it is a new clip, and takes the next name of the clip's Auto Rename
|
||||
/// scheme when it has one.
|
||||
pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
separate_at_if(s, track, at, |_| true)
|
||||
}
|
||||
|
||||
/// [`separate_at`], giving the piece after `at` the next Auto Rename name only when `auto_name`
|
||||
/// returns true for it. Any other piece keeps the clip's name, and its scheme does not count up.
|
||||
pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: impl Fn(&Clip) -> bool) -> usize {
|
||||
let tc_tempo = s.tempo.clone();
|
||||
let sr = s.sample_rate;
|
||||
let t = TimeCtx { tempo: &tc_tempo, sr };
|
||||
@@ -88,15 +96,22 @@ pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
for _ in 0..n_split {
|
||||
ids.push(s.new_clip_id());
|
||||
}
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut schemes = std::mem::take(&mut s.auto_names);
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else {
|
||||
s.auto_names = schemes;
|
||||
return 0;
|
||||
};
|
||||
let mut out = Vec::with_capacity(pl.clips.len() + ids.len());
|
||||
let mut count = 0;
|
||||
for c in pl.clips.drain(..) {
|
||||
if at > c.start
|
||||
&& at < c.end()
|
||||
&& let Some(id) = ids.pop()
|
||||
&& let Some((l, r)) = split_clip(&c, at, id, t)
|
||||
&& let Some((l, mut r)) = split_clip(&c, at, id, t)
|
||||
{
|
||||
if auto_name(&r) {
|
||||
take_auto_name(&mut schemes, &mut r);
|
||||
}
|
||||
out.push(l);
|
||||
out.push(r);
|
||||
count += 1;
|
||||
@@ -106,9 +121,34 @@ pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
}
|
||||
pl.clips = out;
|
||||
pl.sort();
|
||||
s.auto_names = schemes;
|
||||
count
|
||||
}
|
||||
|
||||
/// Gives `c` the next name of its Auto Rename scheme, when it has one, and counts the scheme up.
|
||||
fn take_auto_name(schemes: &mut std::collections::BTreeMap<u64, soundcraft_model::AutoName>, c: &mut Clip) {
|
||||
if let Some(scheme) = c.auto_name.and_then(|k| schemes.get_mut(&k)) {
|
||||
c.name = crate::io::StripNames::from(&*scheme).clip_name(0);
|
||||
scheme.number = scheme.number.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives each clip on a track's active playlist whose id is not in `old`, and for which `auto_name`
|
||||
/// returns true, the next name of its Auto Rename scheme, in time order. Pieces cut with
|
||||
/// [`separate_at_if`] and `|_| false` take their names here once the caller has removed the ones it
|
||||
/// does not keep.
|
||||
pub fn auto_name_new_clips(s: &mut Session, track: TrackId, old: &HashSet<ClipId>, auto_name: impl Fn(&Clip) -> bool) {
|
||||
let mut schemes = std::mem::take(&mut s.auto_names);
|
||||
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
|
||||
let mut new: Vec<&mut Clip> = pl.clips.iter_mut().filter(|c| !old.contains(&c.id) && auto_name(c)).collect();
|
||||
new.sort_by_key(|c| (c.start, c.id.0));
|
||||
for c in new {
|
||||
take_auto_name(&mut schemes, c);
|
||||
}
|
||||
}
|
||||
s.auto_names = schemes;
|
||||
}
|
||||
|
||||
/// Remove material inside `range` on a track. In Shuffle mode later clips move left, up to the
|
||||
/// first locked clip (see [`ripple`]). Returns the removed material as clips positioned relative
|
||||
/// to `range.start`.
|
||||
@@ -149,9 +189,11 @@ pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec<Clip> {
|
||||
let old = part.start;
|
||||
part.trim_start_to(isect.start);
|
||||
rebase_midi_start(&mut part, old, t);
|
||||
part.fade_in = Fade::default();
|
||||
}
|
||||
if isect.end < part.end() {
|
||||
part.trim_end_to(isect.end);
|
||||
part.fade_out = Fade::default();
|
||||
}
|
||||
part.start -= range.start;
|
||||
out.push(part);
|
||||
@@ -405,7 +447,13 @@ pub fn trim_end_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
n
|
||||
}
|
||||
|
||||
/// Heal separations inside `range`: merge neighbouring audio clips that continue the same file.
|
||||
/// Source samples covered by `length` timeline samples of a clip with the given `stretch`.
|
||||
fn source_len(length: Samples, stretch: f64) -> Samples {
|
||||
if stretch.is_finite() && stretch > 0.0 && (stretch - 1.0).abs() >= 1e-9 { (length as f64 / stretch).round() as Samples } else { length }
|
||||
}
|
||||
|
||||
/// Heal separations inside `range`: merge neighbouring audio clips that continue the same file,
|
||||
/// either touching (a split) or with an empty gap that matches the gap in the source (a cut).
|
||||
pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
pl.sort();
|
||||
@@ -413,13 +461,20 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
let mut healed = 0;
|
||||
for c in pl.clips.drain(..) {
|
||||
if let Some(prev) = out.last_mut() {
|
||||
let contiguous = prev.end() == c.start && c.start >= range.start && c.start <= range.end;
|
||||
let contiguous = c.start >= prev.end() && prev.end() >= range.start && c.start <= range.end;
|
||||
let gap = if contiguous { c.start.saturating_sub(prev.end()) } else { 0 };
|
||||
let same = match (&prev.content, &c.content) {
|
||||
(ClipContent::Audio { source: a, offset: oa }, ClipContent::Audio { source: b, offset: ob }) => a == b && oa + prev.length == *ob,
|
||||
(ClipContent::Audio { source: a, offset: oa }, ClipContent::Audio { source: b, offset: ob }) => {
|
||||
a == b && oa.saturating_add(source_len(prev.length.saturating_add(gap), prev.stretch)) == *ob
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if contiguous && same && (prev.gain_db - c.gain_db).abs() < 1e-6 {
|
||||
prev.length += c.length;
|
||||
let same_playback = prev.muted == c.muted && (prev.stretch - c.stretch).abs() < 1e-9 && (prev.gain_db - c.gain_db).abs() < 1e-6;
|
||||
if contiguous && same && same_playback {
|
||||
if !prev.gain_env.is_empty() || !c.gain_env.is_empty() {
|
||||
join_gain_env(prev, &c);
|
||||
}
|
||||
prev.length = c.end().saturating_sub(prev.start);
|
||||
prev.fade_out = c.fade_out;
|
||||
healed += 1;
|
||||
continue;
|
||||
@@ -431,6 +486,24 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
healed
|
||||
}
|
||||
|
||||
/// Give `l` the gain envelope of `l` followed by `r`, which starts at or after where `l` ends, so
|
||||
/// every sample keeps its gain. Call it before `l.length` grows to cover `r`.
|
||||
fn join_gain_env(l: &mut Clip, r: &Clip) {
|
||||
let cut = l.length;
|
||||
let seam = r.start.saturating_sub(l.start);
|
||||
let last = cut.saturating_sub(1);
|
||||
let (end_db, start_db) = (l.env_db_at(last), r.env_db_at(0));
|
||||
l.gain_env.retain(|&(at, _)| at < cut);
|
||||
l.gain_env.extend(r.gain_env.iter().filter(|&&(at, _)| at >= 0).map(|&(at, db)| (at.saturating_add(seam), db)));
|
||||
// Pin both sides of the seam where the joined points would interpolate across it.
|
||||
for (at, db) in [(seam, start_db), (last, end_db)] {
|
||||
if (l.env_db_at(at) - db).abs() > 1e-4 {
|
||||
let i = l.gain_env.partition_point(|&(t, _)| t < at);
|
||||
l.gain_env.insert(i, (at, db));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move clips by `delta`, overwriting what they land on; `dest` sends whole tracks of them
|
||||
/// elsewhere (see [`destinations`]).
|
||||
pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> usize {
|
||||
@@ -812,8 +885,10 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh
|
||||
pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut n = 0;
|
||||
let mut touched: HashSet<ClipId> = HashSet::new();
|
||||
for c in &mut pl.clips {
|
||||
if c.range().overlaps(&range) || (range.is_empty() && c.range().contains(range.start)) {
|
||||
touched.insert(c.id);
|
||||
if c.fade_in.len > 0 || c.fade_out.len > 0 {
|
||||
n += 1;
|
||||
}
|
||||
@@ -821,11 +896,12 @@ pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
c.fade_out = Fade::default();
|
||||
}
|
||||
}
|
||||
// Overlapping (crossfaded) neighbours are trimmed back to the midpoint.
|
||||
// Overlapping (crossfaded) neighbours in the range are trimmed back to the midpoint; other overlaps stay as they are.
|
||||
pl.sort();
|
||||
for i in 0..pl.clips.len().saturating_sub(1) {
|
||||
let (Some(a_end), Some(b_start)) = (pl.clips.get(i).map(Clip::end), pl.clips.get(i + 1).map(|c| c.start)) else { continue };
|
||||
if a_end > b_start {
|
||||
let in_range = pl.clips.get(i).is_some_and(|a| touched.contains(&a.id)) || pl.clips.get(i + 1).is_some_and(|b| touched.contains(&b.id));
|
||||
if in_range && a_end > b_start {
|
||||
let mid = b_start + (a_end - b_start) / 2;
|
||||
if let Some(a) = pl.clips.get_mut(i) {
|
||||
a.trim_end_to(mid);
|
||||
@@ -870,6 +946,20 @@ mod tests {
|
||||
s.track(t).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_range_resets_fades_at_cut_edges() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
if let Some(c) = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()) {
|
||||
c.fade_in = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
|
||||
c.fade_out = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
|
||||
}
|
||||
let fades = |r: Range| -> Vec<(Samples, Samples)> { copy_range(&s, t, r).iter().map(|c| (c.fade_in.len, c.fade_out.len)).collect() };
|
||||
assert_eq!(fades(Range::new(2000, 3000)), vec![(0, 0)]);
|
||||
assert_eq!(fades(Range::new(0, 3000)), vec![(100, 0)]);
|
||||
assert_eq!(fades(Range::new(2000, 6000)), vec![(0, 100)]);
|
||||
assert_eq!(fades(Range::new(0, 6000)), vec![(100, 100)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_splits_and_heal_rejoins() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
@@ -880,6 +970,103 @@ mod tests {
|
||||
assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heal_rejoins_cut_separation() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
clear_range(&mut s, t, Range::new(2000, 3000), false);
|
||||
assert_eq!(heal(&mut s, t, Range::new(1500, 3500)), 1);
|
||||
assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]);
|
||||
// A selection that does not span the whole gap leaves it alone.
|
||||
let (mut s, t) = session_with_clip();
|
||||
clear_range(&mut s, t, Range::new(2000, 3000), false);
|
||||
assert_eq!(heal(&mut s, t, Range::new(2500, 3500)), 0);
|
||||
// Shuffled pieces no longer line up with their source and stay apart.
|
||||
let (mut s, t) = session_with_clip();
|
||||
clear_range(&mut s, t, Range::new(2000, 3000), true);
|
||||
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
|
||||
// Moved apart: the timeline gap no longer matches the source gap.
|
||||
let (mut s, t) = session_with_clip();
|
||||
clear_range(&mut s, t, Range::new(2000, 3000), false);
|
||||
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips[1].start = 3500;
|
||||
}
|
||||
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
|
||||
// Overlapping neighbours whose offsets line up are not a separation.
|
||||
let (mut s, t) = session_with_clip();
|
||||
assert_eq!(separate_at(&mut s, t, 3000), 1);
|
||||
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips[1].start = 2500;
|
||||
}
|
||||
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
|
||||
assert_eq!(clips(&s, t).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heal_skips_clips_with_different_playback() {
|
||||
for tweak in [(|c: &mut Clip| c.muted = true) as fn(&mut Clip), |c: &mut Clip| c.stretch = 1.5] {
|
||||
let (mut s, t) = session_with_clip();
|
||||
separate_at(&mut s, t, 3000);
|
||||
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
|
||||
tweak(&mut pl.clips[1]);
|
||||
}
|
||||
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
|
||||
assert_eq!(s.track(t).unwrap().clips().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heal_keeps_each_side_gain_envelope() {
|
||||
let cases: [(Vec<(Samples, f32)>, Vec<(Samples, f32)>); 4] = [
|
||||
(vec![(500, -3.0), (2500, -12.0)], vec![]),
|
||||
(vec![], vec![(0, -6.0), (1000, 0.0)]),
|
||||
(vec![(0, 0.0), (1999, -20.0)], vec![(-100, 4.0), (300, -9.0)]),
|
||||
(vec![(500, -3.0), (2500, -12.0)], vec![(0, -9.75), (500, -12.0)]),
|
||||
];
|
||||
for (left_env, right_env) in cases {
|
||||
let (mut s, t) = session_with_clip();
|
||||
separate_at(&mut s, t, 3000);
|
||||
let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap();
|
||||
pl.clips[0].gain_env = left_env;
|
||||
pl.clips[1].gain_env = right_env;
|
||||
let (l, r) = (pl.clips[0].clone(), pl.clips[1].clone());
|
||||
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 1);
|
||||
let healed = &s.track(t).unwrap().clips()[0];
|
||||
for rel in 0..4000 {
|
||||
let want = if rel < 2000 { l.gain_at(rel) } else { r.gain_at(rel - 2000) };
|
||||
assert!((healed.gain_at(rel) - want).abs() < 1e-4, "sample {rel}: {} != {want}", healed.gain_at(rel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heal_keeps_gain_envelopes_across_a_gap() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
clear_range(&mut s, t, Range::new(2000, 3000), false);
|
||||
let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap();
|
||||
pl.clips[0].gain_env = vec![(0, 0.0), (999, -6.0)];
|
||||
pl.clips[1].gain_env = vec![(0, -12.0), (1999, -3.0)];
|
||||
let (l, r) = (pl.clips[0].clone(), pl.clips[1].clone());
|
||||
assert_eq!(heal(&mut s, t, Range::new(1500, 3500)), 1);
|
||||
let healed = &s.track(t).unwrap().clips()[0];
|
||||
assert_eq!(healed.length, 4000);
|
||||
for rel in (0..1000).chain(2000..4000) {
|
||||
let want = if rel < 1000 { l.gain_at(rel) } else { r.gain_at(rel - 2000) };
|
||||
assert!((healed.gain_at(rel) - want).abs() < 1e-4, "sample {rel}: {} != {want}", healed.gain_at(rel));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_then_heal_rejoins_stretched_clip() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
if let Some(c) = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()) {
|
||||
c.stretch = 2.0;
|
||||
}
|
||||
assert_eq!(separate_at(&mut s, t, 3000), 1);
|
||||
assert_eq!(clips(&s, t), vec![(1000, 2000, 1000), (3000, 2000, 2000)]);
|
||||
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 1);
|
||||
assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_slip_and_shuffle() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
@@ -1057,4 +1244,23 @@ mod tests {
|
||||
assert!(got.windows(2).all(|w| w[0].0 <= w[1].0), "playlist not sorted: {got:?}");
|
||||
assert!(got.iter().all(|c| c.1 > 0), "empty clip: {got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_fades_leaves_unrelated_overlaps_alone() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
let ids: Vec<ClipId> = (0..4).map(|_| s.new_clip_id()).collect();
|
||||
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips.clear();
|
||||
for (id, start, len) in [(ids[0], 0, 1100), (ids[1], 1000, 1000), (ids[2], 5000, 1100), (ids[3], 6000, 1000)] {
|
||||
let mut c = Clip::audio(id, "c", SourceId(500), start, start, len);
|
||||
c.fade_in = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
|
||||
c.fade_out = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
|
||||
pl.clips.push(c);
|
||||
}
|
||||
}
|
||||
assert_eq!(delete_fades(&mut s, t, Range::new(900, 1200)), 2);
|
||||
assert_eq!(clips(&s, t), vec![(0, 1050, 0), (1050, 950, 1050), (5000, 1100, 5000), (6000, 1000, 6000)]);
|
||||
let c = s.track(t).unwrap().clips();
|
||||
assert_eq!((c[1].fade_in.len, c[2].fade_out.len, c[3].fade_in.len), (0, 100, 100));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
//! The folders automation may read and write.
|
||||
//!
|
||||
//! `soundcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the
|
||||
//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in
|
||||
//! force, every path a command reads goes through [`check_read`] and every path it writes through [`check_write`]:
|
||||
//! files an agent's commands reach; each flag may be given more than once (#874). [`AutomationRoots`]
|
||||
//! holds the folders of each kind. While they are in force, every path a command reads goes through
|
||||
//! [`check_read`] and every path it writes through [`check_write`]:
|
||||
//!
|
||||
//! - The path is made absolute against the working directory, as the file system reads it. Its
|
||||
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
|
||||
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
|
||||
//! is a link that leads nowhere).
|
||||
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
|
||||
//! `/work`), without regard to case on Windows.
|
||||
//! - The result must lie inside one of the roots of its kind, compared by whole components
|
||||
//! (`/work2` isn't inside `/work`), without regard to case on Windows.
|
||||
//! - Read and write access are separate: a root given alone grants only its own kind of access.
|
||||
//!
|
||||
//! Roots are in force on a thread inside [`confine`], and in the whole process after
|
||||
@@ -32,11 +33,11 @@ pub enum Access {
|
||||
Write,
|
||||
}
|
||||
|
||||
/// The folders automation may read and write; a root left out grants no access of its kind.
|
||||
/// The folders automation may read and write; a kind with no root grants no access of its kind.
|
||||
#[derive(Debug)]
|
||||
pub struct AutomationRoots {
|
||||
read: Option<Root>,
|
||||
write: Option<Root>,
|
||||
read: Vec<Root>,
|
||||
write: Vec<Root>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -62,48 +63,61 @@ impl Root {
|
||||
}
|
||||
|
||||
impl AutomationRoots {
|
||||
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
|
||||
/// (nothing is confined then).
|
||||
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
|
||||
if read.is_none() && write.is_none() {
|
||||
/// The roots `read` and `write`, which must be existing folders (one given twice counts once);
|
||||
/// `None` when neither kind has any (nothing is confined then).
|
||||
pub fn new(read: &[&Path], write: &[&Path]) -> Result<Option<Arc<Self>>, String> {
|
||||
if read.is_empty() && write.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
|
||||
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
|
||||
Ok(Some(Arc::new(Self { read, write })))
|
||||
let open = |paths: &[&Path], access: Access| -> Result<Vec<Root>, String> {
|
||||
let mut roots: Vec<Root> = Vec::with_capacity(paths.len());
|
||||
for path in paths {
|
||||
let root = Root::open(path, access)?;
|
||||
if !roots.iter().any(|r| r.resolved == root.resolved) {
|
||||
roots.push(root);
|
||||
}
|
||||
}
|
||||
Ok(roots)
|
||||
};
|
||||
Ok(Some(Arc::new(Self { read: open(read, Access::Read)?, write: open(write, Access::Write)? })))
|
||||
}
|
||||
|
||||
/// The read root, absolute with its links followed.
|
||||
pub fn read_root(&self) -> Option<&Path> {
|
||||
self.read.as_ref().map(|r| r.shown.as_path())
|
||||
/// The roots for people: `read A, B; write C` (`none` for a kind without any).
|
||||
pub fn describe(&self) -> String {
|
||||
let list = |roots: &[Root]| {
|
||||
if roots.is_empty() {
|
||||
return "none".to_string();
|
||||
}
|
||||
roots.iter().map(|r| r.shown.display().to_string()).collect::<Vec<_>>().join(", ")
|
||||
};
|
||||
format!("read {}; write {}", list(&self.read), list(&self.write))
|
||||
}
|
||||
|
||||
/// The write root, absolute with its links followed.
|
||||
pub fn write_root(&self) -> Option<&Path> {
|
||||
self.write.as_ref().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
||||
/// May the file or folder at `path` be read (or written)? It must lie inside one of the roots
|
||||
/// of that kind. `Err` says why not.
|
||||
pub fn check(&self, path: impl AsRef<Path>, access: Access) -> Result<(), String> {
|
||||
let path = path.as_ref();
|
||||
let shown_path = path.display();
|
||||
let root = match access {
|
||||
Access::Read => self.read.as_ref(),
|
||||
Access::Write => self.write.as_ref(),
|
||||
let roots = match access {
|
||||
Access::Read => &self.read,
|
||||
Access::Write => &self.write,
|
||||
};
|
||||
let Some(Root { resolved: folder, shown }) = root else {
|
||||
if roots.is_empty() {
|
||||
return match access {
|
||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {shown_path}")),
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown_path}")),
|
||||
};
|
||||
};
|
||||
if inside(&resolve(path)?, folder) {
|
||||
}
|
||||
let resolved = resolve(path)?;
|
||||
if roots.iter().any(|r| inside(&resolved, &r.resolved)) {
|
||||
return Ok(());
|
||||
}
|
||||
let root = shown.display();
|
||||
match access {
|
||||
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {shown_path}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {shown_path}")),
|
||||
let shown: Vec<String> = roots.iter().map(|r| r.shown.display().to_string()).collect();
|
||||
match (shown.as_slice(), access) {
|
||||
([root], Access::Read) => Err(format!("automation path rejected: outside the read root {root}: {shown_path}")),
|
||||
([root], Access::Write) => Err(format!("automation path rejected: outside the write root {root}: {shown_path}")),
|
||||
(_, Access::Read) => Err(format!("automation path rejected: outside the read roots {}: {shown_path}", shown.join(", "))),
|
||||
(_, Access::Write) => Err(format!("automation path rejected: outside the write roots {}: {shown_path}", shown.join(", "))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,7 +294,7 @@ mod tests {
|
||||
}
|
||||
std::fs::write(base.join("in/a.scraft"), "x").unwrap();
|
||||
std::fs::write(base.join("outside/secret.scraft"), "x").unwrap();
|
||||
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
|
||||
let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap().unwrap();
|
||||
(base, roots)
|
||||
}
|
||||
|
||||
@@ -340,18 +354,18 @@ mod tests {
|
||||
#[test]
|
||||
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
|
||||
let (base, _) = folders("missing");
|
||||
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
|
||||
let e = AutomationRoots::new(&[&base.join("nope")], &[]).unwrap_err();
|
||||
assert!(e.starts_with("cannot open the automation read root"), "{e}");
|
||||
let e = AutomationRoots::new(None, Some(&base.join("in/a.scraft"))).unwrap_err();
|
||||
let e = AutomationRoots::new(&[], &[&base.join("in/a.scraft")]).unwrap_err();
|
||||
assert!(
|
||||
e.starts_with("cannot open the automation write root")
|
||||
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
|
||||
"{e}"
|
||||
);
|
||||
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(None, None).unwrap().is_none());
|
||||
assert!(AutomationRoots::new(&[Path::new("")], &[]).unwrap_err().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(&[], &[]).unwrap().is_none());
|
||||
// One root alone grants only its own access.
|
||||
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
|
||||
let read_only = AutomationRoots::new(&[&base.join("in")], &[]).unwrap().unwrap();
|
||||
let e = read_only.check(s(base.join("in/x.scraft")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("write authority is absent"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
@@ -388,11 +402,66 @@ mod tests {
|
||||
assert_eq!(roots.check(s(base.join("in/alias/x.scraft")), Access::Read), Ok(()));
|
||||
// A root given through a link is the folder it leads to.
|
||||
assert!(made(&base.join("in"), &base.join("in-link")));
|
||||
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
|
||||
let linked = AutomationRoots::new(&[&base.join("in-link")], &[]).unwrap().unwrap();
|
||||
assert_eq!(linked.check(s(base.join("in/a.scraft")), Access::Read), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// #874: several roots of a kind: a path inside any of them passes, one inside none is refused
|
||||
/// naming them all, and read and write stay separate.
|
||||
#[test]
|
||||
fn several_roots_of_a_kind_each_grant_their_access() {
|
||||
let (base, _) = folders("several");
|
||||
std::fs::create_dir_all(base.join("out2")).unwrap();
|
||||
std::fs::write(base.join("in2/b.scraft"), "x").unwrap();
|
||||
std::fs::write(base.join("parent.scraft"), "x").unwrap();
|
||||
let roots = AutomationRoots::new(&[&base.join("in"), &base.join("in2")], &[&base.join("out"), &base.join("out2")]).unwrap().unwrap();
|
||||
assert_eq!(roots.check(s(base.join("in/a.scraft")), Access::Read), Ok(()));
|
||||
assert_eq!(roots.check(s(base.join("in2/b.scraft")), Access::Read), Ok(()));
|
||||
let e = roots.check(s(base.join("outside/secret.scraft")), Access::Read).unwrap_err();
|
||||
assert!(e.starts_with("automation path rejected: outside the read roots ") && e.contains(", "), "{e}");
|
||||
// Two sibling roots don't make their parent a root.
|
||||
assert!(roots.check(s(base.join("parent.scraft")), Access::Read).unwrap_err().contains("outside the read roots"));
|
||||
assert!(roots.check(s(base.clone()), Access::Read).is_err());
|
||||
assert_eq!(roots.check(s(base.join("out/x.wav")), Access::Write), Ok(()));
|
||||
assert_eq!(roots.check(s(base.join("out2/new/x.wav")), Access::Write), Ok(()));
|
||||
assert!(roots.check(s(base.join("outside/x.wav")), Access::Write).unwrap_err().contains("outside the write roots"));
|
||||
assert!(roots.check(s(base.join("x.wav")), Access::Write).is_err());
|
||||
assert!(roots.check(s(base.join("in2/b.scraft")), Access::Write).is_err());
|
||||
assert!(roots.check(s(base.join("out2/x.wav")), Access::Read).is_err());
|
||||
// `..` out of one root is outside, also when it lands next to another root.
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let up = format!("{}{sep}..{sep}outside{sep}secret.scraft", s(base.join("in2")));
|
||||
assert!(roots.check(up, Access::Read).is_err());
|
||||
// `..` from one root into another stays inside.
|
||||
let across = format!("{}{sep}..{sep}in{sep}a.scraft", s(base.join("in2")));
|
||||
assert_eq!(roots.check(across, Access::Read), Ok(()));
|
||||
// A folder given twice (here once through `..`) counts once.
|
||||
let twice = AutomationRoots::new(&[&base.join("in"), &base.join("in/sub/..")], &[]).unwrap().unwrap();
|
||||
assert!(!twice.describe().contains(", ") && twice.describe().ends_with("; write none"), "{}", twice.describe());
|
||||
// A missing folder among several is an error.
|
||||
let e = AutomationRoots::new(&[&base.join("in"), &base.join("nope")], &[]).unwrap_err();
|
||||
assert!(e.starts_with("cannot open the automation read root"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A link inside one root that leads into another root of the same kind stays inside; one that
|
||||
/// leads out of all of them is outside.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn links_between_several_roots() {
|
||||
let (base, _) = folders("several-links");
|
||||
std::fs::write(base.join("in2/b.scraft"), "x").unwrap();
|
||||
std::os::unix::fs::symlink(base.join("in2/b.scraft"), base.join("in/to-in2.scraft")).unwrap();
|
||||
std::os::unix::fs::symlink(base.join("outside/secret.scraft"), base.join("in2/leak.scraft")).unwrap();
|
||||
std::os::unix::fs::symlink(&base, base.join("in2/up")).unwrap();
|
||||
let roots = AutomationRoots::new(&[&base.join("in"), &base.join("in2")], &[]).unwrap().unwrap();
|
||||
assert_eq!(roots.check(s(base.join("in/to-in2.scraft")), Access::Read), Ok(()));
|
||||
assert!(roots.check(s(base.join("in2/leak.scraft")), Access::Read).unwrap_err().contains("outside the read roots"));
|
||||
assert!(roots.check(s(base.join("in2/up/outside/secret.scraft")), Access::Read).unwrap_err().contains("outside the read roots"));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_ignores_case_and_refuses_devices() {
|
||||
|
||||
+591
-41
@@ -8,6 +8,7 @@ use soundcraft_time::{Range, Samples};
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
|
||||
pub(crate) const MAX_IMPORT_SAMPLES: usize = 1 << 30;
|
||||
@@ -75,7 +76,10 @@ pub fn stored_path(stored: &str, session: &Path) -> std::result::Result<PathBuf,
|
||||
/// Create `dir` and its parents, inside the automation write root when there is one.
|
||||
pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> {
|
||||
let dir = dir.as_ref();
|
||||
crate::file_access::check_write(dir).map_err(EngineError::Io)?;
|
||||
// The empty folder of a bare file name ("t.scraft") is the working directory, which the root
|
||||
// check would refuse as an empty path.
|
||||
let checked = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
|
||||
crate::file_access::check_write(checked).map_err(EngineError::Io)?;
|
||||
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))
|
||||
}
|
||||
|
||||
@@ -114,6 +118,11 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, sr);
|
||||
buf.sample_rate = sr;
|
||||
}
|
||||
add_source_audio(s, name, Arc::new(SourceAudio::new(buf)), path, format)
|
||||
}
|
||||
|
||||
/// Register audio already at the session rate, with its peaks built, as a new source.
|
||||
fn add_source_audio(s: &mut Session, name: &str, audio: Arc<SourceAudio>, path: Option<&str>, format: FileFormat) -> SourceId {
|
||||
let id = SourceId(s.alloc());
|
||||
let stem = Path::new(name).file_stem().and_then(OsStr::to_str).unwrap_or(name).to_string();
|
||||
let stored = match path {
|
||||
@@ -124,14 +133,14 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio
|
||||
id,
|
||||
name: stem.clone(),
|
||||
path: stored,
|
||||
channels: u16::try_from(buf.num_channels()).unwrap_or(u16::MAX),
|
||||
frames: buf.frames() as u64,
|
||||
sample_rate: sr,
|
||||
channels: u16::try_from(audio.buffer.num_channels()).unwrap_or(u16::MAX),
|
||||
frames: audio.frames() as u64,
|
||||
sample_rate: s.sample_rate.hz(),
|
||||
format,
|
||||
time_reference: 0,
|
||||
unsaved: true,
|
||||
});
|
||||
s.pool.insert(id, Arc::new(SourceAudio::new(buf)));
|
||||
s.pool.insert(id, audio);
|
||||
id
|
||||
}
|
||||
|
||||
@@ -144,20 +153,69 @@ fn stamp_at_rate(stamp: u64, file_rate: u32, session_rate: u32) -> u64 {
|
||||
u64::try_from((u128::from(stamp) * to + from / 2) / from).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// Decode and place an audio file. With no target track, a new track of matching width is made.
|
||||
pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> {
|
||||
/// An audio file decoded, resampled to a session rate and given its waveform peaks: everything an
|
||||
/// import costs except placing the clip. Making one needs no engine, so the app makes it on a
|
||||
/// worker thread ([`decode_import`]) and hands it over with [`Engine::offer_import`] before it
|
||||
/// runs `file.import_audio`, which then only places the clip (issue #597).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DecodedImport {
|
||||
pub(crate) key: PathBuf,
|
||||
pub(crate) rate: u32,
|
||||
info: soundcraft_audio_io::AudioInfo,
|
||||
audio: Arc<SourceAudio>,
|
||||
}
|
||||
|
||||
impl DecodedImport {
|
||||
/// Frames at the session rate it was decoded for.
|
||||
pub fn frames(&self) -> usize {
|
||||
self.audio.frames()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and decodes the audio file at `path` for a session at `rate`. Thread-safe and slow (the
|
||||
/// whole file is read, decoded, resampled and given peaks): run it off the UI thread.
|
||||
pub fn decode_import(path: &str, rate: u32) -> Result<DecodedImport> {
|
||||
let bytes = read_file(path)?;
|
||||
let name = Path::new(path).file_name().and_then(OsStr::to_str).unwrap_or("Audio");
|
||||
let (info, audio) = decode_import_bytes(name, &bytes, rate)?;
|
||||
Ok(DecodedImport { key: file_key(Path::new(path)), rate, info, audio })
|
||||
}
|
||||
|
||||
fn decode_import_bytes(name: &str, bytes: &[u8], rate: u32) -> Result<(soundcraft_audio_io::AudioInfo, Arc<SourceAudio>)> {
|
||||
let ext = Path::new(name).extension().and_then(OsStr::to_str).map(str::to_ascii_lowercase);
|
||||
let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
|
||||
let buf = at_rate(buf, e.session().sample_rate.hz(), MAX_IMPORT_SAMPLES)
|
||||
.ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
|
||||
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
|
||||
if buf.frames() == 0 {
|
||||
return Err(EngineError::Io(format!("{name}: no audio")));
|
||||
}
|
||||
let channels = buf.num_channels();
|
||||
Ok((info, Arc::new(SourceAudio::new(buf))))
|
||||
}
|
||||
|
||||
/// Decode and place an audio file. With no target track, a new track of matching width is made.
|
||||
pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> {
|
||||
let (info, audio) = decode_import_bytes(name, bytes, e.session().sample_rate.hz())?;
|
||||
place_import(e, name, &info, audio, path, target, at)
|
||||
}
|
||||
|
||||
/// Place audio from [`decode_import`] (made for this session's rate) as `import_audio_bytes` does.
|
||||
pub(crate) fn place_decoded(e: &mut Engine, name: &str, d: DecodedImport, path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> {
|
||||
place_import(e, name, &d.info, d.audio, path, target, at)
|
||||
}
|
||||
|
||||
fn place_import(
|
||||
e: &mut Engine,
|
||||
name: &str,
|
||||
info: &soundcraft_audio_io::AudioInfo,
|
||||
audio: Arc<SourceAudio>,
|
||||
path: Option<&str>,
|
||||
target: Option<TrackId>,
|
||||
at: Samples,
|
||||
) -> Result<Value> {
|
||||
let channels = audio.buffer.num_channels();
|
||||
let file_rate = info.sample_rate;
|
||||
let s = e.session_mut();
|
||||
let session_rate = s.sample_rate.hz();
|
||||
let src = add_source(s, name, buf, path, info.format);
|
||||
let src = add_source_audio(s, name, audio, path, info.format);
|
||||
if let (Some(bwf), Some(sx)) = (info.bwf.as_ref(), s.sources.last_mut()) {
|
||||
sx.time_reference = stamp_at_rate(bwf.time_reference, file_rate, session_rate);
|
||||
}
|
||||
@@ -676,23 +734,126 @@ pub fn transients_in(s: &Session, t: TrackId, r: Range, sens: f32) -> Vec<Sample
|
||||
soundcraft_dsp::offline::detect_transients(&audio, s.sample_rate.as_f64() as f32, sens).into_iter().map(|i| r.start + i as i64).collect()
|
||||
}
|
||||
|
||||
/// Strip Silence: keep only the non-silent ranges of clips inside `r`.
|
||||
pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> Result<usize> {
|
||||
/// What Strip Silence does with the clips inside the selection once it has found the silence.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum StripMode {
|
||||
/// Remove the silent parts and keep the parts that hold audio.
|
||||
Strip,
|
||||
/// Remove the parts that hold audio and keep the silent parts.
|
||||
Extract,
|
||||
/// Separate the clips at the boundaries of the silent parts without removing any clip.
|
||||
Separate,
|
||||
}
|
||||
|
||||
impl StripMode {
|
||||
pub fn from_id(id: &str) -> Option<Self> {
|
||||
match id {
|
||||
"strip" => Some(Self::Strip),
|
||||
"extract" => Some(Self::Extract),
|
||||
"separate" => Some(Self::Separate),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The names Strip Silence gives the clips it leaves in the selection: `name`, then `zeros` zeros,
|
||||
/// then a number that starts at `number` and counts up one clip at a time, then `suffix`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StripNames {
|
||||
pub name: String,
|
||||
pub number: u64,
|
||||
pub zeros: usize,
|
||||
pub suffix: String,
|
||||
}
|
||||
|
||||
impl Default for StripNames {
|
||||
fn default() -> Self {
|
||||
Self { name: String::new(), number: 1, zeros: 0, suffix: String::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl StripNames {
|
||||
/// The largest first number.
|
||||
pub const MAX_NUMBER: u64 = soundcraft_model::AutoName::MAX_NUMBER;
|
||||
/// The most zeros before the number.
|
||||
pub const MAX_ZEROS: usize = soundcraft_model::AutoName::MAX_ZEROS;
|
||||
|
||||
/// The name of the clip `i` places after the first one.
|
||||
pub fn clip_name(&self, i: u64) -> String {
|
||||
format!("{}{}{}{}", self.name, "0".repeat(self.zeros), self.number.saturating_add(i), self.suffix)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&soundcraft_model::AutoName> for StripNames {
|
||||
fn from(a: &soundcraft_model::AutoName) -> Self {
|
||||
Self { name: a.name.clone(), number: a.number, zeros: a.zeros, suffix: a.suffix.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StripNames> for soundcraft_model::AutoName {
|
||||
fn from(n: StripNames) -> Self {
|
||||
Self { name: n.name, number: n.number, zeros: n.zeros, suffix: n.suffix, id: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Names the audio clips that lie inside `r` on `t` with `names`, in time order, the first of them
|
||||
/// `next` places after `names.number`. Returns how many clips it named.
|
||||
pub fn name_stripped_clips(s: &mut Session, t: TrackId, r: Range, names: &StripNames, next: u64) -> u64 {
|
||||
let r = bounded_to_track(s, t, r);
|
||||
let keep = non_silent(s, t, r, thr, min_len, pre, post)?;
|
||||
let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) else { return 0 };
|
||||
let mut inside: Vec<&mut Clip> = pl.clips.iter_mut().filter(|c| c.is_audio() && c.start >= r.start && c.end() <= r.end).collect();
|
||||
inside.sort_by_key(|c| (c.start, c.id.0));
|
||||
let mut named = 0;
|
||||
for c in inside {
|
||||
c.name = names.clip_name(next.saturating_add(named));
|
||||
named += 1;
|
||||
}
|
||||
named
|
||||
}
|
||||
|
||||
/// Strip Silence: separate the clips inside `r` where silence starts and ends, then keep only the
|
||||
/// non-silent ranges (`Strip`), only the silent ranges (`Extract`), or every piece (`Separate`).
|
||||
/// `pad` is the clip start and end pad added around each non-silent range. Returns the number of
|
||||
/// ranges kept: non-silent ranges for `Strip` and `Separate`, silent ranges that still hold a clip for
|
||||
/// `Extract`. The new pieces that are kept take Auto Rename names in time order, and the removed
|
||||
/// ones do not. When `named` is true the caller names the audio clips left inside `r` with
|
||||
/// [`name_stripped_clips`], so those clips do not take Auto Rename names either.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn strip_silence(
|
||||
s: &mut Session,
|
||||
t: TrackId,
|
||||
r: Range,
|
||||
thr: f32,
|
||||
min_len: Samples,
|
||||
pad: (Samples, Samples),
|
||||
mode: StripMode,
|
||||
named: bool,
|
||||
) -> Result<usize> {
|
||||
let r = bounded_to_track(s, t, r);
|
||||
let keep = non_silent(s, t, r, thr, min_len, pad, &AtomicBool::new(false))?;
|
||||
let mut cut_points = vec![r.start];
|
||||
for (a, b) in &keep {
|
||||
cut_points.push(*a);
|
||||
cut_points.push(*b);
|
||||
}
|
||||
cut_points.push(r.end);
|
||||
let old: std::collections::HashSet<_> = s.track(t).map(|x| x.clips().iter().map(|c| c.id).collect()).unwrap_or_default();
|
||||
for p in &cut_points {
|
||||
crate::edit::separate_at(s, t, *p);
|
||||
crate::edit::separate_at_if(s, t, *p, |_| false);
|
||||
}
|
||||
let kept = strip_pieces(s, t, r, &keep, mode);
|
||||
crate::edit::auto_name_new_clips(s, t, &old, |c| !(named && c.is_audio() && c.start >= r.start && c.end() <= r.end));
|
||||
Ok(kept)
|
||||
}
|
||||
|
||||
/// Removes the pieces that Strip Silence does not keep in `mode` and returns the number of ranges kept.
|
||||
fn strip_pieces(s: &mut Session, t: TrackId, r: Range, keep: &[(Samples, Samples)], mode: StripMode) -> usize {
|
||||
if mode == StripMode::Separate {
|
||||
return keep.len();
|
||||
}
|
||||
// Remove pieces that fall in the gaps.
|
||||
let mut gaps = Vec::new();
|
||||
let mut prev = r.start;
|
||||
for (a, b) in &keep {
|
||||
for (a, b) in keep {
|
||||
if *a > prev {
|
||||
gaps.push(Range::new(prev, *a));
|
||||
}
|
||||
@@ -701,10 +862,56 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S
|
||||
if prev < r.end {
|
||||
gaps.push(Range::new(prev, r.end));
|
||||
}
|
||||
if let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) {
|
||||
pl.clips.retain(|c| !gaps.iter().any(|g| c.start >= g.start && c.end() <= g.end));
|
||||
let audio: Vec<Range> = keep.iter().map(|(a, b)| Range::new(*a, *b)).collect();
|
||||
let remove = if mode == StripMode::Extract { &audio } else { &gaps };
|
||||
let inside = |c: &Clip, g: &Range| c.start >= g.start && c.end() <= g.end;
|
||||
let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) else { return if mode == StripMode::Extract { 0 } else { keep.len() } };
|
||||
pl.clips.retain(|c| !remove.iter().any(|g| inside(c, g)));
|
||||
if mode == StripMode::Extract {
|
||||
return gaps.iter().filter(|g| pl.clips.iter().any(|c| inside(c, g))).count();
|
||||
}
|
||||
keep.len()
|
||||
}
|
||||
|
||||
/// The ranges [`strip_silence`] finds on `t` inside `r` with the same settings: the non-silent ranges
|
||||
/// with `pad` added, which Strip keeps. The session is read and not changed. Setting `stop` ends the
|
||||
/// scan with an error before the next window of audio is rendered.
|
||||
pub fn strip_silence_ranges(
|
||||
s: &Session,
|
||||
t: TrackId,
|
||||
r: Range,
|
||||
thr: f32,
|
||||
min_len: Samples,
|
||||
pad: (Samples, Samples),
|
||||
stop: &AtomicBool,
|
||||
) -> Result<Vec<Range>> {
|
||||
let r = bounded_to_track(s, t, r);
|
||||
Ok(non_silent(s, t, r, thr, min_len, pad, stop)?.into_iter().map(|(a, b)| Range::new(a, b)).collect())
|
||||
}
|
||||
|
||||
/// A Strip Silence scan of a session's tracks, which can run on another thread
|
||||
/// (`cmd::strip_silence_scan` makes one from Strip Silence's params).
|
||||
#[derive(Clone)]
|
||||
pub struct StripScan {
|
||||
pub session: Arc<Session>,
|
||||
pub tracks: Vec<TrackId>,
|
||||
pub range: Range,
|
||||
pub threshold_db: f32,
|
||||
pub min_len: Samples,
|
||||
pub pad: (Samples, Samples),
|
||||
/// Set from another thread to end [`StripScan::run`] early, with an error.
|
||||
pub stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl StripScan {
|
||||
/// The ranges Strip keeps on each track ([`strip_silence_ranges`]).
|
||||
pub fn run(&self) -> Result<Vec<(TrackId, Vec<Range>)>> {
|
||||
let s = &self.session;
|
||||
self.tracks
|
||||
.iter()
|
||||
.map(|t| Ok((*t, strip_silence_ranges(s, *t, self.range, self.threshold_db, self.min_len, self.pad, &self.stop)?)))
|
||||
.collect()
|
||||
}
|
||||
Ok(keep.len())
|
||||
}
|
||||
|
||||
/// Most samples (over all channels) Strip Silence renders at once.
|
||||
@@ -712,8 +919,17 @@ const STRIP_WINDOW_SAMPLES: usize = 1 << 24;
|
||||
|
||||
/// The non-silent ranges of `t`'s clips in `r`. The audio is rendered a window at a time where
|
||||
/// audio clips play, so all of `r` is scanned however long it is (one render stops at
|
||||
/// `MAX_RENDER_SAMPLES`); at most a day of clips, Pro Tools' longest session.
|
||||
fn non_silent(s: &Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> Result<Vec<(Samples, Samples)>> {
|
||||
/// `MAX_RENDER_SAMPLES`); at most a day of clips, Pro Tools' longest session. `pad` is the clip start
|
||||
/// and end pad. Setting `stop` ends the scan with an error before the next window is rendered.
|
||||
fn non_silent(
|
||||
s: &Session,
|
||||
t: TrackId,
|
||||
r: Range,
|
||||
thr: f32,
|
||||
min_len: Samples,
|
||||
(pre, post): (Samples, Samples),
|
||||
stop: &AtomicBool,
|
||||
) -> Result<Vec<(Samples, Samples)>> {
|
||||
let Some(tr) = s.track(t) else { return Ok(Vec::new()) };
|
||||
let mut clips: Vec<Range> = tr.clips().iter().filter(|c| c.is_audio() && !c.muted).filter_map(|c| c.range().intersect(&r)).collect();
|
||||
clips.sort_by_key(|x| x.start);
|
||||
@@ -735,6 +951,9 @@ fn non_silent(s: &Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre
|
||||
scan.skip(len(x.start - at));
|
||||
at = x.start;
|
||||
while at < x.end {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
return Err(EngineError::Disabled("edit.strip_silence_preview".into(), "the scan was stopped".into()));
|
||||
}
|
||||
let end = at.saturating_add(window).min(x.end);
|
||||
scan.push(&soundcraft_mix::render_clips(s, t, Range::new(at, end)));
|
||||
at = end;
|
||||
@@ -829,12 +1048,27 @@ pub fn commit_track(e: &mut Engine, t: TrackId) -> Result<Option<TrackId>> {
|
||||
if end <= 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let range = Range::new(0, end + s.sample_rate.samples(2.0));
|
||||
// The track renders through a main mix of at least two channels.
|
||||
refuse_longer(s, "track.commit", &tr.name, "commit", range.len(), render_limit(tr.channels().max(2)))?;
|
||||
let fmt = tr.format;
|
||||
if fmt.channels() > soundcraft_mix::MAX_CHANNELS {
|
||||
return Err(EngineError::BadParams(
|
||||
"track.commit".into(),
|
||||
format!("track commit supports at most {} channels; cannot render {}", soundcraft_mix::MAX_CHANNELS, fmt.label()),
|
||||
));
|
||||
}
|
||||
let too_long = || EngineError::BadParams("track.commit".into(), format!("`{}` is too long to commit within the offline render limit", tr.name));
|
||||
let end = end.checked_add(s.sample_rate.samples(2.0)).ok_or_else(too_long)?;
|
||||
let frames = usize::try_from(end).map_err(|_| too_long())?;
|
||||
// The shared renderer uses a stereo main mix for mono, then folds it back to mono.
|
||||
// Reject requests it would truncate before rendering or replacing the source track.
|
||||
if frames > soundcraft_mix::MAX_RENDER_SAMPLES / fmt.channels().max(2) {
|
||||
return Err(too_long());
|
||||
}
|
||||
let range = Range::new(0, end);
|
||||
let audio = soundcraft_mix::render_track_pre_fader(s, t, range);
|
||||
if audio.len() != fmt.channels() || audio.iter().any(|channel| channel.len() != frames) {
|
||||
return Err(EngineError::Io("track commit did not render the complete channel format and range".into()));
|
||||
}
|
||||
let name = format!("{}.cm", tr.name);
|
||||
let fmt = ChannelFormat::for_channels(audio.len().clamp(1, 2));
|
||||
let mixer = tr.mixer.clone();
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
|
||||
let s = e.session_mut();
|
||||
@@ -1104,7 +1338,7 @@ pub fn replaces(e: &Engine, command: &str, file: &Path) -> bool {
|
||||
"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(),
|
||||
"session.save_template" | "file.export_selected_tracks_as_session" | "file.export_session_text" => file.exists(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -1208,9 +1442,15 @@ pub(crate) fn decode_file(files: &mut DecodedFiles, p: &Path, rate: u32) -> std:
|
||||
/// 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;
|
||||
/// Most entries and bytes a bundle may unpack to (bundles are hostile input). The writer keeps to
|
||||
/// them too, so a bundle it saves always opens again (#433).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct BundleLimits {
|
||||
entries: usize,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
const BUNDLE_LIMITS: BundleLimits = BundleLimits { entries: 10_000, bytes: 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.
|
||||
@@ -1239,6 +1479,11 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
/// `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)> {
|
||||
session_bundle_within(session, BUNDLE_LIMITS)
|
||||
}
|
||||
|
||||
/// [`session_bundle`], refused when it would hold more than `limits` allows.
|
||||
fn session_bundle_within(session: &Session, limits: BundleLimits) -> 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}"));
|
||||
@@ -1246,6 +1491,20 @@ pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
|
||||
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();
|
||||
let (mut entries, mut total) = (0usize, 0u64);
|
||||
// Counted as the reader counts them: files, and the bytes they unpack to.
|
||||
let mut fits = |len: usize| {
|
||||
entries += 1;
|
||||
total = total.saturating_add(len as u64);
|
||||
if entries > limits.entries || total > limits.bytes {
|
||||
return Err(EngineError::Io(format!(
|
||||
"bundle: too large to save (a bundle holds at most {} files and {} MiB, or it can't be opened again)",
|
||||
limits.entries,
|
||||
limits.bytes >> 20
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
for src in &mut s.sources {
|
||||
let Some(audio) = s.pool.get(src.id) else { continue };
|
||||
let base = sanitize_name(&src.name);
|
||||
@@ -1257,6 +1516,7 @@ pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
|
||||
}
|
||||
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()))?;
|
||||
fits(bytes.len())?;
|
||||
w.start_file(target.as_str(), opts).map_err(zerr)?;
|
||||
w.write_all(&bytes).map_err(ioerr)?;
|
||||
taken.insert(target.clone());
|
||||
@@ -1265,6 +1525,7 @@ pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
|
||||
src.format = FileFormat::Wav;
|
||||
}
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
fits(text.len())?;
|
||||
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();
|
||||
@@ -1293,7 +1554,7 @@ fn save_session_bundle(e: &mut Engine, path: &str) -> Result<usize> {
|
||||
/// [`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)?
|
||||
read_bundle(bytes, BUNDLE_LIMITS)?
|
||||
} else {
|
||||
(
|
||||
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))?,
|
||||
@@ -1341,11 +1602,11 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
|
||||
}
|
||||
|
||||
/// 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>>)> {
|
||||
fn read_bundle(bytes: &[u8], limits: BundleLimits) -> 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 {
|
||||
if z.len() > limits.entries {
|
||||
return Err(EngineError::Io("bundle: too many files".into()));
|
||||
}
|
||||
let (mut text, mut files, mut total) = (None, std::collections::HashMap::new(), 0u64);
|
||||
@@ -1357,7 +1618,7 @@ fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap<String
|
||||
let rel = f.name().map_err(zerr)?.to_string();
|
||||
let size = f.size();
|
||||
total = total.saturating_add(size);
|
||||
if total > MAX_BUNDLE_BYTES {
|
||||
if total > limits.bytes {
|
||||
return Err(EngineError::Io("bundle: too large".into()));
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
@@ -1451,13 +1712,18 @@ fn add_recording_in(
|
||||
if s.track(tid).is_none() {
|
||||
continue;
|
||||
}
|
||||
let resampled = buf.sample_rate != s.sample_rate.hz();
|
||||
let hz = s.sample_rate.hz();
|
||||
let resampled = buf.sample_rate != hz;
|
||||
let mut buf = buf;
|
||||
if resampled && buf.sample_rate > 0 {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, hz);
|
||||
buf.sample_rate = hz;
|
||||
}
|
||||
let name = path.file_stem().and_then(OsStr::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 len = i64::try_from(buf.frames()).unwrap_or(0);
|
||||
let pass = pass.unwrap_or(len).max(1);
|
||||
// Work out what each pass keeps first, so a take nothing is placed from isn't registered.
|
||||
let mut kept_passes = Vec::new();
|
||||
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 {
|
||||
@@ -1470,6 +1736,16 @@ fn add_recording_in(
|
||||
}
|
||||
None => (start, 0, n),
|
||||
};
|
||||
kept_passes.push((offset, clip_start, cut, clip_len));
|
||||
}
|
||||
if kept_passes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let src = add_source(s, name, buf, Some(&path.to_string_lossy()), FileFormat::Wav);
|
||||
if let Some(x) = s.sources.last_mut() {
|
||||
x.unsaved = resampled;
|
||||
}
|
||||
for (offset, clip_start, cut, clip_len) in kept_passes {
|
||||
if offset > 0
|
||||
&& let Some(t) = s.track_mut(tid)
|
||||
{
|
||||
@@ -1583,6 +1859,39 @@ mod record_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_punch_that_misses_the_take_leaves_the_session_alone() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-punch-miss-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut e = Engine::default();
|
||||
let vox = armed(&mut e, "Vox", "mono", "In 1");
|
||||
let take = dir.join("Audio Files/Take.wav");
|
||||
write_take(&take, 1_000);
|
||||
let shared = add_source(
|
||||
e.session_mut(),
|
||||
"Take",
|
||||
AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 100]] },
|
||||
Some(&take.to_string_lossy()),
|
||||
FileFormat::Wav,
|
||||
);
|
||||
let sources = e.session().sources.len();
|
||||
let pool = e.session().pool.len();
|
||||
let plan = Recording {
|
||||
start: 1_000,
|
||||
pass: None,
|
||||
punch: Some(Range::new(5_000, 6_000)),
|
||||
rate: 48_000,
|
||||
takes: vec![Take { track: vox, path: take.clone(), channels: 1 }],
|
||||
};
|
||||
let placed = place_recording(&mut e, &plan, vec![(vox, take)]);
|
||||
assert!(placed.is_empty());
|
||||
assert_eq!(e.session().sources.len(), sources);
|
||||
assert_eq!(e.session().pool.len(), pool);
|
||||
assert!(e.session().sources.iter().any(|x| x.id == shared));
|
||||
assert!(e.session().pool.contains(shared));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_take_shows_the_pass_under_way_within_the_punch_while_it_records() {
|
||||
assert_eq!(live_span(1_000, None, None, 0), None);
|
||||
@@ -1705,7 +2014,7 @@ mod save_tests {
|
||||
let mut r = Engine::default();
|
||||
assert_eq!(
|
||||
open_session(&mut r, &path("B/Moved.scraft")).unwrap(),
|
||||
vec![dir.join("A").join("Audio Files/Audio 1_02.wav").display().to_string()]
|
||||
vec![dir.join("A").join("Audio Files").join("Audio 1_02.wav").display().to_string()]
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -2056,6 +2365,49 @@ mod stem_tests {
|
||||
assert_eq!(n, 7, "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The largest gap between the 16-bit stem in `dir16` and the 32-bit float one in `dir32`, in LSBs.
|
||||
fn max_error_lsb(dir16: &std::path::Path, dir32: &std::path::Path) -> f64 {
|
||||
let mut worst = 0.0f64;
|
||||
let mut names: Vec<_> = std::fs::read_dir(dir32).unwrap().map(|f| f.unwrap().file_name()).collect();
|
||||
names.sort();
|
||||
assert!(!names.is_empty());
|
||||
for name in names {
|
||||
let (_, float) = soundcraft_audio_io::decode(&std::fs::read(dir32.join(&name)).unwrap(), None).unwrap();
|
||||
let (_, int) = soundcraft_audio_io::decode(&std::fs::read(dir16.join(&name)).unwrap(), None).unwrap();
|
||||
for (f, i) in float.channels.iter().zip(&int.channels) {
|
||||
for (a, b) in f.iter().zip(i) {
|
||||
worst = worst.max((f64::from(a.clamp(-1.0, 1.0)) - f64::from(*b)).abs() * 32768.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stems_dither_only_when_asked() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let root = std::env::temp_dir().join(format!("soundcraft-stems-dither-{}", std::process::id()));
|
||||
let mut bounce = |name: &str, extra: serde_json::Value| {
|
||||
let dir = root.join(name);
|
||||
let mut p = serde_json::json!({"dir": dir.to_string_lossy(), "start": {"seconds": 10.0}, "end": {"seconds": 10.5}});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
e.execute("file.bounce_stems", &p).unwrap();
|
||||
dir
|
||||
};
|
||||
let float = bounce("float", serde_json::json!({"bit_depth": "32f"}));
|
||||
let plain = bounce("plain", serde_json::json!({"bit_depth": "16", "dither": false}));
|
||||
let dithered = bounce("dithered", serde_json::json!({"bit_depth": "16"}));
|
||||
// Without dither every sample is the float one rounded to the nearest step; TPDF dither of
|
||||
// ±1 LSB moves some of them further than half a step.
|
||||
let plain_error = max_error_lsb(&plain, &float);
|
||||
let dithered_error = max_error_lsb(&dithered, &float);
|
||||
assert!(plain_error <= 0.5 + 1e-3, "{plain_error}");
|
||||
assert!(dithered_error > 0.6, "{dithered_error}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2087,6 +2439,189 @@ mod strip_tests {
|
||||
assert!(e.session().track(t).unwrap().clips().iter().any(|c| c.start >= far));
|
||||
}
|
||||
|
||||
/// A stereo track with one three-second clip: a second of tone, a second of silence, a second of tone.
|
||||
fn tone_gap_tone() -> (Engine, TrackId) {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"format": "stereo"})).unwrap();
|
||||
let t = e.session().tracks[0].id;
|
||||
let s = e.session_mut();
|
||||
let mut ch = vec![0.5; 144_000];
|
||||
ch[48_000..96_000].fill(0.0);
|
||||
let src = add_source(s, "tone gap tone", AudioBuffer { sample_rate: 48_000, channels: vec![ch.clone(), ch] }, None, FileFormat::Wav);
|
||||
let id = s.new_clip_id();
|
||||
crate::edit::place_clip(s, t, Clip::audio(id, "tone gap tone", src, 0, 0, 144_000));
|
||||
(e, t)
|
||||
}
|
||||
|
||||
fn strip_mode(mode: &str) -> (Value, Vec<(Samples, Samples)>) {
|
||||
let (mut e, t) = tone_gap_tone();
|
||||
let p = json!({"track": t.0, "start": 0, "end": 144_000, "pad_before_ms": 0, "pad_after_ms": 0, "mode": mode});
|
||||
let r = e.execute("edit.strip_silence", &p).unwrap();
|
||||
(r, e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_keeps_the_audio_and_removes_the_silence() {
|
||||
let (r, clips) = strip_mode("strip");
|
||||
assert_eq!(r["clips"], 2);
|
||||
assert_eq!(clips, vec![(0, 48_000), (96_000, 144_000)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_keeps_the_silence_and_removes_the_audio() {
|
||||
let (r, clips) = strip_mode("extract");
|
||||
assert_eq!(r["clips"], 1);
|
||||
assert_eq!(clips, vec![(48_000, 96_000)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_cuts_at_the_silence_and_removes_nothing() {
|
||||
let (r, clips) = strip_mode("separate");
|
||||
assert_eq!(r["clips"], 2);
|
||||
assert_eq!(clips, vec![(0, 48_000), (48_000, 96_000), (96_000, 144_000)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rectangles_are_what_strip_keeps_and_change_nothing() {
|
||||
let (mut e, t) = tone_gap_tone();
|
||||
let p = json!({"track": t.0, "start": 0, "end": 144_000, "pad_before_ms": 10, "pad_after_ms": 20});
|
||||
let rev = e.revision;
|
||||
let r = e.execute("edit.strip_silence_preview", &p).unwrap();
|
||||
assert_eq!(e.revision, rev, "the preview changed the session");
|
||||
assert_eq!(e.session().track(t).unwrap().clips().len(), 1);
|
||||
assert_eq!(r["tracks"][0]["track"], t.0);
|
||||
let ranges: Vec<(Samples, Samples)> =
|
||||
r["tracks"][0]["ranges"].as_array().unwrap().iter().map(|x| (x[0].as_i64().unwrap(), x[1].as_i64().unwrap())).collect();
|
||||
assert_eq!(ranges, vec![(0, 48_960), (95_520, 144_000)]);
|
||||
e.execute("edit.strip_silence", &p).unwrap();
|
||||
let kept: Vec<(Samples, Samples)> = e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect();
|
||||
assert_eq!(kept, ranges);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_scan_ends_with_an_error() {
|
||||
let (e, t) = tone_gap_tone();
|
||||
let scan = crate::cmd::strip_silence_scan(&e, &json!({"track": t.0, "start": 0, "end": 144_000})).unwrap();
|
||||
assert_eq!(scan.run().unwrap()[0].1.len(), 2);
|
||||
scan.stop.store(true, Ordering::Relaxed);
|
||||
assert!(scan.run().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rectangles_follow_the_threshold() {
|
||||
let (mut e, t) = tone_gap_tone();
|
||||
// The tone plays at -6 dB.
|
||||
let r = e.execute("edit.strip_silence_preview", &json!({"track": t.0, "start": 0, "end": 144_000, "threshold_db": -3})).unwrap();
|
||||
assert_eq!(r["tracks"][0]["ranges"], json!([]));
|
||||
let r = e.execute("edit.strip_silence_preview", &json!({"track": t.0, "start": 0, "end": 144_000, "threshold_db": -12})).unwrap();
|
||||
assert_eq!(r["tracks"][0]["ranges"].as_array().map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_strip_mode_is_refused() {
|
||||
let (mut e, t) = tone_gap_tone();
|
||||
for mode in [json!("remove"), json!(null), json!(true), json!(1)] {
|
||||
assert!(e.execute("edit.strip_silence", &json!({"track": t.0, "start": 0, "end": 144_000, "mode": mode})).is_err());
|
||||
assert_eq!(e.session().track(t).unwrap().clips().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn names(e: &Engine, t: TrackId) -> Vec<String> {
|
||||
e.session().track(t).unwrap().clips().iter().map(|c| c.name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_names_follow_the_guide_example() {
|
||||
let n = StripNames { name: "SFX".into(), number: 23, zeros: 1, suffix: ".Reel1".into() };
|
||||
assert_eq!((0..4).map(|i| n.clip_name(i)).collect::<Vec<_>>(), ["SFX023.Reel1", "SFX024.Reel1", "SFX025.Reel1", "SFX026.Reel1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_names_the_clips_it_keeps() {
|
||||
let (mut e, t) = tone_gap_tone();
|
||||
let p = json!({"track": t.0, "start": 0, "end": 144_000, "pad_before_ms": 0, "pad_after_ms": 0, "name": "SFX", "number": 23, "zeros": 1, "suffix": ".Reel1"});
|
||||
let r = e.execute("edit.strip_silence", &p).unwrap();
|
||||
assert_eq!(r["named"], 2);
|
||||
assert_eq!(names(&e, t), ["SFX023.Reel1", "SFX024.Reel1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_names_every_piece_in_time_order() {
|
||||
let (mut e, t) = tone_gap_tone();
|
||||
let p = json!({"track": t.0, "start": 0, "end": 144_000, "pad_before_ms": 0, "pad_after_ms": 0, "mode": "separate", "name": "Take "});
|
||||
e.execute("edit.strip_silence", &p).unwrap();
|
||||
assert_eq!(names(&e, t), ["Take 1", "Take 2", "Take 3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_numbers_run_on_from_one_track_to_the_next() {
|
||||
let (mut e, a) = tone_gap_tone();
|
||||
e.execute("track.new", &json!({"format": "stereo"})).unwrap();
|
||||
let b = e.session().tracks[1].id;
|
||||
let s = e.session_mut();
|
||||
let src = s.sources[0].id;
|
||||
let id = s.new_clip_id();
|
||||
crate::edit::place_clip(s, b, Clip::audio(id, "copy", src, 0, 0, 144_000));
|
||||
let p = json!({"tracks": [a.0, b.0], "start": 0, "end": 144_000, "pad_before_ms": 0, "pad_after_ms": 0, "name": "Hit", "zeros": 2});
|
||||
let r = e.execute("edit.strip_silence", &p).unwrap();
|
||||
assert_eq!(r["named"], 4);
|
||||
assert_eq!(names(&e, a), ["Hit001", "Hit002"]);
|
||||
assert_eq!(names(&e, b), ["Hit003", "Hit004"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_without_a_name_keeps_the_clip_names() {
|
||||
let (mut e, t) = tone_gap_tone();
|
||||
let r = e.execute("edit.strip_silence", &json!({"track": t.0, "start": 0, "end": 144_000, "name": " "})).unwrap();
|
||||
assert_eq!(r["named"], 0);
|
||||
assert_eq!(names(&e, t), ["tone gap tone", "tone gap tone"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_names_leave_the_auto_rename_sequence_unbroken() {
|
||||
let (mut e, t) = tone_gap_tone();
|
||||
let id = e.session().track(t).unwrap().clips()[0].id;
|
||||
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "Take"})).unwrap();
|
||||
let p = json!({"track": t.0, "start": 0, "end": 120_000, "pad_before_ms": 0, "pad_after_ms": 0, "mode": "separate", "name": "SFX"});
|
||||
assert_eq!(e.execute("edit.strip_silence", &p).unwrap()["named"], 3);
|
||||
// Strip Silence does not name the piece after the range, so that piece takes the first Auto Rename name.
|
||||
assert_eq!(names(&e, t), ["SFX1", "SFX2", "SFX3", "Take1"]);
|
||||
e.execute("edit.separate", &json!({"tracks": [t], "at": 132_000})).unwrap();
|
||||
assert_eq!(names(&e, t)[4], "Take2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_pieces_strip_silence_keeps_take_auto_rename_numbers() {
|
||||
for (mode, kept) in [("strip", ["tone gap tone", "Take1", "Take2"].as_slice()), ("extract", ["Take1", "Take2"].as_slice())] {
|
||||
let (mut e, t) = tone_gap_tone();
|
||||
let id = e.session().track(t).unwrap().clips()[0].id;
|
||||
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "Take"})).unwrap();
|
||||
let p = json!({"track": t.0, "start": 0, "end": 120_000, "pad_before_ms": 0, "pad_after_ms": 0, "mode": mode});
|
||||
e.execute("edit.strip_silence", &p).unwrap();
|
||||
assert_eq!(names(&e, t), kept, "{mode}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_naming_params_are_refused() {
|
||||
for bad in [
|
||||
json!({"suffix": ".Reel1"}),
|
||||
json!({"name": "", "number": 2}),
|
||||
json!({"name": 7}),
|
||||
json!({"name": "SFX", "number": -1}),
|
||||
json!({"name": "SFX", "number": StripNames::MAX_NUMBER + 1}),
|
||||
json!({"name": "SFX", "zeros": StripNames::MAX_ZEROS + 1}),
|
||||
json!({"name": "SFX", "suffix": null}),
|
||||
json!({"name": "S".repeat(300)}),
|
||||
] {
|
||||
let (mut e, t) = tone_gap_tone();
|
||||
let mut p = json!({"track": t.0, "start": 0, "end": 144_000});
|
||||
p.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
|
||||
assert!(e.execute("edit.strip_silence", &p).is_err(), "{bad} was taken");
|
||||
assert_eq!(names(&e, t), ["tone gap tone"], "{bad} changed the track");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_than_a_day_of_clips_is_refused() {
|
||||
let (mut e, t) = tones(&[0], 48_000 * 86_401);
|
||||
@@ -2124,7 +2659,7 @@ mod bundle_tests {
|
||||
use std::io::Write as _;
|
||||
let e = crate::demo::demo_engine();
|
||||
let (bytes, _) = session_bundle(e.session()).unwrap();
|
||||
let (text, files) = read_bundle(&bytes).unwrap();
|
||||
let (text, files) = read_bundle(&bytes, BUNDLE_LIMITS).unwrap();
|
||||
let mut s = Session::from_json(&text).unwrap();
|
||||
s.sources[1].path = s.sources[0].path.clone();
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
@@ -2144,6 +2679,21 @@ mod bundle_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bundle_is_saved_only_if_it_opens_again() {
|
||||
// #433: the writer keeps to the reader's limits.
|
||||
let e = crate::demo::demo_engine();
|
||||
let (bytes, _) = session_bundle(e.session()).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let entries = z.len();
|
||||
let size: u64 = (0..entries).map(|i| z.by_index(i).unwrap().size()).sum();
|
||||
let exact = BundleLimits { entries, bytes: size };
|
||||
let (at_limit, _) = session_bundle_within(e.session(), exact).unwrap();
|
||||
assert!(read_bundle(&at_limit, exact).is_ok(), "what the writer takes, the reader opens");
|
||||
assert!(session_bundle_within(e.session(), BundleLimits { entries: entries - 1, ..exact }).is_err());
|
||||
assert!(session_bundle_within(e.session(), BundleLimits { bytes: size - 1, ..exact }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_named_sources_get_distinct_files() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
||||
@@ -33,6 +33,22 @@ pub fn shutdown_plugin_hosts() {
|
||||
soundcraft_vst3_host::shutdown();
|
||||
}
|
||||
|
||||
/// A new [`soundcraft_model::AutoName::id`] for a scheme in `s`: a hash of a counter, never 0 and
|
||||
/// not held by another scheme in `s`. Native builds seed the hash from the system, so each run of
|
||||
/// the app gives other ids.
|
||||
pub fn new_scheme_id(s: &Session) -> u64 {
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
loop {
|
||||
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
|
||||
h.write_u64(NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
|
||||
let id = h.finish();
|
||||
if id != 0 && !s.auto_names.values().any(|a| a.id == id) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
#[error("unknown command `{0}`")]
|
||||
@@ -94,6 +110,8 @@ pub struct Clipboard {
|
||||
pub grid_offset: Samples,
|
||||
/// Copy Special › Automation: pasting it leaves the clips alone, in Shuffle mode too.
|
||||
pub automation_only: bool,
|
||||
/// The Auto Rename schemes of the copied clips, keyed as in their `auto_name`.
|
||||
pub auto_names: std::collections::BTreeMap<u64, soundcraft_model::AutoName>,
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
@@ -102,6 +120,39 @@ impl Clipboard {
|
||||
let line = s.edit.grid.line_at_or_before(range.start, s.sample_rate, &s.tempo, s.frame_rate);
|
||||
Clipboard { length: range.len(), grid_offset: range.start.saturating_sub(line), ..Clipboard::default() }
|
||||
}
|
||||
|
||||
/// Keep a copy of the Auto Rename schemes that the copied clips use, as `s` holds them.
|
||||
pub fn keep_auto_names(&mut self, s: &Session) {
|
||||
self.auto_names = self.tracks.iter().flatten().filter_map(|c| c.auto_name).filter_map(|k| Some((k, s.auto_names.get(&k)?.clone()))).collect();
|
||||
}
|
||||
|
||||
/// Before a paste into `s`: point the copied clips at schemes that `s` holds. A scheme that `s`
|
||||
/// holds with the same [`AutoName::id`](soundcraft_model::AutoName::id), name, zeros and suffix
|
||||
/// is used as it is, and its numbers count on. Any other goes into `s` under a new key, counting
|
||||
/// on from where it was when it was copied.
|
||||
pub fn adopt_auto_names(&mut self, s: &mut Session) {
|
||||
let mut keys = std::collections::BTreeMap::new();
|
||||
for (k, a) in &self.auto_names {
|
||||
let held = s
|
||||
.auto_names
|
||||
.iter()
|
||||
.find(|(_, h)| a.id != 0 && h.id == a.id && h.name == a.name && h.zeros == a.zeros && h.suffix == a.suffix)
|
||||
.map(|(key, _)| *key);
|
||||
let key = match held {
|
||||
Some(key) => key,
|
||||
None => {
|
||||
let key = s.alloc();
|
||||
s.auto_names.insert(key, a.clone());
|
||||
key
|
||||
}
|
||||
};
|
||||
keys.insert(*k, key);
|
||||
}
|
||||
for c in self.tracks.iter_mut().flatten() {
|
||||
c.auto_name = c.auto_name.and_then(|k| keys.get(&k).copied());
|
||||
}
|
||||
self.auto_names = keys.values().filter_map(|k| Some((*k, s.auto_names.get(k)?.clone()))).collect();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -110,6 +161,9 @@ struct HistoryEntry {
|
||||
doc: Arc<Session>,
|
||||
}
|
||||
|
||||
/// Most decoded imports [`Engine::offer_import`] holds at once (each can be gigabytes).
|
||||
const MAX_OFFERED_IMPORTS: usize = 4;
|
||||
|
||||
/// Engine state: the document, history, clipboard and transport plumbing.
|
||||
pub struct Engine {
|
||||
doc: Arc<Session>,
|
||||
@@ -139,6 +193,8 @@ pub struct Engine {
|
||||
/// The edit selection (range, tracks) before the last change, for Restore Last Selection.
|
||||
/// Kept out of the document so selecting never marks the session modified.
|
||||
pub last_selection: Option<(soundcraft_time::Range, Vec<TrackId>)>,
|
||||
/// Audio decoded off the UI thread, waiting for its `file.import_audio` (see `offer_import`).
|
||||
imports: Vec<io::DecodedImport>,
|
||||
}
|
||||
|
||||
impl Default for Engine {
|
||||
@@ -169,9 +225,33 @@ impl Engine {
|
||||
merge_hold: None,
|
||||
messages: Vec::new(),
|
||||
last_selection: None,
|
||||
imports: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands over audio [`io::decode_import`] made off the UI thread: the next `file.import_audio`
|
||||
/// of the same file at the session's rate places it instead of reading the file again. The
|
||||
/// command still checks the path may be read. Only the few newest are kept.
|
||||
pub fn offer_import(&mut self, d: io::DecodedImport) {
|
||||
self.imports.retain(|x| x.key != d.key);
|
||||
self.imports.push(d);
|
||||
let extra = self.imports.len().saturating_sub(MAX_OFFERED_IMPORTS);
|
||||
self.imports.drain(..extra);
|
||||
}
|
||||
|
||||
/// Drops audio offered for imports that never ran (the command failed or was not sent).
|
||||
pub fn drop_offered_imports(&mut self) {
|
||||
self.imports.clear();
|
||||
}
|
||||
|
||||
/// The offered audio of the file at `path`, if it was decoded for the session's current rate.
|
||||
pub(crate) fn take_import(&mut self, path: &std::path::Path) -> Option<io::DecodedImport> {
|
||||
let key = io::file_key(path);
|
||||
let rate = self.session().sample_rate.hz();
|
||||
let i = self.imports.iter().position(|d| d.key == key && d.rate == rate)?;
|
||||
Some(self.imports.remove(i))
|
||||
}
|
||||
|
||||
pub fn session(&self) -> &Session {
|
||||
&self.doc
|
||||
}
|
||||
@@ -316,6 +396,7 @@ impl Engine {
|
||||
let before = Arc::clone(&self.doc);
|
||||
let rev = self.revision;
|
||||
let dirty = self.dirty;
|
||||
let side_effects = (self.clipboard.clone(), self.messages.clone(), self.transport_requests.clone(), self.last_selection.clone());
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
|
||||
let result = match result {
|
||||
Ok(r) => r,
|
||||
@@ -323,6 +404,7 @@ impl Engine {
|
||||
self.doc = before;
|
||||
self.revision = rev.wrapping_add(1);
|
||||
self.dirty = dirty;
|
||||
(self.clipboard, self.messages, self.transport_requests, self.last_selection) = side_effects;
|
||||
return Err(EngineError::Internal(id.to_string(), panic_message(&*p)));
|
||||
}
|
||||
};
|
||||
@@ -394,6 +476,11 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// The gesture `execute_merged` is continuing, if any.
|
||||
pub fn merge_key(&self) -> Option<&str> {
|
||||
self.merge_key.as_deref()
|
||||
}
|
||||
|
||||
/// End a gesture started with `execute_merged`.
|
||||
pub fn end_merge(&mut self) {
|
||||
self.merge_key = None;
|
||||
@@ -521,3 +608,26 @@ mod dirty_tests {
|
||||
assert!(!e.is_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod panic_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_panicking_command_restores_side_effect_state() {
|
||||
let mut e = Engine::default();
|
||||
// Non-default values, so clearing them instead of restoring them would fail.
|
||||
e.clipboard.length = 42;
|
||||
e.messages.push("prior".into());
|
||||
e.transport_requests.push(TransportRequest::Stop);
|
||||
e.last_selection = Some((soundcraft_time::Range::new(20, 30), Vec::new()));
|
||||
let (clipboard, messages, requests, selection) =
|
||||
(e.clipboard.clone(), e.messages.clone(), e.transport_requests.clone(), e.last_selection.clone());
|
||||
let r = e.execute("test.panic_probe", &json!({"panic": true}));
|
||||
assert!(matches!(r, Err(EngineError::Internal(..))));
|
||||
assert_eq!(e.clipboard, clipboard);
|
||||
assert_eq!(e.messages, messages);
|
||||
assert_eq!(e.transport_requests, requests);
|
||||
assert_eq!(e.last_selection, selection);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,58 @@ use soundcraft_engine::io::{Bounce, bounce_stems};
|
||||
use soundcraft_time::Range;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn float_bounce_preserves_headroom_and_reports_the_written_levels() {
|
||||
let mut e = soundcraft_engine::Engine::default();
|
||||
let sr = e.session().sample_rate.hz();
|
||||
let frames = sr as usize + 17;
|
||||
let samples: Vec<f32> = (0..frames).map(|i| 1.5 * (std::f32::consts::TAU * 440.0 * i as f32 / sr as f32).sin()).collect();
|
||||
let audio = soundcraft_audio_io::AudioBuffer { sample_rate: sr, channels: vec![samples.clone(), samples] };
|
||||
let s = e.session_mut();
|
||||
// A new track has no inserts, so no limiter can mask encoder clipping.
|
||||
let track = s.add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Stereo, Some("Headroom"));
|
||||
let source = soundcraft_engine::io::add_source(s, "headroom", audio, None, FileFormat::Wav);
|
||||
let clip = soundcraft_model::Clip::audio(s.new_clip_id(), "headroom", source, 0, 0, frames as i64);
|
||||
soundcraft_engine::edit::place_clip(s, track, clip);
|
||||
let mix = soundcraft_mix::render_range(e.session(), Range::new(0, frames as i64), 1024);
|
||||
assert!(soundcraft_dsp::offline::peak(&mix) > 1.0);
|
||||
let dir = scratch("bounce-float-headroom");
|
||||
for extension in ["wav", "aiff"] {
|
||||
for normalize in [false, true] {
|
||||
let path = dir.join(format!("Mix.{extension}"));
|
||||
let result = e
|
||||
.execute(
|
||||
"file.bounce_mix",
|
||||
&serde_json::json!({
|
||||
"path": path, "bit_depth": "32f", "dither": false, "normalize": normalize,
|
||||
"start": 0, "end": frames,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let (_, file) = soundcraft_audio_io::decode(&bytes, None).unwrap();
|
||||
let mut expected = mix.clone();
|
||||
if normalize {
|
||||
soundcraft_dsp::offline::normalize(&mut expected, -0.1, false);
|
||||
assert!(file.peak() < 1.0);
|
||||
} else {
|
||||
assert!(file.peak() > 1.0, "{extension}: headroom was clipped");
|
||||
}
|
||||
assert_eq!(file.channels, expected, "{extension}, normalize={normalize}");
|
||||
let mut meter = soundcraft_dsp::meter::LoudnessMeter::new(sr as f32, file.num_channels());
|
||||
meter.process(&file.channels, file.frames());
|
||||
for (key, value) in [
|
||||
("peak_db", soundcraft_dsp::offline::peak_db(&file.channels)),
|
||||
("lufs", meter.integrated_lufs()),
|
||||
("true_peak_db", meter.true_peak_db()),
|
||||
] {
|
||||
assert_eq!(result[key].as_f64(), Some(f64::from(value)), "{extension}, normalize={normalize}, {key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
use serde_json::json;
|
||||
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
|
||||
use soundcraft_engine::{Engine, io};
|
||||
use soundcraft_model::{ChannelFormat, Clip, ClipContent, Insert, Route, SendSlot, Session, TrackId, TrackKind};
|
||||
use soundcraft_time::{Range, SampleRate};
|
||||
use std::sync::Arc;
|
||||
|
||||
const SIGNAL_FRAMES: usize = 128;
|
||||
|
||||
fn fixture(format: ChannelFormat) -> (Engine, TrackId, Vec<Vec<f32>>) {
|
||||
let mut session = Session::new("Commit layout", SampleRate::new(8_000).unwrap());
|
||||
session.outputs[0].format = format;
|
||||
let track = session.add_track(TrackKind::Audio, format, Some("Distinct channels"));
|
||||
// Each channel has a different bipolar waveform: swapping, summing or dropping channels
|
||||
// cannot pass a width-only assertion or an identical-signal comparison.
|
||||
let channels: Vec<Vec<f32>> = (0..format.channels())
|
||||
.map(|ch| (0..SIGNAL_FRAMES).map(|frame| (((frame * (ch + 3)) % 31) as f32 - 15.0) * (ch + 1) as f32 / 3_200.0).collect())
|
||||
.collect();
|
||||
let source =
|
||||
io::add_source(&mut session, "Distinct channels", AudioBuffer { sample_rate: 8_000, channels: channels.clone() }, None, FileFormat::Wav);
|
||||
let clip = session.new_clip_id();
|
||||
let tr = session.track_mut(track).unwrap();
|
||||
tr.playlist_mut().unwrap().clips.push(Clip::audio(clip, "Signal", source, 0, 0, SIGNAL_FRAMES as i64));
|
||||
tr.mixer.inserts[0] = Some(Insert::new("invert"));
|
||||
session.edit.selected_tracks = vec![track];
|
||||
(Engine::new(session), track, channels)
|
||||
}
|
||||
|
||||
fn commit(e: &mut Engine, track: TrackId) -> TrackId {
|
||||
TrackId(e.execute("track.commit", &json!({"tracks": [track.0]})).unwrap()["tracks"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn committed_audio(e: &Engine, track: TrackId) -> &AudioBuffer {
|
||||
let ClipContent::Audio { source, .. } = e.session().track(track).unwrap().clips()[0].content else { panic!("commit must create audio") };
|
||||
&e.session().pool.get(source).unwrap().buffer
|
||||
}
|
||||
|
||||
fn assert_audio_eq(actual: &[Vec<f32>], expected: &[Vec<f32>]) {
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
for (ch, (actual, expected)) in actual.iter().zip(expected).enumerate() {
|
||||
assert_eq!(actual.len(), expected.len(), "channel {ch} length");
|
||||
for (frame, (actual, expected)) in actual.iter().zip(expected).enumerate() {
|
||||
assert!((actual - expected).abs() < 1e-6, "channel {ch}, frame {frame}: {actual} != {expected}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_keeps_every_renderable_exact_format_and_processed_channel_order() {
|
||||
for format in ChannelFormat::ALL.into_iter().filter(|format| format.channels() <= soundcraft_mix::MAX_CHANNELS) {
|
||||
let (mut e, track, signal) = fixture(format);
|
||||
let committed = commit(&mut e, track);
|
||||
assert_eq!(e.session().track(committed).unwrap().format, format, "{}", format.label());
|
||||
let audio = committed_audio(&e, committed);
|
||||
assert_eq!(audio.num_channels(), format.channels(), "{}", format.label());
|
||||
assert_eq!(audio.frames(), SIGNAL_FRAMES + 16_000);
|
||||
// Existing mono rendering uses a -3 dB center pan followed by SQRT_2 compensation.
|
||||
// Model that small baseline gain explicitly, retaining the same strict signal tolerance.
|
||||
let gain = if format == ChannelFormat::Mono { 10.0_f32.powf(-3.0 / 20.0) * std::f32::consts::SQRT_2 } else { 1.0 };
|
||||
let expected: Vec<Vec<f32>> = signal.iter().map(|ch| ch.iter().map(|sample| -sample * gain).collect()).collect();
|
||||
let head: Vec<Vec<f32>> = audio.channels.iter().map(|ch| ch[..SIGNAL_FRAMES].to_vec()).collect();
|
||||
assert_audio_eq(&head, &expected);
|
||||
assert!(audio.channels.iter().all(|ch| ch[SIGNAL_FRAMES..].iter().all(|sample| sample.abs() < 1e-6)));
|
||||
assert!(e.session().track(track).unwrap().inactive && e.session().track(track).unwrap().hidden);
|
||||
assert_eq!(e.session().track_index(committed), e.session().track_index(track).map(|index| index + 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_preserves_bus_and_send_audio_after_undo_reopen_and_wav_export() {
|
||||
for format in [ChannelFormat::Surround60, ChannelFormat::Atmos504, ChannelFormat::Ambisonics(1)] {
|
||||
let (mut e, track, _) = fixture(format);
|
||||
let s = e.session_mut();
|
||||
let bus = s.add_bus("Same-layout bus", format);
|
||||
let aux = s.add_track(TrackKind::Aux, format, Some("Bus return"));
|
||||
s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus);
|
||||
let tr = s.track_mut(track).unwrap();
|
||||
tr.mixer.output = Route::Bus(bus);
|
||||
tr.mixer.volume_db = -4.5;
|
||||
let mut send = SendSlot::new(Route::Main);
|
||||
send.level_db = -9.0;
|
||||
tr.mixer.sends[0] = Some(send);
|
||||
let mixer = tr.mixer.clone();
|
||||
let range = Range::new(0, SIGNAL_FRAMES as i64);
|
||||
let before = soundcraft_mix::render_range(e.session(), range, 64);
|
||||
let committed = commit(&mut e, track);
|
||||
let tr = e.session().track(committed).unwrap();
|
||||
assert_eq!(tr.mixer.output, mixer.output);
|
||||
assert_eq!(tr.mixer.sends, mixer.sends);
|
||||
assert_eq!(tr.mixer.volume_db, mixer.volume_db);
|
||||
assert!(tr.mixer.inserts.iter().all(Option::is_none), "processing is baked once");
|
||||
assert_audio_eq(&soundcraft_mix::render_range(e.session(), range, 64), &before);
|
||||
assert!(e.undo());
|
||||
assert!(e.session().track(committed).is_none());
|
||||
assert!(!e.session().track(track).unwrap().inactive);
|
||||
assert_audio_eq(&soundcraft_mix::render_range(e.session(), range, 64), &before);
|
||||
assert!(e.redo());
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-commit-layout-{}-{}", std::process::id(), format.channels()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = dir.join("Commit.scraft").to_string_lossy().into_owned();
|
||||
io::save_session(&mut e, &path).unwrap();
|
||||
let mut reopened = Engine::default();
|
||||
assert!(io::open_session(&mut reopened, &path).unwrap().is_empty());
|
||||
assert_eq!(reopened.session().track(committed).unwrap().format, format);
|
||||
assert_audio_eq(&committed_audio(&reopened, committed).channels, &committed_audio(&e, committed).channels);
|
||||
assert_audio_eq(&soundcraft_mix::render_range(reopened.session(), range, 64), &before);
|
||||
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
let wav_path = dir.join("mix.wav");
|
||||
io::Bounce::new(reopened.session_arc(), &wav_path, range, &opts, None, false, false).unwrap().run().unwrap();
|
||||
let wav = std::fs::read(&wav_path).unwrap();
|
||||
let (_, decoded) = soundcraft_audio_io::decode(&wav, Some("wav")).unwrap();
|
||||
assert_audio_eq(&decoded.channels, &before);
|
||||
// The WAV extensible mask must describe this layout, not another layout of equal width.
|
||||
let fmt_at = wav.windows(4).position(|tag| tag == b"fmt ").unwrap();
|
||||
let mask = u32::from_le_bytes(wav[fmt_at + 28..fmt_at + 32].try_into().unwrap());
|
||||
assert_eq!(mask, format.channel_mask());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_or_overflowing_commit_rolls_back_all_selected_tracks() {
|
||||
for (format, length) in [
|
||||
(ChannelFormat::Mono, (soundcraft_mix::MAX_RENDER_SAMPLES / 2 + 1) as i64),
|
||||
(ChannelFormat::Surround51, (soundcraft_mix::MAX_RENDER_SAMPLES / 6 + 1) as i64),
|
||||
(ChannelFormat::Ambisonics(3), (soundcraft_mix::MAX_RENDER_SAMPLES / 16 + 1) as i64),
|
||||
(ChannelFormat::Surround51, i64::MAX),
|
||||
] {
|
||||
let (mut e, first, _) = fixture(format);
|
||||
let s = e.session_mut();
|
||||
let second = s.add_track(TrackKind::Audio, format, Some("Too long"));
|
||||
let mut clip = s.track(first).unwrap().clips()[0].clone();
|
||||
clip.id = s.new_clip_id();
|
||||
clip.length = length;
|
||||
s.track_mut(second).unwrap().playlist_mut().unwrap().clips.push(clip);
|
||||
let before = e.session_arc();
|
||||
let dirty = e.is_dirty();
|
||||
let result = e.execute("track.commit", &json!({"tracks": [first.0, second.0]}));
|
||||
assert!(result.unwrap_err().to_string().contains("offline render limit"));
|
||||
assert!(Arc::ptr_eq(&before, &e.session_arc()), "no hidden track, truncated source or partially committed first track");
|
||||
assert_eq!(e.is_dirty(), dirty);
|
||||
assert!(!e.can_undo(), "failed commit must not add an undo step");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_rejects_formats_wider_than_the_renderer_without_replacing_tracks() {
|
||||
for format in ChannelFormat::ALL.into_iter().filter(|format| format.channels() > soundcraft_mix::MAX_CHANNELS) {
|
||||
let (mut e, track, _) = fixture(format);
|
||||
let before = e.session_arc();
|
||||
let result = e.execute("track.commit", &json!({"tracks": [track.0]}));
|
||||
assert!(result.unwrap_err().to_string().contains("at most 16 channels"));
|
||||
assert!(Arc::ptr_eq(&before, &e.session_arc()));
|
||||
assert!(!e.session().track(track).unwrap().inactive);
|
||||
assert!(!e.session().track(track).unwrap().hidden);
|
||||
assert!(!e.can_undo());
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,21 @@ fn audiosuite_refuses_results_longer_than_one_render() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_refuses_mono_clips_longer_than_one_render() {
|
||||
// A mono clip may be longer than one offline buffer, so the speed ramp must refuse it rather than cut it short.
|
||||
for (frames, selection) in [(MAX_OFFLINE_SAMPLES + 1, 0), (MAX_OFFLINE_SAMPLES * 2 / 3 + 1, 1)] {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Talk"));
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 48_000]] };
|
||||
let src = soundcraft_engine::io::add_source(&mut s, "talk", buf, None, FileFormat::Wav);
|
||||
let id = s.new_clip_id();
|
||||
soundcraft_engine::edit::place_clip(&mut s, t, Clip::audio(id, "Talk", src, 0, 0, frames as i64));
|
||||
let mut e = Engine::new(s);
|
||||
refused(&mut e, "audiosuite.process", &json!({"process": "speed_ramp", "params": {"selection": selection}, "clips": [id.0]}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_generator_refuses_a_selection_longer_than_one_render() {
|
||||
let (mut e, _) = long_clip(48_000);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod bounce;
|
||||
mod clap_e2e;
|
||||
mod commit_multichannel;
|
||||
mod consolidate_selection;
|
||||
mod current_feet_frames_position;
|
||||
mod edit_modes;
|
||||
@@ -14,4 +15,6 @@ mod mix_required_params;
|
||||
mod random_edits;
|
||||
mod recording;
|
||||
mod session_transport;
|
||||
mod split_into_mono;
|
||||
mod track_name_collisions;
|
||||
mod trim_to_selection;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Split tracks into mono without replacing the right/surround channels with channel zero.
|
||||
|
||||
use serde_json::json;
|
||||
use soundcraft_audio_io::{AudioBuffer, FileFormat};
|
||||
use soundcraft_engine::{Engine, io};
|
||||
use soundcraft_model::{ChannelFormat, Clip, Session, SourceAudio, SourceId, TrackId, TrackKind};
|
||||
use soundcraft_time::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn fixture(format: ChannelFormat, channels: usize) -> (Engine, TrackId, SourceId) {
|
||||
let mut s = Session::default();
|
||||
let track = s.add_track(TrackKind::Audio, format, Some("Original"));
|
||||
let channels = (0..channels).map(|ch| (0..256).map(|i| (ch + 1) as f32 * 0.1 + i as f32 * 0.0001).collect()).collect();
|
||||
let source = io::add_source(&mut s, "Original.wav", AudioBuffer { sample_rate: 48_000, channels }, None, FileFormat::Wav);
|
||||
s.sources[0].time_reference = 123_456;
|
||||
let mut clip = Clip::audio(s.new_clip_id(), "Edited", source, 7, 13, 47);
|
||||
clip.gain_db = -6.0;
|
||||
clip.gain_env = vec![(0, -3.0), (20, 0.0), (46, -9.0)];
|
||||
clip.fade_in.len = 5;
|
||||
clip.fade_out.len = 9;
|
||||
clip.stretch = 1.25;
|
||||
let mut second = clip.clone();
|
||||
second.id = s.new_clip_id();
|
||||
second.start = 100;
|
||||
second.content = soundcraft_model::ClipContent::Audio { source, offset: 90 };
|
||||
s.track_mut(track).unwrap().playlist_mut().unwrap().clips = vec![clip, second];
|
||||
(Engine::new(s), track, source)
|
||||
}
|
||||
|
||||
fn split(e: &mut Engine, track: TrackId) -> Vec<TrackId> {
|
||||
let result = e.execute("track.split_into_mono", &json!({"track": track.0})).unwrap();
|
||||
result["tracks"].as_array().unwrap().iter().map(|id| TrackId(id.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_into_mono_preserves_every_channel_and_clip_edit() {
|
||||
for (format, channels) in [(ChannelFormat::Stereo, 2), (ChannelFormat::Surround51, 6)] {
|
||||
let (mut e, track, source) = fixture(format, channels);
|
||||
let original = e.session().track(track).unwrap().clone();
|
||||
let audio = Arc::clone(e.session().pool.get(source).unwrap());
|
||||
let expected = soundcraft_mix::render_clips(e.session(), track, Range::new(0, 192));
|
||||
let tracks = split(&mut e, track);
|
||||
assert_eq!(tracks.len(), channels);
|
||||
assert_eq!(e.session().track(track).unwrap(), &original);
|
||||
assert!(Arc::ptr_eq(e.session().pool.get(source).unwrap(), &audio));
|
||||
// Two clips referencing the same source share one extracted source per channel.
|
||||
assert_eq!(e.session().sources.len(), 1 + channels);
|
||||
for (ch, &id) in tracks.iter().enumerate() {
|
||||
let mono = e.session().track(id).unwrap();
|
||||
assert_eq!(mono.format, ChannelFormat::Mono);
|
||||
let actual = soundcraft_mix::render_clips(e.session(), id, Range::new(0, 192));
|
||||
assert_eq!(actual[0], expected[ch], "{format:?} channel {ch}");
|
||||
let mono_source = mono.clips()[0].source().unwrap();
|
||||
assert_ne!(mono_source, source);
|
||||
assert!(mono.clips().iter().all(|clip| clip.source() == Some(mono_source)));
|
||||
let data = e.session().pool.get(mono_source).unwrap();
|
||||
assert_eq!(data.buffer.channels, vec![audio.buffer.channels[ch].clone()]);
|
||||
assert_eq!(data.peaks.len(), 1);
|
||||
let metadata = e.session().source(mono_source).unwrap();
|
||||
assert_eq!((metadata.channels, metadata.time_reference), (1, 123_456));
|
||||
assert!(metadata.unsaved);
|
||||
for (clip, original) in mono.clips().iter().zip(original.clips()) {
|
||||
let mut expected_clip = original.clone();
|
||||
expected_clip.id = clip.id;
|
||||
expected_clip.name = clip.name.clone();
|
||||
expected_clip.content = soundcraft_model::ClipContent::Audio { source: mono_source, offset: original.source_offset() };
|
||||
assert_eq!(clip, &expected_clip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_stereo_matches_the_original_mix_in_playback_and_bounce_blocks() {
|
||||
let (mut e, track, _) = fixture(ChannelFormat::Stereo, 2);
|
||||
let original = e.session().clone();
|
||||
let tracks = split(&mut e, track);
|
||||
assert_eq!(e.session().track(tracks[0]).unwrap().name, "Original.L");
|
||||
assert_eq!(e.session().track(tracks[1]).unwrap().name, "Original.R");
|
||||
e.session_mut().track_mut(track).unwrap().inactive = true;
|
||||
for block in [37, 512, 1024] {
|
||||
let expected = soundcraft_mix::render_range(&original, Range::new(0, 192), block);
|
||||
let actual = soundcraft_mix::render_range(e.session(), Range::new(0, 192), block);
|
||||
assert_eq!(actual, expected, "block {block}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_stereo_preserves_distinct_left_and_right_levels() {
|
||||
let (mut e, track, source) = fixture(ChannelFormat::Stereo, 2);
|
||||
let s = e.session_mut();
|
||||
s.pool.insert(source, Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.2; 256], vec![0.7; 256]] })));
|
||||
let clip = Clip::audio(s.new_clip_id(), "Distinct channels", source, 0, 0, 256);
|
||||
s.track_mut(track).unwrap().playlist_mut().unwrap().clips = vec![clip];
|
||||
split(&mut e, track);
|
||||
e.session_mut().track_mut(track).unwrap().inactive = true;
|
||||
let audio = soundcraft_mix::render_range(e.session(), Range::new(0, 128), 64);
|
||||
eprintln!("Split into Mono output: L={}, R={}", audio[0][64], audio[1][64]);
|
||||
assert_eq!(audio[0][64], 0.2);
|
||||
assert_eq!(audio[1][64], 0.7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_sources_survive_save_and_reopen_with_distinct_audio_files() {
|
||||
let (mut e, track, _) = fixture(ChannelFormat::Stereo, 2);
|
||||
let tracks = split(&mut e, track);
|
||||
let (bytes, saved) = io::session_bundle(e.session()).unwrap();
|
||||
let paths: std::collections::BTreeSet<_> = saved.sources.iter().map(|source| &source.path).collect();
|
||||
assert_eq!(paths.len(), 3);
|
||||
let mut reopened = Engine::default();
|
||||
assert!(io::open_session_bytes(&mut reopened, "split.scraftz", &bytes).unwrap().is_empty());
|
||||
for id in tracks {
|
||||
let expected = soundcraft_mix::render_clips(e.session(), id, Range::new(0, 192));
|
||||
let actual = soundcraft_mix::render_clips(reopened.session(), id, Range::new(0, 192));
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_tracks_and_sources_undo_and_redo_together() {
|
||||
let (mut e, track, _) = fixture(ChannelFormat::Stereo, 2);
|
||||
let before = e.session().clone();
|
||||
split(&mut e, track);
|
||||
let after = e.session().clone();
|
||||
assert_eq!(e.undo_history().len(), 1);
|
||||
assert!(e.undo());
|
||||
assert_eq!(e.session(), &before);
|
||||
assert!(e.redo());
|
||||
assert_eq!(e.session(), &after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_reuses_a_mono_source_on_a_stereo_track() {
|
||||
let (mut e, track, source) = fixture(ChannelFormat::Stereo, 1);
|
||||
let expected = soundcraft_mix::render_clips(e.session(), track, Range::new(0, 192));
|
||||
for id in split(&mut e, track) {
|
||||
assert!(e.session().track(id).unwrap().clips().iter().all(|clip| clip.source() == Some(source)));
|
||||
assert_eq!(soundcraft_mix::render_clips(e.session(), id, Range::new(0, 192))[0], expected[0]);
|
||||
}
|
||||
assert_eq!(e.session().sources.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_empty_media_refuses_the_split_without_partial_changes() {
|
||||
for empty in [false, true] {
|
||||
let (mut e, track, _) = fixture(ChannelFormat::Stereo, 2);
|
||||
let missing = SourceId(9999);
|
||||
if empty {
|
||||
e.session_mut().pool.insert(missing, Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![] })));
|
||||
}
|
||||
let clip = Clip::audio(e.session_mut().new_clip_id(), "Unavailable", missing, 0, 160, 16);
|
||||
e.session_mut().track_mut(track).unwrap().playlist_mut().unwrap().clips.push(clip);
|
||||
let before = e.session().clone();
|
||||
let error = e.execute("track.split_into_mono", &json!({"track": track.0})).unwrap_err();
|
||||
assert!(matches!(error, soundcraft_engine::EngineError::BadParams(..)), "{error}");
|
||||
assert_eq!(e.session(), &before);
|
||||
assert!(e.undo_history().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_model::{ChannelFormat, Session, TrackKind};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn truncated_ascii_and_unicode_names_cannot_collide_with_another_track() {
|
||||
for character in ['A', '界'] {
|
||||
let existing: String = std::iter::repeat_n(character, 64).collect();
|
||||
let mut session = Session::default();
|
||||
let occupied = session.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some(&existing));
|
||||
let target = session.add_track(TrackKind::Aux, ChannelFormat::Mono, Some("Target"));
|
||||
let mut engine = Engine::new(session);
|
||||
let before = engine.session_arc();
|
||||
let revision = engine.revision;
|
||||
|
||||
let result = engine.execute("track.rename", &json!({"track": target.0, "name": format!(" {existing} extra ")}));
|
||||
assert!(result.is_err());
|
||||
assert!(Arc::ptr_eq(&before, &engine.session_arc()));
|
||||
assert_eq!(engine.revision, revision);
|
||||
assert_eq!(engine.session().track(occupied).unwrap().name, existing);
|
||||
assert_eq!(engine.session().track(target).unwrap().name, "Target");
|
||||
assert!(!engine.is_dirty());
|
||||
assert!(!engine.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_returns_the_stored_unicode_name_and_undo_restores_the_document() {
|
||||
let mut session = Session::default();
|
||||
let track = session.add_track(TrackKind::Audio, ChannelFormat::Surround51, Some("Original"));
|
||||
session.track_mut(track).unwrap().comments = "Retain this comment".into();
|
||||
let mut engine = Engine::new(session);
|
||||
let before = engine.session().to_json().unwrap();
|
||||
let stored = "é".repeat(64);
|
||||
|
||||
let result = engine.execute("track.rename", &json!({"track": track.0, "name": format!(" {stored}extra ")})).unwrap();
|
||||
assert_eq!(result["name"], stored);
|
||||
let renamed = engine.session().track(track).unwrap();
|
||||
assert_eq!(renamed.name, stored);
|
||||
assert_eq!(renamed.name.chars().count(), 64);
|
||||
assert_eq!(renamed.id, track);
|
||||
assert_eq!(renamed.format, ChannelFormat::Surround51);
|
||||
assert_eq!(renamed.comments, "Retain this comment");
|
||||
assert_eq!(engine.undo_history(), ["Rename Track"]);
|
||||
assert!(engine.undo());
|
||||
assert_eq!(engine.session().to_json().unwrap(), before);
|
||||
assert!(engine.redo());
|
||||
assert_eq!(engine.session().track(track).unwrap().name, stored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_name_after_truncation_does_not_make_an_undo_step() {
|
||||
let name = "A".repeat(64);
|
||||
let mut session = Session::default();
|
||||
let track = session.add_track(TrackKind::Audio, ChannelFormat::Mono, Some(&name));
|
||||
let mut engine = Engine::new(session);
|
||||
let before = engine.session_arc();
|
||||
let revision = engine.revision;
|
||||
|
||||
let result = engine.execute("track.rename", &json!({"track": track.0, "name": format!("{name} suffix")})).unwrap();
|
||||
assert_eq!(result["name"], name);
|
||||
assert!(Arc::ptr_eq(&before, &engine.session_arc()));
|
||||
assert_eq!(engine.revision, revision);
|
||||
assert!(!engine.is_dirty());
|
||||
assert!(!engine.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing_folder_rename_preserves_unique_owned_bus_routes_and_history() {
|
||||
use soundcraft_model::{BusId, Route, TrackId};
|
||||
|
||||
let stored = "界".repeat(64);
|
||||
let first_name = format!("{stored} Bus");
|
||||
let second_name = format!("{stored} Bus 2");
|
||||
let mut session = Session::default();
|
||||
let member = session.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Member"));
|
||||
let user_bus = session.add_bus(&first_name, ChannelFormat::Stereo);
|
||||
let second_user_bus = session.add_bus(&second_name, ChannelFormat::Stereo);
|
||||
let mut engine = Engine::new(session);
|
||||
let unrelated = engine.execute("track.new", &json!({"kind": "folder", "name": "Unrelated"})).unwrap();
|
||||
let unrelated = TrackId(unrelated["tracks"][0].as_u64().unwrap());
|
||||
let unrelated_route = engine.session().track(unrelated).unwrap().mixer.input.clone();
|
||||
let created = engine.execute("track.move_to_new_folder", &json!({"tracks": [member.0], "name": "Initial"})).unwrap();
|
||||
let folder = TrackId(created["folder"].as_u64().unwrap());
|
||||
let owned_bus = BusId(created["bus"].as_u64().unwrap());
|
||||
let before = engine.session().to_json().unwrap();
|
||||
let history = engine.undo_history();
|
||||
let bus_count = engine.session().busses.len();
|
||||
|
||||
let result = engine.execute("track.rename", &json!({"track": folder.0, "name": format!(" {stored} suffix ")})).unwrap();
|
||||
assert_eq!(result["name"], stored);
|
||||
assert_eq!(engine.session().track(folder).unwrap().name, stored);
|
||||
assert_eq!(engine.session().bus(owned_bus).unwrap().name, format!("{stored} Bus 3"));
|
||||
assert_eq!(engine.session().bus(user_bus).unwrap().name, first_name);
|
||||
assert_eq!(engine.session().bus(second_user_bus).unwrap().name, second_name);
|
||||
assert_eq!(engine.session().track(unrelated).unwrap().mixer.input, unrelated_route);
|
||||
assert_eq!(engine.session().track(folder).unwrap().mixer.input, Route::Bus(owned_bus));
|
||||
assert_eq!(engine.session().track(member).unwrap().mixer.output, Route::Bus(owned_bus));
|
||||
assert_eq!(engine.session().track(member).unwrap().folder, Some(folder));
|
||||
assert_eq!(engine.session().busses.len(), bus_count);
|
||||
assert_eq!(engine.undo_history().len(), history.len() + 1);
|
||||
assert_eq!(engine.undo_label(), Some("Rename Track"));
|
||||
let renamed = engine.session().to_json().unwrap();
|
||||
|
||||
let document = engine.session_arc();
|
||||
let revision = engine.revision;
|
||||
let history = engine.undo_history();
|
||||
engine.execute("track.rename", &json!({"track": folder.0, "name": format!("{stored} more")})).unwrap();
|
||||
assert!(Arc::ptr_eq(&document, &engine.session_arc()));
|
||||
assert_eq!(engine.revision, revision);
|
||||
assert_eq!(engine.undo_history(), history);
|
||||
assert!(engine.undo());
|
||||
assert_eq!(engine.session().to_json().unwrap(), before);
|
||||
assert!(engine.redo());
|
||||
assert_eq!(engine.session().to_json().unwrap(), renamed);
|
||||
|
||||
// Current-main same-name folder rename also repairs the folder's own bus, without taking
|
||||
// either colliding user bus or changing its members' bus identity.
|
||||
engine.session_mut().busses.iter_mut().find(|bus| bus.id == owned_bus).unwrap().name = "Manually named owned bus".into();
|
||||
let manual = engine.session().to_json().unwrap();
|
||||
engine.execute("track.rename", &json!({"track": folder.0, "name": stored})).unwrap();
|
||||
assert_eq!(engine.session().bus(owned_bus).unwrap().name, format!("{stored} Bus 3"));
|
||||
assert_eq!(engine.session().bus(user_bus).unwrap().name, first_name);
|
||||
assert_eq!(engine.session().bus(second_user_bus).unwrap().name, second_name);
|
||||
assert_eq!(engine.session().track(member).unwrap().mixer.output, Route::Bus(owned_bus));
|
||||
assert!(engine.undo());
|
||||
assert_eq!(engine.session().to_json().unwrap(), manual);
|
||||
assert!(engine.redo());
|
||||
assert_eq!(engine.session().to_json().unwrap(), renamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_folder_collision_refuses_bus_and_document_changes_with_redo_pending() {
|
||||
use soundcraft_model::{Route, TrackId};
|
||||
|
||||
for character in ['B', '界'] {
|
||||
let occupied_name: String = std::iter::repeat_n(character, 64).collect();
|
||||
let mut session = Session::default();
|
||||
let occupied = session.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some(&occupied_name));
|
||||
let mut engine = Engine::new(session);
|
||||
let created = engine.execute("track.new", &json!({"kind": "folder", "name": "Target"})).unwrap();
|
||||
let folder = TrackId(created["tracks"][0].as_u64().unwrap());
|
||||
let owned_bus = match engine.session().track(folder).unwrap().mixer.input {
|
||||
Route::Bus(bus) => bus,
|
||||
_ => panic!("the routing folder must own a bus"),
|
||||
};
|
||||
engine.execute("mix.volume", &json!({"track": folder.0, "db": -3})).unwrap();
|
||||
assert!(engine.undo());
|
||||
engine.mark_clean();
|
||||
let document = engine.session_arc();
|
||||
let before = engine.session().to_json().unwrap();
|
||||
let revision = engine.revision;
|
||||
let journal = engine.journal.clone();
|
||||
let undo = engine.undo_history();
|
||||
let redo = engine.redo_label().map(str::to_owned);
|
||||
assert!(redo.is_some());
|
||||
|
||||
let result = engine.execute("track.rename", &json!({"track": folder.0, "name": format!(" {occupied_name} suffix ")}));
|
||||
assert!(result.unwrap_err().to_string().contains(&occupied_name));
|
||||
assert!(Arc::ptr_eq(&document, &engine.session_arc()));
|
||||
assert_eq!(engine.session().to_json().unwrap(), before);
|
||||
assert_eq!(engine.revision, revision);
|
||||
assert_eq!(engine.journal, journal);
|
||||
assert_eq!(engine.undo_history(), undo);
|
||||
assert_eq!(engine.redo_label(), redo.as_deref());
|
||||
assert!(!engine.is_dirty());
|
||||
assert_eq!(engine.session().track(occupied).unwrap().name, occupied_name);
|
||||
assert_eq!(engine.session().track(folder).unwrap().name, "Target");
|
||||
assert_eq!(engine.session().bus(owned_bus).unwrap().name, "Target Bus");
|
||||
assert_eq!(engine.session().track(folder).unwrap().mixer.input, Route::Bus(owned_bus));
|
||||
}
|
||||
}
|
||||
+90
-5
@@ -204,7 +204,12 @@ pub fn read_smf(bytes: &[u8]) -> Result<Smf, MidiError> {
|
||||
tracks.push(track);
|
||||
}
|
||||
|
||||
if format == 1 && tracks.len() > 1 && tracks.first().is_some_and(|t| t.sequence.notes.is_empty() && t.sequence.ctrls.is_empty()) {
|
||||
// A lone track is only a conductor track if it carried global data and has no name; otherwise it is kept.
|
||||
let globals_present = !(globals.tempos.is_empty() && globals.meters.is_empty() && globals.markers.is_empty() && globals.key_sigs.is_empty());
|
||||
if format == 1
|
||||
&& (tracks.len() > 1 || globals_present)
|
||||
&& tracks.first().is_some_and(|t| t.sequence.notes.is_empty() && t.sequence.ctrls.is_empty() && (tracks.len() > 1 || t.name.is_empty()))
|
||||
{
|
||||
tracks.remove(0);
|
||||
}
|
||||
|
||||
@@ -444,7 +449,12 @@ fn write_track(out: &mut Vec<u8>, mut events: Vec<WEvent>) -> Result<(), MidiErr
|
||||
/// Errors: invalid tempo (non-finite or <= 0 bpm), meter denominator that is not a power of two
|
||||
/// (1..=128), more than 65535 tracks, or a gap between events beyond the 28-bit delta limit.
|
||||
pub fn write_smf(smf: &Smf) -> Result<Vec<u8>, MidiError> {
|
||||
let ntrks = u16::try_from(smf.tracks.len().saturating_add(1)).map_err(|_| MidiError::Write("more than 65535 tracks".into()))?;
|
||||
// The conductor track is omitted when there is nothing to put in it and no content tracks, so an
|
||||
// empty `Smf` is written as a file with zero tracks and reads back as zero tracks.
|
||||
let has_conductor =
|
||||
!(smf.tempos.is_empty() && smf.meters.is_empty() && smf.key_sigs.is_empty() && smf.markers.is_empty() && smf.tracks.is_empty());
|
||||
let ntrks =
|
||||
u16::try_from(smf.tracks.len().saturating_add(usize::from(has_conductor))).map_err(|_| MidiError::Write("more than 65535 tracks".into()))?;
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(b"MThd");
|
||||
out.extend_from_slice(&6u32.to_be_bytes());
|
||||
@@ -476,18 +486,58 @@ pub fn write_smf(smf: &Smf) -> Result<Vec<u8>, MidiError> {
|
||||
for (t, text) in &smf.markers {
|
||||
ev.push(WEvent { tick: clamp_tick(*t), order: 3, bytes: meta(0x06, text.as_bytes())? });
|
||||
}
|
||||
write_track(&mut out, ev)?;
|
||||
if has_conductor {
|
||||
write_track(&mut out, ev)?;
|
||||
}
|
||||
|
||||
for track in &smf.tracks {
|
||||
let mut ev = Vec::new();
|
||||
if !track.name.is_empty() {
|
||||
ev.push(WEvent { tick: 0, order: 0, bytes: meta(0x03, track.name.as_bytes())? });
|
||||
}
|
||||
for n in &track.sequence.notes {
|
||||
let notes = &track.sequence.notes;
|
||||
let mut ends: Vec<u64> = notes.iter().map(|n| clamp_tick(n.end()).max(clamp_tick(n.start).saturating_add(1))).collect();
|
||||
let mut dropped = vec![false; notes.len()];
|
||||
// The reader pairs offs with ons first-in first-out, so a note still sounding when the next
|
||||
// same-pitch note starts would swap ends with it: trim it to that start. When two same-pitch
|
||||
// notes start on the same tick they cannot be told apart, so only the longer one is written.
|
||||
let mut order: Vec<usize> = (0..notes.len()).collect();
|
||||
order.sort_by_key(|&i| {
|
||||
notes.get(i).map_or((0, 0, 0, std::cmp::Reverse(0)), |n| {
|
||||
(n.channel & 0x0F, n.pitch.min(127), clamp_tick(n.start), std::cmp::Reverse(ends.get(i).copied().unwrap_or(0)))
|
||||
})
|
||||
});
|
||||
let mut prev: Option<usize> = None;
|
||||
for &b in &order {
|
||||
let Some(nb) = notes.get(b) else { continue };
|
||||
if let Some(a) = prev
|
||||
&& let Some(na) = notes.get(a)
|
||||
&& na.channel & 0x0F == nb.channel & 0x0F
|
||||
&& na.pitch.min(127) == nb.pitch.min(127)
|
||||
{
|
||||
let (sa, sb) = (clamp_tick(na.start), clamp_tick(nb.start));
|
||||
if sa == sb {
|
||||
if let Some(d) = dropped.get_mut(b) {
|
||||
*d = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(ea) = ends.get_mut(a)
|
||||
&& sb < *ea
|
||||
{
|
||||
*ea = sb;
|
||||
}
|
||||
}
|
||||
prev = Some(b);
|
||||
}
|
||||
for (i, n) in notes.iter().enumerate() {
|
||||
if dropped.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let ch = n.channel & 0x0F;
|
||||
let pitch = n.pitch.min(127);
|
||||
let start = clamp_tick(n.start);
|
||||
let end = clamp_tick(n.end()).max(start.saturating_add(1));
|
||||
let end = ends.get(i).copied().unwrap_or(start.saturating_add(1));
|
||||
ev.push(WEvent { tick: start, order: 3, bytes: vec![0x90 | ch, pitch, n.velocity.clamp(1, 127)] });
|
||||
ev.push(WEvent { tick: end, order: 1, bytes: vec![0x80 | ch, pitch, n.release_velocity.min(127)] });
|
||||
}
|
||||
@@ -527,6 +577,19 @@ mod tests {
|
||||
assert!(push_vlq(&mut Vec::new(), MAX_VLQ + 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_smf_round_trips_with_no_tracks() {
|
||||
let r = read_smf(&write_smf(&Smf::default()).unwrap()).unwrap();
|
||||
assert_eq!(r.tracks.len(), 0);
|
||||
let one = Smf { tracks: vec![SmfTrack::default()], ..Smf::default() };
|
||||
let r = read_smf(&write_smf(&one).unwrap()).unwrap();
|
||||
assert_eq!(r.tracks.len(), 1);
|
||||
let cond = Smf { tempos: vec![(0, 90.0)], ..Smf::default() };
|
||||
let r = read_smf(&write_smf(&cond).unwrap()).unwrap();
|
||||
assert_eq!(r.tracks.len(), 0);
|
||||
assert_eq!(r.tempos.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spec_vlq_examples() {
|
||||
let mut b = Vec::new();
|
||||
@@ -536,4 +599,26 @@ mod tests {
|
||||
push_vlq(&mut b, 0x2000).unwrap();
|
||||
assert_eq!(b, [0xC0, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_pitch_overlaps_do_not_swap_ends() {
|
||||
let mk = |start, length| Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start, length };
|
||||
let smf = Smf {
|
||||
format: 1,
|
||||
tracks: vec![SmfTrack {
|
||||
name: String::new(),
|
||||
sequence: Sequence { notes: vec![mk(0, 1000), mk(100, 200), mk(1000, 100), mk(2000, 500), mk(2000, 900)], ..Sequence::default() },
|
||||
}],
|
||||
tempos: vec![],
|
||||
meters: vec![],
|
||||
markers: vec![],
|
||||
key_sigs: vec![],
|
||||
};
|
||||
let back = read_smf(&write_smf(&smf).unwrap()).unwrap();
|
||||
let mut got: Vec<(i64, i64)> = back.tracks.iter().flat_map(|t| t.sequence.notes.iter().map(|n| (n.start, n.length))).collect();
|
||||
got.sort_unstable();
|
||||
// The earlier note is trimmed to the start of the next same-pitch note; later notes keep their length;
|
||||
// of two notes starting together only the longer survives.
|
||||
assert_eq!(got, vec![(0, 100), (100, 200), (1000, 100), (2000, 900)]);
|
||||
}
|
||||
}
|
||||
|
||||
+256
-30
@@ -248,21 +248,37 @@ struct Strip {
|
||||
/// at most this, whatever its plugins claim, so a bogus value can't stall offline renders.
|
||||
const MAX_LATENCY: usize = 1 << 20;
|
||||
|
||||
/// A multichannel delay line (samples), resized only when the delay changes.
|
||||
/// A multichannel delay line (samples). The rings only grow (keeping their contents), so a delay
|
||||
/// change in the callback moves the read offset instead of reallocating and zeroing.
|
||||
#[derive(Default)]
|
||||
struct DelayLine {
|
||||
bufs: Vec<Vec<f32>>,
|
||||
/// Write position in the rings.
|
||||
idx: usize,
|
||||
/// Current delay (samples).
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl DelayLine {
|
||||
fn set(&mut self, len: usize, ch: usize) {
|
||||
let len = len.min(MAX_LATENCY);
|
||||
if len != self.len || self.bufs.len() != ch {
|
||||
self.len = len;
|
||||
self.idx = 0;
|
||||
self.bufs = vec![vec![0.0; len]; ch];
|
||||
self.len = len;
|
||||
let cap = self.bufs.first().map_or(0, Vec::len);
|
||||
if self.bufs.len() != ch {
|
||||
self.bufs.resize_with(ch, || vec![0.0; cap]);
|
||||
}
|
||||
let need = if len == 0 { 0 } else { len + 1 };
|
||||
if need > cap {
|
||||
let new_cap = need.next_power_of_two().min(MAX_LATENCY + 1);
|
||||
let start = self.idx.min(cap);
|
||||
for ring in &mut self.bufs {
|
||||
let mut grown = Vec::with_capacity(new_cap);
|
||||
grown.extend_from_slice(ring.get(start..).unwrap_or(&[]));
|
||||
grown.extend_from_slice(ring.get(..start).unwrap_or(&[]));
|
||||
grown.resize(new_cap, 0.0);
|
||||
*ring = grown;
|
||||
}
|
||||
self.idx = cap;
|
||||
}
|
||||
}
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
@@ -270,20 +286,29 @@ impl DelayLine {
|
||||
return;
|
||||
}
|
||||
let start = self.idx;
|
||||
let mut end = start;
|
||||
for (c, ch) in io.iter_mut().enumerate() {
|
||||
let Some(ring) = self.bufs.get_mut(c) else { continue };
|
||||
let mut i = start;
|
||||
let cap = ring.len();
|
||||
if cap <= self.len {
|
||||
continue;
|
||||
}
|
||||
let mut i = start % cap;
|
||||
for x in ch.iter_mut().take(frames) {
|
||||
if let Some(r) = ring.get_mut(i) {
|
||||
std::mem::swap(r, x);
|
||||
if let Some(w) = ring.get_mut(i) {
|
||||
*w = *x;
|
||||
}
|
||||
if let Some(r) = ring.get((i + cap - self.len) % cap) {
|
||||
*x = *r;
|
||||
}
|
||||
i += 1;
|
||||
if i >= self.len {
|
||||
if i >= cap {
|
||||
i = 0;
|
||||
}
|
||||
}
|
||||
end = i;
|
||||
}
|
||||
self.idx = (start + frames) % self.len.max(1);
|
||||
self.idx = end;
|
||||
}
|
||||
fn clear(&mut self) {
|
||||
self.bufs.iter_mut().for_each(|b| b.fill(0.0));
|
||||
@@ -1045,9 +1070,8 @@ fn process_strip(
|
||||
}
|
||||
strip.gr = gr;
|
||||
strip.latency = latency.min(MAX_LATENCY);
|
||||
let auto_mute = t.mixer.automation_mode.reads() && t.lane(&AutoParam::Mute).is_some_and(|l| l.value_at(pos, 0.0) >= 0.5);
|
||||
let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t);
|
||||
strip.muted = t.mixer.mute || auto_mute || soloed_out;
|
||||
strip.muted = t.mixer.mute || soloed_out;
|
||||
if t.mixer.sends.iter().flatten().any(|x| x.pre_fader) {
|
||||
strip.pre.resize_with(strip.buf.len(), Vec::new);
|
||||
for (d, src) in strip.pre.iter_mut().zip(strip.buf.iter()) {
|
||||
@@ -1088,6 +1112,23 @@ fn process_strip(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Track mute gates the audio and its pre-fader tap without splitting processor blocks.
|
||||
// Keep the existing curve interpolation, first-lane and mode policy, and render-position
|
||||
// clock. Processor/bus age and Master/VCA automation policy are separate concerns.
|
||||
if t.mixer.automation_mode.reads()
|
||||
&& let Some(lane) = t.lane(&AutoParam::Mute).filter(|lane| !lane.points.is_empty())
|
||||
{
|
||||
for i in 0..frames {
|
||||
let at = pos.saturating_add(i64::try_from(i).unwrap_or(i64::MAX));
|
||||
if lane.value_at(at, 0.0) >= 0.5 {
|
||||
for channel in strip.buf.iter_mut().chain(strip.pre.iter_mut()) {
|
||||
if let Some(sample) = channel.get_mut(i) {
|
||||
*sample = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Silences a block the plugin filled with NaN or infinity and takes the plugin out until the
|
||||
@@ -1317,26 +1358,97 @@ fn processing_order(s: &Session) -> Vec<usize> {
|
||||
(0..s.tracks.len()).filter(|&i| s.tracks.get(i).is_some_and(|t| !matches!(t.kind, TrackKind::Aux | TrackKind::Folder))).collect();
|
||||
let mut auxes: Vec<usize> =
|
||||
(0..s.tracks.len()).filter(|&i| s.tracks.get(i).is_some_and(|t| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))).collect();
|
||||
// Depth = how many aux hops feed this aux's input bus (bounded; cycles cap out).
|
||||
let depth = |i: usize| -> usize {
|
||||
let mut d = 0;
|
||||
let mut cur = vec![i];
|
||||
for _ in 0..8 {
|
||||
let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect();
|
||||
let feeders: Vec<usize> = auxes_feeding(s, &inputs);
|
||||
if feeders.is_empty() {
|
||||
break;
|
||||
}
|
||||
d += 1;
|
||||
cur = feeders;
|
||||
}
|
||||
d
|
||||
};
|
||||
auxes.sort_by_key(|&i| depth(i));
|
||||
if let Some(depths) = acyclic_aux_depths(s, &auxes) {
|
||||
// Stable sorting preserves session order among auxes at the same longest-path depth.
|
||||
auxes.sort_by_key(|&i| depths.get(i).copied().unwrap_or(0));
|
||||
} else {
|
||||
// A cycle keeps the existing bounded ordering for the entire graph, including any
|
||||
// independent acyclic branches. Feedback does not have a finite longest-path depth.
|
||||
auxes.sort_by_key(|&i| bounded_aux_depth(s, i));
|
||||
}
|
||||
order.extend(auxes);
|
||||
order
|
||||
}
|
||||
|
||||
/// Exact aux-hop depths when the whole Aux/Folder graph is acyclic. Bus joins retain only
|
||||
/// producer counts and consumers, avoiding a producer-by-consumer adjacency matrix.
|
||||
fn acyclic_aux_depths(s: &Session, auxes: &[usize]) -> Option<Vec<usize>> {
|
||||
#[derive(Default)]
|
||||
struct BusDependencies {
|
||||
remaining: usize,
|
||||
depth: usize,
|
||||
last_producer: Option<usize>,
|
||||
last_processed: Option<usize>,
|
||||
consumers: Vec<usize>,
|
||||
}
|
||||
let mut busses = HashMap::<BusId, BusDependencies>::new();
|
||||
for &i in auxes {
|
||||
for bus in aux_output_busses(s.tracks.get(i)?) {
|
||||
let dependency = busses.entry(bus).or_default();
|
||||
// Output and multiple sends from this producer may name the same bus.
|
||||
if dependency.last_producer != Some(i) {
|
||||
dependency.last_producer = Some(i);
|
||||
dependency.remaining = dependency.remaining.checked_add(1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut depths = vec![0usize; s.tracks.len()];
|
||||
let mut ready = Vec::with_capacity(auxes.len());
|
||||
for &i in auxes {
|
||||
if let Route::Bus(bus) = &s.tracks.get(i)?.mixer.input {
|
||||
let dependency = busses.entry(*bus).or_default();
|
||||
dependency.consumers.push(i);
|
||||
if dependency.remaining > 0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
ready.push(i);
|
||||
}
|
||||
let mut processed = 0usize;
|
||||
while let Some(&i) = ready.get(processed) {
|
||||
processed = processed.checked_add(1)?;
|
||||
let next_depth = depths.get(i)?.checked_add(1)?;
|
||||
for bus in aux_output_busses(s.tracks.get(i)?) {
|
||||
let dependency = busses.get_mut(&bus)?;
|
||||
if dependency.last_processed == Some(i) {
|
||||
continue;
|
||||
}
|
||||
dependency.last_processed = Some(i);
|
||||
dependency.depth = dependency.depth.max(next_depth);
|
||||
dependency.remaining = dependency.remaining.checked_sub(1)?;
|
||||
if dependency.remaining == 0 {
|
||||
for &consumer in &dependency.consumers {
|
||||
*depths.get_mut(consumer)? = dependency.depth;
|
||||
ready.push(consumer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(processed == auxes.len()).then_some(depths)
|
||||
}
|
||||
|
||||
fn aux_output_busses(t: &Track) -> impl Iterator<Item = BusId> + '_ {
|
||||
std::iter::once(&t.mixer.output)
|
||||
.chain(t.mixer.sends.iter().flatten().map(|send| &send.target))
|
||||
.filter_map(|route| if let Route::Bus(bus) = route { Some(*bus) } else { None })
|
||||
}
|
||||
|
||||
/// The previous bounded depth calculation, retained unchanged for any cyclic graph.
|
||||
fn bounded_aux_depth(s: &Session, i: usize) -> usize {
|
||||
let mut d = 0;
|
||||
let mut cur = vec![i];
|
||||
for _ in 0..8 {
|
||||
let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect();
|
||||
let feeders: Vec<usize> = auxes_feeding(s, &inputs);
|
||||
if feeders.is_empty() {
|
||||
break;
|
||||
}
|
||||
d += 1;
|
||||
cur = feeders;
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec<usize> {
|
||||
s.tracks
|
||||
.iter()
|
||||
@@ -1349,6 +1461,60 @@ fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec<usize> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod processing_order_tests {
|
||||
use super::*;
|
||||
use soundcraft_model::SendSlot;
|
||||
|
||||
#[test]
|
||||
fn acyclic_bus_joins_use_longest_depth_and_keep_session_order_for_ties() {
|
||||
let mut s = Session::default();
|
||||
s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
|
||||
let consumer = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None);
|
||||
let first_root = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None);
|
||||
let second_root = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None);
|
||||
let middle = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
|
||||
let first_bus = s.add_bus("First", ChannelFormat::Stereo);
|
||||
let join = s.add_bus("Join", ChannelFormat::Stereo);
|
||||
s.track_mut(consumer).unwrap().mixer.input = Route::Bus(join);
|
||||
let root = s.track_mut(first_root).unwrap();
|
||||
root.mixer.output = Route::Main;
|
||||
root.mixer.sends[0] = Some(SendSlot::new(Route::Bus(first_bus)));
|
||||
let root = s.track_mut(second_root).unwrap();
|
||||
root.mixer.output = Route::Bus(join);
|
||||
root.mixer.sends[0] = Some(SendSlot::new(Route::Bus(join)));
|
||||
root.mixer.sends[1] = Some(SendSlot::new(Route::Bus(join)));
|
||||
let folder = s.track_mut(middle).unwrap();
|
||||
folder.mixer.input = Route::Bus(first_bus);
|
||||
folder.mixer.output = Route::Bus(join);
|
||||
folder.mixer.sends[0] = Some(SendSlot::new(Route::Bus(join)));
|
||||
|
||||
assert_eq!(processing_order(&s), vec![0, 2, 3, 4, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cycle_preserves_bounded_order_for_the_whole_graph() {
|
||||
let mut s = Session::default();
|
||||
let busses: Vec<_> = (0..10).map(|i| s.add_bus(&format!("Chain {i}"), ChannelFormat::Stereo)).collect();
|
||||
let tracks: Vec<_> = (0..10).map(|_| s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None)).collect();
|
||||
for (i, id) in tracks.iter().enumerate() {
|
||||
let track = s.track_mut(*id).unwrap();
|
||||
track.mixer.input = Route::Bus(busses[i]);
|
||||
track.mixer.output = if i == 0 { Route::Main } else { Route::Bus(busses[i - 1]) };
|
||||
}
|
||||
assert_eq!(processing_order(&s), vec![9, 8, 7, 6, 5, 4, 3, 2, 1, 0]);
|
||||
let cycle_bus = s.add_bus("Feedback", ChannelFormat::Stereo);
|
||||
let cycle = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None);
|
||||
let track = s.track_mut(cycle).unwrap();
|
||||
track.mixer.input = Route::Bus(cycle_bus);
|
||||
track.mixer.output = Route::Bus(cycle_bus);
|
||||
|
||||
// Both deepest independent returns tie with the cycle at the legacy eight-hop cap.
|
||||
assert_eq!(processing_order(&s), vec![9, 8, 7, 6, 5, 4, 3, 2, 0, 1, 10]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Implicit solo: an aux is not muted when a soloed track feeds its input bus.
|
||||
fn receives_solo(s: &Session, t: &Track) -> bool {
|
||||
let Route::Bus(b) = &t.mixer.input else { return false };
|
||||
@@ -1455,7 +1621,7 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut
|
||||
let x0 = f.floor();
|
||||
let fr = (f - x0) as f32;
|
||||
let a = if x0 >= 0.0 { src.get(x0 as usize).copied().unwrap_or(0.0) } else { 0.0 };
|
||||
let b = if x0 + 1.0 >= 0.0 { src.get((x0 as usize).saturating_add(1)).copied().unwrap_or(0.0) } else { 0.0 };
|
||||
let b = if x0 + 1.0 >= 0.0 { src.get((x0 + 1.0) as usize).copied().unwrap_or(0.0) } else { 0.0 };
|
||||
a + (b - a) * fr
|
||||
};
|
||||
let t = (k - i) as f32 / n;
|
||||
@@ -2233,6 +2399,13 @@ pub fn render_track_pre_fader(s: &Session, track: TrackId, range: Range) -> Vec<
|
||||
|
||||
/// A built-in plugin, else a hosted CLAP plugin (`clap:<id>`).
|
||||
fn create_plugin(id: &str) -> Option<Box<dyn Plugin>> {
|
||||
// Tests never load real plug-ins; adopt a fake instead. Scanning the machine's plug-in folders
|
||||
// loads every installed plug-in in-process, and some never unload, so a test that reached the
|
||||
// real hosts would hang on a developer machine (CI has none installed).
|
||||
#[cfg(test)]
|
||||
if is_third_party(id) {
|
||||
return None;
|
||||
}
|
||||
soundcraft_dsp::create(id)
|
||||
.or_else(|| soundcraft_clap_host::create(id))
|
||||
.or_else(|| soundcraft_vst3_host::create(id))
|
||||
@@ -2285,6 +2458,10 @@ mod audio_thread_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Rule: tests never call the real plug-in hosts. Third-party ids are not created here
|
||||
//! (create_plugin returns None under cfg(test)); a test that needs a hosted plug-in
|
||||
//! adopts a fake under a clap:test.* id via MixEngine::adopt.
|
||||
|
||||
use super::*;
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_model::{Insert, SourceAudio, SourceId};
|
||||
@@ -2305,6 +2482,31 @@ mod tests {
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delay_line_change_keeps_buffers_and_history() {
|
||||
// #889: a latency change must not reallocate or zero the delay line in the callback.
|
||||
let mut d = DelayLine::default();
|
||||
d.set(480, 2);
|
||||
let ptrs: Vec<_> = d.bufs.iter().map(|b| b.as_ptr()).collect();
|
||||
let mut io = vec![(1..=480).map(|v| v as f32).collect::<Vec<f32>>(); 2];
|
||||
d.process(&mut io, 480);
|
||||
assert!(io.iter().all(|c| c.iter().all(|&x| x == 0.0)));
|
||||
// Shorter delay: the line keeps its storage and outputs what it already holds.
|
||||
d.set(240, 2);
|
||||
let after: Vec<_> = d.bufs.iter().map(|b| b.as_ptr()).collect();
|
||||
assert_eq!(ptrs, after, "delay line reallocated on a shorter delay");
|
||||
let mut io = vec![vec![0.0f32; 240]; 2];
|
||||
d.process(&mut io, 240);
|
||||
let want: Vec<f32> = (241..=480).map(|v| v as f32).collect();
|
||||
for c in &io {
|
||||
assert_eq!(c, &want, "held samples lost on a latency change");
|
||||
}
|
||||
// Back to the longer delay: still the same storage.
|
||||
d.set(480, 2);
|
||||
let after: Vec<_> = d.bufs.iter().map(|b| b.as_ptr()).collect();
|
||||
assert_eq!(ptrs, after, "delay line reallocated going back to its size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_instrument_params_reach_the_plugin() {
|
||||
// #152: a saved instrument level of -12 dB must render quieter than -6 dB.
|
||||
@@ -2382,6 +2584,30 @@ mod tests {
|
||||
assert!(out[0][100] > 0.49 && out[1][100].abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stretched_clip_before_its_source_start_reads_from_the_first_sample() {
|
||||
// A clip that extends before its source (a negative offset) plays silence there. When it
|
||||
// is stretched, the read position passes through [-1, 0), which must interpolate from
|
||||
// that silence towards sample 0 of the file -- not towards sample 1.
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![1.0, 0.5, 0.25, 0.125]] };
|
||||
s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(buf)));
|
||||
let id = s.new_clip_id();
|
||||
let mut c = Clip::audio(id, "late", SourceId(901), -1, 0, 4);
|
||||
c.stretch = 2.0;
|
||||
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips.push(c);
|
||||
}
|
||||
let out = render_clips(&s, t, Range::new(0, 4));
|
||||
// rel 0 -> source -1.0: silence; rel 1 -> source -0.5: halfway from silence to sample 0;
|
||||
// rel 2 -> source 0.0: sample 0; rel 3 -> source 0.5: halfway to sample 1.
|
||||
let want = [0.0, 0.5, 1.0, 0.75];
|
||||
for (i, w) in want.iter().enumerate() {
|
||||
assert!((out[0][i] - w).abs() < 1e-6, "frame {i}: {} vs {w}", out[0][i]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fade_curvature_reaches_the_rendered_audio() {
|
||||
for fade_in in [true, false] {
|
||||
@@ -2959,7 +3185,7 @@ mod tests {
|
||||
s.track_mut(t).unwrap().mixer.inserts[0].as_mut().unwrap().params.insert("gain".into(), -12.0);
|
||||
render_block(&mut eng, &s, 1024);
|
||||
assert_eq!(sets.load(std::sync::atomic::Ordering::Relaxed), 2, "later session edits apply");
|
||||
// Offline (not external): unknown third-party plugins are simply absent.
|
||||
// Offline (not external): third-party plugins are simply absent (tests never load real ones).
|
||||
let out = render_range(&s, Range::new(0, 1024), 512);
|
||||
assert!(out[0][100] > 0.3);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Aux and routing-folder dependencies preserve PCM beyond eight hops. Cyclic graphs keep
|
||||
//! the established whole-graph fallback, including its session-order-dependent audio.
|
||||
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_mix::{MixEngine, render_range};
|
||||
use soundcraft_model::{BusId, ChannelFormat, Clip, Route, SendSlot, Session, SourceAudio, SourceId, TrackId, TrackKind};
|
||||
use soundcraft_time::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
const FRAMES: usize = 4097;
|
||||
const DEPTH: usize = 12;
|
||||
|
||||
fn signal() -> Vec<Vec<f32>> {
|
||||
// Distinct, exactly representable channels expose lost, duplicated, swapped and delayed PCM.
|
||||
vec![
|
||||
(0..FRAMES).map(|frame| ((frame % 31) as i32 - 15) as f32 / 256.0).collect(),
|
||||
(0..FRAMES).map(|frame| ((frame % 17) as i32 - 8) as f32 / 512.0).collect(),
|
||||
]
|
||||
}
|
||||
|
||||
fn scaled_signal(gain: f32) -> Vec<Vec<f32>> {
|
||||
signal().into_iter().map(|channel| channel.into_iter().map(|value| value * gain).collect()).collect()
|
||||
}
|
||||
|
||||
fn session(pdc: bool) -> Session {
|
||||
let mut session = Session::default();
|
||||
session.edit.click = false;
|
||||
session.edit.delay_compensation = pdc;
|
||||
session
|
||||
}
|
||||
|
||||
fn add_source(session: &mut Session, output: Route) -> TrackId {
|
||||
let source = SourceId(session.alloc());
|
||||
session.pool.insert(source, Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: signal() })));
|
||||
let clip = session.new_clip_id();
|
||||
let track = session.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("PCM source"));
|
||||
let track_state = session.track_mut(track).unwrap();
|
||||
track_state.mixer.output = output;
|
||||
track_state.playlist_mut().unwrap().clips.push(Clip::audio(clip, "PCM", source, 0, 0, FRAMES as i64));
|
||||
track
|
||||
}
|
||||
|
||||
fn add_aux(session: &mut Session, kind: TrackKind, input: BusId, output: Route) -> TrackId {
|
||||
let track = session.add_track(kind, ChannelFormat::Stereo, None);
|
||||
let mixer = &mut session.track_mut(track).unwrap().mixer;
|
||||
mixer.input = Route::Bus(input);
|
||||
mixer.output = output;
|
||||
track
|
||||
}
|
||||
|
||||
fn send(bus: BusId) -> SendSlot {
|
||||
let mut send = SendSlot::new(Route::Bus(bus));
|
||||
send.level_db = 0.0;
|
||||
send
|
||||
}
|
||||
|
||||
struct Chain {
|
||||
session: Session,
|
||||
buses: Vec<BusId>,
|
||||
/// Logical routing order, deliberately different from the session's creation order.
|
||||
tracks: Vec<TrackId>,
|
||||
}
|
||||
|
||||
fn reverse_chain(pdc: bool, send_links: bool, folders: bool) -> Chain {
|
||||
let mut session = session(pdc);
|
||||
let buses: Vec<_> = (0..DEPTH).map(|stage| session.add_bus(&format!("Stage {stage}"), ChannelFormat::Stereo)).collect();
|
||||
let mut tracks = Vec::new();
|
||||
for stage in (0..DEPTH).rev() {
|
||||
let kind = if folders && stage % 3 == 1 { TrackKind::Folder } else { TrackKind::Aux };
|
||||
let output = if stage + 1 == DEPTH {
|
||||
Route::Main
|
||||
} else if send_links {
|
||||
Route::None
|
||||
} else {
|
||||
Route::Bus(buses[stage + 1])
|
||||
};
|
||||
let track = add_aux(&mut session, kind, buses[stage], output);
|
||||
if send_links && stage + 1 < DEPTH {
|
||||
session.track_mut(track).unwrap().mixer.sends[0] = Some(send(buses[stage + 1]));
|
||||
}
|
||||
tracks.push(track);
|
||||
}
|
||||
tracks.reverse();
|
||||
add_source(&mut session, Route::Bus(buses[0]));
|
||||
Chain { session, buses, tracks }
|
||||
}
|
||||
|
||||
fn assert_pcm(actual: &[Vec<f32>], expected: &[Vec<f32>], context: &str) {
|
||||
assert_eq!(actual.len(), expected.len(), "{context}: channel count");
|
||||
for (channel, (actual, expected)) in actual.iter().zip(expected).enumerate() {
|
||||
assert_eq!(actual.len(), expected.len(), "{context}: channel {channel} length");
|
||||
for (frame, (actual, expected)) in actual.iter().zip(expected).enumerate() {
|
||||
assert_eq!(actual, expected, "{context}: channel {channel}, frame {frame}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render(session: &Session, block: usize) -> Vec<Vec<f32>> {
|
||||
render_range(session, Range::new(0, FRAMES as i64), block)
|
||||
}
|
||||
|
||||
fn engine_block(engine: &mut MixEngine, session: &Session) -> Vec<Vec<f32>> {
|
||||
let mut out = vec![vec![0.0; 128]; 2];
|
||||
engine.render(session, 0, 128, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn first_block(pcm: Vec<Vec<f32>>) -> Vec<Vec<f32>> {
|
||||
pcm.into_iter().map(|channel| channel.into_iter().take(128).collect()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_created_twelve_aux_chain_preserves_every_sample() {
|
||||
for pdc in [false, true] {
|
||||
let chain = reverse_chain(pdc, false, false);
|
||||
assert_pcm(&render(&chain.session, 1024), &signal(), "deep output chain");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_dependencies_through_sends_without_outputs_preserve_every_sample() {
|
||||
for pdc in [false, true] {
|
||||
let chain = reverse_chain(pdc, true, false);
|
||||
assert_pcm(&render(&chain.session, 97), &signal(), "deep send-only chain");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_and_send_to_the_same_bus_sum_audio_without_duplicate_dependency() {
|
||||
for pdc in [false, true] {
|
||||
let mut chain = reverse_chain(pdc, false, false);
|
||||
chain.session.track_mut(chain.tracks[4]).unwrap().mixer.sends[0] = Some(send(chain.buses[5]));
|
||||
assert_pcm(&render(&chain.session, 256), &scaled_signal(2.0), "duplicate destination");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_and_deep_branches_both_reach_the_join() {
|
||||
for pdc in [false, true] {
|
||||
let mut chain = reverse_chain(pdc, false, false);
|
||||
add_aux(&mut chain.session, TrackKind::Aux, chain.buses[0], Route::Bus(chain.buses[DEPTH - 1]));
|
||||
assert_pcm(&render(&chain.session, 512), &scaled_signal(2.0), "branch join");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing_folders_participate_in_deep_dependencies() {
|
||||
for pdc in [false, true] {
|
||||
let chain = reverse_chain(pdc, true, true);
|
||||
assert_pcm(&render(&chain.session, 63), &signal(), "aux and folder send chain");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_edit_reorders_the_same_engine_before_the_next_block() {
|
||||
let mut session = session(true);
|
||||
let root = session.add_bus("Root", ChannelFormat::Stereo);
|
||||
let join = session.add_bus("Join", ChannelFormat::Stereo);
|
||||
add_aux(&mut session, TrackKind::Aux, join, Route::Main);
|
||||
let producer = add_aux(&mut session, TrackKind::Aux, root, Route::Main);
|
||||
add_source(&mut session, Route::Bus(root));
|
||||
let mut engine = MixEngine::new(48_000.0, 128);
|
||||
let expected = first_block(signal());
|
||||
assert_pcm(&engine_block(&mut engine, &session), &expected, "before output edit");
|
||||
session.track_mut(producer).unwrap().mixer.output = Route::Bus(join);
|
||||
assert_pcm(&engine_block(&mut engine, &session), &expected, "after output edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_edit_reorders_the_same_engine_before_the_next_block() {
|
||||
let mut session = session(true);
|
||||
let root = session.add_bus("Root", ChannelFormat::Stereo);
|
||||
let join = session.add_bus("Join", ChannelFormat::Stereo);
|
||||
let consumer = add_aux(&mut session, TrackKind::Folder, root, Route::Main);
|
||||
add_aux(&mut session, TrackKind::Aux, root, Route::Bus(join));
|
||||
add_source(&mut session, Route::Bus(root));
|
||||
let mut engine = MixEngine::new(48_000.0, 128);
|
||||
let expected = first_block(signal());
|
||||
assert_pcm(&engine_block(&mut engine, &session), &expected, "before input edit");
|
||||
session.track_mut(consumer).unwrap().mixer.input = Route::Bus(join);
|
||||
assert_pcm(&engine_block(&mut engine, &session), &expected, "after input edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_add_retarget_remove_and_readd_update_the_same_engine() {
|
||||
let mut session = session(true);
|
||||
let root = session.add_bus("Root", ChannelFormat::Stereo);
|
||||
let join = session.add_bus("Join", ChannelFormat::Stereo);
|
||||
let unused = session.add_bus("Unused", ChannelFormat::Stereo);
|
||||
add_aux(&mut session, TrackKind::Aux, join, Route::Main);
|
||||
let producer = add_aux(&mut session, TrackKind::Folder, root, Route::None);
|
||||
add_source(&mut session, Route::Bus(root));
|
||||
let mut engine = MixEngine::new(48_000.0, 128);
|
||||
let expected = first_block(signal());
|
||||
let silence = vec![vec![0.0; 128]; 2];
|
||||
assert_pcm(&engine_block(&mut engine, &session), &silence, "before send");
|
||||
session.track_mut(producer).unwrap().mixer.sends[0] = Some(send(join));
|
||||
assert_pcm(&engine_block(&mut engine, &session), &expected, "send added");
|
||||
session.track_mut(producer).unwrap().mixer.sends[0].as_mut().unwrap().target = Route::Bus(unused);
|
||||
assert_pcm(&engine_block(&mut engine, &session), &silence, "send retargeted");
|
||||
session.track_mut(producer).unwrap().mixer.sends[0].as_mut().unwrap().target = Route::Bus(join);
|
||||
assert_pcm(&engine_block(&mut engine, &session), &expected, "send retargeted back");
|
||||
session.track_mut(producer).unwrap().mixer.sends[0] = None;
|
||||
assert_pcm(&engine_block(&mut engine, &session), &silence, "send removed");
|
||||
session.track_mut(producer).unwrap().mixer.sends[0] = Some(send(join));
|
||||
assert_pcm(&engine_block(&mut engine, &session), &expected, "send readded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_reorder_removal_and_new_identity_rebuild_the_same_engine() {
|
||||
let mut chain = reverse_chain(true, false, false);
|
||||
let mut engine = MixEngine::new(48_000.0, 128);
|
||||
let expected = first_block(signal());
|
||||
assert_pcm(&engine_block(&mut engine, &chain.session), &expected, "initial track order");
|
||||
chain.session.tracks.reverse();
|
||||
assert_pcm(&engine_block(&mut engine, &chain.session), &expected, "tracks reordered");
|
||||
chain.session.tracks.retain(|track| track.id != chain.tracks[6]);
|
||||
let silence = vec![vec![0.0; 128]; 2];
|
||||
assert_pcm(&engine_block(&mut engine, &chain.session), &silence, "track removed");
|
||||
let replacement = add_aux(&mut chain.session, TrackKind::Aux, chain.buses[6], Route::Bus(chain.buses[7]));
|
||||
assert_ne!(replacement, chain.tracks[6]);
|
||||
assert_pcm(&engine_block(&mut engine, &chain.session), &expected, "track readded with new identity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_and_bus_format_edits_refresh_the_same_engine_workspace() {
|
||||
let mut chain = reverse_chain(true, false, false);
|
||||
let mut engine = MixEngine::new(48_000.0, 128);
|
||||
let stereo = first_block(signal());
|
||||
assert_pcm(&engine_block(&mut engine, &chain.session), &stereo, "initial stereo formats");
|
||||
chain.session.track_mut(chain.tracks[5]).unwrap().format = ChannelFormat::Mono;
|
||||
// A stereo bus folds to mono at -6 dB; the retained hard-left pan sends that mono to L.
|
||||
let mono: Vec<_> = stereo[0].iter().zip(&stereo[1]).map(|(left, right)| 0.5 * (left + right)).collect();
|
||||
assert_pcm(&engine_block(&mut engine, &chain.session), &[mono.clone(), vec![0.0; 128]], "mono strip");
|
||||
chain.session.track_mut(chain.tracks[5]).unwrap().format = ChannelFormat::Stereo;
|
||||
assert_pcm(&engine_block(&mut engine, &chain.session), &stereo, "stereo strip restored");
|
||||
chain.session.busses.iter_mut().find(|bus| bus.id == chain.buses[5]).unwrap().format = ChannelFormat::Mono;
|
||||
// A mono bus feeds both channels of the following stereo aux at unity.
|
||||
assert_pcm(&engine_block(&mut engine, &chain.session), &[mono.clone(), mono], "mono bus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_routing_is_invariant_to_offline_and_callback_block_sizes() {
|
||||
let chain = reverse_chain(true, true, true);
|
||||
let expected = signal();
|
||||
for block in [16, 63, 97, 256, 512, 1024] {
|
||||
assert_pcm(&render(&chain.session, block), &expected, "offline block size");
|
||||
}
|
||||
let mut engine = MixEngine::new(48_000.0, 1024);
|
||||
let callbacks = [1, 63, 257, 17, 511, 1024];
|
||||
let mut out = vec![Vec::new(), Vec::new()];
|
||||
let mut position = 0;
|
||||
let mut callback = 0;
|
||||
while position < FRAMES {
|
||||
let frames = callbacks[callback % callbacks.len()].min(FRAMES - position);
|
||||
let mut block = vec![vec![0.0; frames]; 2];
|
||||
engine.render(&chain.session, position as i64, frames, &mut block);
|
||||
for (destination, channel) in out.iter_mut().zip(block) {
|
||||
destination.extend(channel);
|
||||
}
|
||||
position += frames;
|
||||
callback += 1;
|
||||
}
|
||||
assert_pcm(&out, &expected, "varying callback sizes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_loop_preserves_audio_and_selects_fallback_for_the_whole_graph() {
|
||||
for pdc in [false, true] {
|
||||
let mut chain = reverse_chain(pdc, false, false);
|
||||
let loop_bus = chain.session.add_bus("Self loop", ChannelFormat::Stereo);
|
||||
let aux = add_aux(&mut chain.session, TrackKind::Aux, loop_bus, Route::Main);
|
||||
chain.session.track_mut(aux).unwrap().mixer.sends[0] = Some(send(loop_bus));
|
||||
add_source(&mut chain.session, Route::Bus(loop_bus));
|
||||
// The self-loop reads its source once, then writes back to the already-read bus.
|
||||
// The disconnected deep chain remains silent under the established eight-hop fallback.
|
||||
assert_pcm(&render(&chain.session, 97), &signal(), "self-loop whole-graph fallback");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_aux_cycle_preserves_session_order_audio_and_whole_graph_fallback() {
|
||||
for pdc in [false, true] {
|
||||
for reverse in [false, true] {
|
||||
let mut chain = reverse_chain(pdc, false, false);
|
||||
let a_bus = chain.session.add_bus("Cycle A", ChannelFormat::Stereo);
|
||||
let b_bus = chain.session.add_bus("Cycle B", ChannelFormat::Stereo);
|
||||
let (a, b) = if reverse {
|
||||
let b = add_aux(&mut chain.session, TrackKind::Aux, b_bus, Route::Main);
|
||||
let a = add_aux(&mut chain.session, TrackKind::Folder, a_bus, Route::Main);
|
||||
(a, b)
|
||||
} else {
|
||||
let a = add_aux(&mut chain.session, TrackKind::Folder, a_bus, Route::Main);
|
||||
let b = add_aux(&mut chain.session, TrackKind::Aux, b_bus, Route::Main);
|
||||
(a, b)
|
||||
};
|
||||
chain.session.track_mut(a).unwrap().mixer.sends[0] = Some(send(b_bus));
|
||||
chain.session.track_mut(b).unwrap().mixer.sends[0] = Some(send(a_bus));
|
||||
add_source(&mut chain.session, Route::Bus(a_bus));
|
||||
// A then B: both hear the source. B then A: B reads its still-empty bus.
|
||||
// Buses clear each block, so neither order feeds a previous block back into this one.
|
||||
let expected = scaled_signal(if reverse { 1.0 } else { 2.0 });
|
||||
assert_pcm(&render(&chain.session, 256), &expected, "two-aux whole-graph fallback");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod aux_routing_order;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod bus_aux_interval;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod master_insert_latency;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod strip_mute_automation;
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Ordinary strip mute changes sample gates without splitting processor blocks.
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_dsp::Plugin;
|
||||
use soundcraft_mix::{MixEngine, PreparedInstance, render_range};
|
||||
use soundcraft_model::{
|
||||
AutoParam, AutomationLane, AutomationMode, ChannelFormat, Clip, Insert, Route, SendSlot, Session, SourceAudio, SourceId, TrackId, TrackKind,
|
||||
};
|
||||
use soundcraft_time::Range;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
const LEN: usize = 768;
|
||||
fn fixture() -> (Session, TrackId) {
|
||||
let mut s = Session::default();
|
||||
s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; LEN]; 2] })));
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Source"));
|
||||
let clip = s.new_clip_id();
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
tr.playlist_mut().unwrap().clips.push(Clip::audio(clip, "tone", SourceId(901), 0, 0, LEN as i64));
|
||||
tr.mixer.pan = vec![-1.0, 1.0];
|
||||
tr.mixer.output = Route::Main;
|
||||
(s, t)
|
||||
}
|
||||
fn mute(s: &mut Session, t: TrackId, start: i64, end: i64) {
|
||||
let lane = s.track_mut(t).unwrap().lane_mut(&AutoParam::Mute);
|
||||
for (at, value) in [(0, 0.0), (start - 1, 0.0), (start, 1.0), (end - 1, 1.0), (end, 0.0)] {
|
||||
lane.set_point(at, value);
|
||||
}
|
||||
}
|
||||
fn rendered(s: &Session, block: usize) -> Vec<Vec<f32>> {
|
||||
render_range(s, Range::new(0, LEN as i64), block)
|
||||
}
|
||||
fn gated(actual: &[Vec<f32>], reference: &[Vec<f32>], start: usize, end: usize) {
|
||||
assert_eq!(actual.len(), reference.len());
|
||||
assert!(reference.iter().flatten().any(|x| x.abs() > 0.01));
|
||||
for (got, want) in actual.iter().zip(reference) {
|
||||
assert_eq!(got.len(), LEN);
|
||||
for i in 0..LEN {
|
||||
assert_eq!(got[i], if (start..end).contains(&i) { 0.0 } else { want[i] }, "frame {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn internal_and_exact_block_edges_are_partition_independent() {
|
||||
for (start, end) in [(101, 200), (128, 256), (1, 767)] {
|
||||
let (mut s, t) = fixture();
|
||||
let reference = rendered(&s, 512);
|
||||
mute(&mut s, t, start, end);
|
||||
for block in [16, 63, 128, 512, 1024] {
|
||||
gated(&rendered(&s, block), &reference, start as usize, end as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn existing_held_mute_curve_and_threshold_are_preserved() {
|
||||
let (mut s, t) = fixture();
|
||||
let reference = rendered(&s, 512);
|
||||
let lane = s.track_mut(t).unwrap().lane_mut(&AutoParam::Mute);
|
||||
lane.set_point(0, 0.49);
|
||||
lane.set_point(200, 0.5);
|
||||
gated(&rendered(&s, 512), &reference, 200, LEN);
|
||||
}
|
||||
#[test]
|
||||
fn pre_and_post_fader_send_audio_share_the_track_mute_gate() {
|
||||
for pre in [false, true] {
|
||||
let (mut s, t) = fixture();
|
||||
let bus = s.add_bus("Send", ChannelFormat::Stereo);
|
||||
let aux = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("Return"));
|
||||
s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus);
|
||||
s.track_mut(aux).unwrap().mixer.output = Route::Main;
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
tr.mixer.output = Route::None;
|
||||
tr.mixer.volume_db = -9.0;
|
||||
let mut send = SendSlot::new(Route::Bus(bus));
|
||||
send.pre_fader = pre;
|
||||
send.level_db = -3.0;
|
||||
tr.mixer.sends[0] = Some(send);
|
||||
let reference = rendered(&s, 512);
|
||||
mute(&mut s, t, 101, 200);
|
||||
for block in [63, 512] {
|
||||
gated(&rendered(&s, block), &reference, 101, 200);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn off_empty_and_first_empty_lane_keep_exact_unautomated_pcm() {
|
||||
let (mut s, t) = fixture();
|
||||
let reference = rendered(&s, 512);
|
||||
s.track_mut(t).unwrap().lane_mut(&AutoParam::Mute);
|
||||
assert_eq!(rendered(&s, 512), reference);
|
||||
let mut second = AutomationLane::new(AutoParam::Mute);
|
||||
second.set_point(0, 1.0);
|
||||
s.track_mut(t).unwrap().automation.push(second);
|
||||
assert_eq!(rendered(&s, 512), reference, "first empty lane remains authoritative");
|
||||
s.track_mut(t).unwrap().automation.clear();
|
||||
mute(&mut s, t, 101, 200);
|
||||
s.track_mut(t).unwrap().mixer.automation_mode = AutomationMode::Off;
|
||||
assert_eq!(rendered(&s, 512), reference);
|
||||
}
|
||||
#[test]
|
||||
fn reading_modes_retain_existing_policy() {
|
||||
for mode in AutomationMode::ALL.into_iter().filter(|mode| mode.reads()) {
|
||||
let (mut s, t) = fixture();
|
||||
let reference = rendered(&s, 512);
|
||||
mute(&mut s, t, 101, 200);
|
||||
s.track_mut(t).unwrap().mixer.automation_mode = mode;
|
||||
gated(&rendered(&s, 512), &reference, 101, 200);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn static_mute_and_solo_suppression_cannot_be_reopened_by_automation() {
|
||||
for solo in [false, true] {
|
||||
let (mut s, t) = fixture();
|
||||
if solo {
|
||||
let other = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
|
||||
s.track_mut(other).unwrap().mixer.solo = true;
|
||||
} else {
|
||||
s.track_mut(t).unwrap().mixer.mute = true;
|
||||
}
|
||||
let reference = rendered(&s, 512);
|
||||
assert!(reference.iter().flatten().all(|x| x.abs() < 1e-6));
|
||||
mute(&mut s, t, 101, 200);
|
||||
gated_silent(&rendered(&s, 512));
|
||||
}
|
||||
}
|
||||
fn gated_silent(output: &[Vec<f32>]) {
|
||||
assert!(output.iter().flatten().all(|x| x.abs() < 1e-6));
|
||||
}
|
||||
#[test]
|
||||
fn aux_and_folder_inputs_gate_their_audio_inside_the_block() {
|
||||
for kind in [TrackKind::Aux, TrackKind::Folder] {
|
||||
let (mut s, source) = fixture();
|
||||
let bus = s.add_bus("Input", ChannelFormat::Stereo);
|
||||
s.track_mut(source).unwrap().mixer.output = Route::Bus(bus);
|
||||
let target = s.add_track(kind, ChannelFormat::Stereo, Some("Return"));
|
||||
s.track_mut(target).unwrap().mixer.input = Route::Bus(bus);
|
||||
s.track_mut(target).unwrap().mixer.output = Route::Main;
|
||||
let reference = rendered(&s, 512);
|
||||
mute(&mut s, target, 101, 200);
|
||||
gated(&rendered(&s, 512), &reference, 101, 200);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn no_active_mute_lane_preserves_fader_and_pan_schedules_exactly() {
|
||||
let (mut s, t) = fixture();
|
||||
for (param, a, b) in [(AutoParam::Volume, -12.0, -3.0), (AutoParam::Pan(0), -1.0, 0.5)] {
|
||||
let lane = s.track_mut(t).unwrap().lane_mut(¶m);
|
||||
lane.set_point(0, a);
|
||||
lane.set_point(LEN as i64, b);
|
||||
}
|
||||
for block in [63, 256, 512] {
|
||||
let reference = rendered(&s, block);
|
||||
let mut with_lane = s.clone();
|
||||
with_lane.track_mut(t).unwrap().lane_mut(&AutoParam::Mute).set_point(0, 0.0);
|
||||
assert_eq!(rendered(&with_lane, block), reference);
|
||||
with_lane.track_mut(t).unwrap().mixer.automation_mode = AutomationMode::Off;
|
||||
let mut no_lane = with_lane.clone();
|
||||
no_lane.track_mut(t).unwrap().automation.retain(|lane| lane.param != AutoParam::Mute);
|
||||
assert_eq!(rendered(&with_lane, block), rendered(&no_lane, block));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn static_vca_mute_remains_dominant_over_member_mute_automation() {
|
||||
let (mut s, t) = fixture();
|
||||
let vca = s.add_track(TrackKind::Vca, ChannelFormat::Stereo, Some("VCA"));
|
||||
s.track_mut(vca).unwrap().mixer.mute = true;
|
||||
s.track_mut(t).unwrap().mixer.vca = Some(vca.0);
|
||||
let reference = rendered(&s, 512);
|
||||
gated_silent(&reference);
|
||||
mute(&mut s, t, 101, 200);
|
||||
gated_silent(&rendered(&s, 512));
|
||||
}
|
||||
|
||||
struct Counted {
|
||||
inner: Box<dyn Plugin>,
|
||||
calls: Arc<Mutex<Vec<usize>>>,
|
||||
instrument: bool,
|
||||
}
|
||||
impl Plugin for Counted {
|
||||
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
|
||||
self.inner.info()
|
||||
}
|
||||
fn prepare(&mut self, sr: f32, block: usize, ch: usize) {
|
||||
self.inner.prepare(sr, block, ch);
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn set_param(&mut self, id: &str, value: f32) -> bool {
|
||||
self.inner.set_param(id, value)
|
||||
}
|
||||
fn param(&self, id: &str) -> Option<f32> {
|
||||
self.inner.param(id)
|
||||
}
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
self.calls.lock().unwrap().push(frames);
|
||||
if self.instrument {
|
||||
for channel in io {
|
||||
channel.iter_mut().take(frames).for_each(|sample| *sample = 0.25);
|
||||
}
|
||||
} else {
|
||||
self.inner.process(io, frames);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn hosted_processing_keeps_the_callers_blocks_and_unmuted_pcm() {
|
||||
for instrument in [false, true] {
|
||||
let (mut s, t) = fixture();
|
||||
let reference = rendered(&s, 256);
|
||||
mute(&mut s, t, 101, 200);
|
||||
let slot = if instrument {
|
||||
s.track_mut(t).unwrap().kind = TrackKind::Instrument;
|
||||
s.track_mut(t).unwrap().instrument = Some(Insert::new("clap:test.mute-cadence"));
|
||||
soundcraft_mix::INSTRUMENT_SLOT
|
||||
} else {
|
||||
s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.mute-cadence"));
|
||||
0
|
||||
};
|
||||
let calls = Arc::new(Mutex::new(Vec::with_capacity(4)));
|
||||
let mut plugin = soundcraft_dsp::create("gain").unwrap();
|
||||
plugin.prepare(48_000.0, 256, 2);
|
||||
let mut mix = MixEngine::new(48_000.0, 256);
|
||||
mix.set_external_instances(true);
|
||||
mix.adopt(vec![PreparedInstance {
|
||||
track: t,
|
||||
slot,
|
||||
id: "clap:test.mute-cadence".into(),
|
||||
ch: 2,
|
||||
plugin: Box::new(Counted { inner: plugin, calls: Arc::clone(&calls), instrument }),
|
||||
}]);
|
||||
let mut output = vec![Vec::new(); 2];
|
||||
for (pos, frames) in [(0, 256), (256, 128), (384, 256), (640, 128)] {
|
||||
let mut block = vec![vec![0.0; frames]; 2];
|
||||
mix.render(&s, pos, frames, &mut block);
|
||||
for (out, channel) in output.iter_mut().zip(block) {
|
||||
out.extend(channel);
|
||||
}
|
||||
}
|
||||
assert_eq!(*calls.lock().unwrap(), vec![256, 128, 256, 128]);
|
||||
gated(&output, &reference, 101, 200);
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,10 @@ pub struct Clip {
|
||||
/// Optional colour override (RGB).
|
||||
#[serde(default)]
|
||||
pub color: Option<[u8; 3]>,
|
||||
/// The key in [`crate::Session::auto_names`] of the names that editing gives the clips it
|
||||
/// separates from this one (Clip List › Auto Rename). `None` keeps the clip's name.
|
||||
#[serde(default)]
|
||||
pub auto_name: Option<u64>,
|
||||
/// Elastic / Vari-speed ratio (1.0 = none) — applied by rendered processing.
|
||||
#[serde(default = "one")]
|
||||
pub stretch: f64,
|
||||
@@ -160,6 +164,7 @@ impl Clip {
|
||||
rating: 0,
|
||||
sync_point: 0,
|
||||
color: None,
|
||||
auto_name: None,
|
||||
stretch: 1.0,
|
||||
group: None,
|
||||
loop_id: None,
|
||||
@@ -242,15 +247,33 @@ impl Clip {
|
||||
g
|
||||
}
|
||||
|
||||
/// Gain envelope value in dB at `rel` samples from the clip start (0 without an envelope).
|
||||
pub fn env_db_at(&self, rel: Samples) -> f32 {
|
||||
env_value(&self.gain_env, rel)
|
||||
}
|
||||
|
||||
/// Move the start edge to `new_start` keeping material anchored (trim start).
|
||||
pub fn trim_start_to(&mut self, new_start: Samples) {
|
||||
let new_start = new_start.min(self.end() - 1);
|
||||
let delta = new_start - self.start;
|
||||
if let ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } = &mut self.content {
|
||||
*offset = offset.saturating_add(delta);
|
||||
// #907: these are public APIs, so they have to survive arguments a command would never
|
||||
// pass (commands clamp to `MAX_POSITION` first). `Samples` is `i64`, so an extreme
|
||||
// `new_start` against an extreme `start` wrapped in release and panicked in debug.
|
||||
// Never so far back that the length would pass `MAX_POSITION`: trimming the start keeps the end.
|
||||
let end = self.end();
|
||||
let new_start = new_start.max(end.saturating_sub(MAX_POSITION)).clamp(-MAX_POSITION, MAX_POSITION).min(end.saturating_sub(1));
|
||||
let delta = new_start.saturating_sub(self.start);
|
||||
// The mixer plays a stretched audio clip from `offset + rel / stretch`.
|
||||
let stretch = if self.stretch.is_finite() && self.stretch > 0.0 { self.stretch } else { 1.0 };
|
||||
match &mut self.content {
|
||||
ClipContent::Audio { offset, .. } if (stretch - 1.0).abs() >= 1e-9 => {
|
||||
*offset = offset.saturating_add((delta as f64 / stretch).round() as Samples);
|
||||
}
|
||||
ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } => {
|
||||
*offset = offset.saturating_add(delta);
|
||||
}
|
||||
ClipContent::Midi { .. } => {}
|
||||
}
|
||||
// MIDI note ticks are re-anchored by the engine, which knows the tempo map.
|
||||
self.length -= delta;
|
||||
self.length = self.length.saturating_sub(delta).clamp(1, MAX_POSITION);
|
||||
self.start = new_start;
|
||||
if self.sync_point != 0 {
|
||||
self.sync_point = self.sync_point.saturating_sub(delta);
|
||||
@@ -267,8 +290,9 @@ impl Clip {
|
||||
|
||||
/// Move the end edge to `new_end`.
|
||||
pub fn trim_end_to(&mut self, new_end: Samples) {
|
||||
let new_end = new_end.max(self.start + 1);
|
||||
self.length = new_end - self.start;
|
||||
// #907: saturating, like `trim_start_to`.
|
||||
let new_end = new_end.clamp(-MAX_POSITION, MAX_POSITION).max(self.start.saturating_add(1));
|
||||
self.length = new_end.saturating_sub(self.start).clamp(1, MAX_POSITION);
|
||||
self.drop_stray_sync();
|
||||
self.clamp_fades();
|
||||
}
|
||||
@@ -479,4 +503,42 @@ mod tests {
|
||||
c.trim_start_to(900);
|
||||
assert!(c.fade_out.len <= c.length);
|
||||
}
|
||||
|
||||
/// #907: `trim_start_to` / `trim_end_to` are public APIs, so they have to survive arguments a
|
||||
/// command would never pass. `Samples` is `i64`, so an extreme `new_start` against an extreme
|
||||
/// `start` wrapped in release and panicked in debug. Every position stays within
|
||||
/// `±MAX_POSITION` and every length within `1..=MAX_POSITION`.
|
||||
#[test]
|
||||
fn trims_saturate_on_extreme_arguments() {
|
||||
for &new_start in &[Samples::MIN, -(MAX_POSITION * 2), -1, 0, MAX_POSITION, MAX_POSITION * 2, Samples::MAX] {
|
||||
let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 1000, MAX_POSITION / 2, 1000);
|
||||
c.trim_start_to(new_start);
|
||||
assert!((-(MAX_POSITION as i128)..=(MAX_POSITION as i128)).contains(&(c.start as i128)), "start {new_start}: {}", c.start);
|
||||
assert!((1..=MAX_POSITION).contains(&c.length), "start {new_start}: length {}", c.length);
|
||||
assert_eq!(c.end(), MAX_POSITION / 2 + 1000, "start {new_start} moved the end");
|
||||
}
|
||||
for &new_end in &[Samples::MIN, -(MAX_POSITION * 2), -1, 0, MAX_POSITION, MAX_POSITION * 2, Samples::MAX] {
|
||||
let mut c = Clip::audio(ClipId(2), "b", SourceId(1), 1000, -(MAX_POSITION / 2), 1000);
|
||||
c.trim_end_to(new_end);
|
||||
assert!((1..=MAX_POSITION).contains(&c.length), "end {new_end}: length {}", c.length);
|
||||
assert!(c.end() - c.start == c.length, "end {new_end}: start {} end {}", c.start, c.end());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_start_of_stretched_audio_advances_offset_by_source_time() {
|
||||
// The mixer plays source `offset + rel / stretch`, so a split at timeline 1000 of a 2x clip
|
||||
// must start the right half at source sample 500.
|
||||
let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 0, 2000);
|
||||
c.stretch = 2.0;
|
||||
c.trim_start_to(1000);
|
||||
assert_eq!(c.source_offset(), 500);
|
||||
let mut c = Clip::audio(ClipId(2), "b", SourceId(1), 0, 0, 2000);
|
||||
c.stretch = 0.5;
|
||||
c.trim_start_to(1000);
|
||||
assert_eq!(c.source_offset(), 2000);
|
||||
let mut c = Clip::audio(ClipId(3), "c", SourceId(1), 0, 0, 2000);
|
||||
c.trim_start_to(1000);
|
||||
assert_eq!(c.source_offset(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ pub use mixer::{
|
||||
Bus, ChannelFormat, INSERT_SLOTS, Insert, Mixer, OutputPath, Route, SEND_SLOTS, SendSlot, Speaker, SurroundPan, fader_db_to_pos, fader_pos_to_db,
|
||||
};
|
||||
pub use session::{
|
||||
BitDepthSetting, EditMode, EditState, FEET_FRAMES_START, MAX_COMMENT_CHARS, MAX_NAME_CHARS, SESSION_EXTENSION, Session, SessionError, Tool,
|
||||
TrackView, ZoomState, ZoomToggle, truncate_chars,
|
||||
AutoName, BitDepthSetting, EditMode, EditState, FEET_FRAMES_START, MAX_COMMENT_CHARS, MAX_NAME_CHARS, SESSION_EXTENSION, Session, SessionError,
|
||||
Tool, TrackView, ZoomState, ZoomToggle, truncate_chars,
|
||||
};
|
||||
pub use source::{Source, SourceAudio, SourcePool, VideoSource};
|
||||
pub use track::{Playlist, TRACK_COLORS, Track, TrackHeight, TrackKind};
|
||||
|
||||
@@ -295,6 +295,40 @@ impl Default for EditState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip List › Auto Rename: the names that editing gives the clips it separates from a clip.
|
||||
/// Each new clip is named `name`, then `zeros` zeros, then `number`, then `suffix`, and `number`
|
||||
/// counts up by one for the next clip.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AutoName {
|
||||
pub name: String,
|
||||
/// The number the next new clip gets.
|
||||
pub number: u64,
|
||||
pub zeros: usize,
|
||||
pub suffix: String,
|
||||
/// Tells this scheme from another one under the same key after an undo or in another session,
|
||||
/// for pastes from the clipboard. 0 matches no scheme.
|
||||
#[serde(default)]
|
||||
pub id: u64,
|
||||
}
|
||||
|
||||
impl AutoName {
|
||||
/// The largest starting number.
|
||||
pub const MAX_NUMBER: u64 = 999_999_999;
|
||||
/// The most zeros before the number.
|
||||
pub const MAX_ZEROS: usize = 9;
|
||||
|
||||
/// Room for `name` and `suffix` together: 29 characters stay free for 9 zeros and the 20
|
||||
/// digits of a u64, so a clip name never loses its number to [`MAX_NAME_CHARS`].
|
||||
pub const MAX_TEXT_CHARS: usize = MAX_NAME_CHARS - 29;
|
||||
|
||||
/// Repair a scheme read from a file: names that can be drawn and at most [`Self::MAX_ZEROS`] zeros.
|
||||
pub fn sanitize(&mut self) {
|
||||
truncate_chars(&mut self.name, Self::MAX_TEXT_CHARS);
|
||||
truncate_chars(&mut self.suffix, Self::MAX_TEXT_CHARS - self.name.chars().count());
|
||||
self.zeros = self.zeros.min(Self::MAX_ZEROS);
|
||||
}
|
||||
}
|
||||
|
||||
/// The document.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Session {
|
||||
@@ -317,6 +351,10 @@ pub struct Session {
|
||||
pub outputs: Vec<OutputPath>,
|
||||
pub markers: Vec<MemoryLocation>,
|
||||
pub groups: Vec<Group>,
|
||||
/// Clip List › Auto Rename schemes, keyed by the ids in [`Clip::auto_name`]. The clips separated
|
||||
/// from one clip share its scheme, so they count up one number sequence.
|
||||
#[serde(default)]
|
||||
pub auto_names: std::collections::BTreeMap<u64, crate::AutoName>,
|
||||
pub edit: EditState,
|
||||
pub next_id: u64,
|
||||
#[serde(default)]
|
||||
@@ -358,6 +396,7 @@ impl Session {
|
||||
outputs: vec![OutputPath { name: "Out 1-2".into(), first_channel: 0, format: ChannelFormat::Stereo }],
|
||||
markers: Vec::new(),
|
||||
groups: Vec::new(),
|
||||
auto_names: std::collections::BTreeMap::new(),
|
||||
edit: EditState::default(),
|
||||
next_id: 1,
|
||||
comments: String::new(),
|
||||
@@ -526,6 +565,12 @@ impl Session {
|
||||
ClipId(self.alloc())
|
||||
}
|
||||
|
||||
/// Drop the Auto Rename schemes that no clip on any playlist uses.
|
||||
pub fn prune_auto_names(&mut self) {
|
||||
let used: std::collections::BTreeSet<u64> = self.all_clips().filter_map(|c| c.auto_name).collect();
|
||||
self.auto_names.retain(|k, _| used.contains(k));
|
||||
}
|
||||
|
||||
// ---- persistence ---------------------------------------------------------------------
|
||||
|
||||
pub fn to_json(&self) -> Result<String, SessionError> {
|
||||
@@ -541,6 +586,7 @@ impl Session {
|
||||
}
|
||||
let mut s: Session = serde_json::from_value(v).map_err(|e| SessionError::Format(e.to_string()))?;
|
||||
s.sanitize();
|
||||
s.validate()?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
@@ -552,6 +598,7 @@ impl Session {
|
||||
.map(|t| t.id.0)
|
||||
.chain(self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0))))
|
||||
.chain(self.sources.iter().map(|s| s.id.0))
|
||||
.chain(self.auto_names.keys().copied())
|
||||
.chain(self.videos.iter().map(|v| v.id.0))
|
||||
.chain(self.busses.iter().map(|b| b.id.0))
|
||||
.chain(self.markers.iter().map(|m| m.id.0))
|
||||
@@ -625,6 +672,8 @@ impl Session {
|
||||
let clip_ids: std::collections::BTreeSet<u64> =
|
||||
self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0))).collect();
|
||||
self.midi_originals.retain(|id, _| clip_ids.contains(id));
|
||||
self.prune_auto_names();
|
||||
self.auto_names.values_mut().for_each(crate::AutoName::sanitize);
|
||||
if !self.edit.zoom.samples_per_px.is_finite() || self.edit.zoom.samples_per_px <= 0.0 {
|
||||
self.edit.zoom.samples_per_px = 1024.0;
|
||||
}
|
||||
@@ -633,6 +682,25 @@ impl Session {
|
||||
}
|
||||
self.tempo.sanitize();
|
||||
}
|
||||
|
||||
/// Reject a document whose ids are ambiguous or exhausted, run after [`Session::sanitize`] on
|
||||
/// untrusted data. Duplicate ids in a namespace make commands, selections and source
|
||||
/// associations act on an arbitrary one of the objects; a maximal `next_id` -- or a maximal
|
||||
/// existing id, which `sanitize` folds into `next_id` -- makes every later allocation return
|
||||
/// `u64::MAX` and repeat itself.
|
||||
fn validate(&self) -> Result<(), SessionError> {
|
||||
if self.next_id == u64::MAX {
|
||||
return Err(SessionError::Invalid("the session id space is exhausted".into()));
|
||||
}
|
||||
distinct("track", self.tracks.iter().map(|t| t.id.0))?;
|
||||
distinct("clip", self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0))))?;
|
||||
distinct("source", self.sources.iter().map(|s| s.id.0))?;
|
||||
distinct("video", self.videos.iter().map(|v| v.id.0))?;
|
||||
distinct("bus", self.busses.iter().map(|b| b.id.0))?;
|
||||
distinct("marker", self.markers.iter().map(|m| m.id.0))?;
|
||||
distinct("group", self.groups.iter().map(|g| g.id.0))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The most characters kept of a name read from a file: longer ones can't be shown, and drawing
|
||||
@@ -648,6 +716,17 @@ pub fn truncate_chars(s: &mut String, max: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every id in `ids` distinct, or an error naming the namespace and the repeated id.
|
||||
fn distinct(kind: &str, ids: impl Iterator<Item = u64>) -> Result<(), SessionError> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for id in ids {
|
||||
if !seen.insert(id) {
|
||||
return Err(SessionError::Invalid(format!("duplicate {kind} id {id}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -681,6 +760,19 @@ mod tests {
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_with_legacy_fit_height_loads() {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
|
||||
if let Some(track) = s.track_mut(t) {
|
||||
track.height = crate::TrackHeight::Large;
|
||||
}
|
||||
let text = s.to_json().unwrap().replace("\"height\": \"Large\"", "\"height\": \"Fit\"");
|
||||
assert!(text.contains("\"height\": \"Fit\""));
|
||||
let back = Session::from_json(&text).unwrap();
|
||||
assert_eq!(back.track(t).map(|tr| tr.height), Some(crate::TrackHeight::Medium));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_clip_round_trips_and_old_sessions_load() {
|
||||
let mut s = Session::default();
|
||||
@@ -770,6 +862,33 @@ mod tests {
|
||||
assert!(matches!(Session::from_json(&v.to_string()), Err(SessionError::Version(999))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_ids_are_rejected_on_load() {
|
||||
let mut s = Session::default();
|
||||
s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let mut v = serde_json::to_value(&s).unwrap();
|
||||
v["tracks"][1]["id"] = v["tracks"][0]["id"].clone();
|
||||
let err = Session::from_json(&v.to_string()).unwrap_err();
|
||||
assert!(matches!(&err, SessionError::Invalid(m) if m.contains("duplicate track id")), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exhausted_id_space_is_rejected_on_load() {
|
||||
let mut s = Session::default();
|
||||
s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let mut v = serde_json::to_value(&s).unwrap();
|
||||
v["next_id"] = serde_json::json!(u64::MAX);
|
||||
let err = Session::from_json(&v.to_string()).unwrap_err();
|
||||
assert!(matches!(&err, SessionError::Invalid(m) if m.contains("exhausted")), "{err}");
|
||||
// A maximal existing id is exhausted too, however `next_id` was written: every later
|
||||
// allocation would return `u64::MAX` again.
|
||||
let mut v = serde_json::to_value(&s).unwrap();
|
||||
v["tracks"][0]["id"] = serde_json::json!(u64::MAX);
|
||||
let err = Session::from_json(&v.to_string()).unwrap_err();
|
||||
assert!(matches!(&err, SessionError::Invalid(m) if m.contains("exhausted")), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_repairs() {
|
||||
let mut s = Session::default();
|
||||
@@ -785,4 +904,11 @@ mod tests {
|
||||
assert_eq!(tr.mixer.volume_db, 0.0);
|
||||
assert!(s.next_id > t.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loaded_auto_name_scheme_leaves_room_for_its_number() {
|
||||
let mut scheme = crate::AutoName { name: "n".repeat(300), suffix: "s".repeat(300), ..Default::default() };
|
||||
scheme.sanitize();
|
||||
assert_eq!(scheme.name.chars().count() + scheme.suffix.chars().count(), crate::AutoName::MAX_TEXT_CHARS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ impl TrackKind {
|
||||
|
||||
/// Track heights (View › Track Height presets).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(from = "TrackHeightRepr")]
|
||||
pub enum TrackHeight {
|
||||
/// Any height from Micro to Extreme, in points.
|
||||
Custom(u16),
|
||||
@@ -99,6 +100,51 @@ pub enum TrackHeight {
|
||||
Fit(u16),
|
||||
}
|
||||
|
||||
/// What a saved track height may look like: the current form, or the bare `"Fit"` string older sessions used.
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum TrackHeightRepr {
|
||||
Current(TrackHeightCurrent),
|
||||
LegacyFit(LegacyFit),
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
enum LegacyFit {
|
||||
Fit,
|
||||
}
|
||||
|
||||
/// Mirror of `TrackHeight` that derives the current serialized form.
|
||||
#[derive(serde::Deserialize)]
|
||||
enum TrackHeightCurrent {
|
||||
Custom(u16),
|
||||
Micro,
|
||||
Mini,
|
||||
Small,
|
||||
Medium,
|
||||
Large,
|
||||
Jumbo,
|
||||
Extreme,
|
||||
Fit(u16),
|
||||
}
|
||||
|
||||
impl From<TrackHeightRepr> for TrackHeight {
|
||||
fn from(repr: TrackHeightRepr) -> Self {
|
||||
match repr {
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Custom(p)) => TrackHeight::Custom(p),
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Micro) => TrackHeight::Micro,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Mini) => TrackHeight::Mini,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Small) => TrackHeight::Small,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Medium) => TrackHeight::Medium,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Large) => TrackHeight::Large,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Jumbo) => TrackHeight::Jumbo,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Extreme) => TrackHeight::Extreme,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Fit(p)) => TrackHeight::Fit(p),
|
||||
// Older sessions stored Fit with no size, drawn 88 points tall: the default Medium height.
|
||||
TrackHeightRepr::LegacyFit(LegacyFit::Fit) => TrackHeight::Medium,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackHeight {
|
||||
pub const ALL: [TrackHeight; 7] = [
|
||||
TrackHeight::Micro,
|
||||
@@ -328,6 +374,15 @@ impl Track {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_fit_string_loads_as_default_height() {
|
||||
assert_eq!(serde_json::from_str::<TrackHeight>("\"Fit\"").unwrap(), TrackHeight::Medium);
|
||||
assert_eq!(serde_json::from_str::<TrackHeight>("{\"Fit\":120}").unwrap(), TrackHeight::Fit(120));
|
||||
assert_eq!(serde_json::to_string(&TrackHeight::Fit(120)).unwrap(), "{\"Fit\":120}");
|
||||
assert_eq!(serde_json::from_str::<TrackHeight>("\"Large\"").unwrap(), TrackHeight::Large);
|
||||
assert_eq!(serde_json::from_str::<TrackHeight>("{\"Custom\":50}").unwrap(), TrackHeight::Custom(50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_track_defaults() {
|
||||
let t = Track::new(TrackId(1), "Gtr", TrackKind::Audio, ChannelFormat::Stereo, TRACK_COLORS[0]);
|
||||
|
||||
+123
-10
@@ -362,8 +362,9 @@ impl AudioState {
|
||||
let sample = |c: usize| {
|
||||
let ch = self.pending.get(c);
|
||||
let a = ch.and_then(|v| v.get(i0)).copied().unwrap_or(0.0);
|
||||
let b = ch.and_then(|v| v.get(i0 + 1)).copied().unwrap_or(a);
|
||||
a + (b - a) * fr
|
||||
// `pending` is BLOCK long; only `pending_len` frames are valid. Hold the last one at the edge.
|
||||
let b = if i0 + 1 < self.pending_len { ch.and_then(|v| v.get(i0 + 1)).copied().unwrap_or(a) } else { a };
|
||||
a * (1.0 - fr) + b * fr
|
||||
};
|
||||
if channels < nch {
|
||||
// Fold surround to stereo first; a mono device averages the two sides.
|
||||
@@ -381,18 +382,15 @@ impl AudioState {
|
||||
}
|
||||
for (c, o) in frame.iter_mut().enumerate() {
|
||||
*o = match c {
|
||||
0 if channels == 1 => {
|
||||
let mono = (0.5 * l + 0.5 * r).clamp(-1.0, 1.0);
|
||||
if mono.is_finite() { mono } else { 0.0 }
|
||||
}
|
||||
0 => l.clamp(-1.0, 1.0),
|
||||
1 => r.clamp(-1.0, 1.0),
|
||||
0 if channels == 1 => out_sample(0.5 * l + 0.5 * r),
|
||||
0 => out_sample(l),
|
||||
1 => out_sample(r),
|
||||
_ => 0.0,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
for (c, o) in frame.iter_mut().enumerate() {
|
||||
*o = if c < nch.max(1) && sounding { sample(c).clamp(-1.0, 1.0) } else { 0.0 };
|
||||
*o = if c < nch.max(1) && sounding { out_sample(sample(c)) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
self.read += step;
|
||||
@@ -400,6 +398,11 @@ impl AudioState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp to the device range, mapping NaN and infinities to silence.
|
||||
fn out_sample(v: f32) -> f32 {
|
||||
if v.is_finite() { v.clamp(-1.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// UI-thread bookkeeping of the third-party instances the audio thread holds.
|
||||
struct Side {
|
||||
/// What was last created for each slot: (plugin id, channels, created ok).
|
||||
@@ -684,6 +687,17 @@ impl Player {
|
||||
self.side().capture_states()
|
||||
}
|
||||
|
||||
/// The plugins that said their state changed since the last call, so the session can keep
|
||||
/// changes made only in plugin editors.
|
||||
pub fn changed_states(&self) -> Vec<(TrackId, usize)> {
|
||||
self.side().changed_states()
|
||||
}
|
||||
|
||||
/// Reads the state of the plugins in `slots`.
|
||||
pub fn capture_states_of(&self, slots: &[(TrackId, usize)]) -> PluginStates {
|
||||
self.side().capture_states_of(slots)
|
||||
}
|
||||
|
||||
/// Per-UI-frame housekeeping: drops retired instances and services plugin editors. Returns
|
||||
/// the parameter edits made in open editors since the last call.
|
||||
pub fn idle(&self) -> Vec<EditorEdit> {
|
||||
@@ -812,6 +826,24 @@ impl Side {
|
||||
self.states.iter_mut().filter(|(k, _)| !self.refused.contains(*k)).filter_map(|(&(t, slot), r)| Some((t, slot, r.save_state()?))).collect()
|
||||
}
|
||||
|
||||
fn changed_states(&mut self) -> Vec<(TrackId, usize)> {
|
||||
self.editors.iter_mut().filter_map(|(k, e)| e.state_changed().then_some(*k)).collect()
|
||||
}
|
||||
|
||||
fn capture_states_of(&mut self, slots: &[(TrackId, usize)]) -> PluginStates {
|
||||
self.states
|
||||
.iter_mut()
|
||||
.filter(|(k, _)| !self.refused.contains(*k) && slots.contains(k))
|
||||
.filter_map(|(&(t, slot), r)| Some((t, slot, r.save_state()?)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A plugin that marks its state changed while it loads or restores it hasn't changed.
|
||||
fn add_editor(&mut self, key: (TrackId, usize), mut e: Box<dyn PluginEditor>) {
|
||||
let _ = e.state_changed();
|
||||
self.editors.insert(key, e);
|
||||
}
|
||||
|
||||
fn open_editor(&mut self, key: (TrackId, usize)) -> Result<(), String> {
|
||||
let e = self.editors.get_mut(&key).ok_or_else(|| "this plugin has no editor (or it is not loaded)".to_string())?;
|
||||
e.open()?;
|
||||
@@ -848,7 +880,7 @@ impl Side {
|
||||
self.shadow.insert(key, (spec.id.clone(), spec.ch, made.is_some()));
|
||||
if let Some(soundcraft_mix::NewInstance { mut plugin, refused_state }) = made {
|
||||
if let Some(e) = plugin.editor() {
|
||||
self.editors.insert(key, e);
|
||||
self.add_editor(key, e);
|
||||
}
|
||||
if let Some(r) = plugin.state_reader() {
|
||||
self.states.insert(key, r);
|
||||
@@ -1223,6 +1255,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resampling_at_a_short_block_edge_does_not_mix_in_stale_frames() {
|
||||
use soundcraft_model::ChannelFormat;
|
||||
// DC for the first long block, then silence. The device runs slower than the session, so
|
||||
// reads land between frames; a short block leaves the long block's DC past `pending_len`.
|
||||
let mut st = audio_output_state(ChannelFormat::Stereo, vec![vec![0.8; BLOCK]; 2]);
|
||||
st.device_rate = 44_100.0;
|
||||
let mut long = vec![0.0; 2 * 470];
|
||||
st.fill(&mut long, 2);
|
||||
assert_eq!(st.pending_len, BLOCK);
|
||||
let mut short = [0.0; 2 * 8];
|
||||
for call in 0..20 {
|
||||
st.fill(&mut short, 2);
|
||||
assert!(st.pending_len < BLOCK);
|
||||
// The first frame of the first short call still reads the DC block's last frame.
|
||||
let from = if call == 0 { 2 } else { 0 };
|
||||
assert!(short[from..].iter().all(|x| x.abs() < 1e-6), "call {call}: {short:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mono_device_preserves_centered_mono_level_and_includes_surround_right_channels() {
|
||||
use soundcraft_model::ChannelFormat;
|
||||
@@ -1253,6 +1305,18 @@ mod tests {
|
||||
assert!(out.iter().all(|x| *x == 0.0), "overflow in the stereo fold-down must not send NaN to a mono device");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolation_overflow_between_huge_samples_never_reaches_the_device() {
|
||||
use soundcraft_model::ChannelFormat;
|
||||
let alternating: Vec<f32> = (0..512).map(|i| if i % 2 == 0 { -3.4e38 } else { 3.4e38 }).collect();
|
||||
for (format, nch, device) in [(ChannelFormat::Stereo, 2, 2), (ChannelFormat::Stereo, 2, 8), (ChannelFormat::Mono, 1, 1)] {
|
||||
let mut st = audio_output_state(format, vec![alternating.clone(); nch]);
|
||||
let mut out = vec![0.5; 64 * device];
|
||||
st.fill(&mut out, device);
|
||||
assert!(out.iter().all(|x| x.is_finite() && (-1.0..=1.0).contains(x)), "{format:?} on {device} channels: {out:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surround_main_maps_to_device_channels_or_folds_down() {
|
||||
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
|
||||
@@ -1370,6 +1434,55 @@ mod tests {
|
||||
assert_eq!(side.capture_states(), vec![(TrackId(1), 0, b"defaults".to_vec())]);
|
||||
}
|
||||
|
||||
/// An editor whose plugin said its state changed (CLAP `state.mark_dirty`).
|
||||
struct Dirty(bool);
|
||||
|
||||
impl PluginEditor for Dirty {
|
||||
fn open(&mut self) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn close(&mut self) {}
|
||||
fn is_open(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn idle(&mut self) -> Vec<(String, f32)> {
|
||||
Vec::new()
|
||||
}
|
||||
fn state_changed(&mut self) -> bool {
|
||||
std::mem::take(&mut self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_states_are_reported_once_and_read_for_the_session() {
|
||||
let mut side = Side::new(48_000.0, channel().1);
|
||||
let (a, b) = (TrackId(1), TrackId(2));
|
||||
let synth = (a, soundcraft_mix::INSTRUMENT_SLOT);
|
||||
side.states.insert((a, 0), Box::new(|| Some(b"a0".to_vec())));
|
||||
side.states.insert((a, 1), Box::new(|| Some(b"a1".to_vec())));
|
||||
side.states.insert(synth, Box::new(|| Some(b"synth".to_vec())));
|
||||
side.states.insert((b, 0), Box::new(|| Some(b"b0".to_vec())));
|
||||
side.editors.insert((a, 0), Box::new(Dirty(true)));
|
||||
side.editors.insert((a, 1), Box::new(Dirty(false)));
|
||||
side.editors.insert((b, 0), Box::new(Dirty(true)));
|
||||
side.refused.insert((b, 0));
|
||||
let mut changed = side.changed_states();
|
||||
changed.sort();
|
||||
assert_eq!(changed, vec![(a, 0), (b, 0)]);
|
||||
assert!(side.changed_states().is_empty(), "each change is reported once");
|
||||
let want = vec![(a, 0, b"a0".to_vec()), (a, soundcraft_mix::INSTRUMENT_SLOT, b"synth".to_vec())];
|
||||
assert_eq!(side.capture_states_of(&[(a, 0), synth, (b, 0)]), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_marked_changed_while_loading_is_not_a_change() {
|
||||
let mut side = Side::new(48_000.0, channel().1);
|
||||
let k = (TrackId(1), 0);
|
||||
side.states.insert(k, Box::new(|| Some(b"loaded".to_vec())));
|
||||
side.add_editor(k, Box::new(Dirty(true)));
|
||||
assert!(side.changed_states().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_creates_each_missing_instance_once() {
|
||||
use soundcraft_model::{ChannelFormat, Insert, TrackKind};
|
||||
|
||||
@@ -186,6 +186,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_round_trips_timecode_negative() {
|
||||
let (sr, map, _) = ctx();
|
||||
let rate = FrameRate::Fps25;
|
||||
for fmt in [TimeFormat::Timecode, TimeFormat::FeetFrames] {
|
||||
for s in [-48_000i64, -96_000 * 3, -1_440_000] {
|
||||
let text = format_position(s, fmt, sr, &map, rate, 0, 0);
|
||||
let back = parse_position(&text, fmt, sr, &map, rate, 0, 0).unwrap();
|
||||
assert_eq!(back, s, "{fmt:?} {s} → {text} → {back}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_garbage() {
|
||||
let (sr, map, rate) = ctx();
|
||||
|
||||
+73
-4
@@ -155,15 +155,49 @@ impl GridValue {
|
||||
let k = (t - bar_tick).div_euclid(step);
|
||||
map.tick_to_samples(bar_tick + k * step, sr)
|
||||
}
|
||||
_ => {
|
||||
let step = self.step_samples(pos, sr, map, rate);
|
||||
pos.div_euclid(step).saturating_mul(step)
|
||||
}
|
||||
_ => self.abs_line(self.abs_index_at_or_before(pos, sr, rate), sr, rate),
|
||||
}
|
||||
}
|
||||
|
||||
/// Position of absolute grid line `k` (Seconds, Frames, Samples), at an exact multiple of the step
|
||||
/// rounded once, so rounding never accumulates from line to line.
|
||||
fn abs_line(&self, k: i64, sr: SampleRate, rate: FrameRate) -> Samples {
|
||||
match *self {
|
||||
GridValue::Seconds(s) => to_samples(k as f64 * Self::seconds_step(s, sr)),
|
||||
GridValue::Frames(f) => rate.samples_at_frame(k.saturating_mul(f.clamp(1, 1_000_000)), sr),
|
||||
GridValue::Samples(n) => k.saturating_mul(n.max(1)),
|
||||
GridValue::Note { .. } => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the absolute grid line at or before `pos`.
|
||||
fn abs_index_at_or_before(&self, pos: Samples, sr: SampleRate, rate: FrameRate) -> i64 {
|
||||
let mut k = match *self {
|
||||
GridValue::Seconds(s) => (pos as f64 / Self::seconds_step(s, sr)).floor() as i64,
|
||||
GridValue::Frames(f) => rate.frames_at(pos, sr).div_euclid(f.clamp(1, 1_000_000)),
|
||||
GridValue::Samples(n) => return pos.div_euclid(n.max(1)),
|
||||
GridValue::Note { .. } => return 0,
|
||||
};
|
||||
// Lines are rounded to whole samples, so the floored index can be one off either way.
|
||||
if self.abs_line(k, sr, rate) > pos {
|
||||
k = k.saturating_sub(1);
|
||||
} else if self.abs_line(k.saturating_add(1), sr, rate) <= pos {
|
||||
k = k.saturating_add(1);
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// The exact (fractional) Seconds step in samples, at least one sample.
|
||||
fn seconds_step(s: f64, sr: SampleRate) -> f64 {
|
||||
(s.clamp(0.000_01, 3600.0) * sr.as_f64()).max(1.0)
|
||||
}
|
||||
|
||||
/// The first grid line strictly after `line`.
|
||||
pub fn next_line(&self, line: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
if !matches!(self, GridValue::Note { .. }) {
|
||||
let next = self.abs_line(self.abs_index_at_or_before(line, sr, rate).saturating_add(1), sr, rate);
|
||||
return if next > line { next } else { line.saturating_add(1) };
|
||||
}
|
||||
let step = self.step_samples(line, sr, map, rate);
|
||||
let candidate = self.line_at_or_before(line.saturating_add(step), sr, map, rate);
|
||||
if candidate > line { candidate } else { line.saturating_add(step) }
|
||||
@@ -222,6 +256,41 @@ mod tests {
|
||||
assert_eq!(GridValue::Samples(0).step_samples(0, sr, &map, rate), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seconds_grid_lines_land_on_whole_seconds() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_44100, TempoMap::default(), FrameRate::Fps30);
|
||||
for g in [GridValue::Seconds(0.001), GridValue::Seconds(0.01)] {
|
||||
for n in 0..20 {
|
||||
let s = n * 44_100;
|
||||
assert_eq!(g.snap(s, sr, &map, rate), s, "{g:?} at {n} s");
|
||||
assert_eq!(g.line_at_or_before(s, sr, &map, rate), s, "{g:?} at {n} s");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_grid_lines_land_on_frame_starts() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let map = TempoMap::default();
|
||||
for rate in [FrameRate::Fps29_97, FrameRate::Fps23_976] {
|
||||
for f in [1, 10] {
|
||||
let g = GridValue::Frames(f);
|
||||
for k in (0..5000).step_by(37) {
|
||||
let s = rate.samples_at_frame(k, sr);
|
||||
let line = rate.samples_at_frame(k.div_euclid(f) * f, sr);
|
||||
assert_eq!(g.line_at_or_before(s, sr, &map, rate), line, "{rate:?} {f} frames at frame {k}");
|
||||
if k % f == 0 {
|
||||
assert_eq!(g.snap(s, sr, &map, rate), s, "{rate:?} {f} frames at frame {k}");
|
||||
}
|
||||
}
|
||||
let start = rate.samples_at_frame(4000, sr);
|
||||
let end = rate.samples_at_frame(4000 + 20 * f, sr);
|
||||
let want: Vec<_> = (0..20).map(|i| rate.samples_at_frame(4000 + i * f, sr)).collect();
|
||||
assert_eq!(g.lines(start, end, sr, &map, rate, 100), want, "{rate:?} {f} frames");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_are_capped() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
|
||||
|
||||
@@ -108,7 +108,9 @@ impl FrameRate {
|
||||
if n == 0 {
|
||||
return 0;
|
||||
}
|
||||
let v = (i128::from(f) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n);
|
||||
// Round half away from zero so negative frames mirror positive ones.
|
||||
let mag = (i128::from(f.unsigned_abs()) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n);
|
||||
let v = if f < 0 { -mag } else { mag };
|
||||
i64::try_from(v).unwrap_or(if v < 0 { i64::MIN } else { i64::MAX })
|
||||
}
|
||||
|
||||
@@ -229,6 +231,18 @@ mod tests {
|
||||
assert_eq!(tc.to_samples(FrameRate::Fps25, sr), 48_000 * 3661);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_timecode_rounds_symmetrically() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let tc = Timecode::parse("-00:00:01:00").unwrap();
|
||||
assert_eq!(tc.to_samples(FrameRate::Fps25, sr), -48_000);
|
||||
for r in [FrameRate::Fps25, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps23_976] {
|
||||
for f in [1i64, 7, 25, 1799, 107_892] {
|
||||
assert_eq!(r.samples_at_frame(-f, sr), -r.samples_at_frame(f, sr), "{r:?} frame {f}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_frame_labels() {
|
||||
let r = FrameRate::Fps29_97Drop;
|
||||
|
||||
@@ -27,6 +27,9 @@ serde_json.workspace = true
|
||||
log.workspace = true
|
||||
image.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = "5"
|
||||
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! The desktop's dark/light setting for the System theme on Linux, where eframe/winit reports no
|
||||
//! system theme to egui.
|
||||
//!
|
||||
//! The XDG settings portal (`org.freedesktop.appearance` / `color-scheme`) is read once on a worker
|
||||
//! thread, which then follows its `SettingChanged` signal and wakes the UI only when the value
|
||||
//! changes. Nothing is polled. Elsewhere egui's own report (`Context::system_theme`) is enough.
|
||||
|
||||
/// egui's own report when it has one, otherwise the desktop's.
|
||||
pub fn resolve(reported: Option<egui::Theme>, desktop: impl FnOnce() -> Option<egui::Theme>) -> Option<egui::Theme> {
|
||||
reported.or_else(desktop)
|
||||
}
|
||||
|
||||
/// The desktop's appearance, or `None` when it is unknown or this platform has no portal.
|
||||
/// Unit tests never ask the real desktop, so they do not depend on the machine's setting.
|
||||
pub fn desktop_theme(ctx: &egui::Context) -> Option<egui::Theme> {
|
||||
#[cfg(all(target_os = "linux", not(test)))]
|
||||
{
|
||||
linux::current(ctx)
|
||||
}
|
||||
#[cfg(any(not(target_os = "linux"), test))]
|
||||
{
|
||||
let _ = ctx;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg_attr(test, allow(dead_code))]
|
||||
mod linux {
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
|
||||
const PORTAL_KEY: &str = "color-scheme";
|
||||
|
||||
/// The last `color-scheme` seen: 0 = unknown or no preference, 1 = dark, 2 = light.
|
||||
static STATE: OnceLock<Arc<AtomicU8>> = OnceLock::new();
|
||||
|
||||
pub fn current(ctx: &egui::Context) -> Option<egui::Theme> {
|
||||
let state = STATE.get_or_init(|| {
|
||||
let value = Arc::new(AtomicU8::new(0));
|
||||
let (worker_value, worker_ctx) = (Arc::clone(&value), ctx.clone());
|
||||
// If the thread cannot start, the value stays 0 and System keeps following egui only.
|
||||
let _ = std::thread::Builder::new().name("appearance-portal".into()).spawn(move || watch(&worker_value, &worker_ctx));
|
||||
value
|
||||
});
|
||||
decode(u32::from(state.load(Ordering::Relaxed)))
|
||||
}
|
||||
|
||||
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
|
||||
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
|
||||
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
|
||||
}
|
||||
|
||||
fn watch(value: &AtomicU8, ctx: &egui::Context) {
|
||||
let Some(proxy) = portal_proxy() else { return };
|
||||
// Subscribe before Read so a change during startup is queued rather than lost.
|
||||
let Ok(signals) = proxy.receive_signal("SettingChanged") else { return };
|
||||
let initial: Option<zbus::zvariant::OwnedValue> = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok();
|
||||
publish(value, ctx, initial.and_then(portal_code).and_then(decode));
|
||||
// Blocks until the portal sends a signal: no polling.
|
||||
for message in signals {
|
||||
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
|
||||
continue;
|
||||
};
|
||||
if namespace == PORTAL_NAMESPACE && key == PORTAL_KEY {
|
||||
publish(value, ctx, portal_code(changed).and_then(decode));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn publish(value: &AtomicU8, ctx: &egui::Context, theme: Option<egui::Theme>) {
|
||||
if value.swap(code_of(theme), Ordering::Relaxed) != code_of(theme) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The `u32` inside a portal value. `Settings.Read` answers a variant, and some portals nest
|
||||
/// another variant inside it.
|
||||
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
|
||||
let mut value: zbus::zvariant::Value<'_> = value.into();
|
||||
for _ in 0..4 {
|
||||
match value {
|
||||
zbus::zvariant::Value::Value(inner) => value = *inner,
|
||||
other => return u32::try_from(other).ok(),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The portal's `color-scheme`: 0 = no preference, 1 = prefer dark, 2 = prefer light.
|
||||
pub(super) fn decode(code: u32) -> Option<egui::Theme> {
|
||||
match code {
|
||||
1 => Some(egui::Theme::Dark),
|
||||
2 => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn code_of(theme: Option<egui::Theme>) -> u8 {
|
||||
match theme {
|
||||
Some(egui::Theme::Dark) => 1,
|
||||
Some(egui::Theme::Light) => 2,
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn desktop_setting_is_used_only_when_egui_reports_nothing() {
|
||||
assert_eq!(resolve(None, || Some(egui::Theme::Light)), Some(egui::Theme::Light));
|
||||
assert_eq!(resolve(None, || None), None);
|
||||
assert_eq!(resolve(Some(egui::Theme::Dark), || Some(egui::Theme::Light)), Some(egui::Theme::Dark));
|
||||
let asked = std::cell::Cell::new(false);
|
||||
let theme = resolve(Some(egui::Theme::Light), || {
|
||||
asked.set(true);
|
||||
None
|
||||
});
|
||||
assert_eq!(theme, Some(egui::Theme::Light));
|
||||
assert!(!asked.get(), "must not ask the desktop");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn portal_color_scheme_codes() {
|
||||
assert_eq!(linux::decode(1), Some(egui::Theme::Dark));
|
||||
assert_eq!(linux::decode(2), Some(egui::Theme::Light));
|
||||
assert_eq!(linux::decode(0), None);
|
||||
assert_eq!(linux::decode(7), None);
|
||||
}
|
||||
}
|
||||
+524
-16
@@ -5,14 +5,16 @@ use crate::theme::{Tokens, bold};
|
||||
use egui::{Align2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_engine::io::StripNames;
|
||||
use soundcraft_model::{ClipId, GroupId, Session, SourceId, TrackId};
|
||||
use soundcraft_time::Samples;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Dialog {
|
||||
NewTracks { count: u32, format: String, kind: String, timebase: String, name: String },
|
||||
NewSession { name: String, sample_rate: u32, demo: bool },
|
||||
Bounce { path: String, format: String, bit_depth: String, normalize: bool },
|
||||
Bounce { path: String, format: String, bit_depth: String, normalize: bool, dither: bool },
|
||||
RenameTrack { id: TrackId, name: String },
|
||||
RenameClip { id: ClipId, name: String },
|
||||
RenameMarker { id: soundcraft_model::MarkerId, name: String },
|
||||
@@ -23,13 +25,31 @@ pub enum Dialog {
|
||||
Spot(crate::spot::Spot),
|
||||
Fades { shape: String },
|
||||
StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 },
|
||||
// Strip Silence › Rename: `strip` holds the Strip Silence values to return to.
|
||||
StripSilenceRename { strip: [f64; 4], names: StripNames },
|
||||
// Clip List › Auto Rename: the names that editing gives the clips it separates from `clips`.
|
||||
AutoRename { clips: Vec<ClipId>, names: StripNames },
|
||||
// `id: None` creates (Track › Group…); `id: Some` modifies that group (Groups list › Modify Group…).
|
||||
Group { id: Option<GroupId>, name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> },
|
||||
Session { frame_rate: String, bit_depth: String },
|
||||
// Clip › Loop…: `length` is typed in `format`, measured from `start` (the first selected clip's).
|
||||
// `filled` is the text the field opened with and the exact length it stands for.
|
||||
Loop { by: LoopBy, count: u32, length: String, format: soundcraft_time::TimeFormat, start: Samples, filled: (String, Samples) },
|
||||
ScoreSetup { title: String, composer: String, bars_per_system: u32, show_track_names: bool },
|
||||
SaveChanges { then: (String, Value) },
|
||||
}
|
||||
|
||||
/// The Clip Looping dialog's choice of how far a clip is looped.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LoopBy {
|
||||
/// Number of Loops.
|
||||
Count,
|
||||
/// Loop Length.
|
||||
Length,
|
||||
/// Loop Until End of Session or Next Clip.
|
||||
Fill,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Dialogs {
|
||||
pub open: Option<Dialog>,
|
||||
@@ -37,6 +57,41 @@ pub struct Dialogs {
|
||||
after_save: Option<(String, Value)>,
|
||||
/// A session the browser read while the open one had changes, waiting for `SaveChanges`.
|
||||
pending_session: Option<(String, Vec<u8>)>,
|
||||
/// The naming scheme Strip Silence gives the clips it leaves, kept from one use to the next.
|
||||
pub strip_names: StripNames,
|
||||
/// The Strip Silence rectangles the Edit window draws while Strip Silence is open.
|
||||
strip_rectangles: StripRectangles,
|
||||
}
|
||||
|
||||
/// What Strip keeps on each track: the Strip Silence rectangles.
|
||||
pub type StripRects = Arc<Vec<(TrackId, Vec<soundcraft_time::Range>)>>;
|
||||
|
||||
/// The session revision and the Strip Silence values (as bits) that rectangles were found for.
|
||||
type StripKey = (u64, [u64; 4]);
|
||||
|
||||
/// The Strip Silence rectangles last found, and the scan running for newer values.
|
||||
#[derive(Default)]
|
||||
struct StripRectangles {
|
||||
shown: Option<(StripKey, StripRects)>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
running: Option<StripScanning>,
|
||||
}
|
||||
|
||||
/// A Strip Silence scan on a worker thread: what it was started for, where its result arrives, and
|
||||
/// the flag that stops it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct StripScanning {
|
||||
key: StripKey,
|
||||
rx: std::sync::mpsc::Receiver<StripRects>,
|
||||
stop: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
/// A scan whose rectangles nobody will draw stops before its next window of audio.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl Drop for StripScanning {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Dialogs {
|
||||
@@ -55,14 +110,26 @@ impl Dialogs {
|
||||
Dialog::Spot(_) => "spot",
|
||||
Dialog::Fades { .. } => "fades",
|
||||
Dialog::StripSilence { .. } => "strip_silence",
|
||||
Dialog::StripSilenceRename { .. } => "strip_silence_rename",
|
||||
Dialog::AutoRename { .. } => "auto_rename",
|
||||
Dialog::Group { id: None, .. } => "group",
|
||||
Dialog::Group { id: Some(_), .. } => "modify_group",
|
||||
Dialog::Session { .. } => "session",
|
||||
Dialog::Loop { .. } => "clip_looping",
|
||||
Dialog::ScoreSetup { .. } => "score_setup",
|
||||
Dialog::SaveChanges { .. } => "save_changes",
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a worker thread is finding Strip Silence rectangles.
|
||||
pub fn strip_scan_running(&self) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.strip_rectangles.running.is_some() {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn open_rename_track(&mut self, id: TrackId, name: &str) {
|
||||
self.open = Some(Dialog::RenameTrack { id, name: name.to_string() });
|
||||
}
|
||||
@@ -92,6 +159,16 @@ impl Dialogs {
|
||||
true
|
||||
}
|
||||
|
||||
/// Clip List › Auto Rename: the clips' naming scheme, filled in from the first clip's. False
|
||||
/// when none of the clips is in the session.
|
||||
pub fn open_auto_rename(&mut self, e: &Engine, clips: Vec<ClipId>) -> bool {
|
||||
let s = e.session();
|
||||
let Some((_, first)) = clips.iter().find_map(|id| s.find_clip(*id)) else { return false };
|
||||
let names = first.auto_name.and_then(|k| s.auto_names.get(&k)).map_or_else(StripNames::default, StripNames::from);
|
||||
self.open = Some(Dialog::AutoRename { clips, names });
|
||||
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;
|
||||
@@ -113,7 +190,13 @@ impl Dialogs {
|
||||
.and_then(|p| std::path::Path::new(p).parent())
|
||||
.filter(|d| !d.as_os_str().is_empty())
|
||||
.map_or(home.clone(), |d| d.display().to_string());
|
||||
Dialog::Bounce { path: format!("{dir}/{name} Bounce.wav"), format: "wav".into(), bit_depth: "24".into(), normalize: false }
|
||||
Dialog::Bounce {
|
||||
path: format!("{dir}/{name} Bounce.wav"),
|
||||
format: "wav".into(),
|
||||
bit_depth: "24".into(),
|
||||
normalize: false,
|
||||
dither: false,
|
||||
}
|
||||
}
|
||||
"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() },
|
||||
@@ -152,11 +235,21 @@ 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.auto_rename" => return self.open_auto_rename(e, e.session().edit.selected_clips.clone()),
|
||||
"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() }
|
||||
}
|
||||
"clip.loop" => {
|
||||
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 };
|
||||
let s = e.session();
|
||||
let format = s.edit.main_counter;
|
||||
let exact = clip.length.saturating_mul(4);
|
||||
let length = soundcraft_time::format_length(clip.start, exact, format, s.sample_rate, &s.tempo, s.frame_rate);
|
||||
Dialog::Loop { by: LoopBy::Count, count: 4, length: length.clone(), format, start: clip.start, filled: (length, exact) }
|
||||
}
|
||||
_ => match path_dialog(id, &home, &name) {
|
||||
Some(d) => d,
|
||||
None => return false,
|
||||
@@ -307,11 +400,16 @@ fn default_dir() -> String {
|
||||
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
audiosuite_window(app, ctx);
|
||||
crate::bounce::show(app, ctx);
|
||||
crate::import::show(app, ctx);
|
||||
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 { .. });
|
||||
// Strip Silence's Rename button, and the naming scheme its dialog's OK keeps.
|
||||
let mut rename_from: Option<[f64; 4]> = None;
|
||||
let mut renamed: Option<StripNames> = None;
|
||||
let strip_names = app.dialogs.strip_names.clone();
|
||||
let title = match &d {
|
||||
Dialog::NewTracks { .. } => "New Tracks",
|
||||
Dialog::NewSession { .. } => "New Session",
|
||||
@@ -325,9 +423,11 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
Dialog::Spot(_) => "Spot",
|
||||
Dialog::Fades { .. } => "Fades",
|
||||
Dialog::StripSilence { .. } => "Strip Silence",
|
||||
Dialog::StripSilenceRename { .. } | Dialog::AutoRename { .. } => "Rename Selected Clips",
|
||||
Dialog::Group { id: None, .. } => "Create Group",
|
||||
Dialog::Group { id: Some(_), .. } => "Modify Group",
|
||||
Dialog::Session { .. } => "Session Setup",
|
||||
Dialog::Loop { .. } => "Clip Looping",
|
||||
Dialog::ScoreSetup { .. } => "Score Setup",
|
||||
Dialog::SaveChanges { then } if then.0 == "session.revert" => "Revert to Saved",
|
||||
Dialog::SaveChanges { .. } => "Unsaved Changes",
|
||||
@@ -370,8 +470,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
ui.selectable_value(timebase, "ticks".to_string(), "Ticks");
|
||||
},
|
||||
);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name:");
|
||||
ui.add(egui::TextEdit::singleline(name).desired_width(140.0));
|
||||
ui.add(egui::TextEdit::singleline(name).desired_width(240.0));
|
||||
});
|
||||
if buttons(ui, "Create", enter) {
|
||||
action =
|
||||
@@ -396,7 +498,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
));
|
||||
}
|
||||
}
|
||||
Dialog::Bounce { path, format, bit_depth, normalize } => {
|
||||
Dialog::Bounce { path, format, bit_depth, normalize, dither } => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("File");
|
||||
ui.add(egui::TextEdit::singleline(path).desired_width(420.0));
|
||||
@@ -426,6 +528,11 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
});
|
||||
});
|
||||
crate::widgets::checkbox(ui, normalize, "Normalize to -0.1 dBFS");
|
||||
// 32-bit float keeps every sample as it is: there is nothing to dither.
|
||||
ui.add_enabled_ui(bit_depth.as_str() != "32f", |ui| {
|
||||
crate::widgets::checkbox(ui, dither, "Dither (TPDF)")
|
||||
.on_hover_text("Adds ±1 LSB triangular dither when writing 16- or 24-bit files. Leave it off when the master chain already dithers.");
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new("Bounces the edit selection, or the whole session when nothing is selected.")
|
||||
.small()
|
||||
@@ -435,7 +542,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let p = std::path::Path::new(path.as_str()).with_extension(if format == "aiff" { "aif" } else { format.as_str() });
|
||||
crate::bounce::start(
|
||||
app,
|
||||
&json!({"path": p.to_string_lossy(), "format": format, "bit_depth": bit_depth, "normalize": normalize}),
|
||||
&json!({"path": p.to_string_lossy(), "format": format, "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}),
|
||||
);
|
||||
keep = false;
|
||||
}
|
||||
@@ -540,15 +647,61 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms } => {
|
||||
let before = [*threshold, *min_ms, *pre_ms, *post_ms];
|
||||
ui.add(egui::Slider::new(threshold, -96.0..=0.0).text("Threshold dB"));
|
||||
ui.add(egui::Slider::new(min_ms, 0.0..=2000.0).text("Min strip duration ms"));
|
||||
ui.add(egui::Slider::new(min_ms, 0.0..=4000.0).text("Min strip duration ms"));
|
||||
ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text("Clip start pad ms"));
|
||||
ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text("Clip end pad ms"));
|
||||
if buttons(ui, "Strip", enter) {
|
||||
action = Some((
|
||||
"edit.strip_silence".into(),
|
||||
json!({"threshold_db": threshold, "min_length_ms": min_ms, "pad_before_ms": pre_ms, "pad_after_ms": post_ms}),
|
||||
));
|
||||
let values = [*threshold, *min_ms, *pre_ms, *post_ms];
|
||||
// The Edit window is drawn before this window, so it draws the rectangles for new values in the next frame.
|
||||
if values != before {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
if !strip_names.name.trim().is_empty() {
|
||||
let names = format!("New clips are named {}, {} and so on.", strip_names.clip_name(0), strip_names.clip_name(1));
|
||||
ui.label(egui::RichText::new(names).small().color(Tokens::current().text_dim));
|
||||
}
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
let mut mode = None;
|
||||
let mut rename = false;
|
||||
if ui.button("Extract").clicked() {
|
||||
mode = Some("extract");
|
||||
}
|
||||
if ui.button("Rename…").clicked() {
|
||||
rename = true;
|
||||
}
|
||||
if ui.button("Separate").clicked() {
|
||||
mode = Some("separate");
|
||||
}
|
||||
// Enter on a focused Extract or Separate clicks that button, so Enter presses Strip only when neither was clicked.
|
||||
if buttons(ui, "Strip", enter) && mode.is_none() {
|
||||
mode = Some("strip");
|
||||
}
|
||||
// Enter on a focused Rename… also presses Strip, so Rename… goes first.
|
||||
if rename {
|
||||
rename_from = Some(values);
|
||||
} else if let Some(mode) = mode {
|
||||
action = Some(strip_silence_action(mode, values, &strip_names));
|
||||
}
|
||||
});
|
||||
}
|
||||
Dialog::StripSilenceRename { names, .. } => {
|
||||
strip_name_fields(ui, names, STRIP_NAME_LABELS);
|
||||
let cleared = ui.button("Clear").clicked();
|
||||
if cleared {
|
||||
*names = StripNames::default();
|
||||
}
|
||||
// Enter on a focused Clear clicks it, and leaves the dialog open.
|
||||
if buttons(ui, "OK", enter) && !cleared {
|
||||
renamed = Some(names.clone());
|
||||
}
|
||||
}
|
||||
Dialog::AutoRename { clips, names } => {
|
||||
ui.label("Text for naming the clips that editing makes from the selected clips:");
|
||||
strip_name_fields(ui, names, AUTO_NAME_LABELS);
|
||||
if buttons(ui, "OK", enter) {
|
||||
action = Some(auto_rename_action(clips, names));
|
||||
}
|
||||
}
|
||||
Dialog::Group { id, name, edit, mix, members } => {
|
||||
@@ -595,6 +748,23 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
action = Some(("setup.session".into(), json!({"frame_rate": frame_rate, "bit_depth": bit_depth})));
|
||||
}
|
||||
}
|
||||
Dialog::Loop { by, count, length, format, start, filled } => {
|
||||
let s = app.engine.session();
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(by, LoopBy::Count, "Number of Loops");
|
||||
ui.add_enabled(*by == LoopBy::Count, egui::DragValue::new(count).range(1..=512));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(by, LoopBy::Length, "Loop Length");
|
||||
ui.add_enabled(*by == LoopBy::Length, egui::TextEdit::singleline(length).desired_width(140.0));
|
||||
ui.label(format.label());
|
||||
});
|
||||
ui.radio_value(by, LoopBy::Fill, "Loop Until End of Session or Next Clip");
|
||||
let params = loop_params(s, *by, *count, length, *format, *start, filled);
|
||||
if buttons_enabled(ui, "OK", enter, params.is_some()) {
|
||||
action = params.map(|p| ("clip.loop".into(), p));
|
||||
}
|
||||
}
|
||||
Dialog::ScoreSetup { title, composer, bars_per_system, show_track_names } => {
|
||||
egui::Grid::new("score_setup").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
|
||||
ui.label("Title");
|
||||
@@ -649,6 +819,30 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
app.dialogs.pending_session = None;
|
||||
ctx.memory_mut(|m| m.data.remove::<bool>(egui::Id::new("dlg_cancel")));
|
||||
}
|
||||
// Enter on a focused Cancel also presses OK. In Auto Rename, the dialog then closes without running the command.
|
||||
if !keep && matches!(d, Dialog::AutoRename { .. }) {
|
||||
action = None;
|
||||
}
|
||||
if let Some(strip) = rename_from
|
||||
&& keep
|
||||
{
|
||||
d = Dialog::StripSilenceRename { strip, names: strip_names };
|
||||
}
|
||||
// OK, Cancel and Escape in Rename Selected Clips go back to Strip Silence; OK keeps the names.
|
||||
// Enter on a focused Cancel also presses OK, and `keep` is false then, so Cancel wins.
|
||||
let back = match &d {
|
||||
Dialog::StripSilenceRename { strip, .. } if !keep || renamed.is_some() => Some(*strip),
|
||||
_ => None,
|
||||
};
|
||||
if let Some([threshold, min_ms, pre_ms, post_ms]) = back {
|
||||
if let Some(names) = renamed
|
||||
&& keep
|
||||
{
|
||||
app.dialogs.strip_names = names;
|
||||
}
|
||||
d = Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms };
|
||||
keep = true;
|
||||
}
|
||||
if let Some(then) = save_first {
|
||||
keep = false;
|
||||
if !app.dialogs.open_for_command(&app.engine, "session.save") {
|
||||
@@ -666,12 +860,17 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let saving = action.as_ref().is_some_and(|(cmd, _)| cmd.starts_with("session.save"));
|
||||
if let Some((cmd, params)) = action {
|
||||
keep = false;
|
||||
match run_then(app, &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());
|
||||
if cmd == "file.import_audio" {
|
||||
// Decoded in the background so a long file doesn't freeze the app (#597).
|
||||
crate::import::start(app, params);
|
||||
} else {
|
||||
match run_then(app, &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,
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => app.ui.status = e,
|
||||
}
|
||||
}
|
||||
if saving
|
||||
@@ -695,6 +894,137 @@ fn run_then(app: &mut SoundApp, cmd: &str, params: Value) -> Result<Value, Strin
|
||||
app.run(cmd, params)
|
||||
}
|
||||
|
||||
/// The ranges Strip keeps on each selected track, found by the scan of `edit.strip_silence_preview`
|
||||
/// with the values of the open Strip Silence window (or of the one Rename Selected Clips goes back
|
||||
/// to). The Edit window draws them as the Strip Silence rectangles. Empty when neither window is
|
||||
/// open. A scan runs again only when the session or a value changes. Off the web it runs on a worker
|
||||
/// thread, one at a time, and the rectangles last found stay until it ends. A scan for older values,
|
||||
/// or one that runs on after the window closes, stops before its next window of audio.
|
||||
pub fn strip_silence_rectangles(app: &mut SoundApp, ctx: &egui::Context) -> StripRects {
|
||||
let values = match &app.dialogs.open {
|
||||
Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) => [*threshold, *min_ms, *pre_ms, *post_ms],
|
||||
Some(Dialog::StripSilenceRename { strip, .. }) => *strip,
|
||||
_ => {
|
||||
app.dialogs.strip_rectangles = StripRectangles::default();
|
||||
return Arc::default();
|
||||
}
|
||||
};
|
||||
let key: StripKey = (app.engine.revision, values.map(f64::to_bits));
|
||||
let r = &mut app.dialogs.strip_rectangles;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(job) = &r.running {
|
||||
match job.rx.try_recv() {
|
||||
Ok(rects) => {
|
||||
r.shown = Some((job.key, rects));
|
||||
r.running = None;
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => r.running = None,
|
||||
// Values changed since it started: stop it, and the next scan starts when it has ended.
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) if job.key != key => job.stop.store(true, std::sync::atomic::Ordering::Relaxed),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
||||
}
|
||||
}
|
||||
let shown = r.shown.as_ref().map(|(k, rects)| (*k == key, Arc::clone(rects)));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let busy = r.running.is_some();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let busy = false;
|
||||
if let Some((true, rects)) = shown {
|
||||
return rects;
|
||||
}
|
||||
if busy {
|
||||
return shown.map(|(_, rects)| rects).unwrap_or_default();
|
||||
}
|
||||
let [threshold, min_ms, pre_ms, post_ms] = values;
|
||||
let params = json!({"threshold_db": threshold, "min_length_ms": min_ms, "pad_before_ms": pre_ms, "pad_after_ms": post_ms});
|
||||
let Ok(scan) = soundcraft_engine::cmd::strip_silence_scan(&app.engine, ¶ms) else {
|
||||
app.dialogs.strip_rectangles.shown = Some((key, Arc::default()));
|
||||
return Arc::default();
|
||||
};
|
||||
let scan = Arc::new(scan);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (ctx, job) = (ctx.clone(), Arc::clone(&scan));
|
||||
let work = move || {
|
||||
let found = job.run();
|
||||
// A stopped scan sends nothing: its sender drops, and the rectangles last found stay.
|
||||
if !job.stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
let _ = tx.send(Arc::new(found.unwrap_or_default()));
|
||||
}
|
||||
ctx.request_repaint();
|
||||
};
|
||||
if std::thread::Builder::new().name("strip-silence-scan".into()).spawn(work).is_ok() {
|
||||
app.dialogs.strip_rectangles.running = Some(StripScanning { key, rx, stop: Arc::clone(&scan.stop) });
|
||||
return shown.map(|(_, rects)| rects).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = ctx;
|
||||
// The browser has no threads, and without a thread to spare the scan runs on this one.
|
||||
let rects: StripRects = Arc::new(scan.run().unwrap_or_default());
|
||||
app.dialogs.strip_rectangles.shown = Some((key, Arc::clone(&rects)));
|
||||
rects
|
||||
}
|
||||
|
||||
/// The command a Strip Silence button runs: `mode` is strip, extract or separate, and `values` holds
|
||||
/// the threshold in dB, the minimum strip duration and the clip start and end pads in milliseconds.
|
||||
/// `names` names the clips it leaves when its name is not blank.
|
||||
fn strip_silence_action(mode: &str, values: [f64; 4], names: &StripNames) -> (String, Value) {
|
||||
let [threshold, min_ms, pre_ms, post_ms] = values;
|
||||
let mut params = json!({"threshold_db": threshold, "min_length_ms": min_ms, "pad_before_ms": pre_ms, "pad_after_ms": post_ms, "mode": mode});
|
||||
if !names.name.trim().is_empty() {
|
||||
params["name"] = json!(names.name);
|
||||
params["number"] = json!(names.number);
|
||||
params["zeros"] = json!(names.zeros);
|
||||
params["suffix"] = json!(names.suffix);
|
||||
}
|
||||
("edit.strip_silence".into(), params)
|
||||
}
|
||||
|
||||
/// The command Auto Rename's OK runs: the scheme for `clips`, or no scheme when the name is blank.
|
||||
fn auto_rename_action(clips: &[ClipId], names: &StripNames) -> (String, Value) {
|
||||
let mut params = json!({"clips": clips});
|
||||
if !names.name.trim().is_empty() {
|
||||
params["name"] = json!(names.name);
|
||||
params["number"] = json!(names.number);
|
||||
params["zeros"] = json!(names.zeros);
|
||||
params["suffix"] = json!(names.suffix);
|
||||
}
|
||||
("clip.auto_rename".into(), params)
|
||||
}
|
||||
|
||||
/// The labels of the number and zeros fields in Strip Silence's Rename Selected Clips.
|
||||
const STRIP_NAME_LABELS: [&str; 2] = ["Number", "Zeros"];
|
||||
/// The labels of the number and zeros fields in Auto Rename's Rename Selected Clips.
|
||||
const AUTO_NAME_LABELS: [&str; 2] = ["Starting Number", "Number of Places"];
|
||||
|
||||
/// The Name, number, zeros and Suffix fields of Rename Selected Clips, with the first name they
|
||||
/// give. `labels` names the number and zeros fields.
|
||||
fn strip_name_fields(ui: &mut egui::Ui, names: &mut StripNames, labels: [&str; 2]) {
|
||||
let [number, zeros] = labels;
|
||||
egui::Grid::new("strip_names").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
|
||||
ui.label("Name");
|
||||
ui.text_edit_singleline(&mut names.name);
|
||||
ui.end_row();
|
||||
ui.label(number);
|
||||
ui.add(egui::DragValue::new(&mut names.number).range(0..=StripNames::MAX_NUMBER));
|
||||
ui.end_row();
|
||||
ui.label(zeros);
|
||||
ui.add(egui::DragValue::new(&mut names.zeros).range(0..=StripNames::MAX_ZEROS));
|
||||
ui.end_row();
|
||||
ui.label("Suffix");
|
||||
ui.text_edit_singleline(&mut names.suffix);
|
||||
ui.end_row();
|
||||
});
|
||||
let example = if names.name.trim().is_empty() {
|
||||
"Without a name, the clips keep their names.".to_string()
|
||||
} else {
|
||||
format!("First clip: {}", names.clip_name(0))
|
||||
};
|
||||
ui.label(egui::RichText::new(example).small().color(Tokens::current().text_dim));
|
||||
}
|
||||
|
||||
/// Cancel/OK row; returns true when OK was pressed (or Enter).
|
||||
fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
|
||||
buttons_enabled(ui, ok, enter, true)
|
||||
@@ -732,6 +1062,13 @@ fn tempo_change_action(at: Samples, bpm: f64) -> (String, Value) {
|
||||
("event.tempo".into(), json!({"bpm": bpm, "at": at}))
|
||||
}
|
||||
|
||||
/// The speed ramp's choices in the AudioSuite window: param id, label, default, and (value, name) pairs.
|
||||
const SPEED_RAMP_CHOICES: [(&str, &str, f32, &[(f32, &str)]); 3] = [
|
||||
("change", "Change", 0.0, &[(0.0, "Slow Down"), (1.0, "Speed Up")]),
|
||||
("selection", "Selection", 0.0, &[(0.0, "Fit To"), (1.0, "Extend")]),
|
||||
("fades", "Fades", 1.0, &[(1.0, "On"), (0.0, "Off")]),
|
||||
];
|
||||
|
||||
fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let Some(process) = app.ui.audiosuite.clone() else { return };
|
||||
let mut open = true;
|
||||
@@ -762,6 +1099,17 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let v = params.entry("speed".into()).or_insert(1.0);
|
||||
ui.add(egui::Slider::new(v, 0.25..=4.0).text("Speed"));
|
||||
}
|
||||
"speed_ramp" => {
|
||||
for (id, label, default, choices) in SPEED_RAMP_CHOICES {
|
||||
let v = params.entry(id.to_string()).or_insert(default);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
for &(value, name) in choices {
|
||||
ui.radio_value(v, value, name);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
ui.label("No parameters.");
|
||||
}
|
||||
@@ -784,6 +1132,42 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `clip.loop` params the Clip Looping dialog's OK sends; None while the typed length does not
|
||||
/// read as a duration longer than zero. An untouched length field sends the exact length it was
|
||||
/// filled with, which its text can round.
|
||||
fn loop_params(
|
||||
s: &Session,
|
||||
by: LoopBy,
|
||||
count: u32,
|
||||
length: &str,
|
||||
format: soundcraft_time::TimeFormat,
|
||||
start: Samples,
|
||||
filled: &(String, Samples),
|
||||
) -> Option<Value> {
|
||||
match by {
|
||||
LoopBy::Count => Some(json!({"count": count})),
|
||||
LoopBy::Length if length == filled.0 => Some(json!({"length": filled.1})),
|
||||
LoopBy::Length => loop_length(s, length, format, start).filter(|l| *l > 0).map(|l| json!({"length": l})),
|
||||
LoopBy::Fill => Some(json!({"fill": true})),
|
||||
}
|
||||
}
|
||||
|
||||
/// A duration typed in `format`, the way `format_length` writes it, for a span that starts at `start`.
|
||||
fn loop_length(s: &Session, text: &str, format: soundcraft_time::TimeFormat, start: Samples) -> Option<Samples> {
|
||||
if format != soundcraft_time::TimeFormat::BarsBeats {
|
||||
return soundcraft_time::parse_position(text, format, s.sample_rate, &s.tempo, s.frame_rate, 0, 0).ok();
|
||||
}
|
||||
let parts: Vec<i64> = text.split(['|', '.', ' ']).filter(|p| !p.is_empty()).map(str::parse::<i64>).collect::<Result<_, _>>().ok()?;
|
||||
if parts.is_empty() || parts.len() > 3 || parts.iter().any(|p| *p < 0) {
|
||||
return None;
|
||||
}
|
||||
let from = s.tempo.samples_to_ticks(start, s.sample_rate);
|
||||
let meter = s.tempo.meter_at_tick(from);
|
||||
let part = |i: usize, unit: i64| parts.get(i).copied().unwrap_or(0).checked_mul(unit);
|
||||
let ticks = part(0, meter.ticks_per_bar())?.checked_add(part(1, meter.ticks_per_beat())?)?.checked_add(part(2, 1)?)?;
|
||||
Some(s.tempo.tick_to_samples(from.checked_add(ticks)?, s.sample_rate).saturating_sub(start))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -823,6 +1207,30 @@ mod tests {
|
||||
assert!(matches!(d.open, Some(Dialog::RenameClip { id, ref name }) if id == clip.id && *name == clip.name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_menu_opens_the_window_and_its_choices_reach_the_engine() {
|
||||
let mut app = SoundApp::new(Engine::default(), None, crate::Services::default());
|
||||
crate::menus::invoke_menu(&mut app, "audiosuite.process", "AudioSuite > Pitch Shift > Vari-Fi");
|
||||
assert_eq!(app.ui.audiosuite.as_deref(), Some("speed_ramp"));
|
||||
// Renders the Kick clip with `params` and returns its new length and samples.
|
||||
let render = |params: Value| {
|
||||
let mut e = soundcraft_engine::demo::demo_engine();
|
||||
let clip = e.session().track_by_name("Kick").unwrap().clips()[0].id;
|
||||
e.execute("audiosuite.process", &json!({"process": "speed_ramp", "params": params, "clips": [clip.0]})).unwrap();
|
||||
let c = e.session().track_by_name("Kick").unwrap().clips()[0].clone();
|
||||
(c.length, e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels.clone())
|
||||
};
|
||||
let defaults: serde_json::Map<String, Value> = SPEED_RAMP_CHOICES.iter().map(|(id, _, d, _)| (id.to_string(), json!(d))).collect();
|
||||
let (length, audio) = render(Value::Object(defaults));
|
||||
assert!(render(json!({})) == (length, audio.clone()), "the window's defaults are the engine's");
|
||||
let (extended, _) = render(json!({"selection": 1.0}));
|
||||
assert_eq!(extended, (length as f64 * 1.5).round() as i64, "Extend makes the clip 150 % long");
|
||||
let (_, fast) = render(json!({"change": 1.0}));
|
||||
assert_ne!(fast, audio, "Speed Up renders a different ramp");
|
||||
let (_, unfaded) = render(json!({"fades": 0.0}));
|
||||
assert_ne!(unfaded, audio, "Fades Off renders without the fade");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tempo_change_dialog_runs_a_real_command_at_its_position() {
|
||||
let (cmd, params) = tempo_change_action(96_000, 133.5);
|
||||
@@ -831,6 +1239,55 @@ mod tests {
|
||||
assert_eq!(params["bpm"], 133.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_silence_buttons_send_strip_extract_and_separate() {
|
||||
let mut e = soundcraft_engine::demo::demo_engine();
|
||||
let kick = e.session().track_by_name("Kick").unwrap().id;
|
||||
let end = e.session().content_end();
|
||||
e.execute("edit.select", &json!({"tracks": [kick.0], "start": 0, "end": end})).unwrap();
|
||||
let mut d = Dialogs::default();
|
||||
assert!(d.open_for_command(&e, "edit.strip_silence"));
|
||||
let Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) = d.open.take() else {
|
||||
panic!("Strip Silence did not open its dialog")
|
||||
};
|
||||
let values = [threshold, min_ms, pre_ms, post_ms];
|
||||
let clips = |e: &Engine| e.session().track(kick).unwrap().clips().iter().map(|c| (c.start, c.end())).collect::<Vec<_>>();
|
||||
let covered = |c: &[(i64, i64)]| c.iter().map(|(a, b)| b - a).sum::<i64>();
|
||||
let before = clips(&e);
|
||||
let mut run = |mode: &str| {
|
||||
let (cmd, params) = strip_silence_action(mode, values, &StripNames::default());
|
||||
assert_eq!(params["mode"], mode);
|
||||
e.execute(&cmd, ¶ms).unwrap();
|
||||
let after = clips(&e);
|
||||
e.execute("edit.undo", &json!({})).unwrap();
|
||||
after
|
||||
};
|
||||
let (strip, extract, separate) = (run("strip"), run("extract"), run("separate"));
|
||||
assert!(separate.len() > before.len(), "Separate cuts the clips");
|
||||
assert_eq!(covered(&separate), covered(&before), "Separate keeps all the audio");
|
||||
assert!(!strip.is_empty() && !extract.is_empty());
|
||||
assert_eq!(covered(&strip) + covered(&extract), covered(&before), "Strip and Extract keep opposite parts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_silence_sends_the_naming_scheme_only_with_a_name() {
|
||||
let values = [-48.0, 50.0, 5.0, 20.0];
|
||||
let (_, plain) = strip_silence_action("strip", values, &StripNames::default());
|
||||
assert!(plain.get("name").is_none() && plain.get("suffix").is_none());
|
||||
let names = StripNames { name: "SFX".into(), number: 23, zeros: 1, suffix: ".Reel1".into() };
|
||||
let (cmd, params) = strip_silence_action("separate", values, &names);
|
||||
assert_eq!((params["name"].clone(), params["number"].clone(), params["zeros"].clone()), (json!("SFX"), json!(23), json!(1)));
|
||||
assert_eq!(params["suffix"], ".Reel1");
|
||||
let mut e = soundcraft_engine::demo::demo_engine();
|
||||
let kick = e.session().track_by_name("Kick").unwrap().id;
|
||||
let end = e.session().content_end();
|
||||
e.execute("edit.select", &json!({"tracks": [kick.0], "start": 0, "end": end})).unwrap();
|
||||
e.execute(&cmd, ¶ms).unwrap();
|
||||
let named: Vec<String> = e.session().track(kick).unwrap().clips().iter().map(|c| c.name.clone()).collect();
|
||||
assert!(named.len() > 1);
|
||||
assert_eq!(named[..2], ["SFX023.Reel1", "SFX024.Reel1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_always_asks_and_names_the_timeline_clips_that_go_too() {
|
||||
let mut e = soundcraft_engine::demo::demo_engine();
|
||||
@@ -862,4 +1319,55 @@ mod tests {
|
||||
"Remove 2 files from the session? They are used by 4 clips on the timeline, which are removed too. The files stay on disk."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_loop_opens_clip_looping_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.loop"));
|
||||
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.loop"));
|
||||
assert_eq!(d.open_name(), Some("clip_looping"));
|
||||
let Some(Dialog::Loop { by, count, length, format, start, filled }) = &d.open else { panic!("{:?}", d.open) };
|
||||
assert_eq!((*by, *count, *start), (LoopBy::Count, 4, clip.start));
|
||||
assert_eq!(*format, e.session().edit.main_counter);
|
||||
// OK on the untouched field loops exactly four times, whatever its text rounds to.
|
||||
let sent = loop_params(e.session(), LoopBy::Length, *count, length, *format, *start, filled);
|
||||
assert_eq!(sent, Some(json!({"length": clip.length * 4})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_length_reads_a_duration_in_every_time_format() {
|
||||
let e = soundcraft_engine::demo::demo_engine();
|
||||
let s = e.session();
|
||||
let start = s.sample_rate.samples(3.0);
|
||||
for format in soundcraft_time::TimeFormat::ALL {
|
||||
for len in [s.sample_rate.samples(2.0), s.sample_rate.samples(7.5)] {
|
||||
let text = soundcraft_time::format_length(start, len, format, s.sample_rate, &s.tempo, s.frame_rate);
|
||||
assert_eq!(loop_length(s, &text, format, start), Some(len), "{format:?} {text}");
|
||||
}
|
||||
}
|
||||
let bars = soundcraft_time::TimeFormat::BarsBeats;
|
||||
assert_eq!(loop_length(s, "1| 0| 000", bars, start), loop_length(s, "1", bars, start));
|
||||
assert_eq!(loop_length(s, "-1| 0| 000", bars, start), None);
|
||||
assert_eq!(loop_length(s, "two bars", bars, start), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_looping_sends_the_chosen_option() {
|
||||
let e = soundcraft_engine::demo::demo_engine();
|
||||
let s = e.session();
|
||||
let samples = soundcraft_time::TimeFormat::Samples;
|
||||
let filled = ("0:01.000".to_string(), 48_001);
|
||||
let send = |by, length: &str| loop_params(s, by, 3, length, samples, 0, &filled);
|
||||
assert_eq!(send(LoopBy::Count, ""), Some(json!({"count": 3})));
|
||||
assert_eq!(send(LoopBy::Length, "4800"), Some(json!({"length": 4800})));
|
||||
assert_eq!(send(LoopBy::Length, "0:01.000"), Some(json!({"length": 48_001})));
|
||||
assert_eq!(send(LoopBy::Length, "0"), None);
|
||||
assert_eq!(send(LoopBy::Length, "soon"), None);
|
||||
assert_eq!(send(LoopBy::Fill, "soon"), Some(json!({"fill": true})));
|
||||
assert!(soundcraft_engine::command_specs().iter().any(|c| c.id == "clip.loop"));
|
||||
}
|
||||
}
|
||||
|
||||
+203
-256
@@ -1,7 +1,10 @@
|
||||
//! Application panel identities and persistence around the shared docking renderer.
|
||||
//! Document contents remain owned by their existing view implementations.
|
||||
|
||||
use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone};
|
||||
use craft_ui::docking::{
|
||||
Action, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Floating, Layout, Node, PanelLimits, Permissions, Placement,
|
||||
UiDock, Zone, ZoneName,
|
||||
};
|
||||
use craft_ui::layout::{SplitAxis, SplitSize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -19,16 +22,6 @@ pub enum Panel {
|
||||
|
||||
impl Panel {
|
||||
pub const ALL: &[Self] = &[Self::Canvas, Self::Tracks, Self::Clips, Self::Midi, Self::Memory, Self::History];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Canvas => "canvas",
|
||||
Self::Tracks => "tracks",
|
||||
Self::Clips => "clips",
|
||||
Self::Midi => "midi",
|
||||
Self::Memory => "memory",
|
||||
Self::History => "history",
|
||||
}
|
||||
}
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Canvas => "Session",
|
||||
@@ -49,15 +42,16 @@ impl Panel {
|
||||
Self::History => Zone::Right,
|
||||
}
|
||||
}
|
||||
fn parse(id: &str) -> Result<Self, String> {
|
||||
Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// An Edit or Mix panel arrangement, saved with the UI preferences.
|
||||
pub type Workspace = craft_ui::docking::Workspace<Panel>;
|
||||
|
||||
const MIDI_TAB_HEIGHT: f32 = 26.0;
|
||||
|
||||
/// The document view stays docked and open, and panels open in splits beside it.
|
||||
fn permissions(panel: &Panel) -> Permissions {
|
||||
if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
|
||||
if *panel == Panel::Canvas { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() }
|
||||
}
|
||||
|
||||
fn tabs(panel: Panel) -> Node<Panel> {
|
||||
@@ -68,232 +62,149 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Pane
|
||||
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Workspace {
|
||||
pub layout: Layout<Panel>,
|
||||
/// Closed panels retain their most recent group, side, size or floating rectangle.
|
||||
pub hidden: Vec<(Panel, Location<Panel>)>,
|
||||
/// The dock area that draws the Edit or Mix workspace.
|
||||
pub fn area_id(window: crate::MainWindow) -> egui::Id {
|
||||
egui::Id::new(("soundcraft-docking", window == crate::MainWindow::Edit))
|
||||
}
|
||||
|
||||
impl Default for Workspace {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
layout: Layout {
|
||||
root: Some(split(
|
||||
SplitAxis::Vertical,
|
||||
SplitSize::FixedSecond(320.0 + MIDI_TAB_HEIGHT),
|
||||
split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedFirst(168.0),
|
||||
tabs(Panel::Tracks),
|
||||
split(SplitAxis::Horizontal, SplitSize::FixedSecond(230.0), tabs(Panel::Canvas), tabs(Panel::Clips)),
|
||||
),
|
||||
tabs(Panel::Midi),
|
||||
)),
|
||||
floating: vec![
|
||||
craft_ui::docking::Floating { panels: vec![Panel::Memory], active: 0, rect: [160.0, 120.0, 480.0, 280.0] },
|
||||
craft_ui::docking::Floating { panels: vec![Panel::History], active: 0, rect: [680.0, 120.0, 260.0, 300.0] },
|
||||
],
|
||||
},
|
||||
hidden: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn area(edit: bool) -> egui::Id {
|
||||
area_id(if edit { crate::MainWindow::Edit } else { crate::MainWindow::Mix })
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
self.layout.validate().map_err(|error| error.to_string())?;
|
||||
if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() {
|
||||
return Err("The panel layout must contain its document view and bounded hidden panels".into());
|
||||
}
|
||||
// Saved settings cannot turn the protected document view into a floating or closable tab.
|
||||
if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) {
|
||||
return Err("The document view cannot float".into());
|
||||
}
|
||||
let mut nodes: Vec<_> = self.layout.root.iter().collect();
|
||||
while let Some(node) = nodes.pop() {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => {
|
||||
nodes.push(first);
|
||||
nodes.push(second);
|
||||
}
|
||||
Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => {
|
||||
return Err("The document view cannot share a tab group".into());
|
||||
}
|
||||
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => {
|
||||
return Err("The document view cannot be collapsed".into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (panel, location) in &self.hidden {
|
||||
if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) {
|
||||
return Err("A hidden panel has an invalid or duplicate identity".into());
|
||||
}
|
||||
if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) {
|
||||
return Err("A hidden panel cannot restore over the document view".into());
|
||||
}
|
||||
let mut probe = self.layout.clone();
|
||||
if let Err(error) = probe.restore(*panel, location)
|
||||
&& error != craft_ui::docking::DockError::MissingTarget
|
||||
{
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_mix(&self) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
if self.layout.contains(&Panel::Clips) || self.layout.contains(&Panel::Midi) {
|
||||
return Err("Clips and MIDI panels belong to the Edit window".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply(&mut self, action: Action<Panel>) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
if let Action::Close { panel } = &action {
|
||||
let location = next.layout.location(panel).map_err(|error| error.to_string())?;
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
next.hidden.push((*panel, location));
|
||||
} else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
}
|
||||
next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
|
||||
if self.layout.contains(&panel) == visible {
|
||||
return Ok(());
|
||||
}
|
||||
if !visible {
|
||||
return self.apply(Action::Close { panel });
|
||||
}
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
|
||||
next.hidden.retain(|(id, _)| *id != panel);
|
||||
if let Some(saved) = &mut location
|
||||
&& saved.floating.is_some()
|
||||
&& saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor)))
|
||||
{
|
||||
// The other members of a floating group may have been closed too.
|
||||
saved.anchor = None;
|
||||
saved.placement = Placement::Tab { before: None };
|
||||
}
|
||||
let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok());
|
||||
if !restored {
|
||||
next.layout
|
||||
.apply_with_permissions(
|
||||
Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) },
|
||||
permissions,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command(&mut self, params: &Value) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
if let Some(action) = params.get("action") {
|
||||
let action: Action<Panel> = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
|
||||
return self.apply(action);
|
||||
}
|
||||
let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
|
||||
if operation == "resizeSplit" {
|
||||
let path: Vec<bool> =
|
||||
serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
|
||||
let size: SplitSize =
|
||||
serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
|
||||
return self.apply(Action::ResizeSplit { path, size });
|
||||
}
|
||||
let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
|
||||
let action = match operation {
|
||||
"open" => return self.set_visible(panel, true),
|
||||
"close" => Action::Close { panel },
|
||||
"activate" => Action::Activate { panel },
|
||||
"setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
|
||||
"resizeStack" => {
|
||||
let value = params.get("height").ok_or("height is required")?;
|
||||
let height = if value.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
value
|
||||
.as_f64()
|
||||
.filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
|
||||
.ok_or("height must be finite and bounded")? as f32,
|
||||
)
|
||||
};
|
||||
Action::ResizeStack { panel, height }
|
||||
}
|
||||
"float" | "moveFloating" => {
|
||||
let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
|
||||
let mut values = [0.0f32; 4];
|
||||
for (output, value) in values.iter_mut().zip(rect) {
|
||||
*output = value
|
||||
.as_f64()
|
||||
.filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
|
||||
.ok_or("rect values must be finite and bounded")? as f32;
|
||||
}
|
||||
if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
|
||||
}
|
||||
"move" => {
|
||||
let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
|
||||
let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
|
||||
"center" => Placement::Split(Zone::Center),
|
||||
"left" => Placement::Split(Zone::Left),
|
||||
"right" => Placement::Split(Zone::Right),
|
||||
"top" => Placement::Split(Zone::Top),
|
||||
"bottom" => Placement::Split(Zone::Bottom),
|
||||
_ => return Err("zone must be center, left, right, top or bottom".into()),
|
||||
};
|
||||
let placement = if let Some(before) = params.get("before") {
|
||||
if !matches!(placement, Placement::Split(Zone::Center)) {
|
||||
return Err("before only applies to a center move".into());
|
||||
}
|
||||
Placement::Tab {
|
||||
before: if before.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
|
||||
},
|
||||
}
|
||||
} else {
|
||||
placement
|
||||
};
|
||||
Action::Move { panel, anchor, placement }
|
||||
}
|
||||
_ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
|
||||
};
|
||||
self.apply(action)
|
||||
}
|
||||
/// The Edit window's built-in arrangement.
|
||||
pub fn edit_default() -> Workspace {
|
||||
Workspace::new(Layout {
|
||||
root: Some(split(
|
||||
SplitAxis::Vertical,
|
||||
SplitSize::FixedSecond(320.0 + MIDI_TAB_HEIGHT),
|
||||
split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedFirst(168.0),
|
||||
tabs(Panel::Tracks),
|
||||
split(SplitAxis::Horizontal, SplitSize::FixedSecond(230.0), tabs(Panel::Canvas), tabs(Panel::Clips)),
|
||||
),
|
||||
tabs(Panel::Midi),
|
||||
)),
|
||||
// Fixed ids: the default is rebuilt every frame and must stay the same windows.
|
||||
floating: vec![
|
||||
Floating::single(vec![Panel::Memory], 0, [160.0, 120.0, 480.0, 280.0]).with_id(1),
|
||||
Floating::single(vec![Panel::History], 0, [680.0, 120.0, 260.0, 300.0]).with_id(2),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Mix retains its own arrangement; the timeline-only Clips and MIDI panels stay in Edit.
|
||||
pub fn mix_default() -> Workspace {
|
||||
let mut workspace = Workspace::default();
|
||||
let _ = workspace.set_visible(Panel::Clips, false);
|
||||
let _ = workspace.set_visible(Panel::Midi, false);
|
||||
let mut workspace = edit_default();
|
||||
let _ = set_visible(&mut workspace, Panel::Clips, false);
|
||||
let _ = set_visible(&mut workspace, Panel::Midi, false);
|
||||
workspace
|
||||
}
|
||||
|
||||
/// The document view where the Edit window's rules keep it: in the docked frames, alone in
|
||||
/// its tab group. `None` when `node` does not hold it.
|
||||
fn canvas_placement(node: &Node<Panel>) -> Option<Result<(), &'static str>> {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => canvas_placement(first).or_else(|| canvas_placement(second)),
|
||||
Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) => {
|
||||
Some(if panels.len() == 1 { Ok(()) } else { Err("The document view cannot share a tab group") })
|
||||
}
|
||||
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => Some(Err("The document view cannot be collapsed")),
|
||||
Node::Dock { columns, .. } if columns.iter().flat_map(|column| &column.groups).any(|group| group.panels.contains(&Panel::Canvas)) => {
|
||||
Some(Err("The document view cannot join a dock column"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check a workspace against craft-ui's rules and SoundCraft's own: saved settings cannot
|
||||
/// float, hide, collapse or share the protected document view.
|
||||
pub fn validate(workspace: &Workspace) -> Result<(), String> {
|
||||
workspace.validate().map_err(|error| error.to_string())?;
|
||||
let layout = &workspace.layout;
|
||||
if !layout.contains(&Panel::Canvas) || workspace.hidden.len() > Panel::ALL.len() {
|
||||
return Err("The panel layout must contain its document view and bounded hidden panels".into());
|
||||
}
|
||||
if layout.floating.iter().any(|window| window.contains(&Panel::Canvas)) {
|
||||
return Err("The document view cannot float".into());
|
||||
}
|
||||
layout.root.as_ref().and_then(canvas_placement).unwrap_or(Err("The document view cannot float"))?;
|
||||
for (panel, location) in &workspace.hidden {
|
||||
if *panel == Panel::Canvas {
|
||||
return Err("A hidden panel has an invalid or duplicate identity".into());
|
||||
}
|
||||
if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) {
|
||||
return Err("A hidden panel cannot restore over the document view".into());
|
||||
}
|
||||
let mut probe = layout.clone();
|
||||
if let Err(error) = probe.restore(*panel, location)
|
||||
&& error != DockError::MissingTarget
|
||||
{
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`validate`] plus the Mix window's rule: Clips and MIDI stay in Edit.
|
||||
pub fn validate_mix(workspace: &Workspace) -> Result<(), String> {
|
||||
validate(workspace)?;
|
||||
if workspace.layout.contains(&Panel::Clips) || workspace.layout.contains(&Panel::Midi) {
|
||||
return Err("Clips and MIDI panels belong to the Edit window".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change a copy of `workspace` and keep it only when it passes [`validate`].
|
||||
fn checked(workspace: &mut Workspace, change: impl FnOnce(&mut Workspace) -> Result<(), DockError>) -> Result<(), String> {
|
||||
validate(workspace)?;
|
||||
let mut next = workspace.clone();
|
||||
change(&mut next).map_err(|error| error.to_string())?;
|
||||
validate(&next)?;
|
||||
*workspace = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply(workspace: &mut Workspace, action: Action<Panel>) -> Result<(), String> {
|
||||
checked(workspace, |next| next.apply(action, permissions).map(drop))
|
||||
}
|
||||
|
||||
/// Show a panel at its recorded place, else beside the document view on its default side.
|
||||
fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> {
|
||||
if workspace.layout.contains(&panel) == visible {
|
||||
return Ok(());
|
||||
}
|
||||
checked(workspace, |next| {
|
||||
if !visible {
|
||||
return next.apply(Action::Close { panel }, permissions).map(drop);
|
||||
}
|
||||
if next.set_visible(panel, true, None, permissions).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
next.apply(Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, permissions).map(drop)
|
||||
})
|
||||
}
|
||||
|
||||
/// Run a shared docking command. A hidden panel that is opened or activated comes back with
|
||||
/// [`set_visible`], so it gets SoundCraft's default place when it has no recorded one.
|
||||
fn run(workspace: &mut Workspace, command: &DockCommand<Panel>) -> Result<(), String> {
|
||||
if let DockCommand::Activate { panel } | DockCommand::UiDock(UiDock::Operation(DockOperation::Open { panel } | DockOperation::Activate { panel })) =
|
||||
command
|
||||
&& !workspace.layout.contains(panel)
|
||||
{
|
||||
return set_visible(workspace, *panel, true);
|
||||
}
|
||||
// Only `window.panel.float` reads the float space, and SoundCraft does not register it.
|
||||
checked(workspace, |next| command.apply(next, false, &FloatSpace::new(egui::Rect::NOTHING), permissions).map(drop))
|
||||
}
|
||||
|
||||
fn sync(workspace: &mut Workspace, app: &crate::SoundApp, edit: bool) -> Result<(), String> {
|
||||
workspace.set_visible(Panel::Tracks, app.ui.show_tracks_list)?;
|
||||
workspace.set_visible(Panel::Memory, app.ui.show_memory_locations)?;
|
||||
workspace.set_visible(Panel::History, app.ui.show_undo_history)?;
|
||||
set_visible(workspace, Panel::Tracks, app.ui.show_tracks_list)?;
|
||||
set_visible(workspace, Panel::Memory, app.ui.show_memory_locations)?;
|
||||
set_visible(workspace, Panel::History, app.ui.show_undo_history)?;
|
||||
if edit {
|
||||
workspace.set_visible(Panel::Clips, app.ui.show_clip_list)?;
|
||||
workspace.set_visible(Panel::Midi, app.ui.show_midi_editor)?;
|
||||
set_visible(workspace, Panel::Clips, app.ui.show_clip_list)?;
|
||||
set_visible(workspace, Panel::Midi, app.ui.show_midi_editor)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -316,20 +227,57 @@ fn store(app: &mut crate::SoundApp, workspace: Workspace, edit: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command(app: &mut crate::SoundApp, reset: bool, params: &Value) -> Result<Value, String> {
|
||||
/// A `ui.dock` operation. Panels and zones are checked first so an error names the field;
|
||||
/// parsed through the untagged [`UiDock`], every mistake reads "did not match any variant".
|
||||
fn operation(fields: &serde_json::Map<String, Value>) -> Result<DockOperation<Panel>, String> {
|
||||
let name = fields.get("operation").ok_or("operation is required")?.as_str().ok_or("operation must be a string")?;
|
||||
if name != "resizeSplit" && !fields.contains_key("panel") {
|
||||
return Err("panel is required".into());
|
||||
}
|
||||
for field in ["panel", "target"] {
|
||||
if let Some(value) = fields.get(field) {
|
||||
let id = value.as_str().ok_or_else(|| format!("{field} must be a panel ID"))?;
|
||||
serde_json::from_value::<Panel>(json!(id)).map_err(|_| format!("Unknown panel: {id}"))?;
|
||||
}
|
||||
}
|
||||
if let Some(zone) = fields.get("zone")
|
||||
&& serde_json::from_value::<ZoneName>(zone.clone()).is_err()
|
||||
{
|
||||
return Err("zone must be tab, center, left, right, top, bottom, above or below".into());
|
||||
}
|
||||
serde_json::from_value(Value::Object(fields.clone())).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// `ui.dock` and `ui.dock.reset`: the shared [`DockCommand`] vocabulary, applied to the
|
||||
/// front window's workspace.
|
||||
pub fn command(app: &mut crate::SoundApp, id: &str, params: &Value) -> Result<Value, String> {
|
||||
let edit = app.ui.window == crate::MainWindow::Edit;
|
||||
if !edit && ["panel", "target"].iter().any(|field| matches!(params.get(*field).and_then(Value::as_str), Some("clips" | "midi"))) {
|
||||
return Err("Clips and MIDI panels belong to the Edit window".into());
|
||||
}
|
||||
let mut fields = match params {
|
||||
Value::Object(fields) => fields.clone(),
|
||||
Value::Null => serde_json::Map::new(),
|
||||
_ => return Err("Docking params must be an object".into()),
|
||||
};
|
||||
let command = if id == "ui.dock" && !fields.contains_key("action") {
|
||||
DockCommand::UiDock(UiDock::Operation(operation(&fields)?))
|
||||
} else {
|
||||
fields.insert("command".into(), json!(id));
|
||||
serde_json::from_value(Value::Object(fields)).map_err(|error| error.to_string())?
|
||||
};
|
||||
let previous = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() };
|
||||
let result = command_for(app, reset, params, edit)?;
|
||||
let result = command_for(app, &command, edit)?;
|
||||
let next = if edit { &app.ui.docking } else { &app.ui.mix_docking };
|
||||
cancel_replaced_workspace(app, &previous, next, edit);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn cancel_replaced_workspace(app: &crate::SoundApp, previous: &Workspace, next: &Workspace, edit: bool) {
|
||||
if (previous.layout != next.layout || previous.hidden != next.hidden)
|
||||
if previous != next
|
||||
&& let Some(ctx) = &app.docking_context
|
||||
{
|
||||
craft_ui::docking::cancel_drag::<Panel>(ctx, egui::Id::new(("soundcraft-docking", edit)));
|
||||
craft_ui::docking::cancel_drag::<Panel>(ctx, area(edit));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,22 +287,23 @@ pub(crate) fn cancel_replaced_state(app: &crate::SoundApp, next: &crate::UiState
|
||||
cancel_replaced_workspace(app, &app.ui.mix_docking, &next.mix_docking, false);
|
||||
}
|
||||
|
||||
fn command_for(app: &mut crate::SoundApp, reset: bool, params: &Value, edit: bool) -> Result<Value, String> {
|
||||
if !edit && ["panel", "target"].iter().any(|field| matches!(params.get(*field).and_then(Value::as_str), Some("clips" | "midi"))) {
|
||||
return Err("Clips and MIDI panels belong to the Edit window".into());
|
||||
}
|
||||
fn command_for(app: &mut crate::SoundApp, command: &DockCommand<Panel>, edit: bool) -> Result<Value, String> {
|
||||
let mut workspace = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() };
|
||||
if !reset && !edit {
|
||||
workspace.validate_mix()?;
|
||||
}
|
||||
if reset {
|
||||
workspace = if edit { Workspace::default() } else { mix_default() };
|
||||
if command.is_app_owned() {
|
||||
// Reset is the only application-owned command SoundCraft registers.
|
||||
if !matches!(command, DockCommand::ResetWorkspace {}) {
|
||||
return Err("Named panel workspaces are not supported".into());
|
||||
}
|
||||
workspace = if edit { edit_default() } else { mix_default() };
|
||||
sync(&mut workspace, app, edit)?;
|
||||
} else {
|
||||
sync(&mut workspace, app, edit)?;
|
||||
workspace.command(params)?;
|
||||
if !edit {
|
||||
workspace.validate_mix()?;
|
||||
validate_mix(&workspace)?;
|
||||
}
|
||||
sync(&mut workspace, app, edit)?;
|
||||
run(&mut workspace, command)?;
|
||||
if !edit {
|
||||
validate_mix(&workspace)?;
|
||||
}
|
||||
}
|
||||
publish_visibility(app, &workspace, edit);
|
||||
@@ -368,16 +317,16 @@ pub(crate) fn arrange(app: &mut crate::SoundApp) -> Result<(), String> {
|
||||
let edit = app.ui.window == crate::MainWindow::Edit;
|
||||
let mut workspace = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() };
|
||||
if edit {
|
||||
workspace.validate()?;
|
||||
validate(&workspace)?;
|
||||
} else {
|
||||
workspace.validate_mix()?;
|
||||
validate_mix(&workspace)?;
|
||||
}
|
||||
let previous = workspace.clone();
|
||||
let floating: Vec<_> = workspace.layout.floating.iter().filter_map(|group| group.panels.first().map(|panel| (*panel, group.rect))).collect();
|
||||
let floating: Vec<_> = workspace.layout.floating.iter().filter_map(|window| window.panels().next().map(|panel| (*panel, window.rect))).collect();
|
||||
for (index, (panel, mut rect)) in floating.into_iter().enumerate() {
|
||||
rect[0] = 32.0 + index as f32 * 28.0;
|
||||
rect[1] = 80.0 + index as f32 * 28.0;
|
||||
workspace.apply(Action::MoveFloating { panel, rect })?;
|
||||
apply(&mut workspace, Action::MoveFloating { panel, rect })?;
|
||||
}
|
||||
cancel_replaced_workspace(app, &previous, &workspace, edit);
|
||||
store(app, workspace, edit);
|
||||
@@ -388,11 +337,12 @@ pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWin
|
||||
app.docking_context = Some(ui.ctx().clone());
|
||||
let t = crate::theme::Tokens::current();
|
||||
let edit = window == crate::MainWindow::Edit;
|
||||
let mut workspace = if edit { std::mem::take(&mut app.ui.docking) } else { std::mem::replace(&mut app.ui.mix_docking, mix_default()) };
|
||||
let mut workspace =
|
||||
if edit { std::mem::replace(&mut app.ui.docking, edit_default()) } else { std::mem::replace(&mut app.ui.mix_docking, mix_default()) };
|
||||
let before_sync = workspace.clone();
|
||||
if let Err(error) = (if edit { workspace.validate() } else { workspace.validate_mix() }).and_then(|()| sync(&mut workspace, app, edit)) {
|
||||
if let Err(error) = (if edit { validate(&workspace) } else { validate_mix(&workspace) }).and_then(|()| sync(&mut workspace, app, edit)) {
|
||||
app.ui.status = format!("Panel layout could not be restored: {error}");
|
||||
workspace = if edit { Workspace::default() } else { mix_default() };
|
||||
workspace = if edit { edit_default() } else { mix_default() };
|
||||
let _ = sync(&mut workspace, app, edit);
|
||||
}
|
||||
cancel_replaced_workspace(app, &before_sync, &workspace, edit);
|
||||
@@ -407,7 +357,7 @@ pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWin
|
||||
style.border = egui::Stroke::new(1.0, t.border);
|
||||
style.accent = t.accent;
|
||||
style.font = crate::theme::regular(11.5);
|
||||
let output = DockArea::new(egui::Id::new(("soundcraft-docking", edit))).show_with_limits(
|
||||
let output = DockArea::new(area(edit)).show_with_limits(
|
||||
ui,
|
||||
&workspace.layout,
|
||||
&style,
|
||||
@@ -437,10 +387,7 @@ pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWin
|
||||
publish_visibility(app, &workspace, edit);
|
||||
store(app, workspace, edit);
|
||||
for action in output.actions {
|
||||
if let Err(error) = serde_json::to_value(action)
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|action| command_for(app, false, &json!({"action": action}), edit))
|
||||
{
|
||||
if let Err(error) = command_for(app, &DockCommand::Layout { action }, edit) {
|
||||
app.ui.status = format!("Panel move was not applied: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,26 +115,81 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
||||
crate::docking::show(app, ui, crate::MainWindow::Edit);
|
||||
}
|
||||
|
||||
/// Universe: the whole session in miniature; the box is the visible area (drag or click to move).
|
||||
fn universe(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let r = ui.max_rect().shrink2(vec2(8.0, 4.0));
|
||||
let s = app.engine.session();
|
||||
/// The Universe's clip blocks for one document revision and area: each track's clips merged into
|
||||
/// runs of one colour. A drum edit holds tens of thousands of clips a pixel or less wide here;
|
||||
/// painting one rect per clip every frame would cost more than all the rest of the window.
|
||||
#[derive(Clone)]
|
||||
struct UniverseRuns {
|
||||
revision: u64,
|
||||
area: Rect,
|
||||
runs: std::sync::Arc<Vec<(Rect, Color32)>>,
|
||||
}
|
||||
|
||||
/// Each clip covers `[x0, max(x1, x0 + 1)]`; neighbours of one colour that touch or overlap are
|
||||
/// painted as one rect, which covers exactly the same pixels in the same order.
|
||||
fn universe_runs(s: &Session, r: Rect) -> Vec<(Rect, Color32)> {
|
||||
let total = (s.content_end() + s.sample_rate.samples(10.0)).max(1) as f32;
|
||||
let tracks: Vec<&Track> = s.tracks.iter().filter(|t| !t.hidden).collect();
|
||||
let n = tracks.len().max(1) as f32;
|
||||
let rh = (r.height() / n).max(1.0);
|
||||
let mut runs = Vec::new();
|
||||
for (i, t) in tracks.iter().enumerate() {
|
||||
let y = r.min.y + i as f32 * rh;
|
||||
let block = |x0: f32, x1: f32, color| (Rect::from_min_max(pos2(x0, y + 0.5), pos2(x1, y + rh - 0.5)), color);
|
||||
let mut run: Option<(f32, f32, Color32)> = None;
|
||||
for c in t.clips() {
|
||||
let x0 = r.min.x + c.start as f32 / total * r.width();
|
||||
let x1 = r.min.x + c.end() as f32 / total * r.width();
|
||||
ui.painter().rect_filled(
|
||||
Rect::from_min_max(pos2(x0, y + 0.5), pos2(x1.max(x0 + 1.0), y + rh - 0.5)),
|
||||
0.0,
|
||||
rgb(c.color.unwrap_or(t.color)),
|
||||
);
|
||||
let x1 = (r.min.x + c.end() as f32 / total * r.width()).max(x0 + 1.0);
|
||||
let color = rgb(c.color.unwrap_or(t.color));
|
||||
run = match run {
|
||||
Some((a, b, col)) if col == color && x0 >= a && x0 <= b => Some((a, b.max(x1), col)),
|
||||
Some((a, b, col)) => {
|
||||
runs.push(block(a, b, col));
|
||||
Some((x0, x1, color))
|
||||
}
|
||||
None => Some((x0, x1, color)),
|
||||
};
|
||||
}
|
||||
if let Some((a, b, col)) = run {
|
||||
runs.push(block(a, b, col));
|
||||
}
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
/// [`Session::content_end`], worked out once per document revision: it walks every clip.
|
||||
fn content_end(app: &SoundApp, ctx: &egui::Context) -> Samples {
|
||||
let key = egui::Id::new("content_end");
|
||||
let revision = app.engine.revision;
|
||||
if let Some((at, end)) = ctx.memory(|m| m.data.get_temp::<(u64, Samples)>(key))
|
||||
&& at == revision
|
||||
{
|
||||
return end;
|
||||
}
|
||||
let end = app.engine.session().content_end();
|
||||
ctx.memory_mut(|m| m.data.insert_temp(key, (revision, end)));
|
||||
end
|
||||
}
|
||||
|
||||
/// Universe: the whole session in miniature; the box is the visible area (drag or click to move).
|
||||
fn universe(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let r = ui.max_rect().shrink2(vec2(8.0, 4.0));
|
||||
let key = egui::Id::new("universe_runs");
|
||||
let revision = app.engine.revision;
|
||||
let runs = match ui.ctx().memory(|m| m.data.get_temp::<UniverseRuns>(key)).filter(|u| u.revision == revision && u.area == r) {
|
||||
Some(u) => u.runs,
|
||||
None => {
|
||||
let runs = std::sync::Arc::new(universe_runs(app.engine.session(), r));
|
||||
ui.ctx().memory_mut(|m| m.data.insert_temp(key, UniverseRuns { revision, area: r, runs: runs.clone() }));
|
||||
runs
|
||||
}
|
||||
};
|
||||
for &(rect, color) in runs.iter() {
|
||||
ui.painter().rect_filled(rect, 0.0, color);
|
||||
}
|
||||
let end = content_end(app, ui.ctx());
|
||||
let s = app.engine.session();
|
||||
let total = (end + s.sample_rate.samples(10.0)).max(1) as f32;
|
||||
let vis0 = s.edit.zoom.scroll as f32 / total;
|
||||
let tlw = app.edit_layout.timeline[2] - app.edit_layout.timeline[0];
|
||||
let vis1 = vis0 + (f64::from(tlw.max(100.0)) * s.edit.zoom.samples_per_px) as f32 / total;
|
||||
@@ -239,6 +294,9 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {
|
||||
draw_rulers(app, ui, rulers_rect, tl, &rulers);
|
||||
views_button(app, ui, rulers_rect);
|
||||
column_titles(ui, rulers_rect, &cols);
|
||||
if let Some(id) = app.edit_scroll_into_view.take() {
|
||||
scroll_into_view(app, id, tl.height());
|
||||
}
|
||||
// Tracks.
|
||||
let mut y = tl.min.y - app.edit_layout.scroll_y;
|
||||
app.edit_layout.rows.clear();
|
||||
@@ -289,6 +347,25 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll Into View: track `id` to the top, as far as the scroll bar reaches. A track the Edit
|
||||
/// window doesn't show (hidden, or in a closed folder) leaves the view where it is.
|
||||
fn scroll_into_view(app: &mut SoundApp, id: TrackId, view_h: f32) {
|
||||
let s = app.engine.session();
|
||||
let (mut top, mut total, mut found) = (0.0, 0.0, false);
|
||||
for t in s.tracks.iter().filter(|t| in_edit_window(s, t)) {
|
||||
found |= t.id == id;
|
||||
if !found {
|
||||
top += track_height(t) + 1.0;
|
||||
}
|
||||
total += track_height(t) + 1.0;
|
||||
}
|
||||
if found {
|
||||
let l = &mut app.edit_layout;
|
||||
l.content_h = total;
|
||||
l.scroll_by(top - l.scroll_y, view_h);
|
||||
}
|
||||
}
|
||||
|
||||
fn in_edit_window(s: &Session, t: &Track) -> bool {
|
||||
ops::shown(s, t)
|
||||
}
|
||||
@@ -373,9 +450,10 @@ fn pan(app: &mut SoundApp, px: f64) {
|
||||
fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) {
|
||||
let t = Tokens::current();
|
||||
ui.painter().rect_filled(sb, 0.0, t.panel_bg);
|
||||
let end = content_end(app, ui.ctx());
|
||||
let s = app.engine.session();
|
||||
let vis = f64::from(tl.width()) * s.edit.zoom.samples_per_px;
|
||||
let total = (s.content_end().saturating_add(s.sample_rate.samples(30.0)).max(1) as f64).max(s.edit.zoom.scroll as f64 + vis);
|
||||
let total = (end.saturating_add(s.sample_rate.samples(30.0)).max(1) as f64).max(s.edit.zoom.scroll as f64 + vis);
|
||||
let frac = (vis / total).clamp(0.02, 1.0) as f32;
|
||||
let off = (s.edit.zoom.scroll as f64 / total).clamp(0.0, 1.0) as f32;
|
||||
let thumb = Rect::from_min_size(pos2(sb.min.x + sb.width() * off, sb.min.y + 3.0), vec2((sb.width() * frac).max(24.0), sb.height() - 6.0));
|
||||
@@ -838,11 +916,12 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect,
|
||||
}
|
||||
let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x));
|
||||
let auto_view = AutoParam::parse(&track.view);
|
||||
let sorted = track.clips().is_sorted_by_key(|c| c.start);
|
||||
for clip in track.clips() {
|
||||
if !clip.range().overlaps(&view) {
|
||||
continue;
|
||||
}
|
||||
draw_clip(app, &painter, &s, track, clip, tl, lane, auto_view.is_some());
|
||||
draw_clip(app, &painter, &s, track, sorted, clip, tl, lane, auto_view.is_some());
|
||||
}
|
||||
draw_live_take(app, &painter, &s, track, tl, lane);
|
||||
crate::video_track::draw_lane(app, &painter, &s, track, tl, lane);
|
||||
@@ -865,12 +944,35 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect,
|
||||
painter.rect_filled(Rect::from_min_max(pos2(x0, lane.min.y), pos2(x1, lane.max.y)), 0.0, t.selection);
|
||||
}
|
||||
}
|
||||
let strip = crate::dialogs::strip_silence_rectangles(app, ui.ctx());
|
||||
if let Some((_, ranges)) = strip.iter().find(|(t, _)| *t == id) {
|
||||
draw_strip_silence(&painter, &s, tl, lane, ranges);
|
||||
}
|
||||
lane_interaction(app, ui, track, lane, tl);
|
||||
if track.view == "playlists" {
|
||||
playlist_lanes(app, ui, track, row, main_h, tl);
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline of the Strip Silence rectangles.
|
||||
pub fn strip_silence_stroke() -> Stroke {
|
||||
Stroke::new(1.5, Tokens::current().accent)
|
||||
}
|
||||
|
||||
/// The Strip Silence rectangles on one lane, while the Strip Silence window is open: one around each
|
||||
/// range Strip keeps.
|
||||
fn draw_strip_silence(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect, ranges: &[Range]) {
|
||||
let stroke = strip_silence_stroke();
|
||||
for r in ranges {
|
||||
let x0 = x_of(s, tl, r.start).max(lane.min.x);
|
||||
let x1 = x_of(s, tl, r.end).min(lane.max.x);
|
||||
if x1 > x0 {
|
||||
let rect = Rect::from_min_max(pos2(x0, lane.min.y + 1.0), pos2(x1, lane.max.y - 1.0));
|
||||
painter.rect(rect, 0.0, stroke.color.gamma_multiply(0.12), stroke, StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alternate playlists as lanes under the main one; clicking a clip promotes it (comping).
|
||||
fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, main_h: f32, tl: Rect) {
|
||||
let t = Tokens::current();
|
||||
@@ -885,8 +987,10 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai
|
||||
ui.painter().rect_filled(lane, 0.0, t.playlist_lane);
|
||||
ui.painter().text(pos2(head.min.x + 4.0, head.center().y), Align2::LEFT_CENTER, &pl.name, regular(10.5), t.text_dim);
|
||||
let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect()));
|
||||
for c in &pl.clips {
|
||||
draw_clip(app, &painter, &s, track, c, tl, lane, false);
|
||||
let sorted = track.clips().is_sorted_by_key(|c| c.start);
|
||||
let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x));
|
||||
for c in pl.clips.iter().filter(|c| c.range().overlaps(&view)) {
|
||||
draw_clip(app, &painter, &s, track, sorted, c, tl, lane, false);
|
||||
}
|
||||
let resp = ui.interact(lane, ui.id().with(("pl_lane", track.id.0, i)), Sense::click());
|
||||
if resp.clicked()
|
||||
@@ -1456,6 +1560,10 @@ pub(crate) fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
|
||||
if app.engine.session().track(id).is_some_and(|track| track.folder.is_some()) && ui.button("Remove from Folder").clicked() {
|
||||
let _ = app.run("track.remove_from_folder", json!({"tracks": selection}));
|
||||
}
|
||||
// Only the track clicked: the command has one target, and the selection stays as it is.
|
||||
if ui.button("Scroll Into View").clicked() {
|
||||
let _ = app.run("view.scroll_into_view", json!({"track": id.0}));
|
||||
}
|
||||
ui.separator();
|
||||
ui.menu_button("Track Height", |ui| {
|
||||
for h in TrackHeight::ALL {
|
||||
@@ -1511,9 +1619,13 @@ fn fade_curve_points(fade: soundcraft_model::Fade, fade_in: bool, a: f32, b: f32
|
||||
let scale = if pixels_per_point.is_finite() { pixels_per_point.clamp(0.5, 4.0) } else { 1.0 };
|
||||
let extent = (b - a).abs().max(body.height().abs()) * scale;
|
||||
let extent = if extent.is_finite() { extent.clamp(64.0, 4096.0) } else { 64.0 };
|
||||
// A narrow fade needs fewer: the curve between two samples stays inside a box `width / segments`
|
||||
// wide, so it is never further than that from its chord. Zoomed out on an edited drum take,
|
||||
// thousands of fades are a pixel or two wide.
|
||||
let narrow = ((b - a).abs() * scale * 4.0).ceil() + 1.0;
|
||||
// Physical-pixel sampling avoids visible corners, even for steep bends.
|
||||
// Keep an exact midpoint and cap allocations for extreme zoom/clip sizes.
|
||||
let segments = (extent.ceil() as usize).div_ceil(2) * 2;
|
||||
let segments = (extent.min(narrow).max(2.0).ceil() as usize).div_ceil(2) * 2;
|
||||
(0..=segments)
|
||||
.map(|i| {
|
||||
let k = i as f32 / segments as f32;
|
||||
@@ -1582,7 +1694,8 @@ fn grid_veil(line: Color32, bg: Color32) -> Color32 {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, clip: &Clip, tl: Rect, lane: Rect, dim: bool) {
|
||||
/// `sorted`: whether `track.clips()` is sorted by start (worked out once per track and frame).
|
||||
fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, sorted: bool, clip: &Clip, tl: Rect, lane: Rect, dim: bool) {
|
||||
let x0 = x_of(s, tl, clip.start);
|
||||
let x1 = x_of(s, tl, clip.end()).max(x0 + 1.0);
|
||||
let r = Rect::from_min_max(pos2(x0, lane.min.y + 1.0), pos2(x1, lane.max.y - 1.0));
|
||||
@@ -1604,13 +1717,16 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
|
||||
let nb = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + name_h));
|
||||
painter.rect_filled(nb, CornerRadius { nw: 2, ne: 2, sw: 0, se: 0 }, if clip.muted { Color32::from_rgb(70, 70, 70) } else { bar });
|
||||
let name_clip = nb.intersect(lane);
|
||||
painter.with_clip_rect(name_clip).text(
|
||||
pos2(nb.min.x.max(lane.min.x) + 4.0, nb.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
&clip.name,
|
||||
bold(10.5),
|
||||
Color32::from_rgb(12, 12, 12),
|
||||
);
|
||||
// Narrower than this, no glyph would show: the name starts 4 points in.
|
||||
if name_clip.width() > 6.0 {
|
||||
painter.with_clip_rect(name_clip).text(
|
||||
pos2(nb.min.x.max(lane.min.x) + 4.0, nb.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
&clip.name,
|
||||
bold(10.5),
|
||||
Color32::from_rgb(12, 12, 12),
|
||||
);
|
||||
}
|
||||
}
|
||||
let body_r = Rect::from_min_max(pos2(r.min.x, r.min.y + name_h), r.max);
|
||||
let vis = body_r.intersect(lane);
|
||||
@@ -1772,8 +1888,16 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
|
||||
painter.text(pos2(r.max.x - 6.0, r.max.y - 7.0), Align2::RIGHT_CENTER, "FX", bold(9.0), Color32::from_rgb(250, 230, 120));
|
||||
}
|
||||
if s.edit.flag("view.clip.overlap_shadows") {
|
||||
// Shade where a later clip covers this one (crossfades and layered edits).
|
||||
for o in track.clips() {
|
||||
// Shade where a later clip covers this one (crossfades and layered edits). Only clips that
|
||||
// start inside this one can: on a sorted track, find them instead of checking every clip.
|
||||
let clips = track.clips();
|
||||
let later = if sorted {
|
||||
let from = clips.partition_point(|o| o.start <= clip.start);
|
||||
clips.get(from..clips.partition_point(|o| o.start < clip.end()).max(from)).unwrap_or_default()
|
||||
} else {
|
||||
clips
|
||||
};
|
||||
for o in later {
|
||||
if o.id != clip.id && o.start > clip.start && o.start < clip.end() {
|
||||
let x0 = x_of(s, tl, o.start);
|
||||
let x1 = x_of(s, tl, clip.end().min(o.end()));
|
||||
@@ -2226,8 +2350,13 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
|
||||
"automation.set_point",
|
||||
json!({"track": track.id.0, "param": automation_id(param), "at": at, "value": lo + (hi - lo) * k}),
|
||||
);
|
||||
} else if track.kind.is_midi() && hit.is_none() {
|
||||
new_midi_clip(app, &s, track.id, at, at);
|
||||
} else if track.kind.is_midi() {
|
||||
match &hit {
|
||||
Some(c) => {
|
||||
let _ = app.run("edit.select", json!({"clips": [c.id.0]}));
|
||||
}
|
||||
None => new_midi_clip(app, &s, track.id, at, at),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if !selector_press => place_insertion(app, ui, &s, track.id, at, mods.shift),
|
||||
@@ -2471,13 +2600,18 @@ fn automation_id(p: &AutoParam) -> String {
|
||||
AutoParam::Pan(0) => "pan".into(),
|
||||
AutoParam::Pan(_) => "pan2".into(),
|
||||
AutoParam::Mute => "mute".into(),
|
||||
AutoParam::SendLevel(i) => format!("send_{}_level", (b'a' + i) as char),
|
||||
AutoParam::SendPan(i) => format!("send_{}_pan", (b'a' + i) as char),
|
||||
AutoParam::SendMute(i) => format!("send_{}_mute", (b'a' + i) as char),
|
||||
AutoParam::Plugin { slot, param } => format!("plugin:{}:{param}", (b'a' + slot) as char),
|
||||
AutoParam::SendLevel(i) => format!("send_{}_level", slot_id(*i)),
|
||||
AutoParam::SendPan(i) => format!("send_{}_pan", slot_id(*i)),
|
||||
AutoParam::SendMute(i) => format!("send_{}_mute", slot_id(*i)),
|
||||
AutoParam::Plugin { slot, param } => format!("plugin:{}:{param}", slot_id(*slot)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A slot as `AutoParam::parse` reads it: a letter for 0..=25, the number beyond that.
|
||||
fn slot_id(i: u8) -> String {
|
||||
if i < 26 { char::from(b'a' + i).to_string() } else { i.to_string() }
|
||||
}
|
||||
|
||||
/// Grid snapping while dragging; holding Cmd/Ctrl swaps Grid and Slip.
|
||||
fn drag_snap(s: &Session, at: Samples, flip: bool) -> Samples {
|
||||
if s.edit.edit_mode.is_grid() != flip { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at }
|
||||
@@ -2776,11 +2910,76 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod universe_tests {
|
||||
use super::*;
|
||||
use soundcraft_model::{ChannelFormat, Clip, ClipId, SourceId, TrackKind};
|
||||
|
||||
/// The pixel columns each block covers, per colour, as the old one-rect-per-clip painting did.
|
||||
fn covered(blocks: &[(Rect, Color32)]) -> Vec<(i32, Color32)> {
|
||||
let mut px: Vec<(i32, Color32)> = Vec::new();
|
||||
for (r, c) in blocks {
|
||||
for x in (r.min.x * 4.0).round() as i32..(r.max.x * 4.0).round() as i32 {
|
||||
px.retain(|p| p.0 != x);
|
||||
px.push((x, *c));
|
||||
}
|
||||
}
|
||||
px.sort_by_key(|p| p.0);
|
||||
px
|
||||
}
|
||||
|
||||
fn per_clip(s: &Session, r: Rect) -> Vec<(Rect, Color32)> {
|
||||
let total = (s.content_end() + s.sample_rate.samples(10.0)).max(1) as f32;
|
||||
let t = &s.tracks[0];
|
||||
t.clips()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let x0 = r.min.x + c.start as f32 / total * r.width();
|
||||
let x1 = (r.min.x + c.end() as f32 / total * r.width()).max(x0 + 1.0);
|
||||
(Rect::from_min_max(pos2(x0, r.min.y + 0.5), pos2(x1, r.max.y - 0.5)), rgb(c.color.unwrap_or(t.color)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sliced_track_is_a_few_runs_covering_the_same_pixels() {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let mut clips = Vec::new();
|
||||
let mut at = 0;
|
||||
for i in 0..5000u64 {
|
||||
let mut c = Clip::audio(ClipId(i + 1), "hit", SourceId(1), 0, at, 200 + (i % 7) as i64 * 50);
|
||||
// A recoloured stretch, and a gap, in the middle.
|
||||
if (2000..2100).contains(&i) {
|
||||
c.color = Some([200, 40, 40]);
|
||||
}
|
||||
at = c.end() + if i == 3000 { 2_000_000 } else { 0 };
|
||||
clips.push(c);
|
||||
}
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips = clips;
|
||||
let r = Rect::from_min_size(pos2(8.0, 4.0), vec2(900.0, 56.0));
|
||||
let runs = universe_runs(&s, r);
|
||||
assert!(runs.len() <= 6, "{} rects for 5000 clips", runs.len());
|
||||
assert_eq!(covered(&runs), covered(&per_clip(&s, r)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui_kittest::Harness;
|
||||
|
||||
#[test]
|
||||
fn automation_id_round_trips_for_high_slots() {
|
||||
for slot in [0u8, 25, 26, 155, 156, 255] {
|
||||
for p in
|
||||
[AutoParam::SendLevel(slot), AutoParam::SendPan(slot), AutoParam::SendMute(slot), AutoParam::Plugin { slot, param: "trim".into() }]
|
||||
{
|
||||
assert_eq!(AutoParam::parse(&automation_id(&p)), Some(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_jumps_to_offscreen_playhead_while_playing() {
|
||||
// Steady view, follow mode, playhead left the screen: jump to it.
|
||||
@@ -3444,6 +3643,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_fades_take_few_points_and_stay_within_a_quarter_pixel() {
|
||||
for width in [0.2f32, 1.0, 3.0, 12.0] {
|
||||
for height in [40.0f32, 320.0] {
|
||||
let body = Rect::from_min_size(Pos2::ZERO, vec2(width, height));
|
||||
for scale in [1.0, 2.0] {
|
||||
for shape in soundcraft_model::FadeShape::ALL {
|
||||
for curvature in [-1.0, 0.0, 1.0] {
|
||||
let fade = soundcraft_model::Fade { len: 1000, shape, curvature };
|
||||
let points = fade_curve_points(fade, true, 0.0, width, body, scale);
|
||||
assert!(points.len() <= ((width * scale * 4.0).ceil() as usize + 4), "{width} wide: {} points", points.len());
|
||||
assert_eq!((points.len() - 1) % 2, 0, "an exact midpoint");
|
||||
let segments = (points.len() - 1) as f32;
|
||||
for (i, pair) in points.windows(2).enumerate() {
|
||||
let line = pair[1] - pair[0];
|
||||
for fraction in [0.1, 0.25, 0.5, 0.75, 0.9] {
|
||||
let t = (i as f32 + fraction) / segments;
|
||||
let actual = pos2(width * t, height - height * fade.gain(t));
|
||||
let along =
|
||||
if line.length_sq() > 0.0 { ((actual - pair[0]).dot(line) / line.length_sq()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let distance = actual.distance(pair[0] + line * along) * scale;
|
||||
assert!(distance < 0.25, "{width}x{height}, scale={scale}, {shape:?}, curve={curvature}, error={distance}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn painted_fade_control(h: &Harness<SoundApp>, clip_rect: Rect) -> Pos2 {
|
||||
let controls: Vec<_> = h
|
||||
.output()
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Import Audio reads and decodes on a worker thread, so the app keeps drawing and stays usable
|
||||
//! while a long file loads (issue #597). When the audio is ready it is handed to the engine
|
||||
//! (`Engine::offer_import`) and `file.import_audio` runs as before, now only placing the clip:
|
||||
//! undo, the journal, the CLI, MCP and the control channel see the same command, and calls from
|
||||
//! the control channel still run synchronously.
|
||||
|
||||
use crate::SoundApp;
|
||||
use serde_json::Value;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Imports waiting and the one decoding now. One file decodes at a time: each can take gigabytes.
|
||||
#[derive(Default)]
|
||||
pub struct Imports {
|
||||
queue: VecDeque<Value>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
running: Option<Running>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct Running {
|
||||
params: Value,
|
||||
name: String,
|
||||
/// The session rate the audio is decoded for.
|
||||
rate: u32,
|
||||
rx: std::sync::mpsc::Receiver<Result<soundcraft_engine::io::DecodedImport, String>>,
|
||||
}
|
||||
|
||||
impl Imports {
|
||||
/// Whether a file is decoding or waiting.
|
||||
pub fn busy(&self) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.running.is_some() {
|
||||
return true;
|
||||
}
|
||||
!self.queue.is_empty()
|
||||
}
|
||||
|
||||
/// The file decoding now and how many wait after it.
|
||||
fn label(&self) -> Option<(String, usize)> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(r) = &self.running {
|
||||
return Some((r.name.clone(), self.queue.len()));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports the audio file `params["path"]` names (the `file.import_audio` params) in the
|
||||
/// background. Anything else (several `paths`, no path, the browser) runs the command now.
|
||||
pub fn start(app: &mut SoundApp, params: Value) {
|
||||
if cfg!(target_arch = "wasm32") || params.get("path").and_then(Value::as_str).is_none() {
|
||||
run_now(app, params);
|
||||
return;
|
||||
}
|
||||
app.imports.queue.push_back(params);
|
||||
}
|
||||
|
||||
/// Starts the next import, and places a finished one. Called every frame from `logic`.
|
||||
pub fn step(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
finish(app);
|
||||
if app.imports.running.is_none()
|
||||
&& let Some(params) = app.imports.queue.pop_front()
|
||||
{
|
||||
spawn(app, params, ctx);
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = ctx;
|
||||
while let Some(params) = app.imports.queue.pop_front() {
|
||||
run_now(app, params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spawn(app: &mut SoundApp, params: Value, ctx: &egui::Context) {
|
||||
let path = params.get("path").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let name = std::path::Path::new(&path).file_name().map_or_else(|| path.clone(), |n| n.to_string_lossy().into_owned());
|
||||
let rate = app.engine.session().sample_rate.hz();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ctx = ctx.clone();
|
||||
let work = move || {
|
||||
let r = soundcraft_engine::io::decode_import(&path, rate).map_err(|e| e.to_string());
|
||||
let _ = tx.send(r);
|
||||
ctx.request_repaint();
|
||||
};
|
||||
match std::thread::Builder::new().name("audio-import".into()).spawn(work) {
|
||||
// No status line here: it may hold the previous file's failure, and the progress note names this file.
|
||||
Ok(_) => app.imports.running = Some(Running { params, name, rate, rx }),
|
||||
// No thread to spare: import the old way rather than not at all.
|
||||
Err(_) => {
|
||||
run_now(app, params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn finish(app: &mut SoundApp) {
|
||||
let Some(r) = &app.imports.running else { return };
|
||||
let done = match r.rx.try_recv() {
|
||||
Ok(done) => done,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => Err(format!("{}: the import stopped", r.name)),
|
||||
};
|
||||
let Some(Running { params, name, rate, .. }) = app.imports.running.take() else { return };
|
||||
// The session changed rate meanwhile (another session was opened): decode again for it.
|
||||
if rate != app.engine.session().sample_rate.hz() {
|
||||
app.imports.queue.push_front(params);
|
||||
return;
|
||||
}
|
||||
match done {
|
||||
Ok(decoded) => {
|
||||
app.engine.offer_import(decoded);
|
||||
if run_now(app, params) {
|
||||
app.ui.status = format!("Imported {name}");
|
||||
}
|
||||
app.engine.drop_offered_imports();
|
||||
}
|
||||
Err(e) => app.ui.status = e,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `file.import_audio` on this thread; the status line shows a failure. True on success.
|
||||
fn run_now(app: &mut SoundApp, params: Value) -> bool {
|
||||
match app.run("file.import_audio", params) {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
app.ui.status = e;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A small, non-blocking note while files import: the rest of the app stays usable.
|
||||
pub fn show(app: &SoundApp, ctx: &egui::Context) {
|
||||
let Some((name, waiting)) = app.imports.label() else { return };
|
||||
egui::Area::new(egui::Id::new("audio_import_progress")).anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-16.0, -40.0)).interactable(false).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::Spinner::new());
|
||||
let more = if waiting > 0 { format!(" ({waiting} more)") } else { String::new() };
|
||||
ui.label(format!("Importing {name}{more}"));
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Services, SoundApp};
|
||||
use serde_json::json;
|
||||
|
||||
fn tone_wav(name: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("sc-ui-import-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let tone: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.2).collect();
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone.clone(), tone] };
|
||||
let bytes = soundcraft_audio_io::encode(&buf, &soundcraft_audio_io::EncodeOptions::default()).unwrap();
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn settle(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
for _ in 0..2_000 {
|
||||
step(app, ctx);
|
||||
if !app.imports.busy() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
panic!("import never finished");
|
||||
}
|
||||
|
||||
/// Issue #597: starting an import returns at once; the clip lands, as one undo step, later.
|
||||
#[test]
|
||||
fn import_runs_in_the_background_and_places_one_undoable_clip() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
let wav = tone_wav("bg.wav");
|
||||
start(&mut app, json!({"path": wav, "at": 480}));
|
||||
assert!(app.imports.busy());
|
||||
assert!(app.engine.session().tracks.is_empty(), "nothing is placed before the audio is ready");
|
||||
settle(&mut app, &ctx);
|
||||
let s = app.engine.session();
|
||||
assert_eq!(s.tracks.len(), 1);
|
||||
assert_eq!(s.tracks[0].clips()[0].start, 480);
|
||||
assert_eq!(s.sources[0].frames, 9_600);
|
||||
assert!(app.ui.status.starts_with("Imported bg.wav"), "{}", app.ui.status);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(app.engine.session().tracks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_queue_and_a_bad_file_reports_without_stopping_the_rest() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
let junk = tone_wav("junk.wav");
|
||||
std::fs::write(&junk, b"not audio").unwrap();
|
||||
let good = tone_wav("good.wav");
|
||||
start(&mut app, json!({"path": junk}));
|
||||
start(&mut app, json!({"path": good}));
|
||||
step(&mut app, &ctx);
|
||||
assert_eq!(app.imports.label().map(|l| l.1), Some(1), "the second waits for the first");
|
||||
settle(&mut app, &ctx);
|
||||
assert_eq!(app.engine.session().tracks.len(), 1, "the good file still imports");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_import_stays_on_the_status_line_while_the_next_one_starts() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
let junk = tone_wav("junk-status.wav");
|
||||
std::fs::write(&junk, b"not audio").unwrap();
|
||||
start(&mut app, json!({"path": junk}));
|
||||
start(&mut app, json!({"path": tone_wav("next-status.wav")}));
|
||||
for _ in 0..2_000 {
|
||||
step(&mut app, &ctx);
|
||||
if app.imports.label().is_some_and(|(name, _)| name == "next-status.wav") {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert!(app.imports.label().is_some_and(|(name, _)| name == "next-status.wav"), "the second import never started");
|
||||
assert!(!app.ui.status.is_empty() && !app.ui.status.starts_with("Importing"), "the failure was overwritten: {}", app.ui.status);
|
||||
settle(&mut app, &ctx);
|
||||
}
|
||||
}
|
||||
+257
-9
@@ -4,6 +4,7 @@
|
||||
//! executing commands. It can be replaced without touching anything below it.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod appearance;
|
||||
pub mod bounce;
|
||||
pub mod control;
|
||||
pub mod credits;
|
||||
@@ -13,6 +14,7 @@ pub mod edit_window;
|
||||
pub mod extra_windows;
|
||||
pub mod fonts;
|
||||
pub mod icons;
|
||||
pub mod import;
|
||||
pub mod menus;
|
||||
pub mod midi_editor;
|
||||
pub mod mix_window;
|
||||
@@ -131,7 +133,7 @@ impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
UiState {
|
||||
recent_sessions: Vec::new(),
|
||||
docking: docking::Workspace::default(),
|
||||
docking: docking::edit_default(),
|
||||
mix_docking: docking::mix_default(),
|
||||
window: MainWindow::Edit,
|
||||
show_tracks_list: true,
|
||||
@@ -295,6 +297,10 @@ pub struct SoundApp {
|
||||
pub palette: palette::PaletteState,
|
||||
/// Bring the separate Mix window to the front on the next frame.
|
||||
pub(crate) focus_mix: bool,
|
||||
/// Scroll Into View: the track each window brings to its top (Edit) or left (Mix) when it
|
||||
/// next draws, so a window not on screen catches up when it is shown.
|
||||
pub(crate) edit_scroll_into_view: Option<TrackId>,
|
||||
pub(crate) mix_scroll_into_view: Option<TrackId>,
|
||||
pub synthetic: Vec<egui::Event>,
|
||||
/// Where a dropped audio file loads into a track's sampler (last frame's rects).
|
||||
pub sample_drop_targets: Vec<(egui::Rect, TrackId)>,
|
||||
@@ -322,6 +328,8 @@ pub struct SoundApp {
|
||||
touched: std::collections::HashSet<TrackId>,
|
||||
/// The pass's last point; `i64::MIN` after a stop or a jump, so the next point erases nothing it skipped.
|
||||
last_write_at: Samples,
|
||||
/// Plugins changed in their editors whose state the session hasn't taken yet (see `editor_states_due`).
|
||||
editor_states: Vec<(TrackId, usize)>,
|
||||
pub frame_ms: f32,
|
||||
last_frame: Option<f64>,
|
||||
last_meter_poll: f64,
|
||||
@@ -329,12 +337,37 @@ pub struct SoundApp {
|
||||
pub video: video_track::VideoPool,
|
||||
/// The Bounce Mix in progress, rendered a slice per frame.
|
||||
pub bounce: Option<soundcraft_engine::io::Bounce>,
|
||||
/// Audio files importing in the background (issue #597).
|
||||
pub imports: import::Imports,
|
||||
/// Test-only: owns the process-wide palette lock while this app exists, so frames painted
|
||||
/// and colour assertions in one test can never straddle another test's palette change.
|
||||
#[cfg(test)]
|
||||
_palette_lock: theme::TestPaletteGuard,
|
||||
}
|
||||
|
||||
/// The plugins whose state the session takes this frame, out of those changed in their editors
|
||||
/// (`pending`, kept across frames). A plugin editor doesn't say when a gesture ends: a plugin's
|
||||
/// gesture lasts while its editor reports a change every frame, and its state is read on the first
|
||||
/// frame its editor reports none, or as soon as the editor is closed. A drag reads and stores the
|
||||
/// state once, not every frame, and a plugin that keeps changing doesn't hold back the others.
|
||||
fn editor_states_due(
|
||||
pending: &mut Vec<(TrackId, usize)>,
|
||||
edits: &[soundcraft_playback::EditorEdit],
|
||||
changed: Vec<(TrackId, usize)>,
|
||||
closed: impl Fn((TrackId, usize)) -> bool,
|
||||
) -> Vec<(TrackId, usize)> {
|
||||
let instruments = edits.iter().filter(|e| e.1 == soundcraft_mix::INSTRUMENT_SLOT).map(|e| (e.0, e.1));
|
||||
let reported: Vec<(TrackId, usize)> = instruments.chain(changed).collect();
|
||||
for k in &reported {
|
||||
if !pending.contains(k) {
|
||||
pending.push(*k);
|
||||
}
|
||||
}
|
||||
let (due, rest) = std::mem::take(pending).into_iter().partition(|&k| !reported.contains(&k) || closed(k));
|
||||
*pending = rest;
|
||||
due
|
||||
}
|
||||
|
||||
/// Whether another automation-write pass is due: the transport moved at least
|
||||
/// `step` since `last` in either direction. `last == i64::MIN` marks "never
|
||||
/// written", so the first pass after (re)start is always due. Saturating
|
||||
@@ -381,6 +414,8 @@ impl SoundApp {
|
||||
extra: extra_windows::ExtraState::default(),
|
||||
palette: palette::PaletteState::default(),
|
||||
focus_mix: false,
|
||||
edit_scroll_into_view: None,
|
||||
mix_scroll_into_view: None,
|
||||
synthetic: Vec::new(),
|
||||
sample_drop_targets: Vec::new(),
|
||||
sample_reads: Vec::new(),
|
||||
@@ -399,11 +434,13 @@ impl SoundApp {
|
||||
arrange_request: false,
|
||||
touched: std::collections::HashSet::new(),
|
||||
last_write_at: i64::MIN,
|
||||
editor_states: Vec::new(),
|
||||
frame_ms: 0.0,
|
||||
last_frame: None,
|
||||
last_meter_poll: f64::NEG_INFINITY,
|
||||
video: video_track::VideoPool::default(),
|
||||
bounce: None,
|
||||
imports: import::Imports::default(),
|
||||
#[cfg(test)]
|
||||
_palette_lock: theme::lock_test_palette(),
|
||||
}
|
||||
@@ -422,13 +459,17 @@ impl SoundApp {
|
||||
if let Some(r) = menus::run_ui_command(self, id, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
if id.starts_with("session.save") || id.starts_with("file.bounce") {
|
||||
if id.starts_with("session.save") || id.starts_with("file.bounce") || id == "track.save_preset" {
|
||||
// Third-party plugins keep state the session can't see: store it first.
|
||||
self.capture_plugin_states();
|
||||
}
|
||||
let replacing = matches!(id, "session.new" | "session.open" | "session.close" | "session.revert");
|
||||
match self.engine.execute(id, ¶ms) {
|
||||
Ok(v) => {
|
||||
if replacing {
|
||||
// Note indices and gestures belong to the old session's clips.
|
||||
self.midi = midi_editor::MidiEditorState::default();
|
||||
}
|
||||
if replacing || (id.starts_with("session.save") && !self.engine.is_dirty()) {
|
||||
self.release_autosave(false);
|
||||
}
|
||||
@@ -600,6 +641,13 @@ impl SoundApp {
|
||||
if plan.is_empty() {
|
||||
return Err("no audio track is record-enabled".into());
|
||||
}
|
||||
// The takes are unsaved work: the folder they go into under the autosave root is
|
||||
// owner-only, like the autosaves, before the recorder makes anything in it.
|
||||
if self.engine.path.is_none() && self.autosave_dir.is_some() {
|
||||
for dir in plan.iter().filter_map(|(_, _, path)| path.parent()) {
|
||||
soundcraft_engine::autosave::create_folder(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
}
|
||||
let takes = plan
|
||||
.iter()
|
||||
.map(|(track, inputs, path)| soundcraft_engine::io::Take { track: *track, path: path.clone(), channels: inputs.len() })
|
||||
@@ -612,10 +660,21 @@ impl SoundApp {
|
||||
/// (`mix.insert_state`), so saves and bounces include it.
|
||||
pub fn capture_plugin_states(&mut self) {
|
||||
let Some(p) = &self.player else { return };
|
||||
for (t, slot, data) in p.capture_states() {
|
||||
let states = p.capture_states();
|
||||
self.store_plugin_states(states);
|
||||
}
|
||||
|
||||
fn store_plugin_states(&mut self, states: soundcraft_playback::PluginStates) {
|
||||
for (t, slot, data) in states {
|
||||
let slot = if slot == soundcraft_mix::INSTRUMENT_SLOT { json!("instrument") } else { json!(slot) };
|
||||
let state = soundcraft_model::b64::encode(&data);
|
||||
if let Err(e) = self.engine.execute("mix.insert_state", &json!({"track": t.0, "slot": slot, "state": state})) {
|
||||
let params = json!({"track": t.0, "slot": slot, "state": soundcraft_model::b64::encode(&data)});
|
||||
// A state read while a knob is dragged (or a pass recorded) joins that gesture, so it
|
||||
// stays one undo step.
|
||||
let stored = match self.engine.merge_key().map(str::to_string) {
|
||||
Some(key) => self.engine.execute_merged("mix.insert_state", ¶ms, &key),
|
||||
None => self.engine.execute("mix.insert_state", ¶ms),
|
||||
};
|
||||
if let Err(e) = stored {
|
||||
log::warn!("storing plugin state: {e}");
|
||||
}
|
||||
}
|
||||
@@ -643,15 +702,36 @@ impl SoundApp {
|
||||
}
|
||||
|
||||
/// Parameter edits made in plugin editors go into the session (one undo step per gesture).
|
||||
/// Hosted instruments keep their parameters in their own state, which the session takes
|
||||
/// instead, as it does for plugins that said their state changed: either way the session is
|
||||
/// modified, so it is autosaved and asks to be saved. The state is read once per gesture (see
|
||||
/// `editor_states_due`).
|
||||
fn apply_editor_edits(&mut self) {
|
||||
let Some(p) = &self.player else { return };
|
||||
for (t, slot, param, value) in p.idle() {
|
||||
let Some(p) = &self.player else {
|
||||
self.editor_states.clear();
|
||||
return;
|
||||
};
|
||||
let edits = p.idle();
|
||||
let due = editor_states_due(&mut self.editor_states, &edits, p.changed_states(), |(t, slot)| !p.editor_open(t, slot));
|
||||
let states = if due.is_empty() { Vec::new() } else { p.capture_states_of(&due) };
|
||||
self.store_editor_changes(edits, states);
|
||||
}
|
||||
|
||||
fn store_editor_changes(&mut self, edits: Vec<soundcraft_playback::EditorEdit>, states: soundcraft_playback::PluginStates) {
|
||||
for (t, slot, param, value) in edits {
|
||||
if slot == soundcraft_mix::INSTRUMENT_SLOT {
|
||||
continue;
|
||||
}
|
||||
let key = format!("param:{}:{slot}:{param}", t.0);
|
||||
let _ = self.engine.execute_merged("mix.insert_param", &json!({"track": t.0, "slot": slot, "param": param, "value": value}), &key);
|
||||
}
|
||||
self.store_plugin_states(states);
|
||||
}
|
||||
|
||||
/// The session is modified, or a plugin editor gesture still holds a state the session hasn't
|
||||
/// taken (see `editor_states_due`): both read the plugins' states before autosaving.
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.engine.is_dirty() || !self.editor_states.is_empty()
|
||||
}
|
||||
|
||||
/// Every two minutes, copy a modified session to the autosave folder (not while recording).
|
||||
@@ -659,7 +739,7 @@ impl SoundApp {
|
||||
fn autosave(&mut self) {
|
||||
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 last.elapsed().as_secs() < 120 || !self.has_unsaved_changes() || self.engine.transport.recording {
|
||||
return;
|
||||
}
|
||||
self.last_autosave = Some(std::time::Instant::now());
|
||||
@@ -722,7 +802,7 @@ impl SoundApp {
|
||||
if recording {
|
||||
self.stop_play();
|
||||
}
|
||||
let keep = self.engine.is_dirty() && (recording || !self.quit_requested);
|
||||
let keep = self.has_unsaved_changes() && (recording || !self.quit_requested);
|
||||
if keep {
|
||||
self.capture_plugin_states();
|
||||
self.write_autosave();
|
||||
@@ -1159,6 +1239,7 @@ impl SoundApp {
|
||||
self.note_recent_session();
|
||||
self.apply_editor_edits();
|
||||
bounce::step(self, ctx);
|
||||
import::step(self, ctx);
|
||||
if !ctx.input(|i| i.pointer.any_down()) {
|
||||
if matches!(self.gesture, Some(Gesture::Fader { .. })) {
|
||||
self.gesture = None;
|
||||
@@ -1330,6 +1411,7 @@ fn failed_plugin_status(s: &soundcraft_model::Session, track: TrackId, slot: usi
|
||||
mod tests {
|
||||
use super::{Services, SoundApp, automation_step_due};
|
||||
use serde_json::json;
|
||||
use soundcraft_model::TrackId;
|
||||
|
||||
#[test]
|
||||
fn new_session_stops_playback_and_the_old_clock() {
|
||||
@@ -1370,6 +1452,17 @@ mod tests {
|
||||
assert_eq!(super::failed_plugin_status(&s, t, soundcraft_mix::INSTRUMENT_SLOT), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_the_session_drops_the_midi_note_selection() {
|
||||
for command in ["session.new", "session.close"] {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.midi.selected = vec![0, 1];
|
||||
app.midi.clip = Some(soundcraft_model::ClipId(1));
|
||||
app.run(command, json!({})).unwrap();
|
||||
assert!(app.midi.selected.is_empty() && app.midi.clip.is_none(), "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_session_cancels_play_queued_before_the_next_frame() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
@@ -1417,6 +1510,41 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A clean demo session with its autosave folder, and an editor gesture still open on Keys
|
||||
/// when `gesture` is set (its state hasn't reached the session yet).
|
||||
fn app_with_editor_gesture(name: &str, gesture: bool) -> (SoundApp, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}-{gesture}", std::process::id()));
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.autosave_dir = Some(dir.clone());
|
||||
if gesture {
|
||||
let keys = app.engine.session().track_by_name("Keys").unwrap().id;
|
||||
app.editor_states.push((keys, soundcraft_mix::INSTRUMENT_SLOT));
|
||||
}
|
||||
assert!(!app.engine.is_dirty());
|
||||
(app, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_plugin_editor_gesture_is_autosaved() {
|
||||
for gesture in [false, true] {
|
||||
let (mut app, dir) = app_with_editor_gesture("gesture-autosave", gesture);
|
||||
app.last_autosave = std::time::Instant::now().checked_sub(std::time::Duration::from_secs(121));
|
||||
app.autosave();
|
||||
assert_eq!(app.latest_autosave().is_some(), gesture);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_during_a_plugin_editor_gesture_keeps_the_autosave() {
|
||||
for gesture in [false, true] {
|
||||
let (mut app, dir) = app_with_editor_gesture("gesture-close", gesture);
|
||||
app.close_autosave();
|
||||
assert_eq!(app.latest_autosave().is_some(), gesture);
|
||||
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());
|
||||
@@ -1526,6 +1654,126 @@ mod tests {
|
||||
assert_eq!(app.engine.undo_history().len(), steps + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_states_read_during_a_drag_keep_it_one_undo_step() {
|
||||
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.run("mix.insert", json!({"track": kick.0, "plugin": "gain", "slot": 0})).unwrap();
|
||||
let steps = app.engine.undo_history().len();
|
||||
for frame in 0..60u8 {
|
||||
// What a plugin editor reports each frame of a drag, and the state the plugin marked dirty.
|
||||
let gain = -10.0 + f32::from(frame) / 10.0;
|
||||
app.engine
|
||||
.execute_merged("mix.insert_param", &json!({"track": kick.0, "slot": 0, "param": "gain", "value": gain}), "param:drag")
|
||||
.unwrap();
|
||||
app.store_plugin_states(vec![(kick, 0, vec![frame, 1])]);
|
||||
}
|
||||
assert_eq!(app.engine.undo_history().len(), steps + 1);
|
||||
assert_eq!(app.engine.session().track(kick).unwrap().mixer.inserts[0].as_ref().unwrap().state_bytes(), Some(vec![59, 1]));
|
||||
}
|
||||
|
||||
/// One UI frame of plugin editors: the instruments edited in theirs, the plugins that said
|
||||
/// their state changed, the editors closed, and the state every plugin has now. Returns the
|
||||
/// plugins whose state was read.
|
||||
fn editor_frame(app: &mut SoundApp, edited: &[TrackId], changed: &[(TrackId, usize)], closed: &[TrackId], now: u8) -> Vec<(TrackId, usize)> {
|
||||
let slot = soundcraft_mix::INSTRUMENT_SLOT;
|
||||
let edits: Vec<_> = edited.iter().map(|&t| (t, slot, "cutoff".to_string(), f32::from(now))).collect();
|
||||
let due = super::editor_states_due(&mut app.editor_states, &edits, changed.to_vec(), |(t, _)| closed.contains(&t));
|
||||
let states = due.iter().map(|&(t, slot)| (t, slot, vec![now, 1])).collect();
|
||||
app.store_editor_changes(edits, states);
|
||||
due
|
||||
}
|
||||
|
||||
fn instrument_state(app: &SoundApp, t: TrackId) -> Option<Vec<u8>> {
|
||||
app.engine.session().track(t).unwrap().instrument.as_ref().unwrap().state_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instrument_editor_drag_reads_and_stores_its_state_once() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let keys = app.engine.session().track_by_name("Keys").unwrap().id;
|
||||
let mut reads = Vec::new();
|
||||
let mut revisions = Vec::new();
|
||||
for now in 0..60 {
|
||||
reads.extend(editor_frame(&mut app, &[keys], &[], &[], now));
|
||||
revisions.push(app.engine.revision);
|
||||
}
|
||||
reads.extend(editor_frame(&mut app, &[], &[], &[], 59));
|
||||
revisions.push(app.engine.revision);
|
||||
assert_eq!(reads, [(keys, soundcraft_mix::INSTRUMENT_SLOT)]);
|
||||
// The session goes to the audio thread on each new revision: once, not every frame.
|
||||
revisions.dedup();
|
||||
assert_eq!(revisions.len(), 2, "{revisions:?}");
|
||||
assert_eq!(instrument_state(&app, keys), Some(vec![59, 1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_instrument_editor_drags_store_its_state_twice() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let keys = app.engine.session().track_by_name("Keys").unwrap().id;
|
||||
let mut reads = Vec::new();
|
||||
for now in 0..10 {
|
||||
reads.extend(editor_frame(&mut app, &[keys], &[], &[], now));
|
||||
}
|
||||
reads.extend(editor_frame(&mut app, &[], &[], &[], 9));
|
||||
assert_eq!(instrument_state(&app, keys), Some(vec![9, 1]));
|
||||
for now in 20..30 {
|
||||
reads.extend(editor_frame(&mut app, &[keys], &[], &[], now));
|
||||
}
|
||||
reads.extend(editor_frame(&mut app, &[], &[], &[], 29));
|
||||
assert_eq!(reads.len(), 2);
|
||||
assert_eq!(instrument_state(&app, keys), Some(vec![29, 1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_an_instrument_editor_mid_gesture_still_stores_its_state() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let keys = app.engine.session().track_by_name("Keys").unwrap().id;
|
||||
let lead = app.engine.session().track_by_name("Lead").unwrap().id;
|
||||
let lead_before = instrument_state(&app, lead);
|
||||
for now in 0..10 {
|
||||
editor_frame(&mut app, &[keys, lead], &[], &[], now);
|
||||
}
|
||||
// Keys' editor closes on a frame it still reports a change in.
|
||||
let reads = editor_frame(&mut app, &[keys, lead], &[], &[keys], 10);
|
||||
assert_eq!(reads, [(keys, soundcraft_mix::INSTRUMENT_SLOT)]);
|
||||
assert_eq!(instrument_state(&app, keys), Some(vec![10, 1]));
|
||||
assert_eq!(instrument_state(&app, lead), lead_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_that_keeps_changing_does_not_hold_back_another_ones_state() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let keys = app.engine.session().track_by_name("Keys").unwrap().id;
|
||||
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
|
||||
app.run("mix.insert", json!({"track": kick.0, "plugin": "gain", "slot": 0})).unwrap();
|
||||
// A plugin on Kick marks its state changed on every frame while Keys is dragged, then
|
||||
// keeps doing it after the drag.
|
||||
for now in 0..10 {
|
||||
editor_frame(&mut app, &[keys], &[(kick, 0)], &[], now);
|
||||
}
|
||||
let reads = editor_frame(&mut app, &[], &[(kick, 0)], &[], 10);
|
||||
assert_eq!(reads, [(keys, soundcraft_mix::INSTRUMENT_SLOT)]);
|
||||
assert_eq!(instrument_state(&app, keys), Some(vec![10, 1]));
|
||||
assert_eq!(app.editor_states, [(kick, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preset_changed_in_an_effect_editor_is_stored_once() {
|
||||
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.run("mix.insert", json!({"track": kick.0, "plugin": "gain", "slot": 0})).unwrap();
|
||||
// No parameter edits: the plugin only says its state changed, frame after frame.
|
||||
let mut reads = Vec::new();
|
||||
for now in 0..5 {
|
||||
reads.extend(editor_frame(&mut app, &[], &[(kick, 0)], &[], now));
|
||||
}
|
||||
reads.extend(editor_frame(&mut app, &[], &[], &[], 4));
|
||||
assert_eq!(reads, [(kick, 0)]);
|
||||
let state = app.engine.session().track(kick).unwrap().mixer.inserts[0].as_ref().unwrap().state_bytes();
|
||||
assert_eq!(state, Some(vec![4, 1]));
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
@@ -69,6 +69,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
|
||||
("window.io_setup", "I/O Setup", "", None),
|
||||
("window.shortcuts", "Keyboard Shortcuts", "", None),
|
||||
("view.mix_section", "Mix Window View", "", None),
|
||||
("view.scroll_into_view", "Scroll Into View", "", None),
|
||||
("ui.new_tracks_dialog", "New Tracks…", "", None),
|
||||
("ui.bounce_dialog", "Bounce Mix…", "", None),
|
||||
("ui.set", "Set UI State", "", None),
|
||||
@@ -127,10 +128,10 @@ pub const AUDIOSUITE: &[(&str, &str)] = &[
|
||||
("AudioSuite > Pitch Shift > Pitch II", "pitch_shift"),
|
||||
("AudioSuite > Pitch Shift > Pitch Shift Legacy", "pitch_shift"),
|
||||
("AudioSuite > Pitch Shift > Time Shift", "time_stretch"),
|
||||
("AudioSuite > Pitch Shift > Vari-Fi", "varispeed"),
|
||||
("AudioSuite > Pitch Shift > Vari-Fi", "speed_ramp"),
|
||||
("AudioSuite > Reverb > D-Verb", "room_reverb"),
|
||||
("AudioSuite > Delay > Mod Delay III", "mod_delay"),
|
||||
("AudioSuite > Modulation > Sci-Fi", "flanger"),
|
||||
("AudioSuite > Modulation > Sci-Fi", "ring_mod"),
|
||||
("AudioSuite > Harmonic > Eleven Lite", "saturator"),
|
||||
("AudioSuite > Harmonic > Lo-Fi", "lofi"),
|
||||
("AudioSuite > Harmonic > Recti-Fi", "rectifier"),
|
||||
@@ -165,7 +166,9 @@ const DIALOG_COMMANDS: &[&str] = &[
|
||||
"event.quantize",
|
||||
"event.transpose",
|
||||
"edit.strip_silence",
|
||||
"clip.auto_rename",
|
||||
"clip.rename",
|
||||
"clip.loop",
|
||||
];
|
||||
|
||||
/// Shared menu hierarchy for egui and native desktop hosts.
|
||||
@@ -503,7 +506,7 @@ fn wants_dialog(id: &str) -> bool {
|
||||
/// Handle UI-layer commands. Returns None when `id` is not a UI command.
|
||||
pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
if matches!(id, "ui.dock" | "ui.dock.reset") {
|
||||
return Some(crate::docking::command(app, id == "ui.dock.reset", p));
|
||||
return Some(crate::docking::command(app, id, p));
|
||||
}
|
||||
if let Some(v) = crate::extra_windows::run(app, id, p) {
|
||||
return Some(Ok(v));
|
||||
@@ -590,7 +593,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
||||
}
|
||||
"window.configurations" => toggle(&mut app.ui.show_configurations),
|
||||
"window.config_new" | "window.config_update" => {
|
||||
if let Err(error) = app.ui.docking.validate().and_then(|()| app.ui.mix_docking.validate_mix()) {
|
||||
if let Err(error) = crate::docking::validate(&app.ui.docking).and_then(|()| crate::docking::validate_mix(&app.ui.mix_docking)) {
|
||||
return Some(Err(error));
|
||||
}
|
||||
let mut snap = app.ui.clone();
|
||||
@@ -647,6 +650,20 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
||||
}
|
||||
json!({"mix_views": app.ui.mix_views})
|
||||
}
|
||||
"view.scroll_into_view" => {
|
||||
// `track` (id or name), else the first selected track: the palette passes no params.
|
||||
let track = match soundcraft_engine::cmd::track_param(&app.engine, id, p, "track") {
|
||||
Ok(Some(t)) => t,
|
||||
Ok(None) => match app.engine.session().edit.selected_tracks.first() {
|
||||
Some(t) => *t,
|
||||
None => return Some(Err("view.scroll_into_view: pass `track` or select a track".into())),
|
||||
},
|
||||
Err(e) => return Some(Err(e.to_string())),
|
||||
};
|
||||
app.edit_scroll_into_view = Some(track);
|
||||
app.mix_scroll_into_view = Some(track);
|
||||
json!({"track": track.0})
|
||||
}
|
||||
"ui.new_tracks_dialog" => {
|
||||
return Some(Err("ui.new_tracks_dialog opens a dialog from the menu only; call track.new with parameters".into()));
|
||||
}
|
||||
@@ -672,7 +689,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
||||
}
|
||||
match serde_json::from_value::<crate::UiState>(cur) {
|
||||
Ok(ns) => {
|
||||
if let Err(error) = ns.docking.validate().and_then(|()| ns.mix_docking.validate_mix()) {
|
||||
if let Err(error) = crate::docking::validate(&ns.docking).and_then(|()| crate::docking::validate_mix(&ns.mix_docking)) {
|
||||
return Some(Err(error));
|
||||
}
|
||||
crate::docking::cancel_replaced_state(app, &ns);
|
||||
@@ -757,4 +774,13 @@ mod tests {
|
||||
e.execute("edit.select", &json!({"tracks": [audio, inst]})).unwrap();
|
||||
assert_eq!(instrument_track(e.session()).map(|t| t.0), Some(inst));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audiosuite_sci_fi_opens_the_ring_modulator() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
invoke_menu(&mut app, "audiosuite.process", "AudioSuite > Modulation > Sci-Fi");
|
||||
assert_eq!(app.ui.audiosuite.as_deref(), Some("ring_mod"));
|
||||
let info = soundcraft_dsp::plugin_info("ring_mod").unwrap();
|
||||
assert!(info.audiosuite && info.category == soundcraft_dsp::Category::Modulation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,19 @@ pub struct MidiEditorState {
|
||||
pub band: Option<egui::Pos2>,
|
||||
}
|
||||
|
||||
impl MidiEditorState {
|
||||
/// Start editing `id`. Note indices only mean something inside one clip, so the selection and
|
||||
/// any gesture in progress are dropped when the clip changes.
|
||||
fn switch_clip(&mut self, id: ClipId) {
|
||||
if self.clip != Some(id) {
|
||||
self.selected.clear();
|
||||
self.drag = None;
|
||||
self.band = None;
|
||||
}
|
||||
self.clip = Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NoteDrag {
|
||||
pub index: usize,
|
||||
@@ -148,7 +161,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
||||
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;
|
||||
app.midi.clip = Some(cid);
|
||||
app.midi.switch_clip(cid);
|
||||
}
|
||||
if ui.rect_contains_pointer(roll) {
|
||||
app.midi.scroll -= ui.input(|i| i.smooth_scroll_delta.y);
|
||||
@@ -346,7 +359,10 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
||||
0
|
||||
};
|
||||
if (semi != 0 || ticks != 0) && !app.midi.selected.is_empty() {
|
||||
let _ = app.run("midi.notes_move", json!({"clip": cid.0, "indices": app.midi.selected, "ticks": ticks, "semitones": semi}));
|
||||
let moved = app.run("midi.notes_move", json!({"clip": cid.0, "indices": app.midi.selected, "ticks": ticks, "semitones": semi}));
|
||||
// The engine re-sorts the notes: follow the moved ones to their new indices, so the
|
||||
// selection never points at other notes and the arrows can keep nudging it.
|
||||
app.midi.selected = moved.ok().and_then(|v| serde_json::from_value(v["indices"].clone()).ok()).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
if !app.midi.selected.is_empty()
|
||||
@@ -376,3 +392,19 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let _ = app.engine.execute_merged("midi.note_edit", &json!({"clip": cid.0, "index": i, "velocity": v}), &format!("vel:{}", cid.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn switching_clip_drops_selection_and_gestures() {
|
||||
let mut st = MidiEditorState { selected: vec![0, 2], clip: Some(ClipId(1)), band: Some(pos2(1.0, 2.0)), ..Default::default() };
|
||||
st.switch_clip(ClipId(1));
|
||||
assert_eq!(st.selected, vec![0, 2]);
|
||||
st.switch_clip(ClipId(2));
|
||||
assert!(st.selected.is_empty());
|
||||
assert!(st.drag.is_none() && st.band.is_none());
|
||||
assert_eq!(st.clip, Some(ClipId(2)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +47,14 @@ 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());
|
||||
// Taken for this frame only: a strip that isn't drawn (hidden track) must not scroll the Mix
|
||||
// whenever it comes back. A request made during this frame (the strip's menu) waits for the next.
|
||||
let scroll_to = app.mix_scroll_into_view.take();
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 1.0;
|
||||
for id in ids {
|
||||
strip(app, ui, id, snap.as_ref());
|
||||
strip(app, ui, id, snap.as_ref(), scroll_to == Some(id));
|
||||
}
|
||||
let rest = Rect::from_min_max(pos2(ui.cursor().min.x, ui.clip_rect().min.y), ui.clip_rect().max);
|
||||
if rest.width() > 4.0 && ui.interact(rest, ui.id().with("past_strips"), Sense::click()).double_clicked() {
|
||||
@@ -149,7 +152,7 @@ pub(crate) fn route_popup(app: &mut SoundApp, resp: &egui::Response, id: TrackId
|
||||
egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, input));
|
||||
}
|
||||
|
||||
fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapshot>) {
|
||||
fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapshot>, scroll_here: bool) {
|
||||
let t = Tokens::current();
|
||||
let session = app.engine.session_arc();
|
||||
let Some(track) = session.track(id) else { return };
|
||||
@@ -157,6 +160,12 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
|
||||
let w = strip_width(narrow);
|
||||
let h = ui.available_height().max(560.0);
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
|
||||
if scroll_here {
|
||||
// egui stops `item_spacing` short of the edge (the scroll area's, not the strips' 1 pt),
|
||||
// which would leave a sliver of the previous strip showing.
|
||||
let margin = ui.ctx().global_style().spacing.item_spacing.x;
|
||||
ui.scroll_to_rect(r.translate(vec2(margin, 0.0)), Some(egui::Align::Min));
|
||||
}
|
||||
let selected = app.engine.session().edit.selected_tracks.contains(&id);
|
||||
let bg = if selected { t.strip_selected } else { t.strip_bg };
|
||||
vgradient(ui.painter(), r, shade(bg, 0.06), bg);
|
||||
|
||||
@@ -30,6 +30,10 @@ pub struct OpsState {
|
||||
pub rtp_transpose: i64,
|
||||
pub rtp_duration: f64,
|
||||
pub rtp_delay: i64,
|
||||
/// The MIDI tracks the Real-Time Properties form was last filled from, and the values it read:
|
||||
/// the form re-reads the tracks when either changes (#444).
|
||||
pub rtp_targets: Vec<soundcraft_model::TrackId>,
|
||||
pub rtp_loaded: Option<RtpValues>,
|
||||
pub identify_bars: u32,
|
||||
}
|
||||
|
||||
@@ -57,6 +61,8 @@ impl Default for OpsState {
|
||||
rtp_transpose: 0,
|
||||
rtp_duration: 100.0,
|
||||
rtp_delay: 0,
|
||||
rtp_targets: Vec::new(),
|
||||
rtp_loaded: None,
|
||||
identify_bars: 1,
|
||||
}
|
||||
}
|
||||
@@ -261,15 +267,16 @@ fn rtp(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
if !open {
|
||||
return;
|
||||
}
|
||||
let targets = app.engine.target_tracks();
|
||||
sync_rtp_form(&mut app.ops, app.engine.session(), targets, ctx.dragged_id().is_some());
|
||||
egui::Window::new("MIDI Real-Time Properties").open(&mut open).default_size(vec2(340.0, 200.0)).show(ctx, |ui| {
|
||||
ui.label("Non-destructive, applied at playback to the selected MIDI tracks.");
|
||||
let mut changed = false;
|
||||
changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_velocity, -127..=127).text("Velocity +")).changed();
|
||||
changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_transpose, -48..=48).text("Transpose")).changed();
|
||||
changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_duration, 1.0..=400.0).text("Duration %")).changed();
|
||||
changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_delay, -960..=960).text("Delay (ticks)")).changed();
|
||||
if changed {
|
||||
let p = json!({"velocity": app.ops.rtp_velocity, "transpose": app.ops.rtp_transpose, "duration": app.ops.rtp_duration, "delay": app.ops.rtp_delay});
|
||||
let mut changed = RtpChanged::default();
|
||||
changed.velocity = ui.add(egui::Slider::new(&mut app.ops.rtp_velocity, -127..=127).text("Velocity +")).changed();
|
||||
changed.transpose = ui.add(egui::Slider::new(&mut app.ops.rtp_transpose, -48..=48).text("Transpose")).changed();
|
||||
changed.duration = ui.add(egui::Slider::new(&mut app.ops.rtp_duration, 1.0..=400.0).text("Duration %")).changed();
|
||||
changed.delay = ui.add(egui::Slider::new(&mut app.ops.rtp_delay, -960..=960).text("Delay (ticks)")).changed();
|
||||
if let Some(p) = rtp_params(changed, &app.ops) {
|
||||
let _ = app.engine.execute_merged("event.midi_rtp", &p, "rtp");
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
@@ -288,6 +295,66 @@ fn rtp(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
app.ui.show_rtp = open;
|
||||
}
|
||||
|
||||
/// Which of the form's controls the user changed this frame.
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct RtpChanged {
|
||||
velocity: bool,
|
||||
transpose: bool,
|
||||
duration: bool,
|
||||
delay: bool,
|
||||
}
|
||||
|
||||
/// A form change submits only the properties the user touched: the command stores each key it is
|
||||
/// sent, so sending untouched ones would overwrite the target's values with the form's (#444).
|
||||
fn rtp_params(changed: RtpChanged, ops: &OpsState) -> Option<Value> {
|
||||
let mut p = serde_json::Map::new();
|
||||
if changed.velocity {
|
||||
p.insert("velocity".into(), json!(ops.rtp_velocity));
|
||||
}
|
||||
if changed.transpose {
|
||||
p.insert("transpose".into(), json!(ops.rtp_transpose));
|
||||
}
|
||||
if changed.duration {
|
||||
p.insert("duration".into(), json!(ops.rtp_duration));
|
||||
}
|
||||
if changed.delay {
|
||||
p.insert("delay".into(), json!(ops.rtp_delay));
|
||||
}
|
||||
(!p.is_empty()).then_some(Value::Object(p))
|
||||
}
|
||||
|
||||
/// Velocity, transpose, duration % and delay, as the form shows them.
|
||||
pub type RtpValues = (i64, i64, f64, i64);
|
||||
|
||||
/// Fills the form from the tracks it edits whenever the targets or their stored values change
|
||||
/// (selection, undo, a command), so it never shows (and a control change never writes back) stale
|
||||
/// values (#444). A slider being dragged keeps its value until it is released.
|
||||
fn sync_rtp_form(ops: &mut OpsState, s: &soundcraft_model::Session, targets: Vec<soundcraft_model::TrackId>, dragging: bool) {
|
||||
let values = rtp_form_values(s, &targets);
|
||||
if ops.rtp_targets != targets || (ops.rtp_loaded != Some(values) && !dragging) {
|
||||
let (velocity, transpose, duration, delay) = values;
|
||||
ops.rtp_targets = targets;
|
||||
ops.rtp_loaded = Some(values);
|
||||
ops.rtp_velocity = velocity;
|
||||
ops.rtp_transpose = transpose;
|
||||
ops.rtp_duration = duration;
|
||||
ops.rtp_delay = delay;
|
||||
}
|
||||
}
|
||||
|
||||
/// The values the form shows for `targets`: the first target's own Real-Time Properties, or the
|
||||
/// neutral defaults when nothing is selected.
|
||||
fn rtp_form_values(s: &soundcraft_model::Session, targets: &[soundcraft_model::TrackId]) -> RtpValues {
|
||||
let Some(&t) = targets.first() else { return (0, 0, 100.0, 0) };
|
||||
let value = |key: &str, d: f64| s.edit.value(&format!("rtp.{}.{key}", t.0), d);
|
||||
(
|
||||
value("velocity", 0.0).clamp(-127.0, 127.0) as i64,
|
||||
value("transpose", 0.0).clamp(-127.0, 127.0) as i64,
|
||||
value("duration", 100.0).clamp(1.0, 1000.0),
|
||||
value("delay", 0.0).clamp(-1e7, 1e7) as i64,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -308,4 +375,43 @@ mod tests {
|
||||
assert_eq!(id, "event.tempo_linear");
|
||||
assert_eq!(p["end_bpm"], 140.0);
|
||||
}
|
||||
|
||||
/// One control moved: only its property is submitted, so the command cannot overwrite the
|
||||
/// target's other Real-Time Properties with the form's values (#444).
|
||||
#[test]
|
||||
fn rtp_submits_only_the_property_that_changed() {
|
||||
let ops = OpsState { rtp_velocity: 12, rtp_transpose: 7, rtp_duration: 50.0, rtp_delay: 480, ..OpsState::default() };
|
||||
let only_velocity = RtpChanged { velocity: true, ..RtpChanged::default() };
|
||||
assert_eq!(rtp_params(only_velocity, &ops), Some(json!({"velocity": 12})));
|
||||
let only_duration = RtpChanged { duration: true, ..RtpChanged::default() };
|
||||
assert_eq!(rtp_params(only_duration, &ops), Some(json!({"duration": 50.0})));
|
||||
assert_eq!(rtp_params(RtpChanged::default(), &ops), None, "no control moved, no command");
|
||||
}
|
||||
|
||||
/// The form reads the target's own values when the window opens or the selection changes.
|
||||
#[test]
|
||||
fn the_rtp_form_shows_the_target_tracks_values() {
|
||||
let mut e = soundcraft_engine::Engine::default();
|
||||
e.execute("track.new", &json!({"kind": "instrument", "name": "Synth"})).unwrap();
|
||||
let t = e.session().tracks.last().unwrap().id;
|
||||
e.execute("event.midi_rtp", &json!({"track": t.0, "velocity": -12, "transpose": 7, "duration": 50.0, "delay": 480})).unwrap();
|
||||
assert_eq!(rtp_form_values(e.session(), &[]), (0, 0, 100.0, 0), "no target: the neutral defaults");
|
||||
assert_eq!(rtp_form_values(e.session(), &[t]), (-12, 7, 50.0, 480));
|
||||
}
|
||||
|
||||
/// Undo keeps the same targets but changes their stored values: the form follows.
|
||||
#[test]
|
||||
fn the_rtp_form_follows_an_undo_on_the_same_targets() {
|
||||
let mut e = soundcraft_engine::Engine::default();
|
||||
e.execute("track.new", &json!({"kind": "instrument", "name": "Synth"})).unwrap();
|
||||
let t = e.session().tracks.last().unwrap().id;
|
||||
let mut ops = OpsState::default();
|
||||
sync_rtp_form(&mut ops, e.session(), vec![t], false);
|
||||
e.execute("event.midi_rtp", &json!({"track": t.0, "velocity": -12})).unwrap();
|
||||
sync_rtp_form(&mut ops, e.session(), vec![t], false);
|
||||
assert_eq!(ops.rtp_velocity, -12);
|
||||
e.execute("edit.undo", &json!({})).unwrap();
|
||||
sync_rtp_form(&mut ops, e.session(), vec![t], false);
|
||||
assert_eq!(ops.rtp_velocity, 0, "the form kept the undone value");
|
||||
}
|
||||
}
|
||||
|
||||
+103
-19
@@ -77,7 +77,13 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let _ = app.run("track.hide", json!({"tracks": [id], "hidden": shown}));
|
||||
}
|
||||
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 });
|
||||
ui.painter().with_clip_rect(r).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);
|
||||
}
|
||||
@@ -118,7 +124,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
|
||||
bold(10.0),
|
||||
if active { t.text_dark } else { t.text_dim },
|
||||
);
|
||||
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), t.text);
|
||||
ui.painter().with_clip_rect(r).text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), t.text);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("track.group_toggle", json!({"group": id}));
|
||||
}
|
||||
@@ -184,32 +190,75 @@ fn gutter_scroll<R>(ui: &Ui, gutter: Rect, out: &egui::scroll_area::ScrollAreaOu
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the clip list: a whole file, or a clip on a track (positions in the session).
|
||||
#[derive(Clone, Copy)]
|
||||
enum ClipRow {
|
||||
Source(usize),
|
||||
Clip { track: usize, clip: usize },
|
||||
}
|
||||
|
||||
/// The clip list's rows for one document revision and filter. A drum edit holds tens of thousands
|
||||
/// of clips: the rows are listed (and filtered) when either changes, not every frame.
|
||||
#[derive(Clone)]
|
||||
struct ClipRows {
|
||||
revision: u64,
|
||||
filter: String,
|
||||
rows: std::sync::Arc<Vec<ClipRow>>,
|
||||
}
|
||||
|
||||
fn clip_rows(s: &soundcraft_model::Session, needle: &str) -> Vec<ClipRow> {
|
||||
let shown = |name: &str| needle.is_empty() || name.to_lowercase().contains(needle);
|
||||
// Whole files first (bold), then clips on tracks.
|
||||
let sources =
|
||||
s.sources.iter().enumerate().filter(|(_, src)| shown(&format!("{} ({}ch)", src.name, src.channels))).map(|(i, _)| ClipRow::Source(i));
|
||||
let clips =
|
||||
s.tracks.iter().enumerate().flat_map(|(track, tr)| {
|
||||
tr.clips().iter().enumerate().filter(|(_, c)| shown(&c.name)).map(move |(clip, _)| ClipRow::Clip { track, clip })
|
||||
});
|
||||
sources.chain(clips).collect()
|
||||
}
|
||||
|
||||
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 s = app.engine.session();
|
||||
let mut v = 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)));
|
||||
}
|
||||
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
|
||||
};
|
||||
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();
|
||||
egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show(ui, |ui| {
|
||||
let rkey = egui::Id::new("clip_rows");
|
||||
let revision = app.engine.revision;
|
||||
let cached = ui.ctx().memory(|m| m.data.get_temp::<ClipRows>(rkey)).filter(|c| c.revision == revision && c.filter == needle);
|
||||
let rows = match cached {
|
||||
Some(c) => c.rows,
|
||||
None => {
|
||||
let rows = std::sync::Arc::new(clip_rows(app.engine.session(), &needle));
|
||||
ui.ctx().memory_mut(|m| m.data.insert_temp(rkey, ClipRows { revision, filter: needle, rows: rows.clone() }));
|
||||
rows
|
||||
}
|
||||
};
|
||||
let row_h = 17.0;
|
||||
egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, shown| {
|
||||
// Only the rows in view: what they show is read now, before any of them acts on the session.
|
||||
let items: Vec<(u64, String, bool, [u8; 3], bool)> = {
|
||||
let s = app.engine.session();
|
||||
rows.get(shown)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|row| match *row {
|
||||
ClipRow::Source(i) => s.sources.get(i).map(|src| {
|
||||
(src.id.0, format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150], s.edit.selected_sources.contains(&src.id))
|
||||
}),
|
||||
ClipRow::Clip { track, clip } => {
|
||||
let tr = s.tracks.get(track)?;
|
||||
let c = tr.clips().get(clip)?;
|
||||
Some((c.id.0, c.name.clone(), false, c.color.unwrap_or(tr.color), s.edit.selected_clips.contains(&c.id)))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for (id, name, whole, color, selected) in items {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), if whole { Sense::click_and_drag() } else { Sense::click() });
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), if whole { Sense::click_and_drag() } else { Sense::click() });
|
||||
resp.context_menu(|ui| {
|
||||
if whole {
|
||||
if ui.button("Place on New Track").clicked() {
|
||||
@@ -225,6 +274,12 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.separator();
|
||||
}
|
||||
if !whole && ui.button("Auto Rename…").clicked() {
|
||||
// The selected clips when the row is one of them, otherwise the row's clip.
|
||||
let clips = if selected { app.engine.session().edit.selected_clips.clone() } else { vec![ClipId(id)] };
|
||||
app.dialogs.open_auto_rename(&app.engine, clips);
|
||||
ui.close();
|
||||
}
|
||||
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);
|
||||
@@ -712,3 +767,32 @@ fn preset_menu(
|
||||
});
|
||||
ui.ctx().memory_mut(|m| m.data.insert_temp(key, name));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind};
|
||||
|
||||
#[test]
|
||||
fn clip_rows_list_every_clip_in_track_order_and_filter_by_name() {
|
||||
let mut s = Session::default();
|
||||
for names in [["Kick 1", "Snare 1"], ["Kick 2", "Tom"]] {
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
for (i, n) in names.into_iter().enumerate() {
|
||||
let c = Clip::audio(s.new_clip_id(), n, SourceId(1), 0, i as i64 * 100, 100);
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(c);
|
||||
}
|
||||
}
|
||||
let names = |rows: Vec<ClipRow>| -> Vec<String> {
|
||||
rows.into_iter()
|
||||
.filter_map(|r| match r {
|
||||
ClipRow::Clip { track, clip } => Some(s.tracks[track].clips()[clip].name.clone()),
|
||||
ClipRow::Source(_) => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(names(clip_rows(&s, "")), ["Kick 1", "Snare 1", "Kick 2", "Tom"]);
|
||||
assert_eq!(names(clip_rows(&s, "kick")), ["Kick 1", "Kick 2"]);
|
||||
assert!(clip_rows(&s, "cowbell").is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,12 +393,13 @@ pub fn is_light() -> bool {
|
||||
LIGHT_THEME.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Resolve each frame from the integration's current OS appearance. Unknown uses Dark.
|
||||
/// Resolve each frame from the integration's current OS appearance, or on Linux the desktop's own
|
||||
/// setting when egui reports none. Unknown uses Dark.
|
||||
pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool {
|
||||
match mode {
|
||||
ThemeMode::Light => true,
|
||||
ThemeMode::Dark => false,
|
||||
ThemeMode::System => ctx.system_theme() == Some(egui::Theme::Light),
|
||||
ThemeMode::System => crate::appearance::resolve(ctx.system_theme(), || crate::appearance::desktop_theme(ctx)) == Some(egui::Theme::Light),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -353,7 +353,8 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click());
|
||||
key(ui.painter(), r, &resp, if recording { Color32::from_rgb(110, 30, 30) } else { key_fill(&resp) });
|
||||
icons::draw(ui.painter(), r.shrink(5.0), "record", t.rec);
|
||||
let resp = resp.on_hover_text("Record (Cmd+Space)");
|
||||
// macOS gives Cmd+Space to Spotlight unless the user frees it, so name F12 first.
|
||||
let resp = resp.on_hover_text("Record (F12 or Cmd+Space)");
|
||||
if let Some(why) = app.record_hint() {
|
||||
// Below the whole group, so the hint covers none of its buttons.
|
||||
let below = Rect::from_min_max(r.min, pos2(r.max.x, g.max.y));
|
||||
|
||||
@@ -363,7 +363,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let _ = app.run("file.import_midi", json!({"path": path}));
|
||||
} else {
|
||||
let at = app.engine.session().edit.selection.start;
|
||||
let _ = app.run("file.import_audio", json!({"path": path, "at": at}));
|
||||
crate::import::start(app, json!({"path": path, "at": at}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -391,7 +391,7 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
if ui.button(format!("{} {name}", i + 1)).clicked()
|
||||
&& let Ok(mut st) = serde_json::from_value::<crate::UiState>(v.clone())
|
||||
{
|
||||
if let Err(error) = st.docking.validate().and_then(|()| st.mix_docking.validate_mix()) {
|
||||
if let Err(error) = crate::docking::validate(&st.docking).and_then(|()| crate::docking::validate_mix(&st.mix_docking)) {
|
||||
app.ui.status = format!("Window configuration could not be restored: {error}");
|
||||
} else {
|
||||
st.configurations = app.ui.configurations.clone();
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Key, Modifiers, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::io::StripNames;
|
||||
use soundcraft_model::{ClipId, TrackId};
|
||||
use soundcraft_ui_egui::dialogs::Dialog;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, menus};
|
||||
|
||||
/// Selects the first Kick clip in the Clip List and opens Auto Rename for it.
|
||||
fn auto_rename_dialog() -> (Harness<'static, SoundApp>, TrackId, ClipId) {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let kick = app.engine.session().track_by_name("Kick").unwrap();
|
||||
let (track, clip) = (kick.id, kick.clips()[0].id);
|
||||
app.run("edit.select", json!({"clips": [clip.0]})).unwrap();
|
||||
menus::invoke_menu(&mut app, "clip.auto_rename", "");
|
||||
assert_eq!(app.dialogs.open_name(), Some("auto_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,
|
||||
);
|
||||
h.run_steps(3);
|
||||
(h, track, clip)
|
||||
}
|
||||
|
||||
fn sfx() -> StripNames {
|
||||
StripNames { name: "SFX".into(), number: 23, zeros: 1, suffix: ".Reel1".into() }
|
||||
}
|
||||
|
||||
/// Puts `names` in the open Auto Rename dialog, as typing them would.
|
||||
fn type_names(h: &mut Harness<'_, SoundApp>, names: StripNames) {
|
||||
let Some(Dialog::AutoRename { names: open, .. }) = &mut h.state_mut().dialogs.open else { panic!("Auto Rename is not open") };
|
||||
*open = names;
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Separates the clip `clip` at its middle and returns the name of the piece after the cut.
|
||||
fn separate_middle(h: &mut Harness<'_, SoundApp>, track: TrackId, clip: ClipId) -> String {
|
||||
let (start, len) = {
|
||||
let c = h.state().engine.session().find_clip(clip).unwrap().1;
|
||||
(c.start, c.length)
|
||||
};
|
||||
h.state_mut().run("edit.separate", json!({"tracks": [track.0], "at": start + len / 2})).unwrap();
|
||||
let s = h.state().engine.session();
|
||||
s.track(track).unwrap().clips().iter().find(|c| c.start == start + len / 2).unwrap().name.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_sets_the_names_of_the_clips_separated_from_it() {
|
||||
let (mut h, track, clip) = auto_rename_dialog();
|
||||
h.get_by_label("Starting Number");
|
||||
h.get_by_label("Number of Places");
|
||||
type_names(&mut h, sfx());
|
||||
h.get_by_label("First clip: SFX023.Reel1");
|
||||
h.get_by_role_and_label(Role::Button, "OK").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialogs.open_name(), None);
|
||||
assert_eq!(separate_middle(&mut h, track, clip), "SFX023.Reel1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dialog_opens_with_the_clips_scheme() {
|
||||
let (mut h, _, clip) = auto_rename_dialog();
|
||||
type_names(&mut h, sfx());
|
||||
h.get_by_role_and_label(Role::Button, "OK").click();
|
||||
h.run_steps(2);
|
||||
let app = h.state_mut();
|
||||
assert!(app.dialogs.open_auto_rename(&app.engine, vec![clip]));
|
||||
assert!(matches!(&app.dialogs.open, Some(Dialog::AutoRename { names, clips }) if *names == sfx() && *clips == vec![clip]));
|
||||
assert!(!app.dialogs.open_auto_rename(&app.engine, vec![ClipId(999_999)]), "no dialog for a clip that is gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_a_focused_cancel_sets_no_scheme() {
|
||||
let (mut h, track, clip) = auto_rename_dialog();
|
||||
let old = h.state().engine.session().find_clip(clip).unwrap().1.name.clone();
|
||||
type_names(&mut h, sfx());
|
||||
h.get_by_role_and_label(Role::Button, "Cancel").focus();
|
||||
h.run_steps(2);
|
||||
h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialogs.open_name(), None);
|
||||
assert!(h.state().engine.session().auto_names.is_empty());
|
||||
assert_eq!(separate_middle(&mut h, track, clip), old);
|
||||
}
|
||||
@@ -114,3 +114,57 @@ fn cancel_or_escape_stops_a_bounce_and_leaves_no_file() {
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The largest gap between `int` and `float` in 16-bit LSBs.
|
||||
fn max_error_lsb(int: &Path, float: &Path) -> f64 {
|
||||
let (_, a) = soundcraft_audio_io::decode(&std::fs::read(int).unwrap(), None).unwrap();
|
||||
let (_, b) = soundcraft_audio_io::decode(&std::fs::read(float).unwrap(), None).unwrap();
|
||||
let mut worst = 0.0f64;
|
||||
for (i, f) in a.channels.iter().zip(&b.channels) {
|
||||
for (x, y) in i.iter().zip(f) {
|
||||
worst = worst.max((f64::from(*x) - f64::from(y.clamp(-1.0, 1.0))).abs() * 32768.0);
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
/// Bounces from the dialog at `bit_depth`, ticking Dither when `dither`, and waits for the file.
|
||||
fn bounce_with(h: &mut Harness<'_, SoundApp>, file: &Path, bit_depth: &str, dither: bool) {
|
||||
let app = h.state_mut();
|
||||
assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix"));
|
||||
if let Some(Dialog::Bounce { path, bit_depth: depth, .. }) = &mut app.dialogs.open {
|
||||
*path = file.to_string_lossy().into_owned();
|
||||
*depth = bit_depth.into();
|
||||
}
|
||||
h.run_steps(2);
|
||||
if dither {
|
||||
h.get_by_label("Dither (TPDF)").click();
|
||||
h.run_steps(2);
|
||||
}
|
||||
h.get_by_label("Bounce").click();
|
||||
h.run_steps(3);
|
||||
h.run();
|
||||
assert!(h.state().bounce.is_none());
|
||||
assert!(file.exists(), "{}", h.state().ui.status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bounce_dialog_dithers_only_when_dither_is_ticked() {
|
||||
let dir = scratch("bounce-dither");
|
||||
let mut h = harness();
|
||||
let app = h.state_mut();
|
||||
assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix"));
|
||||
match &app.dialogs.open {
|
||||
Some(Dialog::Bounce { dither, .. }) => assert!(!dither, "Dither starts off"),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
app.dialogs.open = None;
|
||||
bounce_with(&mut h, &dir.join("Float.wav"), "32f", false);
|
||||
bounce_with(&mut h, &dir.join("Plain.wav"), "16", false);
|
||||
bounce_with(&mut h, &dir.join("Dithered.wav"), "16", true);
|
||||
let plain = max_error_lsb(&dir.join("Plain.wav"), &dir.join("Float.wav"));
|
||||
let dithered = max_error_lsb(&dir.join("Dithered.wav"), &dir.join("Float.wav"));
|
||||
assert!(plain <= 0.5 + 1e-3, "{plain}");
|
||||
assert!(dithered > 0.6, "{dithered}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
use egui::accesskit::Role;
|
||||
use egui::vec2;
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, menus};
|
||||
|
||||
/// Clip > Loop... opens Clip Looping instead of looping right away, and Loop Until End of Session
|
||||
/// or Next Clip fills the gap up to the next clip, the last loop cut short to meet it.
|
||||
#[test]
|
||||
fn clip_looping_fills_up_to_the_next_clip() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let kick = app.engine.session().track_by_name("Kick").unwrap();
|
||||
let (track, clip) = (kick.id, kick.clips()[0].clone());
|
||||
let after = kick.clips().iter().filter(|c| c.start >= clip.end()).min_by_key(|c| c.start).unwrap().clone();
|
||||
// Trim the next clip's start so the gap is not a whole number of loops.
|
||||
let next = after.start + clip.length / 2;
|
||||
assert!(next < after.end());
|
||||
app.run("edit.trim_clip", json!({"clip": after.id.0, "start": next})).unwrap();
|
||||
assert_ne!((next - clip.end()) % clip.length, 0);
|
||||
app.run("edit.select", json!({"clips": [clip.id.0]})).unwrap();
|
||||
let before = app.engine.session().track(track).unwrap().clips().len();
|
||||
menus::invoke_menu(&mut app, "clip.loop", "Clip > Loop...");
|
||||
assert_eq!(app.dialogs.open_name(), Some("clip_looping"));
|
||||
assert_eq!(app.engine.session().track(track).unwrap().clips().len(), before);
|
||||
|
||||
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,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h.get_by_role_and_label(Role::RadioButton, "Loop Until End of Session or Next Clip").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_role_and_label(Role::Button, "OK").click();
|
||||
h.run_steps(2);
|
||||
|
||||
assert!(h.state().dialogs.open_name().is_none());
|
||||
let clips = h.state().engine.session().track(track).unwrap().clips();
|
||||
let loops: Vec<_> = clips.iter().filter(|c| c.start >= clip.end() && c.start < next).collect();
|
||||
let whole = (next - clip.end()) / clip.length;
|
||||
assert_eq!(loops.len() as i64, whole + 1);
|
||||
assert!(loops.iter().all(|c| c.loop_id.is_some() && c.loop_id == loops[0].loop_id));
|
||||
assert!(loops.windows(2).all(|w| w[0].end() == w[1].start));
|
||||
let last = loops.last().unwrap();
|
||||
assert_eq!(last.end(), next);
|
||||
assert_eq!(last.length, (next - clip.end()) % clip.length);
|
||||
}
|
||||
@@ -43,25 +43,29 @@ fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() {
|
||||
assert_eq!(h.state().ui.docking.layout.location(&Panel::Clips).unwrap().anchor, Some(Panel::Tracks));
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").click_button(PointerButton::Secondary);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Float panel").click();
|
||||
h.get_by_label("Float Panel").click();
|
||||
h.run_steps(5);
|
||||
let rect = h.state().ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Clips)).unwrap().rect;
|
||||
let rect = h.state().ui.docking.layout.floating.iter().find(|g| g.contains(&Panel::Clips)).unwrap().rect;
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").click_button(PointerButton::Secondary);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Close panel").click();
|
||||
h.get_by_label("Close Panel").click();
|
||||
h.run_steps(4);
|
||||
assert!(!h.state().ui.show_clip_list);
|
||||
h.state_mut().run("ui.dock", json!({"operation":"open","panel":"clips"})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Clips)).unwrap().rect, rect);
|
||||
assert_eq!(h.state().ui.docking.layout.floating.iter().find(|g| g.contains(&Panel::Clips)).unwrap().rect, rect);
|
||||
let saved = serde_json::to_string(&h.state().ui).unwrap();
|
||||
let expected = serde_json::to_value(&h.state().ui.docking).unwrap();
|
||||
let mut reloaded = harness(Some(serde_json::from_str(&saved).unwrap()), 1400.0, 1.0);
|
||||
assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), expected);
|
||||
reloaded.state_mut().run("ui.dock", json!({"operation":"move","panel":"clips","target":"tracks","zone":"center"})).unwrap();
|
||||
reloaded.run_steps(4);
|
||||
reloaded.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").click();
|
||||
// The narrow Tracks frame shows the active Clips tab and lists Tracks under "…".
|
||||
reloaded.get_by_label("Panels").click();
|
||||
reloaded.run_steps(3);
|
||||
reloaded.get_by_label("Tracks and Groups").click();
|
||||
reloaded.run_steps(3);
|
||||
assert_eq!(reloaded.state().ui.docking.layout.panel_state(&Panel::Tracks), craft_ui::docking::PanelState::Front);
|
||||
reloaded.get_by_label("Trim (F6)").click();
|
||||
reloaded.run_steps(3);
|
||||
assert_eq!(reloaded.state().engine.session().edit.tool, soundcraft_model::Tool::Trim);
|
||||
@@ -82,6 +86,18 @@ fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() {
|
||||
assert!(!reloaded.state().engine.transport.recording);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activating_a_hidden_panel_shows_it_and_its_window_flag() {
|
||||
let mut h = harness(None, 1400.0, 1.0);
|
||||
h.state_mut().run("ui.dock", json!({"operation":"close","panel":"tracks"})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(!h.state().ui.show_tracks_list);
|
||||
h.state_mut().run("ui.dock", json!({"operation":"activate","panel":"tracks"})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.show_tracks_list);
|
||||
assert_eq!(h.state().ui.docking.layout.panel_state(&Panel::Tracks), craft_ui::docking::PanelState::Front);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_docking_requests_are_atomic_in_edit_and_mix() {
|
||||
let mut h = harness(None, 1400.0, 1.0);
|
||||
@@ -118,8 +134,8 @@ fn docking_renders_edit_and_mix_default_floating_and_redocked() {
|
||||
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"tracks","target":"canvas","zone":"left"})).unwrap();
|
||||
}
|
||||
h.run_steps(5);
|
||||
h.state().ui.docking.validate().unwrap();
|
||||
h.state().ui.mix_docking.validate().unwrap();
|
||||
soundcraft_ui_egui::docking::validate(&h.state().ui.docking).unwrap();
|
||||
soundcraft_ui_egui::docking::validate(&h.state().ui.mix_docking).unwrap();
|
||||
if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") {
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
h.render().unwrap().save(format!("{directory}/sound-{window:?}-{width}-{scale}-{state}.png")).unwrap();
|
||||
@@ -143,7 +159,7 @@ fn reset_is_enabled_and_resolves_in_native_and_in_window_menu_trees() {
|
||||
assert!(menus::item_state(&app, path, Some("ui.dock.reset")).0);
|
||||
app.run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[50,60,260,420]})).unwrap();
|
||||
menus::invoke_menu(&mut app, "ui.dock.reset", path);
|
||||
assert!(!app.ui.docking.layout.floating.iter().any(|group| group.panels.contains(&Panel::Tracks)));
|
||||
assert!(!app.ui.docking.layout.floating.iter().any(|group| group.contains(&Panel::Tracks)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -154,6 +170,116 @@ fn legacy_preferences_restore_the_theme_and_validate_default_docking() {
|
||||
object.remove("mix_docking");
|
||||
let restored: UiState = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(restored.theme, soundcraft_ui_egui::theme::ThemeMode::Light);
|
||||
restored.docking.validate().unwrap();
|
||||
restored.mix_docking.validate_mix().unwrap();
|
||||
soundcraft_ui_egui::docking::validate(&restored.docking).unwrap();
|
||||
soundcraft_ui_egui::docking::validate_mix(&restored.mix_docking).unwrap();
|
||||
}
|
||||
|
||||
/// The tab group holding `panel` in the docked frames.
|
||||
fn docked_group(node: &craft_ui::docking::Node<Panel>, panel: Panel) -> Option<Vec<Panel>> {
|
||||
use craft_ui::docking::Node;
|
||||
match node {
|
||||
Node::Split { first, second, .. } => docked_group(first, panel).or_else(|| docked_group(second, panel)),
|
||||
Node::Tabs { panels, .. } if panels.contains(&panel) => Some(panels.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_workspace_settings_load_with_their_layout_and_hidden_places() {
|
||||
use craft_ui::docking::Node;
|
||||
// A workspace as releases before the shared docking model saved it: no version, and a
|
||||
// floating window as one `{panels, active, rect}` group.
|
||||
let v1 = json!({
|
||||
"layout": {
|
||||
"root": {"Split": {
|
||||
"axis": "Horizontal",
|
||||
"size": {"FixedFirst": 200.0},
|
||||
"first": {"Tabs": {"panels": ["tracks"], "active": 0}},
|
||||
"second": {"Tabs": {"panels": ["canvas"], "active": 0}}
|
||||
}},
|
||||
"floating": [{"panels": ["clips", "midi"], "active": 1, "rect": [300.0, 120.0, 420.0, 360.0]}]
|
||||
},
|
||||
"hidden": [
|
||||
["memory", {"floating": [160.0, 120.0, 480.0, 280.0], "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null}],
|
||||
["history", {"floating": null, "anchor": "canvas", "placement": {"Split": "Right"}, "size": {"FixedSecond": 260.0}, "stack": null, "sibling": ["canvas"]}]
|
||||
]
|
||||
});
|
||||
let mut settings = serde_json::to_value(UiState::default()).unwrap();
|
||||
settings["docking"] = v1;
|
||||
settings["show_memory_locations"] = json!(false);
|
||||
settings["show_undo_history"] = json!(false);
|
||||
settings["show_midi_editor"] = json!(true);
|
||||
let state: UiState = serde_json::from_value(settings).unwrap();
|
||||
soundcraft_ui_egui::docking::validate(&state.docking).unwrap();
|
||||
let floating = &state.docking.layout.floating;
|
||||
assert_eq!(floating.len(), 1);
|
||||
assert_eq!(floating[0].root, Node::Tabs { panels: vec![Panel::Clips, Panel::Midi], active: 1 });
|
||||
assert_eq!(floating[0].rect, [300.0, 120.0, 420.0, 360.0]);
|
||||
let root = state.docking.layout.root.clone().unwrap();
|
||||
assert!(matches!(&root, Node::Split { size: craft_ui::layout::SplitSize::FixedFirst(200.0), .. }));
|
||||
|
||||
let mut h = harness(Some(state), 1400.0, 1.0);
|
||||
assert_eq!(h.state().ui.docking.layout.root.as_ref(), Some(&root), "drawing the loaded layout must not rearrange it");
|
||||
assert_eq!(h.state().ui.docking.layout.floating[0].rect, [300.0, 120.0, 420.0, 360.0]);
|
||||
h.state_mut().run("ui.dock", json!({"operation":"open","panel":"memory"})).unwrap();
|
||||
h.state_mut().run("ui.dock", json!({"operation":"open","panel":"history"})).unwrap();
|
||||
h.run_steps(3);
|
||||
let layout = &h.state().ui.docking.layout;
|
||||
assert_eq!(layout.floating.iter().find(|w| w.contains(&Panel::Memory)).unwrap().rect, [160.0, 120.0, 480.0, 280.0]);
|
||||
assert_eq!(docked_group(layout.root.as_ref().unwrap(), Panel::History), Some(vec![Panel::History]));
|
||||
assert!(h.state().ui.show_memory_locations && h.state().ui.show_undo_history);
|
||||
|
||||
// Written back, a one-group window keeps the v1 shape older builds read.
|
||||
let saved = serde_json::to_value(&h.state().ui.docking).unwrap();
|
||||
let window = saved["layout"]["floating"].as_array().unwrap().iter().find(|w| w["panels"] == json!(["clips", "midi"])).unwrap();
|
||||
assert_eq!(window["active"], json!(1));
|
||||
assert_eq!(window["rect"], json!([300.0, 120.0, 420.0, 360.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_group_by_its_drag_bar_joins_it_to_another_group() {
|
||||
use craft_ui::docking::{DockArea, Hit};
|
||||
let mut h = harness(None, 1400.0, 1.0);
|
||||
let root = h.state().ui.docking.layout.root.clone().unwrap();
|
||||
assert_eq!(docked_group(&root, Panel::Tracks), Some(vec![Panel::Tracks]));
|
||||
let hits = DockArea::<Panel>::hit_rects(&h.ctx, soundcraft_ui_egui::docking::area_id(MainWindow::Edit));
|
||||
let bar = hits.iter().find(|(hit, _)| *hit == Hit::DragBar(Panel::Clips)).expect("the Clips group has a drag bar").1;
|
||||
let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").rect().center();
|
||||
drag(&mut h, bar.center(), target);
|
||||
let layout = &h.state().ui.docking.layout;
|
||||
let group = docked_group(layout.root.as_ref().unwrap(), Panel::Tracks).unwrap();
|
||||
assert_eq!(group.len(), 2, "the dropped group joins the Tracks tabs: {group:?}");
|
||||
assert!(group.contains(&Panel::Clips));
|
||||
assert!(h.state().ui.show_clip_list);
|
||||
soundcraft_ui_egui::docking::validate(&h.state().ui.docking).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_dock_operations_name_the_field() {
|
||||
let mut h = harness(None, 1400.0, 1.0);
|
||||
for (params, error) in [
|
||||
(json!({"operation":"close"}), "panel is required"),
|
||||
(json!({"operation":"close","panel":"mixer"}), "Unknown panel: mixer"),
|
||||
(
|
||||
json!({"operation":"move","panel":"clips","target":"tracks","zone":"middle"}),
|
||||
"zone must be tab, center, left, right, top, bottom, above or below",
|
||||
),
|
||||
] {
|
||||
assert_eq!(h.state_mut().run("ui.dock", params).unwrap_err(), error);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panel_without_a_recorded_place_opens_beside_the_session_view() {
|
||||
use craft_ui::docking::{Action, Permissions, Placement, Zone};
|
||||
let mut state = UiState::default();
|
||||
state.docking.apply(Action::Close { panel: Panel::Tracks }, |_| Permissions::default()).unwrap();
|
||||
state.docking.hidden.clear();
|
||||
state.show_tracks_list = false;
|
||||
let mut h = harness(Some(state), 1400.0, 1.0);
|
||||
h.state_mut().run("ui.dock", json!({"operation":"open","panel":"tracks"})).unwrap();
|
||||
h.run_steps(4);
|
||||
let location = h.state().ui.docking.layout.location(&Panel::Tracks).unwrap();
|
||||
assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(Zone::Left)));
|
||||
assert!(h.state().ui.show_tracks_list);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
|
||||
mod appearance;
|
||||
mod audio_device;
|
||||
mod auto_rename;
|
||||
mod bounce;
|
||||
mod bounce_dialog;
|
||||
mod clip_gain_fader;
|
||||
mod clip_looping;
|
||||
mod clip_menu;
|
||||
mod command_reachability;
|
||||
mod commands_filter;
|
||||
@@ -35,18 +37,25 @@ mod plugin_window;
|
||||
mod pro_tools_keys;
|
||||
mod record_hint;
|
||||
mod rename_dialog;
|
||||
mod rtp_window;
|
||||
mod sampler_window;
|
||||
mod save_as_replace;
|
||||
mod save_changes;
|
||||
mod scroll_into_view;
|
||||
mod send_window;
|
||||
mod shared_controls;
|
||||
mod shortcuts;
|
||||
mod side_lists_access;
|
||||
mod side_lists_text;
|
||||
mod strip_silence_dialog;
|
||||
mod strip_silence_rectangles;
|
||||
mod strip_silence_rename;
|
||||
mod toolbar_scroll;
|
||||
mod toolbar_tempo;
|
||||
mod track_height;
|
||||
mod track_list;
|
||||
mod track_name_menu;
|
||||
mod track_preset_state;
|
||||
mod trackpad;
|
||||
mod tracks_scroll_bar;
|
||||
mod waveform;
|
||||
|
||||
@@ -135,3 +135,18 @@ fn a_click_in_the_empty_midi_editor_adds_a_clip_at_the_insertion_point() {
|
||||
assert_eq!(r.clips(), vec![(r.bar(3), r.bar(4))]);
|
||||
assert_eq!(midi_editor::target_clip(&r.app), Some(r.app.engine.session().tracks[0].clips()[0].id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pencil_click_on_a_clip_selects_it_for_the_midi_editor() {
|
||||
let mut r = Rig::new("instrument");
|
||||
r.app.ui.show_midi_editor = true;
|
||||
r.drag((r.bar(2) + r.bar(3)) / 2, (r.bar(3) + r.bar(4)) / 2);
|
||||
r.drag((r.bar(6) + r.bar(7)) / 2, (r.bar(7) + r.bar(8)) / 2);
|
||||
let ids: Vec<_> = r.app.engine.session().tracks[0].clips().iter().map(|c| c.id).collect();
|
||||
assert_eq!(r.clips(), vec![(r.bar(2), r.bar(4)), (r.bar(6), r.bar(8))]);
|
||||
assert_eq!(midi_editor::target_clip(&r.app), Some(ids[1]));
|
||||
r.click((r.bar(2) + r.bar(3)) / 2);
|
||||
assert_eq!(r.clips().len(), 2);
|
||||
assert_eq!(r.app.engine.session().edit.selected_clips, vec![ids[0]]);
|
||||
assert_eq!(midi_editor::target_clip(&r.app), Some(ids[0]));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! New Tracks opens from the Tracks list's + button, and from a double-click below the last
|
||||
//! track (Edit) or right of the last strip (Mix).
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Pos2, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use egui_kittest::kittest::{Queryable, by};
|
||||
use soundcraft_ui_egui::{MainWindow, Services, SoundApp};
|
||||
|
||||
fn harness(tracks: usize) -> Harness<'static, SoundApp> {
|
||||
@@ -65,3 +66,14 @@ fn double_click_right_of_the_last_strip_opens_new_tracks() {
|
||||
click(&mut h, at);
|
||||
assert_eq!(h.state().dialogs.open_name(), Some("new_tracks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_tracks_name_field_is_wide_enough_to_read_a_name() {
|
||||
let mut h = harness(0);
|
||||
h.get_by_label_contains("New Tracks").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().dialogs.open_name(), Some("new_tracks"));
|
||||
// The field's text run carries the same value as the field itself.
|
||||
let width = h.get(by().role(Role::TextInput).value("Audio")).rect().width();
|
||||
assert!(width >= 200.0, "the Name field is only {width}px wide");
|
||||
}
|
||||
|
||||
@@ -233,6 +233,44 @@ fn the_piano_roll_leaves_modified_arrows_to_the_shortcuts() {
|
||||
assert_eq!(pitch(&app), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_piano_roll_selection_follows_notes_the_arrows_move_past_others() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.ui.show_midi_editor = true;
|
||||
app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut SoundApp, events: Vec<Event>| {
|
||||
let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() };
|
||||
ctx.run_ui(raw, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
};
|
||||
for _ in 0..6 {
|
||||
frame(&mut app, vec![]);
|
||||
}
|
||||
let body = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("soundcraft-midi-body"))).unwrap();
|
||||
frame(&mut app, vec![Event::PointerMoved(body.center())]);
|
||||
let notes = |app: &SoundApp| {
|
||||
let clip = soundcraft_ui_egui::midi_editor::target_clip(app).unwrap();
|
||||
match &app.engine.session().find_clip(clip).unwrap().1.content {
|
||||
ClipContent::Midi { sequence } => sequence.notes.clone(),
|
||||
_ => panic!("not a MIDI clip"),
|
||||
}
|
||||
};
|
||||
// The first chord's lowest note, an octave up, sorts after the other chord notes.
|
||||
let first = notes(&app)[0];
|
||||
app.midi.selected = vec![0];
|
||||
for octaves in 1..=2u8 {
|
||||
frame(&mut app, keys(Key::ArrowUp, Modifiers::SHIFT));
|
||||
let &[i] = app.midi.selected.as_slice() else { panic!("selection {:?}", app.midi.selected) };
|
||||
assert_ne!(i, 0);
|
||||
assert_eq!((notes(&app)[i].start, notes(&app)[i].pitch), (first.start, first.pitch + 12 * octaves));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_tab_extends_the_selection_and_tab_to_transient_stops_on_hits() {
|
||||
for (os, cmd) in OSES {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, menus};
|
||||
|
||||
fn press(h: &mut Harness<'_, SoundApp>, pos: Pos2, pressed: bool) {
|
||||
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// #444: the MIDI Real-Time Properties window submitted all four properties from its form on any
|
||||
/// change, so editing Velocity reset the target's Transpose, Duration and Delay to the form's
|
||||
/// (stale) defaults — and the form never showed the target's own values.
|
||||
#[test]
|
||||
fn editing_rtp_velocity_leaves_the_other_properties_alone() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
app.engine.execute("track.new", &json!({"kind": "instrument", "name": "Synth"})).unwrap();
|
||||
let t = app.engine.session().tracks.last().unwrap().id;
|
||||
app.engine.execute("event.midi_rtp", &json!({"track": t.0, "transpose": 7, "duration": 50.0})).unwrap();
|
||||
app.run("edit.select", json!({"tracks": [t.0], "exact": true})).unwrap();
|
||||
menus::invoke_menu(&mut app, "window.rtp", "Event > MIDI Real-Time Properties");
|
||||
|
||||
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,
|
||||
);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().ui.show_rtp, "the window did not open");
|
||||
let rtp = |h: &Harness<'_, SoundApp>, key: &str, d: f64| h.state().engine.session().edit.value(&format!("rtp.{}.{key}", t.0), d);
|
||||
|
||||
// Drag the Velocity slider right: the velocity changes, the rest must stay as the track had it.
|
||||
let rect = h.get_by_role_and_label(Role::Slider, "Velocity +").rect();
|
||||
let from = rect.center();
|
||||
let to = from + vec2(30.0, 0.0);
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.run_steps(1);
|
||||
press(&mut h, from, true);
|
||||
h.event(Event::PointerMoved(to));
|
||||
h.run_steps(2);
|
||||
press(&mut h, to, false);
|
||||
assert!(rtp(&h, "velocity", 0.0) > 0.0, "the velocity drag did not reach the engine");
|
||||
assert_eq!(rtp(&h, "transpose", 0.0), 7.0, "editing Velocity reset Transpose");
|
||||
assert_eq!(rtp(&h, "duration", 100.0), 50.0, "editing Velocity reset Duration");
|
||||
assert_eq!(rtp(&h, "delay", 0.0), 0.0, "editing Velocity reset Delay");
|
||||
|
||||
// The form shows the target track's values, not its defaults.
|
||||
assert_eq!(h.state().ops.rtp_transpose, 7, "the form did not show the track's Transpose");
|
||||
assert_eq!(h.state().ops.rtp_duration, 50.0, "the form did not show the track's Duration");
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Scroll Into View, in a track name's right-click menu: the track goes to the top of the Edit
|
||||
//! window and to the left of the Mix window, both windows at once.
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, Shape, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{MainWindow, Services, SoundApp};
|
||||
|
||||
const SIZE: (f32, f32) = (1400.0, 900.0);
|
||||
|
||||
fn harness(tracks: usize) -> Harness<'static, SoundApp> {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
for i in 1..=tracks {
|
||||
app.run("track.new", json!({"name": format!("T{i:02}")})).unwrap();
|
||||
}
|
||||
// Uneven heights, so an offset counted as "n rows" can't pass for the real one.
|
||||
app.run("track.height", json!({"tracks": ["T02"], "height": "large"})).unwrap();
|
||||
app.run("track.height", json!({"tracks": ["T04"], "height": "small"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(SIZE.0, SIZE.1)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn right_click(h: &mut Harness<'_, SoundApp>, at: Pos2) {
|
||||
h.hover_at(at);
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
|
||||
}
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn scroll_into_view(h: &mut Harness<'_, SoundApp>) {
|
||||
h.get_by_label("Scroll Into View").click();
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
fn track_id(h: &Harness<'_, SoundApp>, name: &str) -> u64 {
|
||||
h.state().engine.session().tracks.iter().find(|t| t.name == name).unwrap().id.0
|
||||
}
|
||||
|
||||
fn edit_row(h: &Harness<'_, SoundApp>, name: &str) -> [f32; 4] {
|
||||
let id = track_id(h, name);
|
||||
h.state().edit_layout.rows.iter().find(|(t, _)| *t == id).unwrap().1
|
||||
}
|
||||
|
||||
/// Height of the Edit window's track area, as last drawn.
|
||||
fn view_h(h: &Harness<'_, SoundApp>) -> f32 {
|
||||
let [_, y0, _, y1] = h.state().edit_layout.timeline;
|
||||
y1 - y0
|
||||
}
|
||||
|
||||
fn max_scroll(h: &Harness<'_, SoundApp>) -> f32 {
|
||||
h.state().edit_layout.content_h - view_h(h) + 40.0
|
||||
}
|
||||
|
||||
/// The centre of a Mix strip's name plate: its name is painted centred on it, at the bottom of
|
||||
/// the strip (the galley's box, not the glyphs, so "T01" and "T08" line up alike).
|
||||
fn plate(h: &Harness<'_, SoundApp>, name: &str) -> Option<Pos2> {
|
||||
h.output().shapes.iter().find_map(|shape| match &shape.shape {
|
||||
Shape::Text(text) if text.galley.job.text == name && text.pos.y > SIZE.1 - 40.0 => Some(text.pos + text.galley.size() / 2.0),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_track_goes_to_the_top_of_the_edit_window() {
|
||||
let mut h = harness(20);
|
||||
assert_eq!(h.state().edit_layout.scroll_y, 0.0);
|
||||
let row = edit_row(&h, "T06");
|
||||
right_click(&mut h, pos2(row[0] + 60.0, row[1] + 10.0));
|
||||
scroll_into_view(&mut h);
|
||||
|
||||
let above: f32 = ["T01", "T02", "T03", "T04", "T05"].iter().map(|n| edit_row(&h, n)).map(|r| r[3] - r[1] + 1.0).sum();
|
||||
assert_eq!(h.state().edit_layout.scroll_y, above);
|
||||
assert_eq!(edit_row(&h, "T06")[1], h.state().edit_layout.timeline[1], "T06 is the first row in view");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_the_end_it_scrolls_only_as_far_as_the_scroll_bar_goes() {
|
||||
let mut h = harness(20);
|
||||
let max = max_scroll(&h);
|
||||
h.state_mut().edit_layout.scroll_y = max - 60.0;
|
||||
h.run_steps(3);
|
||||
let row = edit_row(&h, "T19");
|
||||
assert!(row[1] - h.state().edit_layout.timeline[1] < max, "the fixture puts T19 past the furthest scroll");
|
||||
right_click(&mut h, pos2(row[0] + 60.0, row[1] + 10.0));
|
||||
scroll_into_view(&mut h);
|
||||
|
||||
assert_eq!(h.state().edit_layout.scroll_y, max);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_the_mix_the_clicked_strip_goes_left_and_the_edit_window_follows() {
|
||||
let mut h = harness(20);
|
||||
let t03 = track_id(&h, "T03");
|
||||
h.state_mut().engine.session_mut().edit.selected_tracks = vec![soundcraft_model::TrackId(t03)];
|
||||
h.state_mut().ui.window = MainWindow::Mix;
|
||||
h.run_steps(4);
|
||||
let first = plate(&h, "T01").expect("the first strip's name").x;
|
||||
let target = plate(&h, "T08").expect("T08 is on screen");
|
||||
right_click(&mut h, target);
|
||||
scroll_into_view(&mut h);
|
||||
|
||||
let now = plate(&h, "T08").expect("T08 still on screen").x;
|
||||
assert!((now - first).abs() < 1.0, "T08 is the leftmost strip: at {now}, the first was at {first}");
|
||||
assert_eq!(h.state().engine.session().edit.selected_tracks, vec![soundcraft_model::TrackId(t03)], "the selection is left alone");
|
||||
|
||||
h.state_mut().ui.window = MainWindow::Edit;
|
||||
h.run_steps(4);
|
||||
assert_eq!(edit_row(&h, "T08")[1], h.state().edit_layout.timeline[1], "the Edit window scrolled too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_track_in_a_closed_folder_scrolls_the_mix_but_not_the_edit_window() {
|
||||
let mut h = harness(20);
|
||||
h.state_mut().run("track.move_to_new_folder", json!({"tracks": ["T08"], "name": "F"})).unwrap();
|
||||
h.state_mut().run("track.folder_toggle", json!({"track": "F", "open": false})).unwrap();
|
||||
h.state_mut().ui.window = MainWindow::Mix;
|
||||
h.run_steps(4);
|
||||
let target = plate(&h, "T08").expect("the Mix shows tracks of a closed folder");
|
||||
right_click(&mut h, target);
|
||||
scroll_into_view(&mut h);
|
||||
|
||||
h.state_mut().ui.window = MainWindow::Edit;
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().edit_layout.scroll_y, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_takes_a_track_by_name_from_the_control_channel() {
|
||||
let mut h = harness(20);
|
||||
h.state_mut().ui.window = MainWindow::Mix;
|
||||
h.run_steps(4);
|
||||
let first = plate(&h, "T01").expect("the first strip's name").x;
|
||||
h.state_mut().run("view.scroll_into_view", json!({"track": "T08"})).unwrap();
|
||||
h.run_steps(4);
|
||||
|
||||
let now = plate(&h, "T08").expect("T08 on screen").x;
|
||||
assert!((now - first).abs() < 1.0, "T08 is the leftmost strip: at {now}, the first was at {first}");
|
||||
h.state_mut().ui.window = MainWindow::Edit;
|
||||
h.run_steps(4);
|
||||
assert_eq!(edit_row(&h, "T08")[1], h.state().edit_layout.timeline[1], "the Edit window scrolled too");
|
||||
assert!(h.state_mut().run("view.scroll_into_view", json!({"track": "T99"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_track_leaves_no_scroll_waiting_in_the_mix() {
|
||||
let mut h = harness(20);
|
||||
h.state_mut().ui.window = MainWindow::Mix;
|
||||
h.state_mut().run("track.hide", json!({"tracks": ["T08"], "hidden": true})).unwrap();
|
||||
h.run_steps(4);
|
||||
let first = plate(&h, "T01").expect("the first strip's name").x;
|
||||
h.state_mut().run("view.scroll_into_view", json!({"track": "T08"})).unwrap();
|
||||
h.run_steps(4);
|
||||
|
||||
// Shown again later, T08 must not pull the Mix to itself: the request was for frames long gone.
|
||||
h.state_mut().run("track.hide", json!({"tracks": ["T08"], "hidden": false})).unwrap();
|
||||
h.run_steps(4);
|
||||
let now = plate(&h, "T01").expect("T01 still on screen").x;
|
||||
assert!((now - first).abs() < 1.0, "the Mix stayed put: T01 at {now}, was at {first}");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user