mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 18:46:49 -06:00
Compare commits
95
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee050db59c | ||
|
|
aa74bffac1 | ||
|
|
e884d6eb37 | ||
|
|
4ab3bde39b | ||
|
|
824cf5cecb | ||
|
|
f6a27c2ccf | ||
|
|
08679d5a65 | ||
|
|
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 | ||
|
|
c2da4cf912 | ||
|
|
1f72dc30be | ||
|
|
e82de5cb97 | ||
|
|
24fa4668ef | ||
|
|
8198d6e35a | ||
|
|
1c1df8728d | ||
|
|
b5bcf577b2 | ||
|
|
4eee6b17a8 | ||
|
|
82616dcfb8 | ||
|
|
5ddd4b115f | ||
|
|
c8f8fc45eb | ||
|
|
3c04a3c36f | ||
|
|
c09dab9fef | ||
|
|
4110f5ff10 | ||
|
|
a72e46e7b6 | ||
|
|
6f3591ae11 | ||
|
|
a4b6bd0179 | ||
|
|
3ea890496e | ||
|
|
7a086baa7b | ||
|
|
adab577f9a | ||
|
|
05fe56ec1a | ||
|
|
fb087c879b | ||
|
|
075104f448 | ||
|
|
ea0e4719fc | ||
|
|
2cba9ed4cd | ||
|
|
315745b022 | ||
|
|
952ca5e4ed | ||
|
|
800b27013d | ||
|
|
b50373d6db | ||
|
|
1e1458caf1 | ||
|
|
76696826cd | ||
|
|
f8b2f8ce2e | ||
|
|
d4096db1f0 | ||
|
|
28c936a256 | ||
|
|
2dd8f7da55 | ||
|
|
d13f6657f8 | ||
|
|
bddc113f46 | ||
|
|
b7329ee904 | ||
|
|
9a398d79ff | ||
|
|
f9d04ffc98 | ||
|
|
245015fd89 |
Generated
+7
-3
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
+3
-3
@@ -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, chorus, flanger, phaser, ring modulator,
|
||||
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.
|
||||
- **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.
|
||||
|
||||
+9
-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-19 · **Last updated:** 2026-10-23 · **Change:** trivial (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,17 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
## Progress log
|
||||
|
||||
- **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 +166,12 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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) |
|
||||
|
||||
+160
-21
@@ -2,9 +2,11 @@
|
||||
//!
|
||||
//! ```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
|
||||
@@ -40,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
|
||||
@@ -124,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),
|
||||
@@ -132,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}")),
|
||||
};
|
||||
@@ -145,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());
|
||||
@@ -181,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()
|
||||
@@ -214,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),
|
||||
@@ -245,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}")),
|
||||
@@ -514,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);
|
||||
}
|
||||
@@ -215,3 +215,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.
|
||||
|
||||
@@ -239,37 +239,6 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi
|
||||
))
|
||||
}
|
||||
|
||||
/// A demux error ends the decode; it is only an error when nothing was decoded yet.
|
||||
fn demux_failure(frames: u64, e: SymError) -> Result<()> {
|
||||
if frames == 0 {
|
||||
return Err(map_err(e));
|
||||
}
|
||||
log::warn!("stopping decode after demux error: {e}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Note a corrupt packet (the `errors`-th); a stream that never decodes is malformed.
|
||||
fn corrupt_packet(errors: usize, frames: u64, msg: &str) -> Result<()> {
|
||||
log::debug!("skipping corrupt packet: {msg}");
|
||||
if errors > 1000 && frames == 0 {
|
||||
return Err(AudioError::Malformed(format!("no decodable packets: {msg}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The decoded stream is authoritative for the channel count: record it and allocate the planes.
|
||||
fn init_planar(info: &mut AudioInfo, ch: usize) -> Result<Vec<Vec<f32>>> {
|
||||
info.channels = u16::try_from(ch).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
|
||||
Ok((0..ch).map(|_| Vec::new()).collect())
|
||||
}
|
||||
|
||||
/// Append one decoded block to the planes, replacing non-finite samples with silence.
|
||||
fn append_planar(planar: &mut [Vec<f32>], scratch: &[Vec<f32>]) {
|
||||
for (dst, src) in planar.iter_mut().zip(scratch) {
|
||||
dst.extend(src.iter().map(|&s| if s.is_finite() { s } else { 0.0 }));
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the whole first audio track. When `count_only`, samples are counted but not stored.
|
||||
fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
|
||||
let mut reader = match open(bytes, detected)? {
|
||||
@@ -311,7 +280,10 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
|
||||
Err(SymError::ResetRequired) => break,
|
||||
Err(e) => {
|
||||
demux_failure(frames, e)?;
|
||||
if frames == 0 {
|
||||
return Err(map_err(e));
|
||||
}
|
||||
log::warn!("stopping decode after demux error: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -322,7 +294,10 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
Ok(d) => d,
|
||||
Err(SymError::DecodeError(m)) => {
|
||||
errors += 1;
|
||||
corrupt_packet(errors, frames, m)?;
|
||||
log::debug!("skipping corrupt packet: {m}");
|
||||
if errors > 1000 && frames == 0 {
|
||||
return Err(AudioError::Malformed(format!("no decodable packets: {m}")));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(SymError::IoError(_)) => break,
|
||||
@@ -334,7 +309,9 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
continue;
|
||||
}
|
||||
if planar.is_empty() {
|
||||
planar = init_planar(&mut info, ch)?;
|
||||
// The decoded stream is authoritative for the channel count.
|
||||
info.channels = u16::try_from(ch).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
|
||||
planar = (0..ch).map(|_| Vec::new()).collect();
|
||||
}
|
||||
let n = decoded.frames() as u64;
|
||||
frames = frames.saturating_add(n);
|
||||
@@ -345,7 +322,9 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
continue;
|
||||
}
|
||||
decoded.copy_to_vecs_planar::<f32>(&mut scratch);
|
||||
append_planar(&mut planar, &scratch);
|
||||
for (dst, src) in planar.iter_mut().zip(&scratch) {
|
||||
dst.extend(src.iter().map(|&s| if s.is_finite() { s } else { 0.0 }));
|
||||
}
|
||||
}
|
||||
if info.channels == 0 {
|
||||
return Err(AudioError::Malformed("stream contains no audio".into()));
|
||||
|
||||
+41
-47
@@ -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)
|
||||
@@ -164,45 +178,6 @@ fn only_chunks_follow(b: &[u8], from: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// A `data` chunk header as found in the file, for [`data_chunk_len`].
|
||||
struct DataChunk {
|
||||
body: usize,
|
||||
size32: u32,
|
||||
is_rf64: bool,
|
||||
remaining: usize,
|
||||
declared: usize,
|
||||
}
|
||||
|
||||
/// Bytes of audio a `data` chunk holds: leniently, whatever is present when the size is wrong or
|
||||
/// the recording was never finalised.
|
||||
fn data_chunk_len(b: &[u8], c: DataChunk) -> usize {
|
||||
let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64;
|
||||
let empty = c.size32 == 0 && !c.is_rf64 && finished && only_chunks_follow(b, c.body);
|
||||
if c.size32 == 0 && !c.is_rf64 && !empty { c.remaining } else { c.declared.min(c.remaining) }
|
||||
}
|
||||
|
||||
/// The sample layout of a `fmt` chunk, `None` for a format we do not read natively.
|
||||
fn pcm_layout(fmt: &Fmt) -> Result<Option<(PcmKind, SampleFormat)>> {
|
||||
Ok(Some(match (fmt.tag, fmt.bits) {
|
||||
(WAVE_FORMAT_PCM, bits @ 1..=32) => {
|
||||
let bytes = usize::from(bits.div_ceil(8));
|
||||
let sf = match fmt.valid_bits.min(bits) {
|
||||
0..=8 => SampleFormat::Int8,
|
||||
9..=16 => SampleFormat::Int16,
|
||||
17..=24 => SampleFormat::Int24,
|
||||
_ => SampleFormat::Int32,
|
||||
};
|
||||
(PcmKind::Int { bytes, big_endian: false, unsigned: bytes == 1 }, sf)
|
||||
}
|
||||
(WAVE_FORMAT_IEEE_FLOAT, 32) => (PcmKind::Float { bytes: 4, big_endian: false }, SampleFormat::Float32),
|
||||
(WAVE_FORMAT_IEEE_FLOAT, 64) => (PcmKind::Float { bytes: 8, big_endian: false }, SampleFormat::Float64),
|
||||
(WAVE_FORMAT_PCM | WAVE_FORMAT_IEEE_FLOAT, bits) => {
|
||||
return Err(AudioError::Unsupported(format!("WAV format {} with {bits} bits per sample", fmt.tag)));
|
||||
}
|
||||
_ => return Ok(None),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
|
||||
if !is_wav(b) {
|
||||
return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into()));
|
||||
@@ -234,7 +209,9 @@ pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
|
||||
// A size of 0 in a file whose RIFF size is right and that has only chunks after it is an
|
||||
// empty data chunk followed by metadata (LIST, bext, …); otherwise it is a recording
|
||||
// that was never finalised, and what follows is audio.
|
||||
let len = data_chunk_len(b, DataChunk { body, size32, is_rf64, remaining, declared });
|
||||
let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64;
|
||||
let empty = size32 == 0 && !is_rf64 && finished && only_chunks_follow(b, body);
|
||||
let len = if size32 == 0 && !is_rf64 && !empty { remaining } else { declared.min(remaining) };
|
||||
if data.is_none() {
|
||||
data = Some((body, len));
|
||||
}
|
||||
@@ -269,7 +246,24 @@ pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
|
||||
return Err(AudioError::Malformed("WAV declares a zero sample rate".into()));
|
||||
}
|
||||
let channels = usize::from(fmt.channels);
|
||||
let Some((kind, sample_format)) = pcm_layout(&fmt)? else { return Ok(WavParse::Foreign { bwf }) };
|
||||
let (kind, sample_format) = match (fmt.tag, fmt.bits) {
|
||||
(WAVE_FORMAT_PCM, bits @ 1..=32) => {
|
||||
let bytes = usize::from(bits.div_ceil(8));
|
||||
let sf = match fmt.valid_bits.min(bits) {
|
||||
0..=8 => SampleFormat::Int8,
|
||||
9..=16 => SampleFormat::Int16,
|
||||
17..=24 => SampleFormat::Int24,
|
||||
_ => SampleFormat::Int32,
|
||||
};
|
||||
(PcmKind::Int { bytes, big_endian: false, unsigned: bytes == 1 }, sf)
|
||||
}
|
||||
(WAVE_FORMAT_IEEE_FLOAT, 32) => (PcmKind::Float { bytes: 4, big_endian: false }, SampleFormat::Float32),
|
||||
(WAVE_FORMAT_IEEE_FLOAT, 64) => (PcmKind::Float { bytes: 8, big_endian: false }, SampleFormat::Float64),
|
||||
(WAVE_FORMAT_PCM | WAVE_FORMAT_IEEE_FLOAT, bits) => {
|
||||
return Err(AudioError::Unsupported(format!("WAV format {} with {bits} bits per sample", fmt.tag)));
|
||||
}
|
||||
_ => return Ok(WavParse::Foreign { bwf }),
|
||||
};
|
||||
let block = kind.bytes().checked_mul(channels).ok_or_else(|| AudioError::Malformed("block size overflow".into()))?;
|
||||
if usize::from(fmt.block_align) != block {
|
||||
log::debug!("WAV block_align {} disagrees with computed {block}; using computed", fmt.block_align);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -102,35 +102,6 @@ fn note_mode(p: Option<NotePort>) -> NoteMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy `n` frames from `start` of the host buffers into the plugin's input `port` (mono sources
|
||||
/// fill every port channel).
|
||||
fn feed_input(inputs: &mut PortBuffers, port: usize, io: &[Vec<f32>], ch: usize, (start, n): (usize, usize)) {
|
||||
for c in 0..inputs.channels(port) {
|
||||
let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n));
|
||||
if let (Some(src), Some(dst)) = (src, inputs.channel_mut(port, c).and_then(|d| d.get_mut(..n))) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy `n` frames of the plugin's output `port` into the host buffers from `start`; a mono host
|
||||
/// buffer takes the average of a stereo port.
|
||||
fn read_output(outputs: &PortBuffers, port: usize, io: &mut [Vec<f32>], ch: usize, (start, n): (usize, usize)) {
|
||||
let oc = outputs.channels(port);
|
||||
for (c, dst) in io.iter_mut().take(ch).enumerate() {
|
||||
let Some(dst) = dst.get_mut(start..start + n) else { continue };
|
||||
if ch == 1 && oc >= 2 {
|
||||
if let (Some(l), Some(r)) = (outputs.channel(port, 0), outputs.channel(port, 1)) {
|
||||
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
|
||||
*d = 0.5 * (a + b);
|
||||
}
|
||||
}
|
||||
} else if let Some(src) = outputs.channel(port, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for ClapPlugin {
|
||||
fn info(&self) -> &'static PluginInfo {
|
||||
self.info
|
||||
@@ -226,7 +197,12 @@ impl Plugin for ClapPlugin {
|
||||
let end = u32::try_from(start + n).unwrap_or(u32::MAX);
|
||||
// Feed the main input port (mono sources fill every port channel).
|
||||
if let Some(p) = self.in_main {
|
||||
feed_input(&mut self.inputs, p, io, ch, (start, n));
|
||||
for c in 0..self.inputs.channels(p) {
|
||||
let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n));
|
||||
if let (Some(src), Some(dst)) = (src, self.inputs.channel_mut(p, c).and_then(|d| d.get_mut(..n))) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
let k = self.queue.iter().position(|e| e.time() >= end).unwrap_or(self.queue.len());
|
||||
let offset = u32::try_from(start).unwrap_or(u32::MAX);
|
||||
@@ -236,7 +212,19 @@ impl Plugin for ClapPlugin {
|
||||
self.steady = self.steady.saturating_add(i64::try_from(n).unwrap_or(0));
|
||||
match (r, self.out_main) {
|
||||
(Ok(_), Some(p)) => {
|
||||
read_output(&self.outputs, p, io, ch, (start, n));
|
||||
let oc = self.outputs.channels(p);
|
||||
for (c, dst) in io.iter_mut().take(ch).enumerate() {
|
||||
let Some(dst) = dst.get_mut(start..start + n) else { continue };
|
||||
if ch == 1 && oc >= 2 {
|
||||
if let (Some(l), Some(r)) = (self.outputs.channel(p, 0), self.outputs.channel(p, 1)) {
|
||||
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
|
||||
*d = 0.5 * (a + b);
|
||||
}
|
||||
}
|
||||
} else if let Some(src) = self.outputs.channel(p, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
(Err(e), _) => log::debug!("{}: {e}", self.info.id),
|
||||
_ => {}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -189,61 +189,6 @@ fn apply(f: &mut Fx, h: &clap_event_header) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill every output channel with 1.0 when all ports have the wide layout, else -1.0.
|
||||
unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize) {
|
||||
let fits = |bufs: *const clap_audio_buffer, n: u32| {
|
||||
n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels)
|
||||
};
|
||||
let ok = fits(pr.audio_inputs, pr.audio_inputs_count) && fits(pr.audio_outputs, pr.audio_outputs_count);
|
||||
if pr.audio_outputs_count > 0 {
|
||||
let out = unsafe { &*pr.audio_outputs };
|
||||
for c in 0..out.channel_count as usize {
|
||||
let o = unsafe { *out.data32.add(c) };
|
||||
for i in 0..n {
|
||||
unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the host-visible parameter edit and an ignorable note event onto the output queue.
|
||||
unsafe fn push_gui_edit(pr: &clap_process) {
|
||||
let e = clap_event_param_value {
|
||||
header: clap_event_header {
|
||||
size: std::mem::size_of::<clap_event_param_value>() as u32,
|
||||
time: 0,
|
||||
space_id: CLAP_CORE_EVENT_SPACE_ID,
|
||||
type_: CLAP_EVENT_PARAM_VALUE,
|
||||
flags: 0,
|
||||
},
|
||||
param_id: 7,
|
||||
cookie: std::ptr::null_mut(),
|
||||
note_id: -1,
|
||||
port_index: -1,
|
||||
channel: -1,
|
||||
key: -1,
|
||||
value: 0.25,
|
||||
};
|
||||
let out = unsafe { &*pr.out_events };
|
||||
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &e.header) });
|
||||
// Other event types are accepted and ignored by the host.
|
||||
let note = clap_event_note {
|
||||
header: clap_event_header {
|
||||
size: std::mem::size_of::<clap_event_note>() as u32,
|
||||
time: 0,
|
||||
space_id: CLAP_CORE_EVENT_SPACE_ID,
|
||||
type_: CLAP_EVENT_NOTE_END,
|
||||
flags: 0,
|
||||
},
|
||||
note_id: -1,
|
||||
port_index: 0,
|
||||
channel: 0,
|
||||
key: 60,
|
||||
velocity: 0.0,
|
||||
};
|
||||
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, ¬e.header) });
|
||||
}
|
||||
|
||||
unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) -> clap_process_status {
|
||||
let f = unsafe { fx(p) };
|
||||
let pr = unsafe { &*pr };
|
||||
@@ -252,7 +197,19 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
|
||||
let ev = unsafe { &*pr.in_events };
|
||||
let count = unsafe { (ev.size.unwrap())(pr.in_events) };
|
||||
if let Some((ports, channels)) = f.wide {
|
||||
unsafe { fill_wide(pr, ports, channels, n) };
|
||||
let fits = |bufs: *const clap_audio_buffer, n: u32| {
|
||||
n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels)
|
||||
};
|
||||
let ok = fits(pr.audio_inputs, pr.audio_inputs_count) && fits(pr.audio_outputs, pr.audio_outputs_count);
|
||||
if pr.audio_outputs_count > 0 {
|
||||
let out = unsafe { &*pr.audio_outputs };
|
||||
for c in 0..out.channel_count as usize {
|
||||
let o = unsafe { *out.data32.add(c) };
|
||||
for i in 0..n {
|
||||
unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } };
|
||||
}
|
||||
}
|
||||
}
|
||||
return CLAP_PROCESS_CONTINUE;
|
||||
}
|
||||
if pr.audio_outputs_count != 1 {
|
||||
@@ -273,7 +230,40 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
|
||||
if f.gui_edit_pending {
|
||||
f.gui_edit_pending = false;
|
||||
f.gain = 0.25;
|
||||
unsafe { push_gui_edit(pr) };
|
||||
let e = clap_event_param_value {
|
||||
header: clap_event_header {
|
||||
size: std::mem::size_of::<clap_event_param_value>() as u32,
|
||||
time: 0,
|
||||
space_id: CLAP_CORE_EVENT_SPACE_ID,
|
||||
type_: CLAP_EVENT_PARAM_VALUE,
|
||||
flags: 0,
|
||||
},
|
||||
param_id: 7,
|
||||
cookie: std::ptr::null_mut(),
|
||||
note_id: -1,
|
||||
port_index: -1,
|
||||
channel: -1,
|
||||
key: -1,
|
||||
value: 0.25,
|
||||
};
|
||||
let out = unsafe { &*pr.out_events };
|
||||
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &e.header) });
|
||||
// Other event types are accepted and ignored by the host.
|
||||
let note = clap_event_note {
|
||||
header: clap_event_header {
|
||||
size: std::mem::size_of::<clap_event_note>() as u32,
|
||||
time: 0,
|
||||
space_id: CLAP_CORE_EVENT_SPACE_ID,
|
||||
type_: CLAP_EVENT_NOTE_END,
|
||||
flags: 0,
|
||||
},
|
||||
note_id: -1,
|
||||
port_index: 0,
|
||||
channel: 0,
|
||||
key: 60,
|
||||
velocity: 0.0,
|
||||
};
|
||||
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, ¬e.header) });
|
||||
}
|
||||
let mut next = 0u32;
|
||||
let mut last_time = 0u32;
|
||||
|
||||
+173
-75
@@ -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()
|
||||
}
|
||||
@@ -243,77 +322,6 @@ pub fn resample(ch: &[Vec<f32>], from: u32, to: u32) -> Vec<Vec<f32>> {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Time stretch (WSOLA) and pitch shift
|
||||
|
||||
/// Start position in the guide signal whose `hop` samples best match the natural continuation at
|
||||
/// `natural`, searched within `tol` of `nominal` (coarse stride 4, then refined +-3).
|
||||
fn best_start(g: &impl Fn(i64) -> f32, nominal: i64, natural: i64, tol: i64, hop: usize) -> i64 {
|
||||
// Similarity of candidate start `p` with the natural continuation (stride 2).
|
||||
let score = |p: i64| -> f32 {
|
||||
let (mut xy, mut yy) = (0.0f32, 1e-9f32);
|
||||
let mut i = 0i64;
|
||||
while i < hop as i64 {
|
||||
let a = g(natural + i);
|
||||
let b = g(p + i);
|
||||
xy += a * b;
|
||||
yy += b * b;
|
||||
i += 2;
|
||||
}
|
||||
xy / yy.sqrt()
|
||||
};
|
||||
let lo = (nominal - tol).max(0);
|
||||
let hi = nominal + tol;
|
||||
let mut best = nominal.max(0);
|
||||
let mut best_s = f32::NEG_INFINITY;
|
||||
let mut p = lo;
|
||||
while p <= hi {
|
||||
let s = score(p);
|
||||
if s > best_s {
|
||||
best_s = s;
|
||||
best = p;
|
||||
}
|
||||
p += 4;
|
||||
}
|
||||
let (rlo, rhi) = ((best - 3).max(lo), (best + 3).min(hi));
|
||||
for p in rlo..=rhi {
|
||||
let s = score(p);
|
||||
if s > best_s {
|
||||
best_s = s;
|
||||
best = p;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Add one windowed frame of every channel, read from `at.0`, into `out` at `at.1`, and the window
|
||||
/// into `wsum`.
|
||||
fn overlap_add(ch: &[Vec<f32>], out: &mut [Vec<f32>], wsum: &mut [f32], window: &[f32], at: (usize, usize)) {
|
||||
let (in_pos, out_pos) = at;
|
||||
for (c, o) in ch.iter().zip(out.iter_mut()) {
|
||||
for (i, w) in window.iter().enumerate() {
|
||||
let v = c.get(in_pos + i).copied().unwrap_or(0.0);
|
||||
if let Some(d) = o.get_mut(out_pos + i) {
|
||||
*d += v * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, w) in window.iter().enumerate() {
|
||||
if let Some(d) = wsum.get_mut(out_pos + i) {
|
||||
*d += w;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Divide out the accumulated window weights and cut each channel to `out_len`.
|
||||
fn normalise(out: &mut [Vec<f32>], wsum: &[f32], out_len: usize) {
|
||||
for o in out.iter_mut() {
|
||||
for (s, w) in o.iter_mut().zip(wsum) {
|
||||
if *w > 1e-3 {
|
||||
*s /= w;
|
||||
}
|
||||
}
|
||||
o.truncate(out_len);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pitch-preserving time stretch by WSOLA (waveform-similarity overlap-add).
|
||||
/// `ratio` = output length / input length, clamped to 0.1 ..= 10. All channels share the same
|
||||
/// segment choices so the stereo image stays coherent.
|
||||
@@ -346,12 +354,72 @@ pub fn time_stretch(ch: &[Vec<f32>], ratio: f64, sample_rate: f32) -> Vec<Vec<f3
|
||||
if out_pos >= out_len {
|
||||
break;
|
||||
}
|
||||
let in_pos = if k == 0 { 0 } else { best_start(&g, (out_pos as f64 / ratio).round() as i64, prev_in + hop as i64, tol, hop) };
|
||||
overlap_add(ch, &mut out, &mut wsum, &window, (in_pos as usize, out_pos));
|
||||
let in_pos = if k == 0 {
|
||||
0
|
||||
} else {
|
||||
let nominal = (out_pos as f64 / ratio).round() as i64;
|
||||
let natural = prev_in + hop as i64;
|
||||
// Similarity of candidate start `p` with the natural continuation (stride 2).
|
||||
let score = |p: i64| -> f32 {
|
||||
let (mut xy, mut yy) = (0.0f32, 1e-9f32);
|
||||
let mut i = 0i64;
|
||||
while i < hop as i64 {
|
||||
let a = g(natural + i);
|
||||
let b = g(p + i);
|
||||
xy += a * b;
|
||||
yy += b * b;
|
||||
i += 2;
|
||||
}
|
||||
xy / yy.sqrt()
|
||||
};
|
||||
let lo = (nominal - tol).max(0);
|
||||
let hi = nominal + tol;
|
||||
let mut best = nominal.max(0);
|
||||
let mut best_s = f32::NEG_INFINITY;
|
||||
let mut p = lo;
|
||||
while p <= hi {
|
||||
let s = score(p);
|
||||
if s > best_s {
|
||||
best_s = s;
|
||||
best = p;
|
||||
}
|
||||
p += 4;
|
||||
}
|
||||
let (rlo, rhi) = ((best - 3).max(lo), (best + 3).min(hi));
|
||||
for p in rlo..=rhi {
|
||||
let s = score(p);
|
||||
if s > best_s {
|
||||
best_s = s;
|
||||
best = p;
|
||||
}
|
||||
}
|
||||
best
|
||||
};
|
||||
for (c, o) in ch.iter().zip(out.iter_mut()) {
|
||||
for (i, w) in window.iter().enumerate() {
|
||||
let src = in_pos as usize + i;
|
||||
let v = c.get(src).copied().unwrap_or(0.0);
|
||||
if let Some(d) = o.get_mut(out_pos + i) {
|
||||
*d += v * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, w) in window.iter().enumerate() {
|
||||
if let Some(d) = wsum.get_mut(out_pos + i) {
|
||||
*d += w;
|
||||
}
|
||||
}
|
||||
prev_in = in_pos;
|
||||
k += 1;
|
||||
}
|
||||
normalise(&mut out, &wsum, out_len);
|
||||
for o in out.iter_mut() {
|
||||
for (s, w) in o.iter_mut().zip(&wsum) {
|
||||
if *w > 1e-3 {
|
||||
*s /= w;
|
||||
}
|
||||
}
|
||||
o.truncate(out_len);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -673,6 +741,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 {
|
||||
|
||||
@@ -135,65 +135,6 @@ impl Default for DrumSynth {
|
||||
}
|
||||
}
|
||||
|
||||
/// One output sample of a sounding voice of `kind`, advancing its envelopes.
|
||||
fn voice_sample(v: &mut DrumVoice, kind: Kind, sr: f32, clap_gap: usize) -> f32 {
|
||||
match kind {
|
||||
Kind::Kick | Kind::Tom(_) => {
|
||||
v.pitch_env *= v.pitch_coef;
|
||||
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
|
||||
v.phase += f / sr;
|
||||
v.phase -= v.phase.floor();
|
||||
let tone = (v.phase * TAU).sin() * v.amp;
|
||||
let click = v.filt.process_sample(v.rng.bipolar()) * v.noise_amp;
|
||||
v.amp *= v.amp_coef;
|
||||
v.noise_amp *= v.noise_coef;
|
||||
tone + click
|
||||
}
|
||||
Kind::Snare => {
|
||||
v.pitch_env *= v.pitch_coef;
|
||||
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
|
||||
v.phase += f / sr;
|
||||
v.phase -= v.phase.floor();
|
||||
let tone = (v.phase * TAU).sin() * v.amp * 0.6;
|
||||
let n = v.filt2.process_sample(v.filt.process_sample(v.rng.bipolar())) * v.noise_amp * 0.7;
|
||||
v.amp *= v.amp_coef;
|
||||
v.noise_amp *= v.noise_coef;
|
||||
tone + n
|
||||
}
|
||||
Kind::Clap => {
|
||||
// Three quick bursts then a diffuse tail.
|
||||
let burst = if v.t < clap_gap * 3 {
|
||||
if v.t.is_multiple_of(clap_gap) {
|
||||
v.amp = 1.0;
|
||||
}
|
||||
v.amp
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let env = burst.max(if v.t >= clap_gap * 2 { v.noise_amp * 0.6 } else { 0.0 });
|
||||
let y = v.filt.process_sample(v.rng.bipolar()) * env * 2.0;
|
||||
v.amp *= v.amp_coef;
|
||||
if v.t >= clap_gap * 2 {
|
||||
v.noise_amp *= v.noise_coef;
|
||||
}
|
||||
y
|
||||
}
|
||||
Kind::ClosedHat | Kind::OpenHat | Kind::Crash | Kind::Ride => {
|
||||
let scale = if matches!(kind, Kind::Crash | Kind::Ride) { 1.0 } else { 1.9 };
|
||||
let mut metal = 0.0;
|
||||
for (m, f) in v.metal.iter_mut().zip(METAL) {
|
||||
*m += f * scale / sr;
|
||||
*m -= m.floor();
|
||||
metal += if *m < 0.5 { 1.0 } else { -1.0 };
|
||||
}
|
||||
let raw = metal / 6.0 * (1.0 - v.noise_amp * 0.5) + v.rng.bipolar() * v.noise_amp;
|
||||
let y = v.filt2.process_sample(v.filt.process_sample(raw)) * v.amp;
|
||||
v.amp *= v.amp_coef;
|
||||
y
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DrumSynth {
|
||||
pub fn new() -> Self {
|
||||
let mut voices = [DrumVoice::new(1); VOICES];
|
||||
@@ -305,7 +246,61 @@ impl DrumSynth {
|
||||
let mut out = 0.0;
|
||||
for v in &mut self.voices {
|
||||
let Some(kind) = v.kind else { continue };
|
||||
let y = voice_sample(v, kind, sr, clap_gap);
|
||||
let y = match kind {
|
||||
Kind::Kick | Kind::Tom(_) => {
|
||||
v.pitch_env *= v.pitch_coef;
|
||||
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
|
||||
v.phase += f / sr;
|
||||
v.phase -= v.phase.floor();
|
||||
let tone = (v.phase * TAU).sin() * v.amp;
|
||||
let click = v.filt.process_sample(v.rng.bipolar()) * v.noise_amp;
|
||||
v.amp *= v.amp_coef;
|
||||
v.noise_amp *= v.noise_coef;
|
||||
tone + click
|
||||
}
|
||||
Kind::Snare => {
|
||||
v.pitch_env *= v.pitch_coef;
|
||||
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
|
||||
v.phase += f / sr;
|
||||
v.phase -= v.phase.floor();
|
||||
let tone = (v.phase * TAU).sin() * v.amp * 0.6;
|
||||
let n = v.filt2.process_sample(v.filt.process_sample(v.rng.bipolar())) * v.noise_amp * 0.7;
|
||||
v.amp *= v.amp_coef;
|
||||
v.noise_amp *= v.noise_coef;
|
||||
tone + n
|
||||
}
|
||||
Kind::Clap => {
|
||||
// Three quick bursts then a diffuse tail.
|
||||
let burst = if v.t < clap_gap * 3 {
|
||||
if v.t % clap_gap == 0 {
|
||||
v.amp = 1.0;
|
||||
}
|
||||
v.amp
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let env = burst.max(if v.t >= clap_gap * 2 { v.noise_amp * 0.6 } else { 0.0 });
|
||||
let y = v.filt.process_sample(v.rng.bipolar()) * env * 2.0;
|
||||
v.amp *= v.amp_coef;
|
||||
if v.t >= clap_gap * 2 {
|
||||
v.noise_amp *= v.noise_coef;
|
||||
}
|
||||
y
|
||||
}
|
||||
Kind::ClosedHat | Kind::OpenHat | Kind::Crash | Kind::Ride => {
|
||||
let scale = if matches!(kind, Kind::Crash | Kind::Ride) { 1.0 } else { 1.9 };
|
||||
let mut metal = 0.0;
|
||||
for (m, f) in v.metal.iter_mut().zip(METAL) {
|
||||
*m += f * scale / sr;
|
||||
*m -= m.floor();
|
||||
metal += if *m < 0.5 { 1.0 } else { -1.0 };
|
||||
}
|
||||
let raw = metal / 6.0 * (1.0 - v.noise_amp * 0.5) + v.rng.bipolar() * v.noise_amp;
|
||||
let y = v.filt2.process_sample(v.filt.process_sample(raw)) * v.amp;
|
||||
v.amp *= v.amp_coef;
|
||||
y
|
||||
}
|
||||
};
|
||||
v.t += 1;
|
||||
let alive = match kind {
|
||||
Kind::Clap => v.t < clap_gap * 3 || v.noise_amp > OFF,
|
||||
|
||||
@@ -29,6 +29,7 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[
|
||||
&modulation::CHORUS_INFO,
|
||||
&modulation::FLANGER_INFO,
|
||||
&modulation::PHASER_INFO,
|
||||
&modulation::RING_INFO,
|
||||
&harmonic::SAT_INFO,
|
||||
&harmonic::LOFI_INFO,
|
||||
&harmonic::RECT_INFO,
|
||||
@@ -61,6 +62,7 @@ pub(crate) fn instantiate(id: &str) -> Option<Box<dyn Plugin>> {
|
||||
"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()),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,31 +201,6 @@ impl Reverb {
|
||||
}
|
||||
}
|
||||
|
||||
/// Input sample `n` of the left and right channels (a mono input feeds both); non-finite reads as 0.
|
||||
fn input_pair(io: &[Vec<f32>], ch: usize, n: usize) -> (f32, f32) {
|
||||
let xl = io.first().and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let xr = if ch > 1 { io.get(1).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0) } else { xl };
|
||||
(xl, xr)
|
||||
}
|
||||
|
||||
/// Blend the wet signal `[mid, left, right]` into sample `n` of the first `ch` channels at mix `m`.
|
||||
fn mix_out(io: &mut [Vec<f32>], ch: usize, n: usize, wet: [f32; 3], m: f32) {
|
||||
let [mid, wl, wr] = wet;
|
||||
for (c, b) in io.iter_mut().take(ch).enumerate() {
|
||||
let wet = if ch == 1 {
|
||||
mid
|
||||
} else if c % 2 == 0 {
|
||||
wl
|
||||
} else {
|
||||
wr
|
||||
};
|
||||
if let Some(x) = b.get_mut(n) {
|
||||
let dry = if x.is_finite() { *x } else { 0.0 };
|
||||
*x = dry * (1.0 - m) + wet * m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for Reverb {
|
||||
fn info(&self) -> &'static PluginInfo {
|
||||
self.info
|
||||
@@ -304,7 +279,8 @@ impl Plugin for Reverb {
|
||||
let size = self.size.value();
|
||||
let base: [f32; LINES] = self.cfg.delays_ms.map(|ms| ms_to_samples(ms * size, sr));
|
||||
for n in 0..frames {
|
||||
let (xl, xr) = input_pair(io, ch, n);
|
||||
let xl = io.first().and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let xr = if ch > 1 { io.get(1).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0) } else { xl };
|
||||
let pre = ms_to_samples(self.pre_ms.next(), sr);
|
||||
let mut ins = [xl, xr];
|
||||
for (side, x) in ins.iter_mut().enumerate() {
|
||||
@@ -343,7 +319,19 @@ impl Plugin for Reverb {
|
||||
let side = 0.5 * (wl - wr) * w;
|
||||
let (wl, wr) = (mid + side, mid - side);
|
||||
let m = self.mix.next();
|
||||
mix_out(io, ch, n, [mid, wl, wr], m);
|
||||
for (c, b) in io.iter_mut().take(ch).enumerate() {
|
||||
let wet = if ch == 1 {
|
||||
mid
|
||||
} else if c % 2 == 0 {
|
||||
wl
|
||||
} else {
|
||||
wr
|
||||
};
|
||||
if let Some(x) = b.get_mut(n) {
|
||||
let dry = if x.is_finite() { *x } else { 0.0 };
|
||||
*x = dry * (1.0 - m) + wet * m;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -524,3 +524,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:?}");
|
||||
}
|
||||
|
||||
@@ -452,38 +452,6 @@ fn select_all(e: &mut Engine, _: &Value) -> Result<Value> {
|
||||
Ok(json!({"tracks": s.edit.selected_tracks.len(), "end": end}))
|
||||
}
|
||||
|
||||
/// Adds the members of every active edit group that touches `t`, then puts `t` in track order.
|
||||
fn add_group_members(s: &Session, t: &mut Vec<TrackId>) {
|
||||
let extra: Vec<TrackId> =
|
||||
s.groups.iter().filter(|g| g.active && g.edit && g.members.iter().any(|m| t.contains(m))).flat_map(|g| g.members.clone()).collect();
|
||||
for m in extra {
|
||||
if !t.contains(&m) {
|
||||
t.push(m);
|
||||
}
|
||||
}
|
||||
let order: Vec<TrackId> = s.tracks.iter().map(|x| x.id).collect();
|
||||
t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
|
||||
}
|
||||
|
||||
/// Selecting clips also selects their range and tracks (Grabber click).
|
||||
fn select_clip_ids(s: &Session, ed: &mut soundcraft_model::EditState, c: Vec<ClipId>) {
|
||||
let mut range: Option<soundcraft_time::Range> = None;
|
||||
let mut trs = Vec::new();
|
||||
for id in &c {
|
||||
if let Some((tid, clip)) = s.find_clip(*id) {
|
||||
range = Some(range.map_or(clip.range(), |r| soundcraft_time::Range::new(r.start.min(clip.start), r.end.max(clip.end()))));
|
||||
if !trs.contains(&tid) {
|
||||
trs.push(tid);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(r) = range {
|
||||
ed.selection = r;
|
||||
ed.selected_tracks = trs;
|
||||
}
|
||||
ed.selected_clips = c;
|
||||
}
|
||||
|
||||
fn select(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = if p.get("tracks").is_some() || p.get("track").is_some() { Some(tracks_param(e, "edit.select", p)?) } else { None };
|
||||
let st = position_param(e, "edit.select", p, "start")?;
|
||||
@@ -499,7 +467,15 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if let Some(mut t) = tracks {
|
||||
// Active edit groups select all their members together.
|
||||
if !exact {
|
||||
add_group_members(s, &mut t);
|
||||
let extra: Vec<soundcraft_model::TrackId> =
|
||||
s.groups.iter().filter(|g| g.active && g.edit && g.members.iter().any(|m| t.contains(m))).flat_map(|g| g.members.clone()).collect();
|
||||
for m in extra {
|
||||
if !t.contains(&m) {
|
||||
t.push(m);
|
||||
}
|
||||
}
|
||||
let order: Vec<soundcraft_model::TrackId> = s.tracks.iter().map(|x| x.id).collect();
|
||||
t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
|
||||
}
|
||||
ed.selected_tracks = t;
|
||||
}
|
||||
@@ -509,7 +485,22 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
_ => {}
|
||||
}
|
||||
if let Some(c) = clips {
|
||||
select_clip_ids(s, &mut ed, c);
|
||||
// Selecting clips also selects their range and tracks (Grabber click).
|
||||
let mut range: Option<soundcraft_time::Range> = None;
|
||||
let mut trs = Vec::new();
|
||||
for id in &c {
|
||||
if let Some((tid, clip)) = s.find_clip(*id) {
|
||||
range = Some(range.map_or(clip.range(), |r| soundcraft_time::Range::new(r.start.min(clip.start), r.end.max(clip.end()))));
|
||||
if !trs.contains(&tid) {
|
||||
trs.push(tid);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(r) = range {
|
||||
ed.selection = r;
|
||||
ed.selected_tracks = trs;
|
||||
}
|
||||
ed.selected_clips = c;
|
||||
} else if list_changes {
|
||||
ed.selected_clips.clear();
|
||||
}
|
||||
|
||||
@@ -856,57 +856,6 @@ fn coalesce_clip_gain_to_volume(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"clips": n}))
|
||||
}
|
||||
|
||||
/// The automation parameters a trim/glide over a range touches on `tr`.
|
||||
fn trim_params(tr: &soundcraft_model::Track, pan_only: bool) -> Vec<AutoParam> {
|
||||
let mut params: Vec<AutoParam> = tr
|
||||
.automation
|
||||
.iter()
|
||||
.filter(|l| l.enabled && !matches!(l.param, AutoParam::Mute | AutoParam::SendMute(_)))
|
||||
.filter(|l| !pan_only || matches!(l.param, AutoParam::Pan(_)))
|
||||
.map(|l| l.param.clone())
|
||||
.collect();
|
||||
if pan_only {
|
||||
for i in 0..tr.mixer.pan.len().min(2) {
|
||||
let pp = AutoParam::Pan(i as u8);
|
||||
if !params.contains(&pp) {
|
||||
params.push(pp);
|
||||
}
|
||||
}
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
/// Trim (shift the points inside `r` by the change at its end) or glide (ramp across `r`) one lane.
|
||||
fn trim_lane(tr: &mut soundcraft_model::Track, param: &AutoParam, r: Range, glide: bool) {
|
||||
let cur = super::mix::current_value(tr, param);
|
||||
let (lo, hi, _) = param.range();
|
||||
let l = tr.lane_mut(param);
|
||||
let before = l.value_at(r.start - 1, cur);
|
||||
let after = l.value_at(r.end, cur);
|
||||
let from = l.value_at(r.start, cur);
|
||||
if glide {
|
||||
l.clear_range(r.start, r.end.saturating_add(2));
|
||||
if r.start > 0 {
|
||||
l.set_point(r.start - 1, before);
|
||||
}
|
||||
l.set_point(r.start, from);
|
||||
l.set_point(r.end, cur);
|
||||
l.set_point(r.end.saturating_add(1), after);
|
||||
} else {
|
||||
let delta = cur - from;
|
||||
let last = l.value_at(r.end - 1, cur);
|
||||
if r.start > 0 {
|
||||
l.set_point(r.start - 1, before);
|
||||
}
|
||||
l.set_point(r.start, from);
|
||||
l.set_point(r.end - 1, last);
|
||||
l.set_point(r.end, after);
|
||||
for pt in l.points.iter_mut().filter(|pt| pt.at >= r.start && pt.at < r.end) {
|
||||
pt.value = (pt.value + delta).clamp(lo, hi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_glide(e: &mut Engine, p: &Value, glide: bool, pan_only: bool, id: &str) -> Result<Value> {
|
||||
let tracks = tracks_required(e, id, p)?;
|
||||
let r = nonempty_range(e, p, id)?;
|
||||
@@ -914,8 +863,49 @@ fn trim_glide(e: &mut Engine, p: &Value, glide: bool, pan_only: bool, id: &str)
|
||||
let mut lanes = 0;
|
||||
for t in &tracks {
|
||||
let Some(tr) = s.track_mut(*t) else { continue };
|
||||
for param in trim_params(tr, pan_only) {
|
||||
trim_lane(tr, ¶m, r, glide);
|
||||
let mut params: Vec<AutoParam> = tr
|
||||
.automation
|
||||
.iter()
|
||||
.filter(|l| l.enabled && !matches!(l.param, AutoParam::Mute | AutoParam::SendMute(_)))
|
||||
.filter(|l| !pan_only || matches!(l.param, AutoParam::Pan(_)))
|
||||
.map(|l| l.param.clone())
|
||||
.collect();
|
||||
if pan_only {
|
||||
for i in 0..tr.mixer.pan.len().min(2) {
|
||||
let pp = AutoParam::Pan(i as u8);
|
||||
if !params.contains(&pp) {
|
||||
params.push(pp);
|
||||
}
|
||||
}
|
||||
}
|
||||
for param in params {
|
||||
let cur = super::mix::current_value(tr, ¶m);
|
||||
let (lo, hi, _) = param.range();
|
||||
let l = tr.lane_mut(¶m);
|
||||
let before = l.value_at(r.start - 1, cur);
|
||||
let after = l.value_at(r.end, cur);
|
||||
let from = l.value_at(r.start, cur);
|
||||
if glide {
|
||||
l.clear_range(r.start, r.end.saturating_add(2));
|
||||
if r.start > 0 {
|
||||
l.set_point(r.start - 1, before);
|
||||
}
|
||||
l.set_point(r.start, from);
|
||||
l.set_point(r.end, cur);
|
||||
l.set_point(r.end.saturating_add(1), after);
|
||||
} else {
|
||||
let delta = cur - from;
|
||||
let last = l.value_at(r.end - 1, cur);
|
||||
if r.start > 0 {
|
||||
l.set_point(r.start - 1, before);
|
||||
}
|
||||
l.set_point(r.start, from);
|
||||
l.set_point(r.end - 1, last);
|
||||
l.set_point(r.end, after);
|
||||
for pt in l.points.iter_mut().filter(|pt| pt.at >= r.start && pt.at < r.end) {
|
||||
pt.value = (pt.value + delta).clamp(lo, hi);
|
||||
}
|
||||
}
|
||||
lanes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,11 +564,18 @@ pub fn chord_name(weights: &[f64; 12], bass: usize) -> Option<String> {
|
||||
const MAX_CHORD_BARS: usize = 100_000;
|
||||
const MAX_CHORD_NOTES: usize = 10_000_000;
|
||||
|
||||
/// Absolute `(start, end, pitch)` tick notes from every MIDI clip on `tracks`, cut to their clip
|
||||
/// and sorted by start.
|
||||
fn midi_tick_notes(s: &Session, tracks: &[TrackId]) -> Vec<(i64, i64, u8)> {
|
||||
fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "event.extract_chords";
|
||||
let (_, t0, t1) = selection_ticks(e, p, id)?;
|
||||
let s = e.session();
|
||||
let picked = tracks_param(e, id, p)?;
|
||||
let mut tracks: Vec<TrackId> = picked.into_iter().filter(|t| s.track(*t).is_some_and(|x| x.kind.is_midi())).collect();
|
||||
if tracks.is_empty() {
|
||||
tracks = s.tracks.iter().filter(|t| t.kind.is_midi()).map(|t| t.id).collect();
|
||||
}
|
||||
// Absolute (tick) notes from every MIDI clip on those tracks, cut to their clip.
|
||||
let mut notes: Vec<(i64, i64, u8)> = Vec::new();
|
||||
for t in tracks {
|
||||
for t in &tracks {
|
||||
for c in s.track(*t).map(|tr| tr.clips()).unwrap_or(&[]) {
|
||||
let ClipContent::Midi { sequence } = &c.content else { continue };
|
||||
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
|
||||
@@ -583,36 +590,6 @@ fn midi_tick_notes(s: &Session, tracks: &[TrackId]) -> Vec<(i64, i64, u8)> {
|
||||
}
|
||||
}
|
||||
notes.sort_unstable_by_key(|n| n.0);
|
||||
notes
|
||||
}
|
||||
|
||||
/// Duration-weighted pitch classes of the notes sounding in `a..b`, and the lowest pitch among them.
|
||||
fn pitch_class_weights(sounding: &[(i64, i64, u8)], a: i64, b: i64) -> ([f64; 12], Option<u8>) {
|
||||
let mut w = [0.0f64; 12];
|
||||
let mut bass: Option<u8> = None;
|
||||
for (na, nb, pitch) in sounding {
|
||||
let ov = (*nb).min(b) - (*na).max(a);
|
||||
if ov > 0 {
|
||||
if let Some(x) = w.get_mut(usize::from(*pitch) % 12) {
|
||||
*x += ov as f64;
|
||||
}
|
||||
bass = Some(bass.map_or(*pitch, |bp| bp.min(*pitch)));
|
||||
}
|
||||
}
|
||||
(w, bass)
|
||||
}
|
||||
|
||||
fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "event.extract_chords";
|
||||
let (_, t0, t1) = selection_ticks(e, p, id)?;
|
||||
let s = e.session();
|
||||
let picked = tracks_param(e, id, p)?;
|
||||
let mut tracks: Vec<TrackId> = picked.into_iter().filter(|t| s.track(*t).is_some_and(|x| x.kind.is_midi())).collect();
|
||||
if tracks.is_empty() {
|
||||
tracks = s.tracks.iter().filter(|t| t.kind.is_midi()).map(|t| t.id).collect();
|
||||
}
|
||||
// Absolute (tick) notes from every MIDI clip on those tracks, cut to their clip.
|
||||
let notes = midi_tick_notes(s, &tracks);
|
||||
let mut bar = s.tempo.bar_beat_at_tick(t0).bar;
|
||||
let mut sounding: Vec<(i64, i64, u8)> = Vec::new();
|
||||
let (mut next, mut bars, mut heard) = (0, 0, 0);
|
||||
@@ -639,7 +616,17 @@ fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
}
|
||||
bars += 1;
|
||||
heard += sounding.len();
|
||||
let (w, bass) = pitch_class_weights(&sounding, a, b);
|
||||
let mut w = [0.0f64; 12];
|
||||
let mut bass: Option<u8> = None;
|
||||
for (na, nb, pitch) in &sounding {
|
||||
let ov = (*nb).min(b) - (*na).max(a);
|
||||
if ov > 0 {
|
||||
if let Some(x) = w.get_mut(usize::from(*pitch) % 12) {
|
||||
*x += ov as f64;
|
||||
}
|
||||
bass = Some(bass.map_or(*pitch, |bp| bp.min(*pitch)));
|
||||
}
|
||||
}
|
||||
if let Some(name) = bass.and_then(|bp| chord_name(&w, usize::from(bp) % 12)) {
|
||||
let at = s.tempo.tick_to_samples(a, s.sample_rate);
|
||||
chords.push(json!({"bar": bar, "chord": name, "at": at}));
|
||||
|
||||
@@ -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| {
|
||||
@@ -241,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}))
|
||||
@@ -354,6 +362,46 @@ 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()));
|
||||
|
||||
@@ -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();
|
||||
|
||||
+153
-80
@@ -612,76 +612,6 @@ fn env_db(env: &[(i64, f32)], at: i64) -> f32 {
|
||||
prev.map_or(0.0, |(_, v)| v)
|
||||
}
|
||||
|
||||
/// Fold the volume `lane` into the gain envelope of clip `c` over the selection `r`.
|
||||
fn bake_volume_into_clip(c: &mut soundcraft_model::Clip, lane: &soundcraft_model::AutomationLane, base: f32, r: soundcraft_time::Range) {
|
||||
let delta = |at: i64| lane.value_at(at, base) - base;
|
||||
let sel0 = r.start.max(c.start);
|
||||
let sel1 = r.end.min(c.end());
|
||||
if sel1 <= sel0 {
|
||||
return;
|
||||
}
|
||||
let rel = |at: i64| at - c.start;
|
||||
let rel0 = rel(sel0);
|
||||
let rel1 = rel(sel1);
|
||||
// Later writes win, so the selection anchors replace any point they land on.
|
||||
let mut pts: Vec<(i64, f32)> =
|
||||
c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
|
||||
if rel0 > 0 {
|
||||
pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1)));
|
||||
}
|
||||
pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0)));
|
||||
for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) {
|
||||
let at = rel(pt.at);
|
||||
pts.push((at, env_db(&c.gain_env, at) + delta(pt.at)));
|
||||
}
|
||||
// The clip's own gain points inside the selection keep their shape, offset by the lane.
|
||||
for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
|
||||
pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at))));
|
||||
}
|
||||
if sel1 - 1 > sel0 {
|
||||
let at = rel(sel1 - 1);
|
||||
pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1)));
|
||||
}
|
||||
if sel1 < c.end() {
|
||||
pts.push((rel1, env_db(&c.gain_env, rel1)));
|
||||
}
|
||||
pts.sort_by_key(|p| p.0);
|
||||
let mut env = Vec::new();
|
||||
for p in pts {
|
||||
if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
|
||||
env.pop();
|
||||
}
|
||||
env.push(p);
|
||||
}
|
||||
c.gain_env = env;
|
||||
}
|
||||
|
||||
/// Clear the volume lane over `r` when the clips all sit inside it, else hold the fader there.
|
||||
fn reset_volume_lane(tr: &mut soundcraft_model::Track, lane: &soundcraft_model::AutomationLane, base: f32, r: soundcraft_time::Range) {
|
||||
// Clearing the lane is only safe when no audio plays outside the selection; otherwise the
|
||||
// held edge values outside would fall back to the fader level.
|
||||
let clips_inside = tr.clips().iter().all(|c| c.start >= r.start && c.end() <= r.end);
|
||||
let covers_all = clips_inside && lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end);
|
||||
let lane_m = tr.lane_mut(&AutoParam::Volume);
|
||||
if covers_all {
|
||||
lane_m.clear_range(r.start, r.end);
|
||||
} else {
|
||||
// Hold the fader across the selection and anchor the samples on either side, so the
|
||||
// curve outside does not interpolate through the gap or keep the old level inside.
|
||||
let before = (r.start > 0).then(|| lane.value_at(r.start - 1, base));
|
||||
let after = lane.value_at(r.end, base);
|
||||
lane_m.clear_range(r.start, r.end);
|
||||
if let Some(v) = before {
|
||||
lane_m.set_point(r.start - 1, v);
|
||||
}
|
||||
lane_m.set_point(r.start, base);
|
||||
if r.end - 1 > r.start {
|
||||
lane_m.set_point(r.end - 1, base);
|
||||
}
|
||||
lane_m.set_point(r.end, after);
|
||||
}
|
||||
}
|
||||
|
||||
fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "automation.volume_to_clip_gain", p)?;
|
||||
let r = range_param(e, "automation.volume_to_clip_gain", p)?;
|
||||
@@ -690,12 +620,72 @@ fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let Some(tr) = s.track_mut(*t) else { continue };
|
||||
let Some(lane) = tr.lane(&AutoParam::Volume).cloned().filter(|l| !l.points.is_empty()) else { continue };
|
||||
let base = tr.mixer.volume_db;
|
||||
let delta = |at: i64| lane.value_at(at, base) - base;
|
||||
if let Some(pl) = tr.playlist_mut() {
|
||||
for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) {
|
||||
bake_volume_into_clip(c, &lane, base, r);
|
||||
let sel0 = r.start.max(c.start);
|
||||
let sel1 = r.end.min(c.end());
|
||||
if sel1 <= sel0 {
|
||||
continue;
|
||||
}
|
||||
let rel = |at: i64| at - c.start;
|
||||
let rel0 = rel(sel0);
|
||||
let rel1 = rel(sel1);
|
||||
// Later writes win, so the selection anchors replace any point they land on.
|
||||
let mut pts: Vec<(i64, f32)> =
|
||||
c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
|
||||
if rel0 > 0 {
|
||||
pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1)));
|
||||
}
|
||||
pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0)));
|
||||
for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) {
|
||||
let at = rel(pt.at);
|
||||
pts.push((at, env_db(&c.gain_env, at) + delta(pt.at)));
|
||||
}
|
||||
// The clip's own gain points inside the selection keep their shape, offset by the lane.
|
||||
for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
|
||||
pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at))));
|
||||
}
|
||||
if sel1 - 1 > sel0 {
|
||||
let at = rel(sel1 - 1);
|
||||
pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1)));
|
||||
}
|
||||
if sel1 < c.end() {
|
||||
pts.push((rel1, env_db(&c.gain_env, rel1)));
|
||||
}
|
||||
pts.sort_by_key(|p| p.0);
|
||||
let mut env = Vec::new();
|
||||
for p in pts {
|
||||
if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
|
||||
env.pop();
|
||||
}
|
||||
env.push(p);
|
||||
}
|
||||
c.gain_env = env;
|
||||
}
|
||||
}
|
||||
reset_volume_lane(tr, &lane, base, r);
|
||||
// Clearing the lane is only safe when no audio plays outside the selection; otherwise the
|
||||
// held edge values outside would fall back to the fader level.
|
||||
let clips_inside = tr.clips().iter().all(|c| c.start >= r.start && c.end() <= r.end);
|
||||
let covers_all = clips_inside && lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end);
|
||||
let lane_m = tr.lane_mut(&AutoParam::Volume);
|
||||
if covers_all {
|
||||
lane_m.clear_range(r.start, r.end);
|
||||
} else {
|
||||
// Hold the fader across the selection and anchor the samples on either side, so the
|
||||
// curve outside does not interpolate through the gap or keep the old level inside.
|
||||
let before = (r.start > 0).then(|| lane.value_at(r.start - 1, base));
|
||||
let after = lane.value_at(r.end, base);
|
||||
lane_m.clear_range(r.start, r.end);
|
||||
if let Some(v) = before {
|
||||
lane_m.set_point(r.start - 1, v);
|
||||
}
|
||||
lane_m.set_point(r.start, base);
|
||||
if r.end - 1 > r.start {
|
||||
lane_m.set_point(r.end - 1, base);
|
||||
}
|
||||
lane_m.set_point(r.end, after);
|
||||
}
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
@@ -707,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);
|
||||
}
|
||||
}
|
||||
@@ -882,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();
|
||||
|
||||
@@ -221,23 +221,6 @@ fn io_state(s: &Session) -> Value {
|
||||
json!({"busses": s.busses, "outputs": s.outputs})
|
||||
}
|
||||
|
||||
/// Disconnect every track input, output and send that used bus `bid`.
|
||||
fn unroute_bus(s: &mut Session, bid: BusId) {
|
||||
for t in &mut s.tracks {
|
||||
if t.mixer.input == Route::Bus(bid) {
|
||||
t.mixer.input = Route::None;
|
||||
}
|
||||
if t.mixer.output == Route::Bus(bid) {
|
||||
t.mixer.output = Route::None;
|
||||
}
|
||||
for slot in &mut t.mixer.sends {
|
||||
if slot.as_ref().is_some_and(|x| x.target == Route::Bus(bid)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn io_setup(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "setup.io";
|
||||
let action = str_param(p, "action").unwrap_or("list");
|
||||
@@ -269,7 +252,19 @@ fn io_setup(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
"delete_bus" => {
|
||||
let bid: BusId = s.bus_by_name(&name).map(|b| b.id).ok_or_else(|| bad(id, format!("no bus `{name}`")))?;
|
||||
s.busses.retain(|b| b.id != bid);
|
||||
unroute_bus(s, bid);
|
||||
for t in &mut s.tracks {
|
||||
if t.mixer.input == Route::Bus(bid) {
|
||||
t.mixer.input = Route::None;
|
||||
}
|
||||
if t.mixer.output == Route::Bus(bid) {
|
||||
t.mixer.output = Route::None;
|
||||
}
|
||||
for slot in &mut t.mixer.sends {
|
||||
if slot.as_ref().is_some_and(|x| x.target == Route::Bus(bid)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"create_output" => {
|
||||
if s.outputs.iter().any(|o| o.name.eq_ignore_ascii_case(&name)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -635,35 +671,6 @@ fn change_width(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"format": f}))
|
||||
}
|
||||
|
||||
/// The nearest ancestor folder of `folder` that is not itself being deleted (walks at most 64 levels).
|
||||
fn surviving_parent(s: &Session, deleted: &[TrackId], folder: TrackId) -> Option<TrackId> {
|
||||
let mut parent = s.track(folder).and_then(|folder| folder.folder);
|
||||
for _ in 0..64 {
|
||||
match parent {
|
||||
Some(candidate) if deleted.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
parent
|
||||
}
|
||||
|
||||
/// Clear inputs and sends that used the removed bus; outputs that used it move to `replacement`.
|
||||
fn reroute_deleted_bus(s: &mut Session, bus: soundcraft_model::BusId, replacement: &Route) {
|
||||
for track in &mut s.tracks {
|
||||
if track.mixer.input == Route::Bus(bus) {
|
||||
track.mixer.input = Route::None;
|
||||
}
|
||||
if track.mixer.output == Route::Bus(bus) {
|
||||
track.mixer.output = replacement.clone();
|
||||
}
|
||||
for send in &mut track.mixer.sends {
|
||||
if send.as_ref().is_some_and(|send| send.target == Route::Bus(bus)) {
|
||||
*send = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.delete", p)?;
|
||||
let s = e.session_mut();
|
||||
@@ -671,7 +678,13 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
.iter()
|
||||
.filter_map(|folder| {
|
||||
let bus = owned_folder_bus(s, *folder)?;
|
||||
let parent = surviving_parent(s, &tracks, *folder);
|
||||
let mut parent = s.track(*folder).and_then(|folder| folder.folder);
|
||||
for _ in 0..64 {
|
||||
match parent {
|
||||
Some(candidate) if tracks.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Some((bus, parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main)))
|
||||
})
|
||||
.collect();
|
||||
@@ -683,7 +696,13 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
let Some(folder) = child.folder.filter(|folder| tracks.contains(folder)) else { continue };
|
||||
let parent = surviving_parent(s, &tracks, folder);
|
||||
let mut parent = s.track(folder).and_then(|folder| folder.folder);
|
||||
for _ in 0..64 {
|
||||
match parent {
|
||||
Some(candidate) if tracks.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
promoted.push((child.id, folder_bus(s, folder), parent, parent.and_then(|parent| folder_bus(s, parent))));
|
||||
}
|
||||
for (child, old_bus, parent, parent_bus) in promoted {
|
||||
@@ -701,7 +720,19 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
}
|
||||
for (bus, replacement) in removed_busses {
|
||||
s.busses.retain(|candidate| candidate.id != bus);
|
||||
reroute_deleted_bus(s, bus, &replacement);
|
||||
for track in &mut s.tracks {
|
||||
if track.mixer.input == Route::Bus(bus) {
|
||||
track.mixer.input = Route::None;
|
||||
}
|
||||
if track.mixer.output == Route::Bus(bus) {
|
||||
track.mixer.output = replacement.clone();
|
||||
}
|
||||
for send in &mut track.mixer.sends {
|
||||
if send.as_ref().is_some_and(|send| send.target == Route::Bus(bus)) {
|
||||
*send = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"deleted": tracks.len()}))
|
||||
}
|
||||
@@ -709,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);
|
||||
}
|
||||
|
||||
+119
-4
@@ -407,6 +407,11 @@ pub fn trim_end_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
n
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -420,11 +425,15 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
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.saturating_add(prev.length).saturating_add(gap) == *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 {
|
||||
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;
|
||||
@@ -437,6 +446,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 {
|
||||
@@ -818,8 +845,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;
|
||||
}
|
||||
@@ -827,11 +856,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);
|
||||
@@ -931,6 +961,72 @@ mod tests {
|
||||
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();
|
||||
@@ -1108,4 +1204,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));
|
||||
}
|
||||
}
|
||||
|
||||
+177
-23
@@ -117,6 +117,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 {
|
||||
@@ -127,14 +132,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
|
||||
}
|
||||
|
||||
@@ -147,20 +152,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);
|
||||
}
|
||||
@@ -832,12 +886,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();
|
||||
@@ -1211,9 +1280,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.
|
||||
@@ -1242,6 +1317,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}"));
|
||||
@@ -1249,6 +1329,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);
|
||||
@@ -1260,6 +1354,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());
|
||||
@@ -1268,6 +1363,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();
|
||||
@@ -1296,7 +1392,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")))?,
|
||||
@@ -1344,11 +1440,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);
|
||||
@@ -1360,7 +1456,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();
|
||||
@@ -1756,7 +1852,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);
|
||||
}
|
||||
@@ -2107,6 +2203,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)]
|
||||
@@ -2175,7 +2314,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()));
|
||||
@@ -2195,6 +2334,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();
|
||||
|
||||
@@ -110,6 +110,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 +142,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 +174,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
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+180
-181
@@ -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);
|
||||
}
|
||||
|
||||
@@ -231,84 +236,6 @@ fn close_note(open: &mut OpenNotes, notes: &mut Vec<Note>, channel: u8, pitch: u
|
||||
notes.push(Note { pitch, velocity, release_velocity: release, channel, start, length });
|
||||
}
|
||||
|
||||
fn data_byte(cur: &mut Cursor<'_>, event_at: usize) -> Result<u8, MidiError> {
|
||||
let d = cur.u8()?;
|
||||
if d & 0x80 != 0 {
|
||||
return Err(MidiError::Invalid { offset: event_at, reason: "status byte where a data byte was expected" });
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Apply a meta event (other than End of Track) to the track name and the file-wide maps.
|
||||
fn apply_meta(kind: u8, data: &[u8], tick: i64, name: &mut Option<String>, g: &mut Globals) {
|
||||
match kind {
|
||||
0x03 if name.is_none() => {
|
||||
*name = Some(String::from_utf8_lossy(data).into_owned());
|
||||
}
|
||||
0x06 => g.markers.push((tick, String::from_utf8_lossy(data).into_owned())),
|
||||
0x51 => {
|
||||
if let [a, b, c] = data {
|
||||
let us = (u32::from(*a) << 16) | (u32::from(*b) << 8) | u32::from(*c);
|
||||
if us > 0 {
|
||||
g.tempos.push((tick, 60_000_000.0 / f64::from(us)));
|
||||
}
|
||||
}
|
||||
}
|
||||
0x58 => {
|
||||
if let [nn, dd, ..] = data
|
||||
&& *nn > 0
|
||||
&& *dd <= 7
|
||||
{
|
||||
g.meters.push((tick, *nn, 1u8 << *dd));
|
||||
}
|
||||
}
|
||||
0x59 => {
|
||||
if let [sf, mi, ..] = data {
|
||||
let sf = *sf as i8;
|
||||
if (-7..=7).contains(&sf) {
|
||||
g.key_sigs.push((tick, sf, *mi == 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a channel voice event `(status, d1, d2)` at `tick`.
|
||||
fn channel_event(open: &mut OpenNotes, seq: &mut Sequence, (status, d1, d2): (u8, u8, u8), tick: i64) {
|
||||
let channel = status & 0x0F;
|
||||
match status & 0xF0 {
|
||||
0x80 => close_note(open, &mut seq.notes, channel, d1, tick, d2),
|
||||
0x90 if d2 == 0 => close_note(open, &mut seq.notes, channel, d1, tick, DEFAULT_RELEASE),
|
||||
0x90 => {
|
||||
if let Some(stack) = open.get_mut(usize::from(channel) * 128 + usize::from(d1)) {
|
||||
stack.push_back((tick, d2));
|
||||
}
|
||||
}
|
||||
0xA0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::PolyPressure(d1), value: i32::from(d2) }),
|
||||
0xB0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::Cc(d1), value: i32::from(d2) }),
|
||||
0xC0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::Program, value: i32::from(d1) }),
|
||||
0xD0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::ChannelPressure, value: i32::from(d1) }),
|
||||
_ => {
|
||||
// 0xE0 pitch bend: LSB then MSB, centred on 8192.
|
||||
let v = (i32::from(d2) << 7 | i32::from(d1)) - 8192;
|
||||
seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::PitchBend, value: v });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close hanging notes at the end of the track.
|
||||
fn close_hanging(open: &mut OpenNotes, seq: &mut Sequence, tick: i64) {
|
||||
for (key, stack) in open.iter_mut().enumerate() {
|
||||
let channel = u8::try_from(key / 128).unwrap_or(0);
|
||||
let pitch = u8::try_from(key % 128).unwrap_or(0);
|
||||
for (start, velocity) in stack.drain(..) {
|
||||
let length = tick.saturating_sub(start).max(1);
|
||||
seq.notes.push(Note { pitch, velocity, release_velocity: DEFAULT_RELEASE, channel, start, length });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_track(body: &[u8], base: usize, conv: TickConv, g: &mut Globals, budget: &mut usize) -> Result<SmfTrack, MidiError> {
|
||||
let mut cur = Cursor { data: body, pos: 0, base };
|
||||
let mut name: Option<String> = None;
|
||||
@@ -343,10 +270,38 @@ fn read_track(body: &[u8], base: usize, conv: TickConv, g: &mut Globals, budget:
|
||||
let kind = cur.u8()?;
|
||||
let len = cur.vlq()?;
|
||||
let data = cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?;
|
||||
if kind == 0x2F {
|
||||
break; // End of Track
|
||||
match kind {
|
||||
0x2F => break, // End of Track
|
||||
0x03 if name.is_none() => {
|
||||
name = Some(String::from_utf8_lossy(data).into_owned());
|
||||
}
|
||||
0x06 => g.markers.push((tick, String::from_utf8_lossy(data).into_owned())),
|
||||
0x51 => {
|
||||
if let [a, b, c] = data {
|
||||
let us = (u32::from(*a) << 16) | (u32::from(*b) << 8) | u32::from(*c);
|
||||
if us > 0 {
|
||||
g.tempos.push((tick, 60_000_000.0 / f64::from(us)));
|
||||
}
|
||||
}
|
||||
}
|
||||
0x58 => {
|
||||
if let [nn, dd, ..] = data
|
||||
&& *nn > 0
|
||||
&& *dd <= 7
|
||||
{
|
||||
g.meters.push((tick, *nn, 1u8 << *dd));
|
||||
}
|
||||
}
|
||||
0x59 => {
|
||||
if let [sf, mi, ..] = data {
|
||||
let sf = *sf as i8;
|
||||
if (-7..=7).contains(&sf) {
|
||||
g.key_sigs.push((tick, sf, *mi == 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
apply_meta(kind, data, tick, &mut name, g);
|
||||
}
|
||||
0xF0 | 0xF7 => {
|
||||
running = None;
|
||||
@@ -355,16 +310,53 @@ fn read_track(body: &[u8], base: usize, conv: TickConv, g: &mut Globals, budget:
|
||||
}
|
||||
0x80..=0xEF => {
|
||||
running = Some(status);
|
||||
let d1 = data_byte(&mut cur, event_at)?;
|
||||
let channel = status & 0x0F;
|
||||
let d1 = cur.u8()?;
|
||||
if d1 & 0x80 != 0 {
|
||||
return Err(MidiError::Invalid { offset: event_at, reason: "status byte where a data byte was expected" });
|
||||
}
|
||||
let needs_two = !matches!(status & 0xF0, 0xC0 | 0xD0);
|
||||
let d2 = if needs_two { data_byte(&mut cur, event_at)? } else { 0 };
|
||||
channel_event(&mut open, &mut seq, (status, d1, d2), tick);
|
||||
let d2 = if needs_two {
|
||||
let d = cur.u8()?;
|
||||
if d & 0x80 != 0 {
|
||||
return Err(MidiError::Invalid { offset: event_at, reason: "status byte where a data byte was expected" });
|
||||
}
|
||||
d
|
||||
} else {
|
||||
0
|
||||
};
|
||||
match status & 0xF0 {
|
||||
0x80 => close_note(&mut open, &mut seq.notes, channel, d1, tick, d2),
|
||||
0x90 if d2 == 0 => close_note(&mut open, &mut seq.notes, channel, d1, tick, DEFAULT_RELEASE),
|
||||
0x90 => {
|
||||
if let Some(stack) = open.get_mut(usize::from(channel) * 128 + usize::from(d1)) {
|
||||
stack.push_back((tick, d2));
|
||||
}
|
||||
}
|
||||
0xA0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::PolyPressure(d1), value: i32::from(d2) }),
|
||||
0xB0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::Cc(d1), value: i32::from(d2) }),
|
||||
0xC0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::Program, value: i32::from(d1) }),
|
||||
0xD0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::ChannelPressure, value: i32::from(d1) }),
|
||||
_ => {
|
||||
// 0xE0 pitch bend: LSB then MSB, centred on 8192.
|
||||
let v = (i32::from(d2) << 7 | i32::from(d1)) - 8192;
|
||||
seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::PitchBend, value: v });
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(MidiError::Invalid { offset: event_at, reason: "system real-time/common status in a track" }),
|
||||
}
|
||||
}
|
||||
|
||||
close_hanging(&mut open, &mut seq, tick);
|
||||
// Close hanging notes at the end of the track.
|
||||
for (key, stack) in open.iter_mut().enumerate() {
|
||||
let channel = u8::try_from(key / 128).unwrap_or(0);
|
||||
let pitch = u8::try_from(key % 128).unwrap_or(0);
|
||||
for (start, velocity) in stack.drain(..) {
|
||||
let length = tick.saturating_sub(start).max(1);
|
||||
seq.notes.push(Note { pitch, velocity, release_velocity: DEFAULT_RELEASE, channel, start, length });
|
||||
}
|
||||
}
|
||||
seq.sort();
|
||||
Ok(SmfTrack { name: name.unwrap_or_default(), sequence: seq })
|
||||
}
|
||||
@@ -447,8 +439,30 @@ fn write_track(out: &mut Vec<u8>, mut events: Vec<WEvent>) -> Result<(), MidiErr
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The conductor track: tempos, meters, key signatures and markers.
|
||||
fn conductor_events(smf: &Smf) -> Result<Vec<WEvent>, MidiError> {
|
||||
/// Writes a format-1 Standard MIDI File at [`PPQ`] (960) ticks per quarter note.
|
||||
///
|
||||
/// Track 1 is a conductor track holding tempos, meters, key signatures and markers; every
|
||||
/// [`SmfTrack`] follows as its own track. Negative ticks are clamped to 0, data values are
|
||||
/// clamped to their MIDI ranges, and note-on velocity 0 is written as 1 (0 would mean
|
||||
/// note-off). At each tick note-offs are written before controllers, then note-ons.
|
||||
///
|
||||
/// 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> {
|
||||
// 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());
|
||||
out.extend_from_slice(&1u16.to_be_bytes());
|
||||
out.extend_from_slice(&ntrks.to_be_bytes());
|
||||
out.extend_from_slice(&u16::try_from(PPQ).unwrap_or(960).to_be_bytes());
|
||||
|
||||
// Conductor track.
|
||||
let mut ev = Vec::new();
|
||||
for &(t, bpm) in &smf.tempos {
|
||||
if !bpm.is_finite() || bpm <= 0.0 {
|
||||
@@ -472,105 +486,77 @@ fn conductor_events(smf: &Smf) -> Result<Vec<WEvent>, MidiError> {
|
||||
for (t, text) in &smf.markers {
|
||||
ev.push(WEvent { tick: clamp_tick(*t), order: 3, bytes: meta(0x06, text.as_bytes())? });
|
||||
}
|
||||
Ok(ev)
|
||||
}
|
||||
if has_conductor {
|
||||
write_track(&mut out, ev)?;
|
||||
}
|
||||
|
||||
/// Trim notes still sounding when the next same-pitch note starts, and drop duplicates.
|
||||
fn trim_overlaps(notes: &[Note], ends: &mut [u64], dropped: &mut [bool]) {
|
||||
// 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;
|
||||
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())? });
|
||||
}
|
||||
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;
|
||||
}
|
||||
if let Some(ea) = ends.get_mut(a)
|
||||
&& sb < *ea
|
||||
{
|
||||
*ea = sb;
|
||||
}
|
||||
let ch = n.channel & 0x0F;
|
||||
let pitch = n.pitch.min(127);
|
||||
let start = clamp_tick(n.start);
|
||||
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)] });
|
||||
}
|
||||
prev = Some(b);
|
||||
}
|
||||
}
|
||||
|
||||
/// One track's events: its name, notes (with overlap trimming) and controllers.
|
||||
fn track_events(track: &SmfTrack) -> Result<Vec<WEvent>, MidiError> {
|
||||
let mut ev = Vec::new();
|
||||
if !track.name.is_empty() {
|
||||
ev.push(WEvent { tick: 0, order: 0, bytes: meta(0x03, track.name.as_bytes())? });
|
||||
}
|
||||
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()];
|
||||
trim_overlaps(notes, &mut ends, &mut dropped);
|
||||
for (i, n) in notes.iter().enumerate() {
|
||||
if dropped.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
for c in &track.sequence.ctrls {
|
||||
let ch = c.channel & 0x0F;
|
||||
let v7 = c.value.clamp(0, 127) as u8;
|
||||
let bytes = match c.kind {
|
||||
CtrlKind::Cc(num) => vec![0xB0 | ch, num.min(127), v7],
|
||||
CtrlKind::Program => vec![0xC0 | ch, v7],
|
||||
CtrlKind::ChannelPressure => vec![0xD0 | ch, v7],
|
||||
CtrlKind::PolyPressure(p) => vec![0xA0 | ch, p.min(127), v7],
|
||||
CtrlKind::PitchBend => {
|
||||
let v = (c.value.saturating_add(8192)).clamp(0, 16383);
|
||||
vec![0xE0 | ch, (v & 0x7F) as u8, ((v >> 7) & 0x7F) as u8]
|
||||
}
|
||||
};
|
||||
ev.push(WEvent { tick: clamp_tick(c.tick), order: 2, bytes });
|
||||
}
|
||||
let ch = n.channel & 0x0F;
|
||||
let pitch = n.pitch.min(127);
|
||||
let start = clamp_tick(n.start);
|
||||
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)] });
|
||||
}
|
||||
for c in &track.sequence.ctrls {
|
||||
let ch = c.channel & 0x0F;
|
||||
let v7 = c.value.clamp(0, 127) as u8;
|
||||
let bytes = match c.kind {
|
||||
CtrlKind::Cc(num) => vec![0xB0 | ch, num.min(127), v7],
|
||||
CtrlKind::Program => vec![0xC0 | ch, v7],
|
||||
CtrlKind::ChannelPressure => vec![0xD0 | ch, v7],
|
||||
CtrlKind::PolyPressure(p) => vec![0xA0 | ch, p.min(127), v7],
|
||||
CtrlKind::PitchBend => {
|
||||
let v = (c.value.saturating_add(8192)).clamp(0, 16383);
|
||||
vec![0xE0 | ch, (v & 0x7F) as u8, ((v >> 7) & 0x7F) as u8]
|
||||
}
|
||||
};
|
||||
ev.push(WEvent { tick: clamp_tick(c.tick), order: 2, bytes });
|
||||
}
|
||||
Ok(ev)
|
||||
}
|
||||
|
||||
/// Writes a format-1 Standard MIDI File at [`PPQ`] (960) ticks per quarter note.
|
||||
///
|
||||
/// Track 1 is a conductor track holding tempos, meters, key signatures and markers; every
|
||||
/// [`SmfTrack`] follows as its own track. Negative ticks are clamped to 0, data values are
|
||||
/// clamped to their MIDI ranges, and note-on velocity 0 is written as 1 (0 would mean
|
||||
/// note-off). At each tick note-offs are written before controllers, then note-ons.
|
||||
///
|
||||
/// 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()))?;
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(b"MThd");
|
||||
out.extend_from_slice(&6u32.to_be_bytes());
|
||||
out.extend_from_slice(&1u16.to_be_bytes());
|
||||
out.extend_from_slice(&ntrks.to_be_bytes());
|
||||
out.extend_from_slice(&u16::try_from(PPQ).unwrap_or(960).to_be_bytes());
|
||||
|
||||
write_track(&mut out, conductor_events(smf)?)?;
|
||||
for track in &smf.tracks {
|
||||
write_track(&mut out, track_events(track)?)?;
|
||||
write_track(&mut out, ev)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -591,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();
|
||||
|
||||
+196
-20
@@ -1070,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()) {
|
||||
@@ -1113,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
|
||||
@@ -1342,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()
|
||||
@@ -1374,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 };
|
||||
@@ -1480,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;
|
||||
@@ -2258,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))
|
||||
@@ -2310,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};
|
||||
@@ -2432,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] {
|
||||
@@ -3009,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);
|
||||
}
|
||||
}
|
||||
@@ -242,6 +242,11 @@ 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) {
|
||||
// #907: these are public APIs, so they have to survive arguments a command would never
|
||||
@@ -251,8 +256,16 @@ impl Clip {
|
||||
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);
|
||||
if let ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } = &mut self.content {
|
||||
*offset = offset.saturating_add(delta);
|
||||
// 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 = self.length.saturating_sub(delta).clamp(1, MAX_POSITION);
|
||||
@@ -506,4 +519,21 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,6 +541,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)
|
||||
}
|
||||
|
||||
@@ -633,6 +634,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 +668,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 +712,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 +814,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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
+25
-31
@@ -366,40 +366,34 @@ impl AudioState {
|
||||
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
|
||||
};
|
||||
write_frame(frame, channels, nch, sounding, &self.fold, sample);
|
||||
self.read += step;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write one device frame from `nch` rendered channels: fold surround to stereo (a mono device
|
||||
/// averages the two sides), or copy the channels straight through. `sample(c)` is channel `c`.
|
||||
fn write_frame(frame: &mut [f32], channels: usize, nch: usize, sounding: bool, fold: &[(f32, f32)], sample: impl Fn(usize) -> f32) {
|
||||
if channels < nch {
|
||||
// Fold surround to stereo first; a mono device averages the two sides.
|
||||
let (mut l, mut r) = (0.0f32, 0.0f32);
|
||||
if sounding {
|
||||
if nch == 2 {
|
||||
(l, r) = (sample(0), sample(1));
|
||||
if channels < nch {
|
||||
// Fold surround to stereo first; a mono device averages the two sides.
|
||||
let (mut l, mut r) = (0.0f32, 0.0f32);
|
||||
if sounding {
|
||||
if nch == 2 {
|
||||
(l, r) = (sample(0), sample(1));
|
||||
} else {
|
||||
for (c, (gl, gr)) in self.fold.iter().enumerate().take(nch) {
|
||||
let v = sample(c);
|
||||
l += v * gl;
|
||||
r += v * gr;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (c, o) in frame.iter_mut().enumerate() {
|
||||
*o = match c {
|
||||
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, (gl, gr)) in fold.iter().enumerate().take(nch) {
|
||||
let v = sample(c);
|
||||
l += v * gl;
|
||||
r += v * gr;
|
||||
for (c, o) in frame.iter_mut().enumerate() {
|
||||
*o = if c < nch.max(1) && sounding { out_sample(sample(c)) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
for (c, o) in frame.iter_mut().enumerate() {
|
||||
*o = match c {
|
||||
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 { out_sample(sample(c)) } else { 0.0 };
|
||||
self.read += step;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,7 +12,7 @@ use soundcraft_time::Samples;
|
||||
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 },
|
||||
@@ -113,7 +113,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() },
|
||||
@@ -307,6 +313,7 @@ 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;
|
||||
@@ -370,8 +377,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 +405,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 +435,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 +449,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;
|
||||
}
|
||||
@@ -666,12 +680,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
|
||||
|
||||
+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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2327,8 +2327,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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -14,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;
|
||||
@@ -132,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,
|
||||
@@ -336,6 +337,8 @@ 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)]
|
||||
@@ -437,6 +440,7 @@ impl SoundApp {
|
||||
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(),
|
||||
}
|
||||
@@ -455,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);
|
||||
}
|
||||
@@ -1231,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;
|
||||
@@ -1443,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());
|
||||
|
||||
@@ -131,7 +131,7 @@ pub const AUDIOSUITE: &[(&str, &str)] = &[
|
||||
("AudioSuite > Pitch Shift > Vari-Fi", "varispeed"),
|
||||
("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"),
|
||||
@@ -504,7 +504,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));
|
||||
@@ -591,7 +591,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();
|
||||
@@ -687,7 +687,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);
|
||||
@@ -772,4 +772,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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ 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;
|
||||
@@ -43,11 +44,13 @@ mod send_window;
|
||||
mod shared_controls;
|
||||
mod shortcuts;
|
||||
mod side_lists_access;
|
||||
mod side_lists_text;
|
||||
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,57 @@
|
||||
//! #990: track and group names in the side lists are clipped to their row, so a long name cannot
|
||||
//! paint across the scrollbar gutter and over the panel's edge.
|
||||
|
||||
use egui::{Shape, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
|
||||
fn harness() -> Harness<'static, SoundApp> {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let first = app.engine.session().tracks.first().map(|t| t.id.0).unwrap_or_default();
|
||||
app.run("track.rename", json!({"track": first, "name": LONG_TRACK})).unwrap();
|
||||
app.run("track.group", json!({"name": LONG_GROUP, "tracks": [first]})).unwrap();
|
||||
app.engine.session_mut().edit.selected_tracks.clear();
|
||||
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
|
||||
}
|
||||
|
||||
const LONG_TRACK: &str = "24H2026_27_JANVIER_SESSION_AUDIO_BED_A";
|
||||
const LONG_GROUP: &str = "Rhythm section drums bass and percussion group";
|
||||
|
||||
/// The name's paint must not be clipped any further right than its row: a wider clip lets the text
|
||||
/// run over the lists' scrollbar gutter and across the panel's edge. Only the paint inside the row
|
||||
/// counts — the name is drawn in its own clipped column in the edit window's headers, too.
|
||||
fn name_is_clipped_to_its_row(h: &Harness<'_, SoundApp>, name: &str) {
|
||||
let row = h.get_by_label(name).rect();
|
||||
let painted = h.output().shapes.iter().filter_map(|s| match &s.shape {
|
||||
Shape::Text(t) if t.galley.text().contains(name) && s.clip_rect.intersects(row) => Some(s.clip_rect),
|
||||
_ => None,
|
||||
});
|
||||
let mut count = 0;
|
||||
for clip in painted {
|
||||
count += 1;
|
||||
assert!(clip.right() <= row.right() + 0.5, "the name's paint is clipped at x {}, past its row's right edge {}", clip.right(), row.right());
|
||||
}
|
||||
assert!(count > 0, "no painted text found for {name:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_track_name_stays_inside_its_row() {
|
||||
let h = harness();
|
||||
name_is_clipped_to_its_row(&h, LONG_TRACK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_group_name_stays_inside_its_row() {
|
||||
let h = harness();
|
||||
name_is_clipped_to_its_row(&h, LONG_GROUP);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Track presets capture the hosted plugin's current state before writing the model.
|
||||
|
||||
#[path = "../../../clap-host/tests/common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_model::{ChannelFormat, Insert, TrackKind, b64};
|
||||
use soundcraft_playback::Player;
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
|
||||
#[test]
|
||||
fn track_preset_captures_live_plugin_state_before_serializing() {
|
||||
common::fixture_dir();
|
||||
let mut engine = Engine::default();
|
||||
let track = engine.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Vox"));
|
||||
engine.session_mut().track_mut(track).unwrap().mixer.inserts[0] = Some(Insert::new("clap:org.soundcraft.test.gain"));
|
||||
let player = Player::new(engine.session_arc());
|
||||
let states = player.capture_states();
|
||||
assert_eq!(states.len(), 1, "the synthetic CLAP plugin must load even without an audio device");
|
||||
assert_eq!((states[0].0, states[0].1), (track, 0));
|
||||
let live = states[0].2.clone();
|
||||
|
||||
// The model still holds the earlier capture while the live plugin has its current settings.
|
||||
let stale: Vec<u8> = [0.25_f64, 0.0].into_iter().flat_map(f64::to_le_bytes).collect();
|
||||
assert_ne!(live, stale);
|
||||
engine.session_mut().track_mut(track).unwrap().mixer.inserts[0].as_mut().unwrap().set_state_bytes(&stale);
|
||||
let mut app = SoundApp::new(engine, Some(player), Services::default());
|
||||
assert_eq!(app.engine.session().track(track).unwrap().mixer.inserts[0].as_ref().unwrap().state_bytes(), Some(stale));
|
||||
|
||||
let path = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("track-preset-state-{}.json", std::process::id()));
|
||||
app.run("track.save_preset", json!({"track": track.0, "path": path.to_string_lossy()})).unwrap();
|
||||
let preset: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
assert_eq!(preset["mixer"]["inserts"][0]["state"], b64::encode(&live), "the preset must contain the current hosted state");
|
||||
}
|
||||
@@ -36,7 +36,7 @@ fn shared_panels_and_controls_follow_manual_and_live_system_appearance() {
|
||||
assert_eq!(theme::is_light(), light);
|
||||
assert_eq!(h.state().ui.theme, mode);
|
||||
assert_eq!(*h.state().engine.session(), session);
|
||||
h.state().ui.docking.validate().unwrap();
|
||||
soundcraft_ui_egui::docking::validate(&h.state().ui.docking).unwrap();
|
||||
if let Some(directory) = std::env::var_os("CRAFT_UI_THEME_SHOTS").map(std::path::PathBuf::from) {
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
h.render().unwrap().save(directory.join(format!("sound-{name}-{width_name}-{scale}x.png"))).unwrap();
|
||||
|
||||
+260
-5
@@ -646,13 +646,32 @@ impl IHostApplicationTrait for HostContext {
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn createInstance(&self, _cid: *mut sb::TUID, _iid: *mut sb::TUID, obj: *mut *mut c_void) -> sb::tresult {
|
||||
// IMessage / IAttributeList are not provided yet; plugins must cope with that.
|
||||
/// Creates the objects plugins may ask the host for: `IMessage` (what a controller and its
|
||||
/// processor send each other through `IConnectionPoint::notify`) and `IAttributeList`.
|
||||
/// Plugins that talk between their halves this way fail without them (Line 6 Helix
|
||||
/// reported "Failed to get content database recovery info (Out of memory condition)").
|
||||
unsafe fn createInstance(&self, cid: *mut sb::TUID, iid: *mut sb::TUID, obj: *mut *mut c_void) -> sb::tresult {
|
||||
// SAFETY: `obj` is the plugin's out-pointer (or null, handled).
|
||||
if let Some(o) = unsafe { obj.as_mut() } {
|
||||
*o = std::ptr::null_mut();
|
||||
let Some(out) = (unsafe { obj.as_mut() }) else { return sb::kInvalidArgument };
|
||||
*out = std::ptr::null_mut();
|
||||
// SAFETY: `cid` and `iid` point to 16-byte TUIDs (or are null, handled).
|
||||
let (Some(cid), Some(iid)) = (unsafe { cid.as_ref() }, unsafe { iid.as_ref() }) else { return sb::kInvalidArgument };
|
||||
let (cid, iid) = (tuid_bytes(cid), tuid_bytes(iid));
|
||||
let wants = |class: [u8; 16]| cid == class && (iid == class || iid == FUnknown::IID);
|
||||
let made = if wants(sv::IMessage::IID) {
|
||||
ComWrapper::new(HostMessage::default()).to_com_ptr::<sv::IMessage>().map(|p| p.into_raw().cast::<c_void>())
|
||||
} else if wants(sv::IAttributeList::IID) {
|
||||
ComWrapper::new(HostAttributes::default()).to_com_ptr::<sv::IAttributeList>().map(|p| p.into_raw().cast::<c_void>())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match made {
|
||||
Some(p) => {
|
||||
*out = p;
|
||||
sb::kResultOk
|
||||
}
|
||||
None => sb::kResultFalse,
|
||||
}
|
||||
sb::kResultFalse
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,6 +703,191 @@ impl IComponentHandlerTrait for HostContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest string attribute kept, in UTF-16 units (longer ones are cut).
|
||||
const ATTR_STRING_MAX: usize = 1 << 20;
|
||||
/// Largest binary attribute kept.
|
||||
const ATTR_BINARY_MAX: usize = 256 << 20;
|
||||
|
||||
/// One `IAttributeList` value. Strings and blobs live in their own allocation, so a pointer
|
||||
/// handed out by `getBinary` stays valid while the list holds the value.
|
||||
enum Attr {
|
||||
Int(i64),
|
||||
Float(f64),
|
||||
Str(Box<[u16]>),
|
||||
Bin(Box<[u8]>),
|
||||
}
|
||||
|
||||
/// The host's `IAttributeList`: what a plugin puts in an `IMessage`.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct HostAttributes {
|
||||
values: Mutex<std::collections::HashMap<Vec<u8>, Attr>>,
|
||||
}
|
||||
|
||||
impl Class for HostAttributes {
|
||||
type Interfaces = (sv::IAttributeList,);
|
||||
}
|
||||
|
||||
/// An attribute id's bytes (`None` for a null id).
|
||||
fn attr_key(id: sv::IAttributeList_::AttrID) -> Option<Vec<u8>> {
|
||||
// SAFETY: a non-null AttrID is a NUL-terminated C string owned by the caller.
|
||||
(!id.is_null()).then(|| unsafe { std::ffi::CStr::from_ptr(id) }.to_bytes().to_vec())
|
||||
}
|
||||
|
||||
impl HostAttributes {
|
||||
fn values(&self) -> std::sync::MutexGuard<'_, std::collections::HashMap<Vec<u8>, Attr>> {
|
||||
self.values.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn set(&self, id: sv::IAttributeList_::AttrID, v: Attr) -> sb::tresult {
|
||||
let Some(k) = attr_key(id) else { return sb::kInvalidArgument };
|
||||
self.values().insert(k, v);
|
||||
sb::kResultOk
|
||||
}
|
||||
}
|
||||
|
||||
impl sv::IAttributeListTrait for HostAttributes {
|
||||
unsafe fn setInt(&self, id: sv::IAttributeList_::AttrID, value: sb::int64) -> sb::tresult {
|
||||
self.set(id, Attr::Int(value))
|
||||
}
|
||||
|
||||
unsafe fn getInt(&self, id: sv::IAttributeList_::AttrID, value: *mut sb::int64) -> sb::tresult {
|
||||
// SAFETY: `value` is the caller's out-pointer (or null, handled).
|
||||
match (
|
||||
attr_key(id).and_then(|k| match self.values().get(&k) {
|
||||
Some(Attr::Int(v)) => Some(*v),
|
||||
_ => None,
|
||||
}),
|
||||
unsafe { value.as_mut() },
|
||||
) {
|
||||
(Some(v), Some(out)) => {
|
||||
*out = v;
|
||||
sb::kResultOk
|
||||
}
|
||||
_ => sb::kResultFalse,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn setFloat(&self, id: sv::IAttributeList_::AttrID, value: f64) -> sb::tresult {
|
||||
self.set(id, Attr::Float(value))
|
||||
}
|
||||
|
||||
unsafe fn getFloat(&self, id: sv::IAttributeList_::AttrID, value: *mut f64) -> sb::tresult {
|
||||
// SAFETY: `value` is the caller's out-pointer (or null, handled).
|
||||
match (
|
||||
attr_key(id).and_then(|k| match self.values().get(&k) {
|
||||
Some(Attr::Float(v)) => Some(*v),
|
||||
_ => None,
|
||||
}),
|
||||
unsafe { value.as_mut() },
|
||||
) {
|
||||
(Some(v), Some(out)) => {
|
||||
*out = v;
|
||||
sb::kResultOk
|
||||
}
|
||||
_ => sb::kResultFalse,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn setString(&self, id: sv::IAttributeList_::AttrID, string: *const sv::TChar) -> sb::tresult {
|
||||
if string.is_null() {
|
||||
return sb::kInvalidArgument;
|
||||
}
|
||||
let mut s = Vec::new();
|
||||
// SAFETY: a non-null string is NUL-terminated UTF-16 owned by the caller; reading stops at
|
||||
// the NUL or at ATTR_STRING_MAX units.
|
||||
while s.len() < ATTR_STRING_MAX {
|
||||
let c = unsafe { *string.add(s.len()) };
|
||||
if c == 0 {
|
||||
break;
|
||||
}
|
||||
s.push(c);
|
||||
}
|
||||
self.set(id, Attr::Str(s.into_boxed_slice()))
|
||||
}
|
||||
|
||||
unsafe fn getString(&self, id: sv::IAttributeList_::AttrID, string: *mut sv::TChar, size_in_bytes: sb::uint32) -> sb::tresult {
|
||||
let units = size_in_bytes as usize / 2;
|
||||
if string.is_null() || units == 0 {
|
||||
return sb::kInvalidArgument;
|
||||
}
|
||||
let Some(k) = attr_key(id) else { return sb::kInvalidArgument };
|
||||
let values = self.values();
|
||||
let Some(Attr::Str(s)) = values.get(&k) else { return sb::kResultFalse };
|
||||
let n = s.len().min(units - 1);
|
||||
// SAFETY: the caller's buffer holds `units` UTF-16 units; `n + 1 <= units` are written.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(s.as_ptr(), string, n);
|
||||
*string.add(n) = 0;
|
||||
}
|
||||
sb::kResultOk
|
||||
}
|
||||
|
||||
unsafe fn setBinary(&self, id: sv::IAttributeList_::AttrID, data: *const c_void, size_in_bytes: sb::uint32) -> sb::tresult {
|
||||
let n = size_in_bytes as usize;
|
||||
if n > ATTR_BINARY_MAX {
|
||||
return sb::kOutOfMemory;
|
||||
}
|
||||
let bytes: Box<[u8]> = if n == 0 || data.is_null() {
|
||||
Box::new([])
|
||||
} else {
|
||||
// SAFETY: the caller's `data` holds `n` readable bytes.
|
||||
unsafe { std::slice::from_raw_parts(data.cast::<u8>(), n) }.into()
|
||||
};
|
||||
self.set(id, Attr::Bin(bytes))
|
||||
}
|
||||
|
||||
unsafe fn getBinary(&self, id: sv::IAttributeList_::AttrID, data: *mut *const c_void, size_in_bytes: *mut sb::uint32) -> sb::tresult {
|
||||
let Some(k) = attr_key(id) else { return sb::kInvalidArgument };
|
||||
let values = self.values();
|
||||
let Some(Attr::Bin(b)) = values.get(&k) else { return sb::kResultFalse };
|
||||
// SAFETY: both are the caller's out-pointers (or null, handled). The pointer handed out
|
||||
// stays valid until this attribute is replaced or the list is released (VST3 contract).
|
||||
match (unsafe { data.as_mut() }, unsafe { size_in_bytes.as_mut() }) {
|
||||
(Some(d), Some(sz)) => {
|
||||
*d = b.as_ptr().cast::<c_void>();
|
||||
*sz = u32::try_from(b.len()).unwrap_or(u32::MAX);
|
||||
sb::kResultOk
|
||||
}
|
||||
_ => sb::kInvalidArgument,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The host's `IMessage`: an id and an attribute list, created for a plugin through
|
||||
/// `IHostApplication::createInstance`.
|
||||
pub(crate) struct HostMessage {
|
||||
id: Mutex<std::ffi::CString>,
|
||||
attributes: ComWrapper<HostAttributes>,
|
||||
}
|
||||
|
||||
impl Default for HostMessage {
|
||||
fn default() -> Self {
|
||||
HostMessage { id: Mutex::new(std::ffi::CString::default()), attributes: ComWrapper::new(HostAttributes::default()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Class for HostMessage {
|
||||
type Interfaces = (sv::IMessage,);
|
||||
}
|
||||
|
||||
impl sv::IMessageTrait for HostMessage {
|
||||
unsafe fn getMessageID(&self) -> sb::FIDString {
|
||||
// The string's buffer lives until the next `setMessageID`, as the interface expects.
|
||||
self.id.lock().unwrap_or_else(PoisonError::into_inner).as_ptr()
|
||||
}
|
||||
|
||||
unsafe fn setMessageID(&self, id: sb::FIDString) {
|
||||
// SAFETY: a non-null id is a NUL-terminated C string owned by the caller.
|
||||
let s = if id.is_null() { std::ffi::CString::default() } else { unsafe { std::ffi::CStr::from_ptr(id) }.to_owned() };
|
||||
*self.id.lock().unwrap_or_else(PoisonError::into_inner) = s;
|
||||
}
|
||||
|
||||
unsafe fn getAttributes(&self) -> *mut sv::IAttributeList {
|
||||
// Not add-ref'ed: the list belongs to the message (VST3 contract).
|
||||
self.attributes.as_com_ref::<sv::IAttributeList>().map_or(std::ptr::null_mut(), |r| r.as_ptr())
|
||||
}
|
||||
}
|
||||
|
||||
/// One parameter's points for one block. SoundCraft sends at most one point per block; a
|
||||
/// plugin writing output changes may add more, of which only the last is kept.
|
||||
#[derive(Default)]
|
||||
@@ -2151,6 +2355,57 @@ mod window {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sv::{IAttributeListTrait as _, IMessageTrait as _};
|
||||
|
||||
fn tuid(iid: [u8; 16]) -> sb::TUID {
|
||||
iid.map(|b| b as c_char)
|
||||
}
|
||||
|
||||
/// Asks the host for an object the way a plugin does.
|
||||
fn create<I: Interface>(host: &ComWrapper<HostContext>, cid: [u8; 16]) -> Option<ComPtr<I>> {
|
||||
let app = host.to_com_ptr::<sv::IHostApplication>()?;
|
||||
let (mut c, mut i) = (tuid(cid), tuid(I::IID));
|
||||
let mut obj: *mut c_void = std::ptr::null_mut();
|
||||
// SAFETY: valid TUIDs and out-pointer, as a plugin passes them.
|
||||
let r = unsafe { app.createInstance(&mut c, &mut i, &mut obj) };
|
||||
// SAFETY: on success `obj` is an owned (add-ref'ed) pointer to `I`.
|
||||
(r == sb::kResultOk).then(|| unsafe { ComPtr::from_raw(obj.cast::<I>()) }).flatten()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_get_messages_and_attribute_lists_from_the_host() {
|
||||
let host = ComWrapper::new(HostContext::default());
|
||||
let msg = create::<sv::IMessage>(&host, sv::IMessage::IID).expect("IMessage");
|
||||
// SAFETY: calls through the interface with valid C strings and buffers.
|
||||
unsafe {
|
||||
msg.setMessageID(c"DatabaseInfo".as_ptr());
|
||||
assert_eq!(std::ffi::CStr::from_ptr(msg.getMessageID()).to_str(), Ok("DatabaseInfo"));
|
||||
let attrs = vst3::ComRef::from_raw(msg.getAttributes()).expect("attributes");
|
||||
assert_eq!(attrs.setInt(c"count".as_ptr(), 42), sb::kResultOk);
|
||||
assert_eq!(attrs.setFloat(c"gain".as_ptr(), 0.5), sb::kResultOk);
|
||||
let text: Vec<u16> = "Helix".encode_utf16().chain([0]).collect();
|
||||
assert_eq!(attrs.setString(c"name".as_ptr(), text.as_ptr()), sb::kResultOk);
|
||||
assert_eq!(attrs.setBinary(c"blob".as_ptr(), [1u8, 2, 3].as_ptr().cast(), 3), sb::kResultOk);
|
||||
|
||||
let (mut i, mut f) = (0i64, 0f64);
|
||||
assert_eq!(attrs.getInt(c"count".as_ptr(), &mut i), sb::kResultOk);
|
||||
assert_eq!(attrs.getFloat(c"gain".as_ptr(), &mut f), sb::kResultOk);
|
||||
assert_eq!((i, f), (42, 0.5));
|
||||
assert_eq!(attrs.getInt(c"gain".as_ptr(), &mut i), sb::kResultFalse, "a float is not an int");
|
||||
assert_eq!(attrs.getInt(c"missing".as_ptr(), &mut i), sb::kResultFalse);
|
||||
// A buffer of 4 units takes 3 characters and the NUL.
|
||||
let mut buf = [0xFFFFu16; 4];
|
||||
assert_eq!(attrs.getString(c"name".as_ptr(), buf.as_mut_ptr(), 8), sb::kResultOk);
|
||||
assert_eq!(buf, ['H' as u16, 'e' as u16, 'l' as u16, 0]);
|
||||
let (mut p, mut n): (*const c_void, u32) = (std::ptr::null(), 0);
|
||||
assert_eq!(attrs.getBinary(c"blob".as_ptr(), &mut p, &mut n), sb::kResultOk);
|
||||
assert_eq!(std::slice::from_raw_parts(p.cast::<u8>(), n as usize), [1, 2, 3]);
|
||||
assert_eq!(attrs.setInt(std::ptr::null(), 1), sb::kInvalidArgument, "a null id is refused");
|
||||
}
|
||||
assert!(create::<sv::IAttributeList>(&host, sv::IAttributeList::IID).is_some());
|
||||
// Anything else is still not provided.
|
||||
assert!(create::<sv::IMessage>(&host, sv::IComponentHandler::IID).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_transport_fills_the_process_context() {
|
||||
|
||||
@@ -169,40 +169,28 @@ impl Vst3Plugin {
|
||||
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
|
||||
/// allocates on the audio thread).
|
||||
fn push_note(&mut self, e: NoteEvent) {
|
||||
if self.notes.len() >= self.notes.capacity() {
|
||||
return;
|
||||
}
|
||||
let pos = self.notes.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
|
||||
self.notes.insert(pos, e);
|
||||
push_sorted(&mut self.notes, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy `n` frames from `start` of the host buffers into the plugin's input `port` (mono sources
|
||||
/// fill every port channel).
|
||||
fn feed_input(inputs: &mut BusBuffers, port: usize, io: &[Vec<f32>], ch: usize, (start, n): (usize, usize)) {
|
||||
for c in 0..inputs.channels(port) {
|
||||
let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n));
|
||||
if let (Some(src), Some(dst)) = (src, inputs.channel_mut(port, c).and_then(|d| d.get_mut(..n))) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
fn push_sorted(notes: &mut Vec<NoteEvent>, e: NoteEvent) {
|
||||
if notes.len() >= notes.capacity() {
|
||||
return;
|
||||
}
|
||||
let pos = notes.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
|
||||
notes.insert(pos, e);
|
||||
}
|
||||
|
||||
/// Copy `n` frames of the plugin's output `port` into the host buffers from `start`; a mono host
|
||||
/// buffer takes the average of a stereo port.
|
||||
fn read_output(outputs: &BusBuffers, port: usize, io: &mut [Vec<f32>], ch: usize, (start, n): (usize, usize)) {
|
||||
let oc = outputs.channels(port);
|
||||
for (c, dst) in io.iter_mut().take(ch).enumerate() {
|
||||
let Some(dst) = dst.get_mut(start..start + n) else { continue };
|
||||
if ch == 1 && oc >= 2 {
|
||||
if let (Some(l), Some(r)) = (outputs.channel(port, 0), outputs.channel(port, 1)) {
|
||||
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
|
||||
*d = 0.5 * (a + b);
|
||||
}
|
||||
}
|
||||
} else if let Some(src) = outputs.channel(port, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
/// Keeps only the pending releases, one per key and moved to the start of the next block, so at
|
||||
/// most 128 remain and `all_notes_off` always has room in the queue.
|
||||
fn keep_releases(notes: &mut Vec<NoteEvent>) {
|
||||
let mut seen = [false; 128];
|
||||
notes.retain(|e| match *e {
|
||||
NoteEvent::Off { key, .. } => seen.get_mut(usize::from(key)).is_some_and(|s| !std::mem::replace(s, true)),
|
||||
_ => false,
|
||||
});
|
||||
for e in notes.iter_mut() {
|
||||
e.set_time(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,8 +231,11 @@ impl Plugin for Vst3Plugin {
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.notes.clear();
|
||||
// Keep pending releases and release whatever is still held, so the processor's voices stop
|
||||
// on the next block instead of sounding on while the host forgets them.
|
||||
keep_releases(&mut self.notes);
|
||||
self.controls.clear();
|
||||
self.all_notes_off();
|
||||
self.held = [false; 128];
|
||||
}
|
||||
|
||||
@@ -299,7 +290,12 @@ impl Plugin for Vst3Plugin {
|
||||
let end = u32::try_from(start + n).unwrap_or(u32::MAX);
|
||||
// Feed the main input bus (mono sources fill every bus channel).
|
||||
if self.has_input {
|
||||
feed_input(&mut self.inputs, 0, io, ch, (start, n));
|
||||
for c in 0..self.inputs.channels(0) {
|
||||
let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n));
|
||||
if let (Some(src), Some(dst)) = (src, self.inputs.channel_mut(0, c).and_then(|d| d.get_mut(..n))) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
let k = self.notes.iter().position(|e| e.time() >= end).unwrap_or(self.notes.len());
|
||||
let offset = u32::try_from(start).unwrap_or(u32::MAX);
|
||||
@@ -320,7 +316,19 @@ impl Plugin for Vst3Plugin {
|
||||
self.position = self.position.saturating_add(i64::try_from(n).unwrap_or(0));
|
||||
match r {
|
||||
Ok(()) if self.has_output => {
|
||||
read_output(&self.outputs, 0, io, ch, (start, n));
|
||||
let oc = self.outputs.channels(0);
|
||||
for (c, dst) in io.iter_mut().take(ch).enumerate() {
|
||||
let Some(dst) = dst.get_mut(start..start + n) else { continue };
|
||||
if ch == 1 && oc >= 2 {
|
||||
if let (Some(l), Some(r)) = (self.outputs.channel(0, 0), self.outputs.channel(0, 1)) {
|
||||
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
|
||||
*d = 0.5 * (a + b);
|
||||
}
|
||||
}
|
||||
} else if let Some(src) = self.outputs.channel(0, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => log::debug!("{}: {e}", self.info.id),
|
||||
_ => {}
|
||||
@@ -529,3 +537,24 @@ impl PluginEditor for Vst3Editor {
|
||||
if units.is_empty() || text.trim_end().to_lowercase().ends_with(&units.to_lowercase()) { Some(text) } else { Some(format!("{text} {units}")) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A queue full of releases still has room to release every held key after a reset.
|
||||
#[test]
|
||||
fn reset_keeps_room_to_release_every_held_key() {
|
||||
let mut notes = Vec::with_capacity(EVENT_CAP);
|
||||
for i in 0..EVENT_CAP {
|
||||
push_sorted(&mut notes, NoteEvent::Off { time: i as u32, key: (i % 4) as u8 });
|
||||
}
|
||||
keep_releases(&mut notes);
|
||||
for key in 0..128u8 {
|
||||
push_sorted(&mut notes, NoteEvent::Off { time: 0, key });
|
||||
}
|
||||
for key in 0..128u8 {
|
||||
assert!(notes.iter().any(|e| matches!(*e, NoteEvent::Off { key: k, .. } if k == key)), "key {key} was never released");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +160,29 @@ fn synth_plays_notes_at_their_offsets() {
|
||||
assert!(mono[0].iter().all(|&x| (x - 1.0).abs() < 1e-6), "the note is still held");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_after_all_notes_off_still_releases_held_voices() {
|
||||
let mut p = soundcraft_vst3_host::create(&id_of("Fixture Synth")).unwrap();
|
||||
p.prepare(48_000.0, 64, 2);
|
||||
p.note_on(0, 60, 127);
|
||||
let mut io = vec![vec![0.0f32; 64], vec![0.0f32; 64]];
|
||||
p.process(&mut io, 64);
|
||||
assert!((io[0][63] - 1.0).abs() < 1e-6, "the note sounds");
|
||||
// Mixer::reset calls these two in a row on an instrument (stop / seek).
|
||||
p.all_notes_off();
|
||||
p.reset();
|
||||
let mut io = vec![vec![9.0f32; 64], vec![9.0f32; 64]];
|
||||
p.process(&mut io, 64);
|
||||
assert!(io[0].iter().all(|x| x.abs() < 1e-6), "voice still sounding after reset: {}", io[0][63]);
|
||||
// A reset alone also releases what the host still holds.
|
||||
p.note_on(0, 61, 127);
|
||||
p.process(&mut io, 64);
|
||||
assert!((io[0][63] - 1.0).abs() < 1e-6);
|
||||
p.reset();
|
||||
p.process(&mut io, 64);
|
||||
assert!(io[0].iter().all(|x| x.abs() < 1e-6), "voice still sounding after reset: {}", io[0][63]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_instances_create_and_drop_cleanly() {
|
||||
let (g, s) = (id_of("Fixture Gain"), id_of("Fixture Synth"));
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Aux and Folder routing order
|
||||
|
||||
Updated: 2026-10-14
|
||||
|
||||
Aux and Folder strips read audio from busses populated by earlier strips. The mixer prepares their execution order when the existing session-structure fingerprint changes, then reuses that order for subsequent blocks.
|
||||
|
||||
For an acyclic routing graph, preparation computes the longest producer path to every Aux or Folder input. Producers reach a bus before its consumers read it, even when tracks were created in reverse routing order or the chain contains more than eight returns. Output and send routes both create dependencies. Multiple routes from the same producer to the same bus contribute audio normally but count as one producer dependency. Strips at the same depth retain session order, preserving the previous ordering for ordinary short acyclic graphs.
|
||||
|
||||
The earlier ordering calculation capped every depth at eight. In a sufficiently deep reverse-created chain, that made a consumer tie with its producer and execute first. Because bus buffers are cleared each block, the consumer could receive silence. Exact acyclic depths remove that tie.
|
||||
|
||||
If any cycle exists, the whole graph retains the previous bounded depth calculation and deterministic session-order ties. Preparation does not reject a cyclic session or change its feedback policy. This increment does not alter PDC accounting, plugin processing or failure containment, render-helper APIs, pan coefficients, Master insert order, playback ownership, or device selection.
|
||||
|
||||
This is the routing-order increment of the remaining runtime work discussed in #394. Further runtime changes require independent treatment of prepared routing coefficients, workspace and instance lifetime, hosted automation delivery, graph latency, and explicit render failures. This increment does not claim to complete that rewrite or move all preparation off the audio callback.
|
||||
|
||||
Regression tests use synthetic source samples and public mixer APIs. They cover deep reverse-created chains, output/send dependencies, duplicate routes, branching, Folder consumers, callback-size invariance, edits on an existing mixer and preserved cyclic behavior with PDC enabled or disabled. The same public fixture can run against the previous main to demonstrate the depth-cap failure independently of the new preparation helper.
|
||||
|
||||
Revision history: 2026-10-14 — exact acyclic routing order with the existing whole-graph cyclic fallback.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Track Commit channel-format integrity
|
||||
|
||||
**Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13
|
||||
|
||||
Track Commit retains the source track's exact `ChannelFormat`, including surround, height and
|
||||
ambisonic layouts. Channel count alone cannot identify a layout: 5.1 and 6.0 both have six
|
||||
channels, while Quad, LCRS and first-order ambisonics all have four.
|
||||
|
||||
The shared pre-fader renderer already returns multichannel audio in the track's own layout.
|
||||
Commit copies that format to the new audio track and checks the returned channel count and
|
||||
frame count before adding the source or hiding the original track. Formats wider than the
|
||||
renderer’s 16-channel limit are rejected before rendering. The fixed two-second tail uses
|
||||
checked arithmetic; overflowing ranges or requests beyond the renderer’s sample limit return
|
||||
an error. A command committing several tracks rolls back all of them if any selected track
|
||||
fails. Current routing and mixer inheritance remain unchanged.
|
||||
|
||||
The regression fixtures use distinct bipolar signals on each channel across all 30 declared
|
||||
formats within the 16-channel limit, with an insert applied before committing. The four higher
|
||||
ambisonic orders are rejected without replacing the source track. Additional cases check
|
||||
bus/send routing, undo/redo, session save/reopen and float WAV export, including its output
|
||||
speaker mask.
|
||||
|
||||
Mono uses a two-channel frame limit because the existing renderer temporarily targets stereo
|
||||
before folding back to mono. This frame limit does not bound total process memory. The shared
|
||||
mono renderer’s -3 dB center-pan gain followed by `SQRT_2` compensation produces approximately
|
||||
+0.0103 dB gain; the signal fixture accounts for that existing processing behavior.
|
||||
|
||||
Session track metadata retains the exact layout. Generic source WAV saving still infers its
|
||||
channel mask from channel count. This change preserves the shared renderer, the fixed tail
|
||||
and the existing commit options.
|
||||
|
||||
Implementation: `crates/engine/src/io.rs::commit_track` and
|
||||
`crates/mix/src/lib.rs::render_track_pre_fader`. Regression fixtures:
|
||||
`crates/engine/tests/commit_multichannel.rs`.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 2026-10-13 | Refresh against current main; document source-supported format checks, existing renderer limits and regression fixtures. |
|
||||
| 2026-10-12 | Preserve exact committed formats and reject incomplete or unsupported-width renders; add channel-signal, routing, persistence/export and rollback coverage. |
|
||||
@@ -99,7 +99,7 @@ Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use mo
|
||||
|
||||
Panel IDs: `tracks`, `clips`, `midi`, `memory`, `history`. The protected center is `canvas`.
|
||||
|
||||
`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
|
||||
`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: tab|center|left|right|top|bottom|above|below`; PascalCase names such as `Left` also parse). `open` and `activate` show a hidden panel at its saved location, or beside the session view when it has none, and bring a visible panel to the front. Center and `tab` group with the target; side zones split beside it; `above` and `below` stack the panel in a new group above or below a floating target's group. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
|
||||
|
||||
Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tracks","rect":[100,100,320,440]}}`.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (WAV: empty data chunk with trailing metadata (#466); previously minor: continuous fade curvature in SoundCraft sessions) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** trivial (Float32 file headroom, #791) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
|
||||
Every format Pro Tools reads or writes, and ours. The Pro Tools list comes from the document types
|
||||
its bundle declares (`CFBundleDocumentTypes` in Info.plist: names only, nothing else read) and its
|
||||
@@ -33,10 +33,10 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli
|
||||
|
||||
| Format | Pro Tools | Read | Write | Tests / notes |
|
||||
|---|---|---|---|---|
|
||||
| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; also 8-bit and float 64 read. An empty data chunk followed by metadata reads as no audio; an unfinalised recording's audio is still read (#466). |
|
||||
| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; Float32 retains finite headroom (#791); also 8-bit and float 64 read. An empty data chunk followed by metadata reads as no audio; an unfinalised recording's audio is still read (#466). |
|
||||
| RF64 / BW64 (> 4 GiB) | read/write | ✅ | ✅ | `encode_wav_rf64` |
|
||||
| WAV `WAVE_FORMAT_EXTENSIBLE` multichannel with channel mask | read/write | ✅ | ✅ | `encode_with_channel_mask`; default 5.1 import creates a stereo track (#89, PR #128) |
|
||||
| AIFF / AIFF-C | native | ✅ | ✅ (`fl32`) | Native |
|
||||
| AIFF / AIFF-C | native | ✅ | ✅ (`fl32`) | Native; `fl32` retains finite headroom (#791) |
|
||||
| FLAC | import | ✅ | ✅ | Own encoder |
|
||||
| MP3 | import, bounce | ✅ (symphonia) | ❌ | G-21 |
|
||||
| AAC / M4A / MP4 audio | import, bounce | ✅ (symphonia) | ❌ | G-21 |
|
||||
@@ -80,6 +80,7 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-19 | trivial | WAV and AIFF-C Float32 encoders retain finite headroom (#791); whole-file, streaming and bounce regression tests |
|
||||
| 2026-10-13 | trivial | WAV: empty data chunk with trailing metadata (#466) |
|
||||
| 2026-10-13 | minor | Fade curvature round-trips in sessions with a zero default for older files. Format version and readiness estimate unchanged |
|
||||
| 2026-10-10 | major | Created: format inventory against Pro Tools 2026.4.1's declared document types and import/bounce options |
|
||||
|
||||
+13
-3
@@ -1,6 +1,6 @@
|
||||
# Where SoundCraft falls short of Pro Tools
|
||||
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (G-04: mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** trivial (G-04: Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing
|
||||
real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker**
|
||||
@@ -80,6 +80,8 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t
|
||||
|
||||
### G-04. Correctness bugs in "done" features
|
||||
|
||||
**Fixed (2026-10-19, #791):** Float32 WAV and AIFF-C encoding retain finite samples above full scale. Whole-file and streaming tests cover both formats, integer clipping, and the existing NaN/infinity behavior. A bounce regression checks preserved headroom and file/report agreement with normalization off, then checks normalized output.
|
||||
|
||||
**Fixed (2026-10-18):** Mono device playback averages the stereo channels before output clipping,
|
||||
**Fixed (2026-10-16):** Master fader volume automation uses the first and last samples of each
|
||||
**Fixed (2026-10-16):** Aux, folder and master latency calculations now skip inserts already
|
||||
@@ -94,6 +96,9 @@ regression tests verify rendered impulse timing, retained healthy latency and re
|
||||
instead of discarding the right side. Surround mixes average both sides of the ITU stereo
|
||||
fold-down. Regression tests cover right-only and left-only audio, equal and opposite-polarity
|
||||
channels, clipping order, centered mono tracks, each 5.1 channel and 1-, 37- and 512-frame callbacks.
|
||||
**Fixed (2026-10-16):** Split into Mono now gives each output track its own source channel,
|
||||
including surround channels, instead of repeating channel zero. Clip edits, source timestamps,
|
||||
Undo/Redo and save/reopen are covered by regression tests; missing or empty media fails atomically.
|
||||
|
||||
Open on 2026-10-10 (several have PRs open):
|
||||
editing #3, #5, #140, #144; clip/session #86 (PR #123), #88 (PR #130), #91 (PR #124), #92 (PR #132);
|
||||
@@ -178,8 +183,10 @@ varispeed, high-quality offline) plus Elastic Pitch and warp-marker editing dept
|
||||
|
||||
### G-16. Session management
|
||||
|
||||
No session templates, track presets, workspace/sound browser with previews, batch rename (new in
|
||||
2026.4), disk-usage and relink depth. ≈ 25–40 h.
|
||||
Session templates, track-preset recall UI and browser, workspace/sound browser with previews,
|
||||
batch rename (new in 2026.4), disk-usage and relink depth remain. Track-preset saves capture the
|
||||
current hosted-plugin state before writing (#661); recall UI, partial recall and preset browsing remain
|
||||
tracked in #639. ≈ 25–40 h.
|
||||
|
||||
### G-17. Video and post
|
||||
|
||||
@@ -214,9 +221,12 @@ missing. Out of scope unless the owner decides otherwise.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained |
|
||||
| 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged |
|
||||
| 2026-10-16 | trivial | G-04: master volume automation block boundaries fixed and regression-tested; estimates unchanged |
|
||||
| 2026-10-16 | trivial | G-04: failed aux/folder/master inserts excluded from latency compensation after containment; estimates unchanged |
|
||||
| 2026-10-16 | trivial | G-04: Split into Mono channel preservation fixed and regression-tested; estimates unchanged |
|
||||
| 2026-10-16 | minor | G-16: capture current hosted-plugin state before saving a track preset (#661); broader preset work remains (#639) |
|
||||
| 2026-10-12 | minor | G-01 shrunk: audio device, rate and buffer choice; opt-in ASIO |
|
||||
| 2026-10-10 | minor | Marked the alpha blockers (G-01, G-02, G-03; parts of G-05 and G-06) from the core-workflow gate |
|
||||
| 2026-10-10 | major | Created from the full re-measure against Pro Tools Ultimate 2026.4.1: 22 ranked gaps with evidence and estimates |
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# Parity report
|
||||
|
||||
> **Generated:** 2026-10-06 (last run of `cargo xtask parity`; the catalog and aliases are unchanged since) · **Target:** Avid Pro Tools Ultimate 2026.4.1 menu tree
|
||||
|
||||
Generated by `soundcraft-cli parity --write docs/parity-checklist.md` (also `cargo xtask parity`).
|
||||
It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry.
|
||||
UI-layer commands (windows, AudioSuite) are counted by the app's own report (`engine.parity` over the control channel).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Plugin parity: built-in processors and plugin hosting
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
|
||||
Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins
|
||||
cover the rest. This doc tracks both. Our plugins have our own names and are written from
|
||||
@@ -17,12 +17,12 @@ scratch (clean-room rule): Avid plugin names are not used here, only categories.
|
||||
| Dynamics | compressor/limiter, expander/gate, de-esser, maximizer, vintage compressors, multiband | `compressor`, `expander_gate`, `de_esser`, `maximizer` | Vintage/opto models, multiband, sidechain input (no key input anywhere) |
|
||||
| Reverb | algorithmic room/plate/hall; convolution (paid tier) | `room_reverb`, `plate_reverb` | Hall/chamber, convolution, early-reflection editor |
|
||||
| Delay | modulated delay, tape/analog delays | `mod_delay` | Tape, ping-pong, multi-tap, tempo-sync depth |
|
||||
| Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `chorus`, `flanger`, `phaser` | Tremolo, auto-pan, ring modulation |
|
||||
| Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `chorus`, `flanger`, `phaser`, `ring_mod` (ring, frequency modulation, positive and negative resonators; LFO, envelope or sample-and-hold modulation) | Tremolo, auto-pan, frequency shift |
|
||||
| Harmonic | lo-fi, rectifier, amp simulation, saturation, tape | `lofi`, `rectifier`, `saturator` | Amp and cabinet models, tape |
|
||||
| Pitch and time | pitch shift, varispeed effects, time shift | `pitch_shifter`, `time_shift` | Formant control, varispeed effect, pitch correction |
|
||||
| Utility | trim, gain, invert, DC offset, signal generator, dither, time adjuster | `trim`, `gain`, `invert`, `dc_offset_removal`, `signal_generator`, `dither` | Time adjuster, meter/analyzer plugins, noise shaping options |
|
||||
| Instruments | several virtual instruments (multi-sampler, piano, drum machine, organ, synths, sample player; Massive X Player added in 2026.4) | `subtractive_synth`, `drum_synth`; `mix.instrument` takes the instrument's parameters in `params` and refuses unknown fields (#265). The sampler's ping-pong loop keeps its phase when a step is wider than the loop (#463). | Sampler (PR #108 open), piano, organ, wavetable, content library |
|
||||
| **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **26 processors + 2 instruments** | |
|
||||
| **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **25 processors + 3 instruments** | |
|
||||
|
||||
AudioSuite: every catalog AudioSuite leaf maps to one of our processes (`AUDIOSUITE` in
|
||||
`crates/ui-egui/src/menus.rs`), so the AudioSuite menu is 100 % present. Depth: no preview,
|
||||
@@ -58,6 +58,8 @@ User report #152: a saved instrument level does not reach the bounce.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-23 | trivial | Modulation: Ring Modulator (`ring_mod`), also used by the AudioSuite Modulation menu |
|
||||
| 2026-10-13 | minor | VST3 host: IMessage and IAttributeList for plugins whose halves talk through messages (Line 6 Helix Stadium Native verified) |
|
||||
| 2026-10-13 | trivial | Sampler ping-pong loop phase (#463) |
|
||||
| 2026-10-13 | trivial | mix.instrument sets built-in instrument parameters in params (#265) |
|
||||
| 2026-10-12 | minor | Editors: VST3 and embedded CLAP editors on Windows |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Ordinary strip mute automation
|
||||
|
||||
Audio, instrument, Aux and Folder strips evaluate their existing mute lane at each sample represented by a render block. Values at or above 0.5 silence both the processed output and its pre-fader send copy. A mute and unmute inside one block therefore take effect without splitting that block into smaller processor calls.
|
||||
|
||||
The first mute lane, existing interpolation, and existing automation-mode policy remain authoritative. An empty lane or Off mode leaves ordinary audio unchanged. Static mute, solo suppression and static VCA mute remain dominant. Fader ramps, pan scheduling and hosted processor call sizes remain unchanged.
|
||||
|
||||
Mute reads use the current render-position clock. Signal alignment, Master/VCA automation and send-control policies retain their existing behavior.
|
||||
|
||||
Synthetic regressions cover interior and boundary transitions, different block partitions, pre/post-fader sends, Aux/Folder input paths, mode/empty-lane fallbacks, static/solo/VCA suppression, unchanged fader/pan scheduling, and an explicitly adopted mock hosted processor's call cadence. No installed hosted plugin is required.
|
||||
Reference in New Issue
Block a user