diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 332c2f9..69f0f0b 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -43,3 +43,4 @@ and, at runtime, the operating system's own UI fonts when available (never redis | `assets/app-icon/soundcraft.icns` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 | | `assets/app-icon/soundcraft.ico` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 | | `assets/app-icon/hicolor/` | SoundCraft contributors | original, rendered from `icon.svg` (16–512 px PNGs and the scalable SVG) | MIT OR Apache-2.0 | +| `docs/images/` | SoundCraft contributors | screenshots of SoundCraft rendered from the synthesised demo session | MIT OR Apache-2.0 | diff --git a/README.md b/README.md index dbcd66c..6aded0c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,254 @@ -SoundCraft -=========== +

+ + + + ArtCraft + + +

-By ArtCraft +

SoundCraft

+ +

+ Recording, editing and mixing audio; an open-source, clean-room reimplementation of Avid Pro Tools, rebuilt in pure Rust. +

+ +

+ A complete digital audio workstation that runs natively on macOS, Windows, Linux and FreeBSD, and + in the browser. Multitrack editing, a full mixer with plugins, sends and automation, MIDI, recording + and bouncing, and every action scriptable from the command line or by an AI agent. +

+ +

+ Rust + egui + Platforms + License: MIT OR Apache-2.0 + Status: pre-alpha +

+ +

+ Join the ArtCraft community on Discord +

+ +

+ SoundCraft on getartcraft.com · + ArtCraft · + All Crafting Apps +

+ +

+ SoundCraft's Edit window with a nine-track session: drums, bass, pad, keys and lead, a reverb aux and a master fader +
+ The Edit window: tracks, waveforms, MIDI, rulers, markers and the transport, playing the built-in demo song (every sound in it is synthesised by SoundCraft itself). +

+ +> [!NOTE] +> **ArtCraft is a community of artists from all walks of life.** Painters, photographers, +> filmmakers, illustrators, designers, animators, hobbyists, and people who picked up a pencil +> last week. If you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).** + +

+ What it does · + Getting started · + Agents & scripts · + How it's built · + Status · + The Crafting Apps · + License +

+ +## What it does + +SoundCraft follows the workflow audio engineers already know: an **Edit window** for arranging and +editing, a **Mix window** for balancing, edit modes, tools, playlists, memory locations, AudioSuite +processing and the rest. If you've spent years in that workflow, your hands should already know +where things are. + + + + + + + + + + + + + + + + + + +
The Mix window with channel strips, inserts, sends, I/O, pan knobs, faders and meters, and an EQ plugin windowThe MIDI editor showing a lead melody as a piano roll with a velocity lane
Mix window. Ten inserts and ten sends per track, busses and aux inputs, master faders, VCAs, routing folders, solo-in-place with implicit solo, and meters with peak hold.MIDI editor. Instrument tracks with built-in synths, a piano roll with a velocity lane, step input, quantize, transpose, and Standard MIDI File import and export.
Volume automation drawn on the Pad track, with the Memory Locations and Big Counter windows openThe SoundCraft app icon: a singing nightingale on a teal field
Automation and markers. Breakpoint automation for volume, pan, mute, sends and every plugin parameter, written live in Write, Touch and Latch, plus memory locations and a big counter.Everywhere. Signed builds for macOS (universal), Windows (x64, x86, Arm), Linux (AppImage, deb, rpm, Flatpak), FreeBSD, and a WebAssembly build that runs in the browser.
+ +### Feature tour + +- **Editing:** Shuffle, Slip, Spot and Grid modes; Smart, Selector, Grabber, Trim, Pencil, Zoom and + Scrubber tools; cut, copy, paste, duplicate, repeat, shift, insert silence, separate (at + selection, on grid, at transients), heal, consolidate, strip silence, nudge; fades and + crossfades in five shapes; playlists; clip gain lines; edit groups; undo for everything. +- **Mixing:** fader and pan with automation, pre/post sends, busses, aux inputs, master faders, + VCA masters, routing folders, solo modes, mute and solo groups, phase invert and trim. +- **Plugins (all original):** 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 and a drum synth. Any of them can also run offline as AudioSuite. +- **Audio files:** WAV/BWF/RF64, AIFF/AIFC and FLAC in and out; MP3, Ogg Vorbis, AAC, ALAC and CAF in. + Any sample rate, any bit depth, up to 16 channels per track, and formats up to 9.1.6 and + 7th-order Ambisonics in the model. +- **Recording:** record-enable, punch into a selection, loop recording with one playlist per take. +- **Time:** Bars|Beats, Min:Secs, Timecode (every rate including drop-frame), Feet+Frames and + Samples rulers and counters; tempo and meter maps; grid and nudge values in any of them. +- **Bounce:** mix to WAV, AIFF or FLAC at 16/24/32-bit float with dither, normalisation, and + integrated loudness (LUFS) and true peak reported. +- **Fast:** a parallel mix engine (sixty-plus tracks of plugins on one core with headroom to spare), + copy-on-write undo, and waveform overviews that keep the UI at display refresh rate. + +## Getting started + +Download a build from the [releases page](https://github.com/storytold/soundcraft/releases), or build +from source with a recent stable Rust: + +```sh +git clone https://github.com/storytold/soundcraft +cd soundcraft +cargo run --release -p soundcraft -- --demo # opens the demo song +``` + +Linux needs the ALSA headers (`libasound2-dev` on Debian/Ubuntu, `alsa-lib-devel` on Fedora). + +The web build: + +```sh +cd apps/soundcraft-web && trunk serve --release # then open http://127.0.0.1:8080 +``` + +Useful shortcuts: Space play/stop, ⌘= Mix/Edit, +F1–F4 edit modes, F5–F10 tools, ⌘E +separate, ⌘D duplicate, Enter new marker, ⌘⇧N +new tracks. **Setup › Keyboard Shortcuts** lists them all. + +## For agents and scripts + +Everything you can click is also a command with an id and JSON parameters, and the same commands +are reachable from the menus, the keyboard, the command line, a JSON control channel and an +[MCP](https://modelcontextprotocol.io) server. Programmatic calls never open dialogs, and +`session.inspect` reports exactly what changed, so an agent can check its own work. + +```sh +# Headless: load the demo, turn the kick down, add an EQ, bounce. +soundcraft-cli run --demo \ + --cmd 'mix.volume={"track":"Kick","db":-6}' \ + --cmd 'mix.insert={"track":"Bass","plugin":"eq_7band"}' \ + --bounce mix.wav + +# Drive the running app. +soundcraft --demo --control 7801 & +soundcraft-cli app --port 7801 transport.play +soundcraft-cli app --port 7801 ui.screenshot '{"path":"shot.png"}' + +# Give Claude a DAW. +claude mcp add soundcraft -- soundcraft-cli mcp --demo +``` + +See [`docs/control-protocol.md`](docs/control-protocol.md) and [`docs/mcp.md`](docs/mcp.md). +`soundcraft-cli commands` lists every command. + +## How it's built + +SoundCraft is a Cargo workspace of small crates with strict layering: nothing below the UI knows +about egui, so the interface could be swapped for another one. + +| Crate | What it does | +|---|---| +| `soundcraft-time` | Timebases, tempo and meter maps, timecode, grid | +| `soundcraft-audio-io` | Audio file formats and waveform overviews | +| `soundcraft-midi` | Standard MIDI Files and MIDI operations | +| `soundcraft-dsp` | Plugins, offline processing, meters, loudness, FFT | +| `soundcraft-model` | The session document | +| `soundcraft-mix` | The mix engine (realtime and offline share it) | +| `soundcraft-engine` | Commands, undo, editing, import/export, bounce | +| `soundcraft-playback` | Audio devices (CoreAudio, WASAPI, ALSA, WebAudio) and recording | +| `soundcraft-automation` | MCP server and control-channel client | +| `soundcraft-ui-egui` | The user interface | + +The code never panics on bad input: errors are values, and a malformed file or a bad agent call +produces a message, never a crash. `cargo xtask ci` runs formatting, clippy, the tests, the asset +and layering checks and the WebAssembly build. + +## Status and roadmap + +SoundCraft is **pre-alpha**: the core works and is fun to use, and plenty is still missing. The +honest status, what's next and our effort estimates are in [`ROADMAP.md`](ROADMAP.md); the +menu-by-menu comparison is in [`docs/parity.md`](docs/parity.md). Bug reports and wish lists are +very welcome, in the issues or on Discord. + +## The Crafting Apps + +SoundCraft is one of the **Crafting Apps**: free, open-source creative tools from the +[ArtCraft](https://getartcraft.com/) team, each written from scratch in Rust and each able to +stand on its own. + +| | App | What it's for | Code | Learn more | +|:-:|---|---|---|---| +| | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) | +| | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) | +| | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | +| | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) | +| | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) | +| | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | +| | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | +| | **SoundCraft** | **Recording, editing and mixing audio · you are here** | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | + +And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. + +
+ +

+ Join the ArtCraft community on Discord +

+ +

Come make things with us

+ +

+ Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film, + set type, and people still figuring out what they like to make. Share what you're working on, + ask for help, tell us what's broken, or tell us what you wish these tools could do. + Whatever your medium and however long you've been at it, you're welcome here. +

+ +

+ discord.gg/artcraft · + getartcraft.com · + The Crafting Apps · + SoundCraft +

+ +## License and credits + +SoundCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option. +Copyright (c) 2026 ArtCraft Team and the SoundCraft contributors. Required notices are in [NOTICE](NOTICE). + +Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed +with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). + +Every icon in the interface is drawn in code, the app icon is original, and the demo song and the +screenshots' audio are synthesised by SoundCraft itself, so there are no samples or artwork from +anyone else in this repository. + +The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the +ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as +part of this repository and SoundCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). +Forks and modified versions must remove them. + +Avid and Pro Tools are trademarks or registered trademarks of Avid Technology, Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Avid Technology, Inc.; these names are used only to describe the workflows it is compatible with. + +Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with. + +

+ ArtCraft
+ Made by the ArtCraft team and community. +

diff --git a/ROADMAP.md b/ROADMAP.md index ccb552b..4373fc3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ the honest status: what works today, what is missing, and how far we are. | Measure | Value | How | |---|---|---| -| Menu-catalog parity (engine + UI) | see [`docs/parity.md`](docs/parity.md) | `cargo xtask parity` compares the incumbent's 512 menu leaves (names observed black-box) with our command registry | +| Menu-catalog parity (engine + UI) | **462 / 512 menu items (90 %)**; engine alone 388 / 512 (76 %), see [`docs/parity.md`](docs/parity.md) | `cargo xtask parity` compares the incumbent's 512 menu leaves (names observed black-box) with our command registry; the remaining items are mostly video, Atmos, collaboration, notation and third-party services | | Estimated overall feature parity | **~40 %** | judgement across the areas below, weighted by how much professional users rely on them | | Estimated remaining effort | **~220–300 wall-clock hours** of Claude Opus 5.5 work (single agent; roughly 70–100 hours with 3–4 agents in parallel) | sum of the per-area estimates below | @@ -33,9 +33,9 @@ the honest status: what works today, what is missing, and how far we are. ## Current focus -1. Land the engine parity batch (Edit/View/Track/Clip/Event/Options/Setup menu items). -2. Automation write modes during playback (touch/latch/write from fader moves). -3. Loop record and QuickPunch; input monitoring. +1. Play back the model-only state the engine already stores: clip effects, trim automation, MIDI real-time properties. +2. QuickPunch and input monitoring (loop record and Write/Touch/Latch automation passes have landed). +3. Pitch-preserving realtime Elastic Audio. 4. Plugin hosting (CLAP first, it is MIT-licensed and Rust-friendly). 5. Elastic Audio in realtime; Beat Detective workflow window. 6. README screenshots and the first alpha release. diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index c7b27de..0ee3944 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -71,7 +71,7 @@ pub fn catalog() -> Vec<&'static str> { /// Command id implementing a catalog entry, if any. pub fn implemented_by(entry: &str, extra: &[(&str, &str)]) -> Option { - if let Some((_, id)) = ALIASES.iter().chain(extra.iter()).find(|(p, _)| *p == entry) { + if let Some((_, id)) = ALIASES.iter().chain(crate::catalog_more::ALIASES_MORE.iter()).chain(extra.iter()).find(|(p, _)| *p == entry) { return Some((*id).to_string()); } let parts: Vec<&str> = entry.split(" > ").collect(); @@ -116,7 +116,7 @@ mod tests { #[test] fn aliases_point_at_real_commands() { - for (path, id) in ALIASES { + for (path, id) in ALIASES.iter().chain(crate::catalog_more::ALIASES_MORE.iter()) { assert!(crate::find_command(id).is_some(), "{path} → unknown {id}"); assert!(catalog().contains(path), "alias for unknown catalog entry {path}"); } @@ -126,7 +126,9 @@ mod tests { fn parity_floor() { let p = parity_json(); let pct = p["percent"].as_f64().unwrap_or(0.0); + let done = p["implemented"].as_u64().unwrap_or(0); // The floor only ever rises. assert!(pct >= 25.0, "engine parity regressed: {pct}%"); + assert!(done >= 386, "engine parity regressed: {done} menu items"); } } diff --git a/crates/engine/src/catalog_more.rs b/crates/engine/src/catalog_more.rs new file mode 100644 index 0000000..7d09670 --- /dev/null +++ b/crates/engine/src/catalog_more.rs @@ -0,0 +1,53 @@ +//! More catalog → command mappings, for catalog entries served by one parameterised command +//! (e.g. every `Track > Change Track Width > ` runs `track.change_width` with that format). + +/// Catalog path → command id. +pub const ALIASES_MORE: &[(&str, &str)] = &[ + ("Track > Change Track Width > Mono", "track.change_width"), + ("Track > Change Track Width > Stereo", "track.change_width"), + ("Track > Change Track Width > LCR", "track.change_width"), + ("Track > Change Track Width > Quad", "track.change_width"), + ("Track > Change Track Width > LCRS", "track.change_width"), + ("Track > Change Track Width > 5.0", "track.change_width"), + ("Track > Change Track Width > 5.1", "track.change_width"), + ("Track > Change Track Width > 6.0", "track.change_width"), + ("Track > Change Track Width > 6.1", "track.change_width"), + ("Track > Change Track Width > 7.0 SDDS", "track.change_width"), + ("Track > Change Track Width > 7.1 SDDS", "track.change_width"), + ("Track > Change Track Width > 7.0", "track.change_width"), + ("Track > Change Track Width > 7.1", "track.change_width"), + ("Track > Change Track Width > 7.0.2", "track.change_width"), + ("Track > Change Track Width > 7.1.2", "track.change_width"), + ("Track > Change Track Width > 1st Order Ambisonics", "track.change_width"), + ("Track > Change Track Width > 2nd Order Ambisonics", "track.change_width"), + ("Track > Change Track Width > 3rd Order Ambisonics", "track.change_width"), + ("Track > Change Track Width > 4th Order Ambisonics", "track.change_width"), + ("Track > Change Track Width > 5th Order Ambisonics", "track.change_width"), + ("Track > Change Track Width > 6th Order Ambisonics", "track.change_width"), + ("Track > Change Track Width > 7th Order Ambisonics", "track.change_width"), + ("Track > Change Track Width > 5.0.2", "track.change_width"), + ("Track > Change Track Width > 5.1.2", "track.change_width"), + ("Track > Change Track Width > 5.0.4", "track.change_width"), + ("Track > Change Track Width > 5.1.4", "track.change_width"), + ("Track > Change Track Width > 7.0.4", "track.change_width"), + ("Track > Change Track Width > 7.1.4", "track.change_width"), + ("Track > Change Track Width > 7.0.6", "track.change_width"), + ("Track > Change Track Width > 7.1.6", "track.change_width"), + ("Track > Change Track Width > 9.0.4", "track.change_width"), + ("Track > Change Track Width > 9.1.4", "track.change_width"), + ("Track > Change Track Width > 9.0.6", "track.change_width"), + ("Track > Change Track Width > 9.1.6", "track.change_width"), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn change_width_aliases_name_real_formats() { + for (path, _) in ALIASES_MORE.iter().filter(|(_, id)| *id == "track.change_width") { + let leaf = path.rsplit(" > ").next().unwrap_or(""); + assert!(soundcraft_model::ChannelFormat::from_id(leaf).is_some(), "no channel format for `{leaf}`"); + } + } +} diff --git a/crates/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs new file mode 100644 index 0000000..d8cce74 --- /dev/null +++ b/crates/engine/src/cmd/clip_more.rs @@ -0,0 +1,364 @@ +//! More Clip menu commands: Regroup, Unloop, Clip Effects bypass/render, Conform to Tempo and +//! Remove Pitch Shift. + +use super::more_util::{render_stretched, replace_clip}; +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_model::{Clip, ClipContent, ClipId, Session, TrackId}; +use soundcraft_time::Samples; + +pub fn specs() -> Vec { + vec![ + cmd!( + "clip.regroup", + "Regroup", + ["Clip"], + Some("Cmd+Alt+R"), + "{clips?} — puts the clips back into one clip group (reusing their most common group)", + has_selection, + regroup + ), + cmd!( + "clip.unloop", + "Unloop...", + ["Clip"], + None, + "{clips?, mode?: remove|flatten} — remove = keep only the original clip; flatten = keep the copies as ordinary clips", + has_selection, + unloop + ), + cmd!( + "clip.effects_set", + "Set Clip Effects", + [], + None, + "{clips?, params: {name: number}} — stores clip-effect settings on the clips", + has_selection, + effects_set + ), + cmd!( + "clip.effects_bypass", + "Bypass", + ["Clip", "Clip Effects"], + None, + "{clips?, value?: bool} (toggles when omitted)", + has_selection, + effects_bypass + ), + cmd!( + "clip.effects_render", + "Render", + ["Clip", "Clip Effects"], + None, + "{clips?} — renders clip gain and fades into new audio and clears the clip effects", + has_selection, + effects_render + ), + cmd!( + "clip.conform_to_tempo", + "Conform to Tempo", + ["Clip"], + None, + "{clips?, source_bpm?} — stretches audio clips so their length in bars follows the session tempo; the first conform records the clip's tempo", + has_selection, + conform + ), + cmd!( + "clip.remove_pitch_shift", + "Remove Pitch Shift", + ["Clip"], + None, + "{clips?} — re-renders vari-speed (stretched) clips with pitch preserved", + has_selection, + remove_pitch_shift + ), + ] +} + +fn regroup(e: &mut Engine, p: &Value) -> Result { + let ids = clip_ids_param(e, p); + if ids.len() < 2 { + return Err(bad("clip.regroup", "select two or more clips")); + } + let s = e.session_mut(); + let mut counts: std::collections::BTreeMap = Default::default(); + for id in &ids { + if let Some(g) = s.find_clip(*id).and_then(|(_, c)| c.group) { + *counts.entry(g).or_default() += 1; + } + } + let g = counts.iter().max_by_key(|(_, n)| **n).map(|(g, _)| *g).unwrap_or_else(|| s.alloc()); + let mut n = 0; + for id in &ids { + if let Some(c) = s.find_clip_mut(*id) { + c.group = Some(g); + n += 1; + } + } + Ok(json!({"group": g, "clips": n})) +} + +fn same_material(a: &Clip, b: &Clip) -> bool { + a.length == b.length + && match (&a.content, &b.content) { + (ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2, + (ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2, + _ => false, + } +} + +/// The loop a clip belongs to: back-to-back copies of the same material. Origin first. +fn loop_chain(s: &Session, t: TrackId, id: ClipId) -> Vec { + let Some(tr) = s.track(t) else { return Vec::new() }; + let clips = tr.clips(); + let Some(mut cur) = clips.iter().find(|c| c.id == id) else { return Vec::new() }; + let mut guard = 0; + while let Some(prev) = clips.iter().find(|x| x.id != cur.id && x.end() == cur.start && same_material(x, cur)) { + cur = prev; + guard += 1; + if guard > clips.len() { + break; + } + } + let mut chain = vec![cur.id]; + while let Some(next) = clips.iter().find(|x| !chain.contains(&x.id) && x.start == cur.end() && same_material(x, cur)) { + chain.push(next.id); + cur = next; + } + chain +} + +fn unloop(e: &mut Engine, p: &Value) -> Result { + let id = "clip.unloop"; + let remove = match str_param(p, "mode") { + None | Some("remove") => true, + Some("flatten") => false, + Some(m) => return Err(bad(id, format!("unknown mode `{m}`"))), + }; + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + let mut done: Vec = Vec::new(); + let mut gone: Vec = Vec::new(); + let mut affected = 0; + for cid in ids { + if done.contains(&cid) { + continue; + } + let Some(t) = s.find_clip(cid).map(|(t, _)| t) else { continue }; + let chain = loop_chain(s, t, cid); + done.extend(chain.iter().copied()); + if chain.len() < 2 { + continue; + } + let copies: Vec = chain.iter().skip(1).copied().collect(); + if let Some(pl) = s.track_mut(t).and_then(|tr| tr.playlist_mut()) { + if remove { + pl.clips.retain(|c| !copies.contains(&c.id)); + gone.extend(copies.iter().copied()); + } else { + for c in pl.clips.iter_mut().filter(|c| chain.contains(&c.id)) { + c.group = None; + } + } + } + affected += copies.len(); + } + if affected == 0 { + return Err(bad(id, "the selection is not a looped clip")); + } + s.edit.selected_clips.retain(|c| !gone.contains(c)); + Ok(json!({"copies": affected, "removed": remove})) +} + +fn effects_set(e: &mut Engine, p: &Value) -> Result { + let ids = clip_ids_param(e, p); + let params = p.get("params").and_then(Value::as_object).ok_or_else(|| bad("clip.effects_set", "`params` must be an object of numbers"))?; + let vals: Vec<(String, f64)> = + params.iter().filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x))).collect(); + let s = e.session_mut(); + for c in &ids { + for (k, v) in &vals { + s.edit.values.insert(format!("clip_fx.{}.{k}", c.0), *v); + } + } + Ok(json!({"clips": ids.len(), "params": vals.len()})) +} + +fn effects_bypass(e: &mut Engine, p: &Value) -> Result { + let ids = clip_ids_param(e, p); + if ids.is_empty() { + return Err(bad("clip.effects_bypass", "select a clip")); + } + let s = e.session_mut(); + let key = |c: &ClipId| format!("clip_fx.bypass.{}", c.0); + let on = p.get("value").and_then(Value::as_bool).unwrap_or_else(|| !ids.iter().all(|c| s.edit.flag(&key(c)))); + for c in &ids { + s.edit.set_flag(&key(c), on); + } + Ok(json!({"bypass": on, "clips": ids.len()})) +} + +fn effects_render(e: &mut Engine, p: &Value) -> Result { + let ids = clip_ids_param(e, p); + let mut n = 0; + for id in &ids { + if crate::io::render_clip_gain(e, *id)? { + n += 1; + } + let s = e.session_mut(); + let pre = format!("clip_fx.{}.", id.0); + s.edit.values.retain(|k, _| !k.starts_with(&pre)); + s.edit.set_flag(&format!("clip_fx.bypass.{}", id.0), false); + if let Some(c) = s.find_clip_mut(*id) { + c.fade_in.len = c.fade_in.len.min(c.length); + c.fade_out.len = c.fade_out.len.min(c.length); + } + } + Ok(json!({"rendered": n})) +} + +fn conform(e: &mut Engine, p: &Value) -> Result { + let id = "clip.conform_to_tempo"; + let ids = clip_ids_param(e, p); + let given = p.get("source_bpm").and_then(Value::as_f64).filter(|b| b.is_finite()); + if let Some(b) = given + && !(5.0..=1000.0).contains(&b) + { + return Err(bad(id, "source_bpm must be 5..1000")); + } + let s = e.session_mut(); + let mut out = Vec::new(); + for cid in ids { + let Some((_, c)) = s.find_clip(cid).map(|(t, c)| (t, c.clone())) else { continue }; + if !c.is_audio() || c.edit_locked { + continue; + } + let bpm_key = format!("clip.bpm.{}", cid.0); + let len_key = format!("clip.len0.{}", cid.0); + let stretch = if c.stretch.is_finite() && c.stretch > 0.0 { c.stretch } else { 1.0 }; + let now = s.tempo.tempo_at_tick(s.tempo.samples_to_ticks(c.start, s.sample_rate)); + let src_bpm = given.unwrap_or_else(|| s.edit.value(&bpm_key, now)); + let len0 = s.edit.value(&len_key, c.length as f64 / stretch).max(1.0); + s.edit.values.insert(bpm_key, src_bpm); + s.edit.values.insert(len_key, len0); + let ratio = (src_bpm / now).clamp(0.05, 20.0); + let new_len = ((len0 * ratio).round() as Samples).max(1); + if let Some(cl) = s.find_clip_mut(cid) { + cl.stretch = ratio; + cl.length = new_len; + cl.fade_in.len = cl.fade_in.len.min(new_len); + cl.fade_out.len = cl.fade_out.len.min(new_len); + } + out.push(json!({"clip": cid, "source_bpm": src_bpm, "bpm": now, "stretch": ratio})); + } + Ok(json!({"clips": out})) +} + +fn remove_pitch_shift(e: &mut Engine, p: &Value) -> Result { + let id = "clip.remove_pitch_shift"; + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + let mut n = 0; + for cid in ids { + let Some((t, c)) = s.find_clip(cid).map(|(t, c)| (t, c.clone())) else { continue }; + s.edit.values.remove(&format!("clip.pitch.{}", cid.0)); + if !c.is_audio() || (c.stretch - 1.0).abs() < 1e-9 || !c.stretch.is_finite() { + continue; + } + let Some(src) = render_stretched(s, id, &c, c.length)? else { continue }; + let mut nc = c.clone(); + nc.content = ClipContent::Audio { source: src, offset: 0 }; + nc.stretch = 1.0; + replace_clip(s, t, cid, nc); + n += 1; + } + Ok(json!({"clips": n})) +} + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_model::{ChannelFormat, SourceId, TrackKind}; + + fn engine_with(clips: &[(Samples, Samples, Samples)]) -> (Engine, TrackId, Vec) { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let mut ids = Vec::new(); + for (st, len, off) in clips { + let id = s.new_clip_id(); + crate::edit::place_clip(&mut s, t, Clip::audio(id, "c", SourceId(9), *off, *st, *len)); + ids.push(id); + } + s.edit.selected_tracks = vec![t]; + (Engine::new(s), t, ids) + } + + #[test] + fn unloop_removes_copies() { + let (mut e, t, ids) = engine_with(&[(0, 100, 0), (100, 100, 0), (200, 100, 0), (300, 100, 50)]); + e.execute("clip.unloop", &json!({"clip": ids[1].0})).unwrap(); + let left: Vec = e.session().track(t).unwrap().clips().iter().map(|c| c.id).collect(); + assert_eq!(left, vec![ids[0], ids[3]]); + assert!(e.execute("clip.unloop", &json!({"clip": ids[3].0})).is_err()); + } + + #[test] + fn regroup_reuses_group() { + let (mut e, _, ids) = engine_with(&[(0, 100, 0), (500, 100, 0), (900, 100, 0)]); + let all: Vec = ids.iter().map(|c| c.0).collect(); + e.execute("clip.group", &json!({"clips": all})).unwrap(); + let g = e.session().find_clip(ids[0]).unwrap().1.group; + e.execute("clip.ungroup", &json!({"clips": [ids[2].0]})).unwrap(); + e.execute("clip.regroup", &json!({"clips": all})).unwrap(); + assert!(ids.iter().all(|id| e.session().find_clip(*id).unwrap().1.group == g)); + } + + #[test] + fn effects_bypass_and_clear() { + let (mut e, _, ids) = engine_with(&[(0, 100, 0)]); + e.execute("clip.effects_set", &json!({"clip": ids[0].0, "params": {"eq_gain_db": 3.0}})).unwrap(); + e.execute("clip.effects_bypass", &json!({"clip": ids[0].0})).unwrap(); + assert!(e.session().edit.flag(&format!("clip_fx.bypass.{}", ids[0].0))); + e.execute("edit.copy_clip_effects", &json!({"clip": ids[0].0})).unwrap(); + assert_eq!(e.clipboard.clip_effects, vec![("eq_gain_db".to_string(), 3.0)]); + e.execute("edit.clear_clip_effects", &json!({"clip": ids[0].0})).unwrap(); + assert!(e.session().edit.values.is_empty()); + e.execute("edit.paste_clip_effects", &json!({"clip": ids[0].0})).unwrap(); + assert_eq!(e.session().edit.values.len(), 1); + } + + #[test] + fn conform_follows_tempo() { + let (mut e, _, ids) = engine_with(&[(0, 48_000, 0)]); + e.execute("clip.conform_to_tempo", &json!({"clip": ids[0].0})).unwrap(); + assert_eq!(e.session().find_clip(ids[0]).unwrap().1.length, 48_000); + e.execute("event.tempo", &json!({"bpm": 60.0})).unwrap(); + e.execute("clip.conform_to_tempo", &json!({"clip": ids[0].0})).unwrap(); + let c = e.session().find_clip(ids[0]).unwrap().1.clone(); + assert_eq!(c.length, 96_000); + assert!((c.stretch - 2.0).abs() < 1e-9); + } + + #[test] + fn remove_pitch_shift_renders() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let tone: Vec = (0..12_000).map(|i| (i as f32 * 0.07).sin() * 0.5).collect(); + let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] }; + let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav); + let id = s.new_clip_id(); + let mut c = Clip::audio(id, "tone", src, 0, 0, 18_000); + c.stretch = 1.5; + crate::edit::place_clip(&mut s, t, c); + s.edit.selected_clips = vec![id]; + s.edit.selected_tracks = vec![t]; + let mut e = Engine::new(s); + let r = e.execute("clip.remove_pitch_shift", &json!({})).unwrap(); + assert_eq!(r["clips"], 1); + let c = e.session().find_clip(id).unwrap().1.clone(); + assert_eq!(c.stretch, 1.0); + assert_eq!(c.length, 18_000); + assert_ne!(c.source(), Some(src)); + } +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 888b006..babf65e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec { repeat_to_fill ), cmd!(noundo "edit.select_all", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, select_all), - cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id]}", always, select), + cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id], exact?: bool (ignore edit groups)}", always, select), cmd!(noundo "edit.select_none", "Deselect All", [], None, "{}", always, |e, _| { let s = e.session_mut(); s.edit.selected_tracks.clear(); @@ -336,8 +336,21 @@ fn select(e: &mut Engine, p: &Value) -> Result { let en = position_param(e, "edit.select", p, "end")?; let clips = p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::ClipId).collect::>()); + let exact = bool_or(p, "exact", false); let s = e.session_mut(); - if let Some(t) = tracks { + if let Some(mut t) = tracks { + // Active edit groups select all their members together. + if !exact { + let extra: Vec = + 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 = s.tracks.iter().map(|x| x.id).collect(); + t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX)); + } s.edit.selected_tracks = t; } match (st, en) { diff --git a/crates/engine/src/cmd/edit_more.rs b/crates/engine/src/cmd/edit_more.rs new file mode 100644 index 0000000..e60d06f --- /dev/null +++ b/crates/engine/src/cmd/edit_more.rs @@ -0,0 +1,1087 @@ +//! More Edit menu commands: Cut/Copy/Clear Special variants, Paste Special, selection helpers, +//! Space Clips, Snap to, Copy/Move Selection to playlists, TCE, and automation coalesce/trim/glide. + +use super::more_util::{clip_db, render_stretched, replace_clip, set_clip_env, target_playlist}; +use super::*; +use crate::edit as ops; +use crate::{Clipboard, TransportRequest, cmd}; +use serde_json::json; +use soundcraft_model::{AutoParam, AutomationLane, Clip, ClipContent, ClipId, EditMode, Playlist, Session}; +use soundcraft_time::{Range, Samples}; + +#[derive(Clone, Copy, PartialEq)] +enum Lanes { + All, + Pan, + Plugin, +} + +impl Lanes { + fn hit(self, p: &AutoParam) -> bool { + match self { + Lanes::All => true, + Lanes::Pan => matches!(p, AutoParam::Pan(_)), + Lanes::Plugin => matches!(p, AutoParam::Plugin { .. }), + } + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Dest { + Target, + New, + Duplicate, +} + +fn has_clip_gain_clipboard(e: &Engine) -> std::result::Result<(), String> { + if e.clipboard.clip_gain.is_empty() { Err("no clip gain has been copied".into()) } else { Ok(()) } +} + +fn has_clip_effects_clipboard(e: &Engine) -> std::result::Result<(), String> { + if e.clipboard.clip_effects.is_empty() { Err("no clip effects have been copied".into()) } else { Ok(()) } +} + +fn has_automation_clipboard(e: &Engine) -> std::result::Result<(), String> { + if e.clipboard.automation.iter().flatten().any(|l| !l.points.is_empty()) { Ok(()) } else { Err("no automation has been copied".into()) } +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "edit.cut_pan_automation", + "Pan Automation", + ["Edit", "Cut Special"], + None, + "{tracks?, start?, end?} — cuts pan automation only to the clipboard", + has_range, + |e, p| auto_copy(e, p, Lanes::Pan, true, "edit.cut_pan_automation") + ), + cmd!( + "edit.cut_plugin_automation", + "Plugin Automation", + ["Edit", "Cut Special"], + None, + "{tracks?, start?, end?} — cuts plug-in automation only", + has_range, + |e, p| auto_copy(e, p, Lanes::Plugin, true, "edit.cut_plugin_automation") + ), + cmd!(noundo "edit.copy_all_automation", "All Automation", ["Edit", "Copy Special"], None, "{tracks?, start?, end?} — copies automation only (no clips)", has_range, |e, p| auto_copy(e, p, Lanes::All, false, "edit.copy_all_automation")), + cmd!(noundo "edit.copy_pan_automation", "Pan Automation", ["Edit", "Copy Special"], None, "{tracks?, start?, end?}", has_range, |e, p| auto_copy(e, p, Lanes::Pan, false, "edit.copy_pan_automation")), + cmd!(noundo "edit.copy_plugin_automation", "Plugin Automation", ["Edit", "Copy Special"], None, "{tracks?, start?, end?}", has_range, |e, p| auto_copy(e, p, Lanes::Plugin, false, "edit.copy_plugin_automation")), + cmd!(noundo "edit.copy_clip_gain", "Clip Gain", ["Edit", "Copy Special"], None, "{tracks?, start?, end?} — copies the clip gain curve under the selection", has_range, copy_clip_gain), + cmd!( + "edit.paste_clip_gain", + "Paste Clip Gain", + [], + None, + "{tracks?, at?} — pastes copied clip gain onto the clips under the paste range", + has_clip_gain_clipboard, + paste_clip_gain + ), + cmd!(noundo "edit.copy_clip_effects", "Clip Effects", ["Edit", "Copy Special"], None, "{clips?} — copies the first clip's clip-effect settings", has_selection, |e, p| clip_effects(e, p, true, false, "edit.copy_clip_effects")), + cmd!( + "edit.cut_clip_effects", + "Clip Effects", + ["Edit", "Cut Special"], + None, + "{clips?} — copies then removes clip-effect settings", + has_selection, + |e, p| clip_effects(e, p, true, true, "edit.cut_clip_effects") + ), + cmd!( + "edit.clear_clip_effects", + "Clip Effects", + ["Edit", "Clear Special"], + None, + "{clips?} — removes clip-effect settings (and bypass)", + has_selection, + |e, p| clip_effects(e, p, false, true, "edit.clear_clip_effects") + ), + cmd!("edit.paste_clip_effects", "Paste Clip Effects", [], None, "{clips?}", has_clip_effects_clipboard, paste_clip_effects), + cmd!( + "edit.paste_merge_midi", + "Merge MIDI", + ["Edit", "Paste Special"], + None, + "{tracks?, at?} — merges copied MIDI notes into existing MIDI clips instead of replacing them", + has_clipboard, + paste_merge_midi + ), + cmd!( + "edit.paste_to_current_automation", + "To Current Automation Type", + ["Edit", "Paste Special"], + None, + "{tracks?, at?, param?} — pastes copied automation into the shown lane (track view) or `param`, rescaled to its range", + has_automation_clipboard, + paste_to_current_automation + ), + cmd!(noundo "edit.play_timeline", "Play Timeline", ["Edit", "Selection"], None, "{} — plays the timeline selection", always, |e, _| { + let s = e.session(); + let r = if s.edit.timeline_selection.is_empty() { s.edit.selection } else { s.edit.timeline_selection }; + e.transport_requests.push(TransportRequest::Locate(r.start)); + e.transport_requests.push(TransportRequest::Play); + Ok(json!({"start": r.start, "end": r.end})) + }), + cmd!( + "edit.duplicate_extend", + "Duplicate and Extend Edit", + ["Edit", "Selection"], + None, + "{tracks?, start?, end?} — duplicates the selection and extends the selection over the copy", + has_range, + duplicate_extend + ), + cmd!(noundo "edit.extend_up_to_members", "Extend Edit Up to Members", ["Edit", "Selection"], None, "{} — adds the parent folder of each selected member and its members", has_selection, |e, _| extend_members(e, true)), + cmd!(noundo "edit.extend_down_to_members", "Extend Edit Down to Members", ["Edit", "Selection"], None, "{} — adds the members of selected folder tracks (recursively)", has_selection, |e, _| extend_members(e, false)), + cmd!(noundo "edit.marker_lane_move_up", "Move Edit Selection To Marker Lane Above", ["Edit", "Selection"], None, "{} — the selection's marker ruler lane (1..5) moves up", always, |e, _| marker_lane(e, -1, false)), + cmd!(noundo "edit.marker_lane_move_down", "Move Edit Selection To Marker Lane Below", ["Edit", "Selection"], None, "{}", always, |e, _| marker_lane(e, 1, false)), + cmd!(noundo "edit.marker_lane_extend_up", "Extend Edit Selection To Marker Lane Above", ["Edit", "Selection"], None, "{} — adds the marker ruler lane above to the selection", always, |e, _| marker_lane(e, -1, true)), + cmd!(noundo "edit.marker_lane_extend_down", "Extend Edit Selection To Marker Lane Below", ["Edit", "Selection"], None, "{}", always, |e, _| marker_lane(e, 1, true)), + cmd!( + "edit.space_clips", + "Space Clips…", + ["Edit"], + None, + "{clips?, gap?: samples|{seconds} (default 0), mode?: end_to_start|start_to_start}", + has_selection, + space_clips + ), + cmd!( + "edit.snap_next", + "Next", + ["Edit", "Snap to"], + None, + "{clips?} — moves each clip so its end meets the start of the next clip", + has_selection, + |e, p| snap(e, p, true) + ), + cmd!( + "edit.snap_previous", + "Previous", + ["Edit", "Snap to"], + None, + "{clips?} — moves each clip so its start meets the end of the previous clip", + has_selection, + |e, p| snap(e, p, false) + ), + cmd!( + "edit.copy_to_target_playlist", + "Target Playlist", + ["Edit", "Copy Selection to..."], + None, + "{tracks?, start?, end?} — copies the selection into each track's target playlist", + has_range, + |e, p| to_playlist(e, p, Dest::Target, false, "edit.copy_to_target_playlist") + ), + cmd!( + "edit.copy_to_duplicate_playlist", + "Duplicate of Main Playlist", + ["Edit", "Copy Selection to..."], + None, + "{tracks?, start?, end?}", + has_range, + |e, p| to_playlist(e, p, Dest::Duplicate, false, "edit.copy_to_duplicate_playlist") + ), + cmd!( + "edit.move_to_target_playlist", + "Target Playlist", + ["Edit", "Move Selection to..."], + None, + "{tracks?, start?, end?} — copies to the target playlist and clears it from the main", + has_range, + |e, p| to_playlist(e, p, Dest::Target, true, "edit.move_to_target_playlist") + ), + cmd!("edit.move_to_new_playlist", "New Playlist", ["Edit", "Move Selection to..."], None, "{tracks?, start?, end?}", has_range, |e, p| { + to_playlist(e, p, Dest::New, true, "edit.move_to_new_playlist") + }), + cmd!( + "edit.move_to_duplicate_playlist", + "Duplicate of Main Playlist", + ["Edit", "Move Selection to..."], + None, + "{tracks?, start?, end?}", + has_range, + |e, p| to_playlist(e, p, Dest::Duplicate, true, "edit.move_to_duplicate_playlist") + ), + cmd!( + "edit.tce_to_timeline", + "TCE Edit to Timeline Selection", + ["Edit"], + None, + "{clip?, start?, end?} — time-stretches the clip to fill the timeline selection (pitch preserved)", + has_selection, + tce + ), + cmd!( + "edit.automation_coalesce_volume_to_clip_gain", + "Coalesce Volume Automation to Clip Gain", + ["Edit", "Automation"], + None, + "{tracks?} — folds the whole volume curve into clip gain; the fader goes to 0 dB", + has_selection, + coalesce_volume_to_clip_gain + ), + cmd!( + "edit.automation_coalesce_clip_gain_to_volume", + "Coalesce Clip Gain to Volume Automation", + ["Edit", "Automation"], + None, + "{tracks?} — folds all clip gain into volume automation", + has_selection, + coalesce_clip_gain_to_volume + ), + cmd!( + "edit.automation_trim_to_all", + "Trim to All Enabled", + ["Edit", "Automation"], + None, + "{tracks?, start?, end?} — offsets every enabled lane in the selection so it starts at the current control value", + has_range, + |e, p| trim_glide(e, p, false, false, "edit.automation_trim_to_all") + ), + cmd!( + "edit.automation_glide_to_all", + "Glide to All Enabled", + ["Edit", "Automation"], + None, + "{tracks?, start?, end?} — ramps every enabled lane from its value at the start to the current value at the end", + has_range, + |e, p| trim_glide(e, p, true, false, "edit.automation_glide_to_all") + ), + cmd!("edit.automation_glide_pan", "Glide Pan Only", ["Edit", "Automation"], None, "{tracks?, start?, end?}", has_range, |e, p| trim_glide( + e, + p, + true, + true, + "edit.automation_glide_pan" + )), + ] +} + +fn shuffle(s: &Session) -> bool { + s.edit.edit_mode == EditMode::Shuffle +} + +fn nonempty_range(e: &Engine, p: &Value, id: &str) -> Result { + let r = range_param(e, id, p)?; + if r.is_empty() { Err(bad(id, "the selection is empty")) } else { Ok(r) } +} + +fn auto_copy(e: &mut Engine, p: &Value, which: Lanes, cut: bool, id: &str) -> Result { + let tracks = tracks_required(e, id, p)?; + let r = nonempty_range(e, p, id)?; + let mut cb = Clipboard { length: r.len(), ..Clipboard::default() }; + let mut lanes_n = 0; + for t in &tracks { + cb.tracks.push(Vec::new()); + let lanes: Vec = e + .session() + .track(*t) + .map(|tr| { + tr.automation + .iter() + .filter(|l| which.hit(&l.param) && !l.points.is_empty()) + .map(|l| { + let mut out = AutomationLane::new(l.param.clone()); + out.enabled = l.enabled; + let (_, _, def) = l.param.range(); + out.set_point(0, l.value_at(r.start, def)); + for pt in l.points.iter().filter(|pt| pt.at > r.start && pt.at < r.end) { + out.set_point(pt.at - r.start, pt.value); + } + out.set_point(r.len() - 1, l.value_at(r.end - 1, def)); + out + }) + .collect() + }) + .unwrap_or_default(); + lanes_n += lanes.len(); + cb.automation.push(lanes); + } + if cut { + let s = e.session_mut(); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + for l in tr.automation.iter_mut().filter(|l| which.hit(&l.param)) { + l.clear_range(r.start, r.end); + } + } + } + } + e.clipboard = cb; + Ok(json!({"lanes": lanes_n, "length": r.len()})) +} + +fn copy_clip_gain(e: &mut Engine, p: &Value) -> Result { + let id = "edit.copy_clip_gain"; + let tracks = tracks_required(e, id, p)?; + let r = nonempty_range(e, p, id)?; + let s = e.session(); + let mut out = Vec::new(); + for t in &tracks { + let mut env: Vec<(Samples, f32)> = Vec::new(); + for c in s.track(*t).map(|tr| tr.clips()).unwrap_or(&[]) { + let Some(i) = c.range().intersect(&r) else { continue }; + env.push((i.start - r.start, clip_db(c, i.start - c.start))); + for &(o, _) in &c.gain_env { + let at = c.start + o; + if at > i.start && at < i.end - 1 { + env.push((at - r.start, clip_db(c, o))); + } + } + env.push((i.end - 1 - r.start, clip_db(c, i.end - 1 - c.start))); + } + env.sort_by_key(|x| x.0); + env.dedup_by_key(|x| x.0); + out.push(env); + } + let n = out.iter().map(Vec::len).sum::(); + e.clipboard = Clipboard { length: r.len(), clip_gain: out, ..Clipboard::default() }; + Ok(json!({"points": n, "length": r.len()})) +} + +fn paste_clip_gain(e: &mut Engine, p: &Value) -> Result { + let id = "edit.paste_clip_gain"; + let tracks = tracks_required(e, id, p)?; + let at = position_param(e, id, p, "at")?.unwrap_or(e.session().edit.selection.start); + let cb = e.clipboard.clone(); + let paste = Range::new(at, at.saturating_add(cb.length.max(1))); + let s = e.session_mut(); + let mut n = 0; + for (i, t) in tracks.iter().enumerate() { + let Some(env) = cb.clip_gain.get(i % cb.clip_gain.len().max(1)) else { continue }; + let Some(pl) = s.track_mut(*t).and_then(|tr| tr.playlist_mut()) else { continue }; + for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&paste)) { + let Some(ov) = c.range().intersect(&paste) else { continue }; + let (a, b) = (ov.start - c.start, ov.end - c.start); + let mut pts: Vec<(Samples, f32)> = Vec::new(); + // Keep the existing curve outside the paste range, anchored at its edges. + let mut old: Vec<(Samples, f32)> = c.gain_env.iter().map(|&(o, _)| (o, clip_db(c, o))).collect(); + old.push((0, clip_db(c, 0))); + old.push((c.length, clip_db(c, c.length))); + pts.extend(old.into_iter().filter(|(o, _)| *o < a || *o > b)); + if a > 0 { + pts.push((a - 1, clip_db(c, a - 1))); + } + if b < c.length { + pts.push((b, clip_db(c, b))); + } + for &(o, v) in env { + let rel = at + o - c.start; + if rel >= a && rel < b { + pts.push((rel, v)); + } + } + set_clip_env(c, pts); + n += 1; + } + } + Ok(json!({"clips": n})) +} + +fn fx_prefix(c: ClipId) -> String { + format!("clip_fx.{}.", c.0) +} + +fn clip_effects(e: &mut Engine, p: &Value, copy: bool, remove: bool, id: &str) -> Result { + let ids = clip_ids_param(e, p); + let first = *ids.first().ok_or_else(|| bad(id, "select a clip"))?; + if copy { + let pre = fx_prefix(first); + let fx: Vec<(String, f64)> = + e.session().edit.values.iter().filter_map(|(k, v)| k.strip_prefix(&pre).map(|name| (name.to_string(), *v))).collect(); + if fx.is_empty() { + return Err(bad(id, "the clip has no clip effects")); + } + e.clipboard = Clipboard { clip_effects: fx, ..Clipboard::default() }; + } + let mut removed = 0; + if remove { + let s = e.session_mut(); + for c in &ids { + let pre = fx_prefix(*c); + let before = s.edit.values.len(); + s.edit.values.retain(|k, _| !k.starts_with(&pre)); + removed += before - s.edit.values.len(); + s.edit.set_flag(&format!("clip_fx.bypass.{}", c.0), false); + } + } + Ok(json!({"clips": ids.len(), "copied": e.clipboard.clip_effects.len(), "removed": removed})) +} + +fn paste_clip_effects(e: &mut Engine, p: &Value) -> Result { + let ids = clip_ids_param(e, p); + if ids.is_empty() { + return Err(bad("edit.paste_clip_effects", "select a clip")); + } + let fx = e.clipboard.clip_effects.clone(); + let s = e.session_mut(); + for c in &ids { + let pre = fx_prefix(*c); + s.edit.values.retain(|k, _| !k.starts_with(&pre)); + for (k, v) in &fx { + s.edit.values.insert(format!("{pre}{k}"), *v); + } + } + Ok(json!({"clips": ids.len()})) +} + +fn paste_merge_midi(e: &mut Engine, p: &Value) -> Result { + let id = "edit.paste_merge_midi"; + let tracks = tracks_required(e, id, p)?; + let at = position_param(e, id, p, "at")?.unwrap_or(e.session().edit.selection.start).max(0); + let cb = e.clipboard.clone(); + let s = e.session_mut(); + let tempo = s.tempo.clone(); + let sr = s.sample_rate; + let mut merged = 0; + let mut placed = 0; + for (i, t) in tracks.iter().enumerate() { + let Some(clips) = cb.tracks.get(i % cb.tracks.len().max(1)) else { continue }; + for c in clips { + let pos = at.saturating_add(c.start); + let ClipContent::Midi { sequence } = &c.content else { + let mut nc = c.clone(); + nc.id = s.new_clip_id(); + nc.start = pos; + ops::place_clip(s, *t, nc); + placed += 1; + continue; + }; + let target = s.track(*t).and_then(|tr| { + let host = tr.clips().iter().find(|x| matches!(x.content, ClipContent::Midi { .. }) && x.range().contains(pos))?; + let next_start = tr.clips().iter().filter(|x| x.start > host.start && x.id != host.id).map(|x| x.start).min(); + Some((host.id, host.start, next_start)) + }); + match target { + Some((hid, hstart, next_start)) => { + let diff = tempo.samples_to_ticks(pos, sr) - tempo.samples_to_ticks(hstart, sr); + let want_end = pos.saturating_add(c.length); + let limit = next_start.unwrap_or(Samples::MAX); + if let Some(h) = s.find_clip_mut(hid) + && let ClipContent::Midi { sequence: hs } = &mut h.content + { + for n in &sequence.notes { + let mut n = *n; + n.start = n.start.saturating_add(diff); + hs.notes.push(n); + } + for cc in &sequence.ctrls { + let mut cc = *cc; + cc.tick = cc.tick.saturating_add(diff); + hs.ctrls.push(cc); + } + hs.sort(); + if want_end > h.end() { + h.trim_end_to(want_end.min(limit)); + } + merged += 1; + } + } + None => { + let mut nc = c.clone(); + nc.id = s.new_clip_id(); + nc.start = pos; + ops::place_clip(s, *t, nc); + placed += 1; + } + } + } + } + Ok(json!({"merged": merged, "placed": placed})) +} + +fn map_value(v: f32, from: &AutoParam, to: &AutoParam) -> f32 { + let (a0, a1, _) = from.range(); + let (b0, b1, _) = to.range(); + let finite = |x: f32, y: f32| x > f32::MIN && y < f32::MAX && y > x; + if from == to || !finite(a0, a1) || !finite(b0, b1) { + return v; + } + b0 + (v - a0) / (a1 - a0) * (b1 - b0) +} + +fn paste_to_current_automation(e: &mut Engine, p: &Value) -> Result { + let id = "edit.paste_to_current_automation"; + let tracks = tracks_required(e, id, p)?; + let at = position_param(e, id, p, "at")?.unwrap_or(e.session().edit.selection.start).max(0); + let forced = match str_param(p, "param") { + Some(x) => Some(AutoParam::parse(x).ok_or_else(|| bad(id, format!("unknown automation param `{x}`")))?), + None => None, + }; + let cb = e.clipboard.clone(); + let s = e.session_mut(); + let mut written = 0; + for (i, t) in tracks.iter().enumerate() { + let Some(lanes) = cb.automation.get(i % cb.automation.len().max(1)) else { continue }; + let Some(src) = lanes.iter().max_by_key(|l| l.points.len()).filter(|l| !l.points.is_empty()) else { continue }; + let Some(tr) = s.track_mut(*t) else { continue }; + let dst = forced.clone().unwrap_or_else(|| AutoParam::parse(&tr.view).unwrap_or(AutoParam::Volume)); + let lane = tr.lane_mut(&dst); + lane.clear_range(at, at.saturating_add(cb.length)); + for pt in &src.points { + lane.set_point(at.saturating_add(pt.at), map_value(pt.value, &src.param, &dst)); + written += 1; + } + } + Ok(json!({"points": written})) +} + +fn duplicate_extend(e: &mut Engine, p: &Value) -> Result { + let id = "edit.duplicate_extend"; + let tracks = tracks_required(e, id, p)?; + let r = nonempty_range(e, p, id)?; + let s = e.session_mut(); + let mut n = 0; + for t in &tracks { + let clips = ops::copy_range(s, *t, r); + n += ops::paste(s, *t, r.end, &clips, r.len(), false).len(); + if let Some(tr) = s.track_mut(*t) { + for l in &mut tr.automation { + let pts: Vec<_> = l.points.iter().filter(|pt| r.contains(pt.at)).copied().collect(); + for pt in pts { + l.set_point(pt.at.saturating_add(r.len()), pt.value); + } + } + } + } + s.edit.selection = Range::new(r.start, r.end.saturating_add(r.len())); + if s.edit.link_timeline_edit { + s.edit.timeline_selection = s.edit.selection; + } + Ok(json!({"clips": n, "selection": s.edit.selection})) +} + +fn extend_members(e: &mut Engine, up: bool) -> Result { + let s = e.session_mut(); + let mut set: std::collections::BTreeSet = s.edit.selected_tracks.iter().map(|t| t.0).collect(); + let mut roots: Vec = Vec::new(); + for t in &s.edit.selected_tracks { + let Some(tr) = s.track(*t) else { continue }; + if up { + if let Some(f) = tr.folder { + set.insert(f.0); + roots.push(f.0); + } + } else if tr.is_folder() { + roots.push(tr.id.0); + } + } + // Add members (recursively, bounded by the track count). + let mut guard = 0; + while let Some(f) = roots.pop() { + guard += 1; + if guard > s.tracks.len().saturating_mul(2) + 8 { + break; + } + for tr in s.tracks.iter().filter(|x| x.folder.is_some_and(|p| p.0 == f)) { + if set.insert(tr.id.0) && tr.is_folder() { + roots.push(tr.id.0); + } + } + } + s.edit.selected_tracks = s.tracks.iter().filter(|t| set.contains(&t.id.0)).map(|t| t.id).collect(); + Ok(json!({"tracks": s.edit.selected_tracks})) +} + +/// Marker ruler lanes (1..5) in the edit selection, kept in `selection.marker_lane.top/bottom`. +fn marker_lane(e: &mut Engine, dir: i64, extend: bool) -> Result { + let s = e.session_mut(); + let top = s.edit.value("selection.marker_lane.top", 1.0).clamp(1.0, 5.0) as i64; + let bottom = (s.edit.value("selection.marker_lane.bottom", top as f64).clamp(1.0, 5.0) as i64).max(top); + let (top, bottom) = match (extend, dir < 0) { + (false, true) => ((top - 1).max(1), (top - 1).max(1)), + (false, false) => ((bottom + 1).min(5), (bottom + 1).min(5)), + (true, true) => ((top - 1).max(1), bottom), + (true, false) => (top, (bottom + 1).min(5)), + }; + s.edit.values.insert("selection.marker_lane.top".into(), top as f64); + s.edit.values.insert("selection.marker_lane.bottom".into(), bottom as f64); + let r = s.edit.selection; + let markers: Vec = s + .markers + .iter() + .filter(|m| (top..=bottom).contains(&i64::from(m.ruler)) && (r.contains(m.start) || m.start == r.start)) + .map(|m| m.number) + .collect(); + Ok(json!({"lanes": [top, bottom], "markers": markers})) +} + +fn space_clips(e: &mut Engine, p: &Value) -> Result { + let id = "edit.space_clips"; + let ids = clip_ids_param(e, p); + let gap = position_param(e, id, p, "gap")?.unwrap_or(0); + let s2s = match str_param(p, "mode") { + None | Some("end_to_start") => false, + Some("start_to_start") => true, + Some(m) => return Err(bad(id, format!("unknown mode `{m}`"))), + }; + if ids.len() < 2 { + return Err(bad(id, "select two or more clips")); + } + let s = e.session_mut(); + let track_ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect(); + let mut moved = 0; + for t in track_ids { + let mut sel: Vec = s + .track(t) + .map_or_else(Vec::new, |tr| tr.clips().iter().filter(|c| ids.contains(&c.id) && !c.time_locked && !c.edit_locked).cloned().collect()); + if sel.len() < 2 { + continue; + } + sel.sort_by_key(|c| (c.start, c.id.0)); + if let Some(pl) = s.track_mut(t).and_then(|tr| tr.playlist_mut()) { + pl.clips.retain(|c| !sel.iter().any(|x| x.id == c.id)); + } + let mut cur = sel.first().map_or(0, |c| c.start); + for mut c in sel { + c.start = cur.max(0); + cur = if s2s { c.start.saturating_add(gap) } else { c.end().saturating_add(gap) }; + ops::place_clip(s, t, c); + moved += 1; + } + } + Ok(json!({"clips": moved, "gap": gap})) +} + +fn snap(e: &mut Engine, p: &Value, next: bool) -> Result { + let ids = clip_ids_param(e, p); + let s = e.session_mut(); + let mut order: Vec<(ClipId, Samples)> = ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, c.start))).collect(); + // Snap the clip nearest the destination first so neighbours stay put. + order.sort_by_key(|x| if next { -x.1 } else { x.1 }); + let mut moved = 0; + for (id, _) in order { + let Some((t, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue }; + let others = + s.track(t).map(|tr| tr.clips().iter().filter(|x| x.id != id && !ids.contains(&x.id)).cloned().collect::>()).unwrap_or_default(); + let delta = if next { + others.iter().filter(|x| x.start >= c.end()).map(|x| x.start).min().map(|st| st - c.end()) + } else { + others.iter().filter(|x| x.end() <= c.start).map(Clip::end).max().map(|en| en - c.start) + }; + if let Some(d) = delta.filter(|d| *d != 0) { + moved += ops::move_clips(s, &[id], d, None); + } + } + Ok(json!({"moved": moved})) +} + +fn to_playlist(e: &mut Engine, p: &Value, dest: Dest, mv: bool, id: &str) -> Result { + let tracks = tracks_required(e, id, p)?; + let r = nonempty_range(e, p, id)?; + let sh = shuffle(e.session()); + let s = e.session_mut(); + let mut out = Vec::new(); + for t in &tracks { + if s.track(*t).is_none_or(|tr| !tr.kind.has_playlist()) { + continue; + } + let idx = match dest { + Dest::Target => { + let i = target_playlist(s, *t).ok_or_else(|| bad(id, "designate a target playlist first (Track › Designate as Target Playlist)"))?; + if s.track(*t).is_some_and(|tr| tr.active_playlist == i) { + return Err(bad(id, "the target playlist is the main playlist")); + } + i + } + Dest::New | Dest::Duplicate => { + let mut clips: Vec = + if dest == Dest::Duplicate { s.track(*t).map(|tr| tr.clips().to_vec()).unwrap_or_default() } else { Vec::new() }; + for c in &mut clips { + c.id = s.new_clip_id(); + } + let Some(tr) = s.track_mut(*t) else { continue }; + let name = format!("{}.{:02}", tr.name, tr.playlists.len() + 1); + tr.playlists.push(Playlist { name, clips }); + tr.playlists.len() - 1 + } + }; + let clips = ops::copy_range(s, *t, r); + let main = s.track(*t).map_or(0, |tr| tr.active_playlist); + if let Some(tr) = s.track_mut(*t) { + tr.active_playlist = idx; + } + for mut c in clips { + c.id = s.new_clip_id(); + c.start = c.start.saturating_add(r.start); + ops::place_clip(s, *t, c); + } + if let Some(tr) = s.track_mut(*t) { + tr.active_playlist = main; + } + if mv { + ops::clear_range(s, *t, r, sh); + } + out.push(json!({"track": t, "playlist": idx})); + } + Ok(json!({"tracks": out})) +} + +fn tce(e: &mut Engine, p: &Value) -> Result { + let id = "edit.tce_to_timeline"; + let target = { + let st = position_param(e, id, p, "start")?; + let en = position_param(e, id, p, "end")?; + match (st, en) { + (Some(a), Some(b)) => Range::new(a, b), + _ => e.session().edit.timeline_selection, + } + }; + if target.is_empty() || target.start < 0 { + return Err(bad(id, "make a timeline selection first")); + } + let ids = clip_ids_param(e, p); + let Some((t, c)) = ids.iter().find_map(|cid| e.session().find_clip(*cid).filter(|(_, c)| c.is_audio()).map(|(t, c)| (t, c.clone()))) else { + return Err(bad(id, "select an audio clip")); + }; + if c.time_locked || c.edit_locked { + return Err(bad(id, "the clip is locked")); + } + let s = e.session_mut(); + let Some(src) = render_stretched(s, id, &c, target.len())? else { return Err(bad(id, "the clip's audio is not loaded")) }; + let ratio = target.len() as f64 / c.length.max(1) as f64; + let scale = |x: Samples| soundcraft_time::to_samples(x as f64 * ratio); + let mut nc = c.clone(); + nc.content = ClipContent::Audio { source: src, offset: 0 }; + nc.start = target.start; + nc.length = target.len(); + nc.stretch = 1.0; + nc.gain_env = c.gain_env.iter().map(|&(o, v)| (scale(o), v)).collect(); + nc.fade_in.len = scale(c.fade_in.len).clamp(0, nc.length); + nc.fade_out.len = scale(c.fade_out.len).clamp(0, nc.length); + nc.sync_point = scale(c.sync_point).clamp(0, nc.length); + replace_clip(s, t, c.id, nc); + s.edit.selected_clips = vec![c.id]; + Ok(json!({"clip": c.id, "source": src, "ratio": ratio})) +} + +fn volume_at(lane: Option<&AutomationLane>, fader: f32, at: Samples) -> f32 { + match lane { + Some(l) if !l.points.is_empty() => l.value_at(at, fader), + _ => fader, + } +} + +fn coalesce_volume_to_clip_gain(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "edit.automation_coalesce_volume_to_clip_gain", p)?; + let s = e.session_mut(); + let mut n = 0; + for t in &tracks { + let Some(tr) = s.track_mut(*t) else { continue }; + if !tr.kind.has_playlist() || tr.kind.is_midi() { + continue; + } + let lane = tr.lane(&AutoParam::Volume).cloned(); + let fader = tr.mixer.volume_db; + let Some(pl) = tr.playlist_mut() else { continue }; + for c in pl.clips.iter_mut().filter(|c| c.is_audio()) { + let mut offs: Vec = vec![0, c.length]; + offs.extend(c.gain_env.iter().map(|x| x.0)); + if let Some(l) = &lane { + offs.extend(l.points.iter().filter(|pt| c.range().contains(pt.at)).map(|pt| pt.at - c.start)); + } + offs.sort_unstable(); + offs.dedup(); + let pts: Vec<(Samples, f32)> = offs.iter().map(|&o| (o, clip_db(c, o) + volume_at(lane.as_ref(), fader, c.start + o))).collect(); + set_clip_env(c, pts); + n += 1; + } + tr.automation.retain(|l| l.param != AutoParam::Volume); + tr.mixer.volume_db = 0.0; + } + Ok(json!({"clips": n})) +} + +fn coalesce_clip_gain_to_volume(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "edit.automation_coalesce_clip_gain_to_volume", p)?; + let s = e.session_mut(); + let mut n = 0; + for t in &tracks { + let Some(tr) = s.track_mut(*t) else { continue }; + let clips: Vec = tr.clips().iter().filter(|c| c.is_audio() && (c.gain_db != 0.0 || !c.gain_env.is_empty())).cloned().collect(); + if clips.is_empty() { + continue; + } + let lane = tr.lane(&AutoParam::Volume).cloned(); + let fader = tr.mixer.volume_db; + let mut times: Vec = lane.as_ref().map(|l| l.points.iter().map(|pt| pt.at).collect()).unwrap_or_default(); + for c in &clips { + times.push(c.start); + times.push(c.end() - 1); + times.extend(c.gain_env.iter().map(|x| c.start + x.0)); + } + times.sort_unstable(); + times.dedup(); + let gain_at = |at: Samples| clips.iter().find(|c| c.range().contains(at)).map_or(0.0, |c| clip_db(c, at - c.start)); + let pts: Vec<(Samples, f32)> = times.iter().map(|&at| (at, volume_at(lane.as_ref(), fader, at) + gain_at(at))).collect(); + let l = tr.lane_mut(&AutoParam::Volume); + l.points.clear(); + for (at, v) in pts { + l.set_point(at.max(0), v); + } + if let Some(pl) = tr.playlist_mut() { + for c in pl.clips.iter_mut().filter(|c| clips.iter().any(|x| x.id == c.id)) { + c.gain_db = 0.0; + c.gain_env.clear(); + n += 1; + } + } + } + Ok(json!({"clips": n})) +} + +fn trim_glide(e: &mut Engine, p: &Value, glide: bool, pan_only: bool, id: &str) -> Result { + let tracks = tracks_required(e, id, p)?; + let r = nonempty_range(e, p, id)?; + let s = e.session_mut(); + let mut lanes = 0; + for t in &tracks { + let Some(tr) = s.track_mut(*t) else { continue }; + let mut params: Vec = 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; + } + } + Ok(json!({"lanes": lanes})) +} + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_model::{ChannelFormat, SourceId, TrackKind}; + + fn session_with_clips(starts: &[(Samples, Samples)]) -> (Engine, soundcraft_model::TrackId, Vec) { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let mut ids = Vec::new(); + for (st, len) in starts { + let id = s.new_clip_id(); + ops::place_clip(&mut s, t, Clip::audio(id, "c", SourceId(1), 0, *st, *len)); + ids.push(id); + } + s.edit.selected_tracks = vec![t]; + (Engine::new(s), t, ids) + } + + fn starts(e: &Engine, t: soundcraft_model::TrackId) -> Vec { + e.session().track(t).map(|tr| tr.clips().iter().map(|c| c.start).collect()).unwrap_or_default() + } + + #[test] + fn space_clips_butts_with_gap() { + let (mut e, t, ids) = session_with_clips(&[(0, 100), (500, 100), (2000, 50)]); + e.execute("edit.space_clips", &json!({"clips": ids.iter().map(|c| c.0).collect::>(), "gap": 10})).unwrap(); + assert_eq!(starts(&e, t), vec![0, 110, 220]); + assert!(e.can_undo()); + } + + #[test] + fn snap_next_and_previous() { + let (mut e, t, ids) = session_with_clips(&[(0, 100), (300, 100), (1000, 100)]); + e.execute("edit.snap_previous", &json!({"clip": ids[1].0})).unwrap(); + assert_eq!(starts(&e, t), vec![0, 100, 1000]); + e.execute("edit.snap_next", &json!({"clip": ids[1].0})).unwrap(); + assert_eq!(starts(&e, t), vec![0, 900, 1000]); + } + + #[test] + fn copy_and_paste_automation_only() { + let (mut e, t, _) = session_with_clips(&[(0, 1000)]); + e.execute("automation.set_point", &json!({"track": t.0, "param": "pan", "at": 100, "value": -1.0})).unwrap(); + e.execute("automation.set_point", &json!({"track": t.0, "param": "pan", "at": 200, "value": 1.0})).unwrap(); + e.execute("automation.set_point", &json!({"track": t.0, "param": "volume", "at": 150, "value": -6.0})).unwrap(); + e.execute("edit.cut_pan_automation", &json!({"start": 50, "end": 250})).unwrap(); + assert_eq!(e.clipboard.automation[0].len(), 1, "only the pan lane is cut"); + assert!(e.session().track(t).unwrap().lane(&AutoParam::Pan(0)).unwrap().points.is_empty()); + assert_eq!(e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().points.len(), 1); + // Paste pan into the volume lane, rescaled from -1..1 to -144..12. + e.execute("track.view", &json!({"view": "volume"})).unwrap(); + e.execute("edit.paste_to_current_automation", &json!({"at": 1000})).unwrap(); + let vol = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().clone(); + assert!((vol.value_at(1150, 0.0) - 12.0).abs() < 1e-3, "{:?}", vol.points); + assert!((vol.value_at(1050, 0.0) + 144.0).abs() < 1e-3); + } + + #[test] + fn clip_gain_copy_paste() { + let (mut e, t, ids) = session_with_clips(&[(0, 1000), (2000, 1000)]); + e.execute("clip.gain", &json!({"clip": ids[0].0, "db": -6.0})).unwrap(); + e.execute("edit.copy_clip_gain", &json!({"start": 0, "end": 1000})).unwrap(); + e.execute("edit.paste_clip_gain", &json!({"at": 2000})).unwrap(); + let c = e.session().track(t).unwrap().clips()[1].clone(); + assert!((clip_db(&c, 500) + 6.0).abs() < 1e-3, "{c:?}"); + } + + #[test] + fn move_to_new_playlist_clears_main() { + let (mut e, t, _) = session_with_clips(&[(0, 1000)]); + e.execute("edit.move_to_new_playlist", &json!({"start": 200, "end": 400})).unwrap(); + let tr = e.session().track(t).unwrap(); + assert_eq!(tr.playlists.len(), 2); + assert_eq!(tr.active_playlist, 0); + assert_eq!(tr.playlists[1].clips.len(), 1); + assert_eq!(tr.playlists[1].clips[0].range(), Range::new(200, 400)); + assert!(tr.clips().iter().all(|c| !c.range().overlaps(&Range::new(200, 400)))); + // Target playlist needs a designation first. + assert!(e.execute("edit.copy_to_target_playlist", &json!({"start": 0, "end": 100})).is_err()); + e.execute("track.playlist_select", &json!({"index": 1})).unwrap(); + e.execute("track.designate_target_playlist", &json!({})).unwrap(); + e.execute("track.playlist_select", &json!({"index": 0})).unwrap(); + e.execute("edit.copy_to_target_playlist", &json!({"start": 0, "end": 100})).unwrap(); + assert_eq!(e.session().track(t).unwrap().playlists[1].clips.len(), 2); + } + + #[test] + fn coalesce_volume_into_clip_gain_and_back() { + let (mut e, t, _) = session_with_clips(&[(0, 1000)]); + e.execute("automation.set_point", &json!({"track": t.0, "at": 0, "value": -10.0})).unwrap(); + e.execute("automation.set_point", &json!({"track": t.0, "at": 1000, "value": 0.0})).unwrap(); + e.execute("edit.automation_coalesce_volume_to_clip_gain", &json!({})).unwrap(); + let c = e.session().track(t).unwrap().clips()[0].clone(); + assert!((clip_db(&c, 500) + 5.0).abs() < 1e-3); + assert!(e.session().track(t).unwrap().lane(&AutoParam::Volume).is_none()); + e.execute("edit.automation_coalesce_clip_gain_to_volume", &json!({})).unwrap(); + let tr = e.session().track(t).unwrap(); + assert!((tr.lane(&AutoParam::Volume).unwrap().value_at(500, 0.0) + 5.0).abs() < 0.1); + assert_eq!(tr.clips()[0].gain_db, 0.0); + } + + #[test] + fn glide_and_trim() { + let (mut e, t, _) = session_with_clips(&[(0, 1000)]); + e.execute("automation.set_point", &json!({"track": t.0, "at": 0, "value": -10.0})).unwrap(); + e.execute("mix.volume", &json!({"db": 0.0})).unwrap(); + e.execute("edit.automation_glide_to_all", &json!({"start": 100, "end": 300})).unwrap(); + let l = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().clone(); + assert!((l.value_at(200, 0.0) + 5.0).abs() < 0.1); + assert!((l.value_at(600, 0.0) + 10.0).abs() < 1e-3, "after the glide the old curve resumes"); + e.execute("edit.automation_trim_to_all", &json!({"start": 400, "end": 500})).unwrap(); + let l = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().clone(); + assert!((l.value_at(450, 0.0)).abs() < 1e-3); + assert!((l.value_at(700, 0.0) + 10.0).abs() < 1e-3); + } + + #[test] + fn merge_midi_keeps_existing_notes() { + let mut e = crate::demo::demo_engine(); + let keys = e.session().track_by_name("Keys").unwrap().id; + let clip = e.session().track(keys).unwrap().clips()[0].clone(); + let before = match &clip.content { + ClipContent::Midi { sequence } => sequence.notes.len(), + _ => 0, + }; + let r = Range::new(clip.start, clip.start + 48_000); + e.execute("edit.select", &json!({"tracks": [keys.0], "start": r.start, "end": r.end})).unwrap(); + e.execute("edit.copy", &json!({})).unwrap(); + let copied = match &e.clipboard.tracks[0][0].content { + ClipContent::Midi { sequence } => sequence.notes.len(), + _ => 0, + }; + e.execute("edit.paste_merge_midi", &json!({"at": clip.start + 96_000})).unwrap(); + let after = match &e.session().track(keys).unwrap().clips()[0].content { + ClipContent::Midi { sequence } => sequence.notes.len(), + _ => 0, + }; + assert_eq!(after, before + copied); + } + + #[test] + fn tce_fits_timeline() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let tone: Vec = (0..24_000).map(|i| (i as f32 * 0.05).sin() * 0.5).collect(); + let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] }; + let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav); + let id = s.new_clip_id(); + ops::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 24_000)); + s.edit.selected_tracks = vec![t]; + s.edit.selected_clips = vec![id]; + s.edit.timeline_selection = Range::new(1000, 31_000); + let mut e = Engine::new(s); + let r = e.execute("edit.tce_to_timeline", &json!({})).unwrap(); + assert!((r["ratio"].as_f64().unwrap() - 1.25).abs() < 1e-9); + let nc = e.session().find_clip(id).unwrap().1.clone(); + assert_eq!((nc.start, nc.length), (1000, 30_000)); + let nsrc = nc.source().unwrap(); + assert_ne!(nsrc, src); + assert_eq!(e.session().source(nsrc).unwrap().frames, 30_000); + assert!(e.session().pool.get(nsrc).unwrap().buffer.channels[0].iter().any(|v| v.abs() > 0.1)); + } + + #[test] + fn extend_to_members() { + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 2})).unwrap(); + e.execute("track.move_to_new_folder", &json!({})).unwrap(); + let folder = e.session().tracks.iter().find(|t| t.is_folder()).unwrap().id; + e.execute("edit.select", &json!({"tracks": [folder.0]})).unwrap(); + e.execute("edit.extend_down_to_members", &json!({})).unwrap(); + assert_eq!(e.session().edit.selected_tracks.len(), 3); + let member = e.session().tracks.iter().find(|t| t.folder == Some(folder)).unwrap().id; + e.execute("edit.select", &json!({"tracks": [member.0]})).unwrap(); + e.execute("edit.extend_up_to_members", &json!({})).unwrap(); + assert_eq!(e.session().edit.selected_tracks.len(), 3); + } + + #[test] + fn marker_lane_selection() { + let mut e = Engine::default(); + e.execute("edit.marker_lane_move_down", &json!({})).unwrap(); + e.execute("edit.marker_lane_extend_down", &json!({})).unwrap(); + let r = e.execute("edit.marker_lane_extend_up", &json!({})).unwrap(); + assert_eq!(r["lanes"], json!([1, 3])); + let r = e.execute("edit.marker_lane_move_up", &json!({})).unwrap(); + assert_eq!(r["lanes"], json!([1, 1])); + } + + #[test] + fn duplicate_and_extend() { + let (mut e, t, _) = session_with_clips(&[(0, 100)]); + e.execute("edit.duplicate_extend", &json!({"start": 0, "end": 100})).unwrap(); + assert_eq!(starts(&e, t), vec![0, 100]); + assert_eq!(e.session().edit.selection, Range::new(0, 200)); + } +} diff --git a/crates/engine/src/cmd/event_more.rs b/crates/engine/src/cmd/event_more.rs new file mode 100644 index 0000000..3dc634c --- /dev/null +++ b/crates/engine/src/cmd/event_more.rs @@ -0,0 +1,933 @@ +//! More Event menu commands: the operations windows, Move Song Start, tempo curves, MIDI +//! operations (split notes, input quantize, step input, restore/flatten performance), MIDI track +//! offsets and real-time properties, chord extraction, Beat Detective, Identify Beat and +//! Retrospective Record. + +use super::more_util::{store_value, track_key, values_with_prefix}; +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_midi::Note; +use soundcraft_midi::ops::QuantizeOptions; +use soundcraft_model::{ChannelFormat, Clip, ClipContent, ClipId, MarkerKind, Session, TrackId, TrackKind}; +use soundcraft_time::{Range, Samples, TICKS_PER_QUARTER, TempoMap}; + +/// Most tempo events one curve may write. +const MAX_TEMPO_EVENTS: i64 = 10_000; + +#[derive(Clone, Copy)] +enum Curve { + Linear, + Parabolic, + Sigmoid, +} + +pub fn specs() -> Vec { + vec![ + cmd!(query "event.time_ops_window", "Time Operations Window", ["Event", "Time Operations"], None, "{} — tempo/meter map, song start and selection for the Time Operations window", always, |e, _| Ok(time_state(e.session()))), + cmd!(query "event.tempo_ops_window", "Tempo Operations Window", ["Event", "Tempo Operations"], None, "{}", always, |e, _| Ok(time_state(e.session()))), + cmd!(query "event.midi_ops_window", "MIDI Operations Window", ["Event", "MIDI Operations"], None, "{} — input quantize and stored performances", always, |e, _| { + let s = e.session(); + Ok(json!({"input_quantize": input_quantize_state(s), "performances": s.midi_originals.len(), "rtp": values_with_prefix(&s.edit, "rtp.")})) + }), + cmd!( + "event.move_song_start", + "Move Song Start...", + ["Event", "Time Operations"], + None, + "{to: position} — moves the song start; tick-based clips and later tempo/meter events move with it", + always, + move_song_start + ), + cmd!( + "event.tempo_linear", + "Linear...", + ["Event", "Tempo Operations"], + None, + "{start?, end?, start_bpm?, end_bpm, resolution?: '1/8'|ticks}", + always, + |e, p| tempo_curve(e, p, Curve::Linear, "event.tempo_linear") + ), + cmd!( + "event.tempo_parabolic", + "Parabolic...", + ["Event", "Tempo Operations"], + None, + "{start?, end?, start_bpm?, end_bpm, curvature?: -1..1 (0.5), resolution?}", + always, + |e, p| tempo_curve(e, p, Curve::Parabolic, "event.tempo_parabolic") + ), + cmd!( + "event.tempo_s_curve", + "S-Curve...", + ["Event", "Tempo Operations"], + None, + "{start?, end?, start_bpm?, end_bpm, resolution?}", + always, + |e, p| tempo_curve(e, p, Curve::Sigmoid, "event.tempo_s_curve") + ), + cmd!( + "event.tempo_stretch", + "Stretch...", + ["Event", "Tempo Operations"], + None, + "{start?, end?, factor? | length?: new length} — scales the tempo inside the selection so it lasts `factor` times as long", + always, + tempo_stretch + ), + cmd!( + "event.select_split_notes", + "Select/Split Notes...", + ["Event", "MIDI Operations"], + None, + "{clips?, pitch_from?: 0, pitch_to?: 127, action?: select|split} — split moves the matching notes to a new MIDI track", + has_selection, + select_split + ), + cmd!(noundo "event.input_quantize", "Input Quantize...", ["Event", "MIDI Operations"], None, "{enabled?: bool, grid?: '1/16'|ticks, strength?: 0..100, swing?: 0..100}", always, input_quantize), + cmd!( + "event.step_input", + "Step Input...", + ["Event", "MIDI Operations"], + None, + "{track?, pitch?: 60 | pitches?: [..], duration?: '1/8'|ticks, velocity?: 100, rest?: bool, advance?: true}", + has_tracks, + step_input + ), + cmd!( + "event.restore_performance", + "Restore Performance...", + ["Event", "MIDI Operations"], + None, + "{clips?} — returns MIDI clips to their stored original performance", + has_selection, + |e, p| performance(e, p, false) + ), + cmd!( + "event.flatten_performance", + "Flatten Performance...", + ["Event", "MIDI Operations"], + None, + "{clips?} — makes the current notes the performance that Restore returns to", + has_selection, + |e, p| performance(e, p, true) + ), + cmd!(noundo "event.midi_track_offsets", "MIDI Track Offsets", ["Event"], None, "{track?, offset?: samples|{seconds}} — sets (or lists) per-track MIDI playback offsets", always, midi_offsets), + cmd!(noundo "event.midi_rtp", "MIDI Real-Time Properties", ["Event"], None, "{tracks?, velocity?: ±127, transpose?: ±127, duration?: % (100), delay?: ticks, clear?: bool}", always, midi_rtp), + cmd!( + "event.extract_chords", + "Extract Chords from Selection", + ["Event"], + None, + "{tracks?, start?, end?} — names the chord in each bar and adds it as a marker on Markers 2", + has_range, + extract_chords + ), + cmd!( + "event.beat_detective", + "Beat Detective", + ["Event"], + None, + "{tracks?, start?, end?, sensitivity?: 0.5, separate?: false, set_tempo?: false}", + has_range, + beat_detective + ), + cmd!( + "event.identify_beat", + "Identify Beat...", + ["Event"], + Some("Cmd+I"), + "{start?, end?, bars?: 1, beats?: 0} — sets the tempo so the selection lasts that many bars", + has_range, + identify_beat + ), + cmd!( + "event.retrospective_record", + "Retrospective Record", + ["Event"], + None, + "{track?, notes?: [{pitch, start?: position | start_ticks?, length_ticks?: 240, velocity?: 100}]} — turns captured MIDI into a clip (input quantize applies)", + always, + retro_record + ), + ] +} + +fn time_state(s: &Session) -> Value { + json!({ + "tempos": s.tempo.tempos(), + "meters": s.tempo.meters(), + "song_start": s.edit.value("song.start", 0.0), + "selection": s.edit.selection, + "sample_rate": s.sample_rate.hz(), + }) +} + +/// Duration in ticks from a note-value string or a number of ticks. +fn note_ticks(v: Option<&Value>, default: i64) -> i64 { + match v { + Some(Value::Number(n)) => n.as_i64().unwrap_or(default).clamp(1, TICKS_PER_QUARTER * 64), + Some(Value::String(s)) => match s.trim() { + "1/1" | "1" | "bar" => TICKS_PER_QUARTER * 4, + "1/2" => TICKS_PER_QUARTER * 2, + "1/4" => TICKS_PER_QUARTER, + "1/4t" => TICKS_PER_QUARTER * 2 / 3, + "1/8" => TICKS_PER_QUARTER / 2, + "1/8t" => TICKS_PER_QUARTER / 3, + "1/16" => TICKS_PER_QUARTER / 4, + "1/16t" => TICKS_PER_QUARTER / 6, + "1/32" => TICKS_PER_QUARTER / 8, + "1/64" => TICKS_PER_QUARTER / 16, + _ => default, + }, + _ => default, + } +} + +fn move_song_start(e: &mut Engine, p: &Value) -> Result { + let id = "event.move_song_start"; + let to = position_param(e, id, p, "to")?.ok_or_else(|| bad(id, "`to` required"))?.max(0); + let s = e.session_mut(); + let from = s.edit.value("song.start", 0.0).max(0.0) as Samples; + let delta = to - from; + if delta == 0 { + return Ok(json!({"song_start": to, "moved": 0})); + } + let bpm0 = s.tempo.tempo_at_tick(0); + let dticks = (delta as f64 * bpm0 * TICKS_PER_QUARTER as f64 / (60.0 * s.sample_rate.as_f64())).round() as i64; + // Tempo and meter events after the start move with the song. + let old = s.tempo.clone(); + let mut map = TempoMap::new(old.tempo_at_tick(0), old.meter_at_tick(0).numerator, old.meter_at_tick(0).denominator) + .map_err(|err| bad(id, err.to_string()))?; + for t in old.tempos().iter().filter(|t| t.tick > 0) { + map.set_tempo((t.tick + dticks).max(1), t.bpm).map_err(|err| bad(id, err.to_string()))?; + } + for m in old.meters().iter().filter(|m| m.tick > 0) { + map.set_meter((m.tick + dticks).max(1), m.numerator, m.denominator).map_err(|err| bad(id, err.to_string()))?; + } + s.tempo = map; + let mut moved = 0; + for tr in s.tracks.iter_mut().filter(|t| t.ticks_timebase && t.kind.has_playlist()) { + if let Some(pl) = tr.playlist_mut() { + for c in &mut pl.clips { + c.start = c.start.saturating_add(delta).max(0); + moved += 1; + } + pl.sort(); + } + } + s.edit.values.insert("song.start".into(), to as f64); + Ok(json!({"song_start": to, "moved": moved})) +} + +fn selection_ticks(e: &Engine, p: &Value, id: &str) -> Result<(Range, i64, i64)> { + let r = range_param(e, id, p)?; + if r.is_empty() { + return Err(bad(id, "select a range on the timeline")); + } + let s = e.session(); + let t0 = s.tempo.samples_to_ticks(r.start, s.sample_rate); + let t1 = s.tempo.samples_to_ticks(r.end, s.sample_rate); + if t1 <= t0 { + return Err(bad(id, "the selection is too short")); + } + Ok((r, t0, t1)) +} + +fn tempo_curve(e: &mut Engine, p: &Value, curve: Curve, id: &str) -> Result { + let (_, t0, t1) = selection_ticks(e, p, id)?; + let res = note_ticks(p.get("resolution"), TICKS_PER_QUARTER / 2); + if (t1 - t0) / res > MAX_TEMPO_EVENTS { + return Err(bad(id, "too many tempo events: use a coarser resolution")); + } + let s = e.session_mut(); + let a = f64_or(p, "start_bpm", s.tempo.tempo_at_tick(t0)); + let b = p.get("end_bpm").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(id, "`end_bpm` required"))?; + let curvature = f64_or(p, "curvature", 0.5).clamp(-1.0, 1.0); + let after = s.tempo.tempo_at_tick(t1); + let had_end = s.tempo.tempos().iter().any(|x| x.tick == t1); + let inside: Vec = s.tempo.tempos().iter().map(|x| x.tick).filter(|t| *t > t0 && *t < t1).collect(); + for t in inside { + s.tempo.remove_tempo(t); + } + let mut n = 0; + let mut tick = t0; + while tick < t1 { + let f = (tick - t0) as f64 / (t1 - t0) as f64; + let g = match curve { + Curve::Linear => f, + Curve::Parabolic => f.powf(2f64.powf(curvature * 2.0)), + Curve::Sigmoid => 0.5 - 0.5 * (f * std::f64::consts::PI).cos(), + }; + s.tempo.set_tempo(tick, a + (b - a) * g).map_err(|err| bad(id, err.to_string()))?; + n += 1; + tick += res; + } + if !had_end { + s.tempo.set_tempo(t1, after).map_err(|err| bad(id, err.to_string()))?; + } + Ok(json!({"events": n, "start_tick": t0, "end_tick": t1})) +} + +fn tempo_stretch(e: &mut Engine, p: &Value) -> Result { + let id = "event.tempo_stretch"; + let (r, t0, t1) = selection_ticks(e, p, id)?; + let factor = match position_param(e, id, p, "length")? { + Some(l) if l > 0 => l as f64 / r.len() as f64, + _ => f64_or(p, "factor", f64::NAN), + }; + if !factor.is_finite() || !(0.05..=20.0).contains(&factor) { + return Err(bad(id, "give `factor` (0.05..20) or a new `length`")); + } + let s = e.session_mut(); + let after = s.tempo.tempo_at_tick(t1); + let had_end = s.tempo.tempos().iter().any(|x| x.tick == t1); + let mut events: Vec<(i64, f64)> = s.tempo.tempos().iter().filter(|x| x.tick > t0 && x.tick < t1).map(|x| (x.tick, x.bpm)).collect(); + events.push((t0, s.tempo.tempo_at_tick(t0))); + for (tick, bpm) in &events { + s.tempo.set_tempo(*tick, bpm / factor).map_err(|err| bad(id, err.to_string()))?; + } + if !had_end { + s.tempo.set_tempo(t1, after).map_err(|err| bad(id, err.to_string()))?; + } + Ok(json!({"factor": factor, "events": events.len()})) +} + +fn midi_clip_ids(e: &Engine, p: &Value) -> Vec<(TrackId, ClipId)> { + let s = e.session(); + clip_ids_param(e, p) + .into_iter() + .filter_map(|id| s.find_clip(id).filter(|(_, c)| matches!(c.content, ClipContent::Midi { .. })).map(|(t, _)| (t, id))) + .collect() +} + +fn select_split(e: &mut Engine, p: &Value) -> Result { + let id = "event.select_split_notes"; + let from = i64_or(p, "pitch_from", 0).clamp(0, 127) as u8; + let to = i64_or(p, "pitch_to", 127).clamp(0, 127) as u8; + let split = match str_param(p, "action") { + None | Some("select") => false, + Some("split") => true, + Some(a) => return Err(bad(id, format!("unknown action `{a}`"))), + }; + let clips = midi_clip_ids(e, p); + if clips.is_empty() { + return Err(bad(id, "select MIDI clips")); + } + let s = e.session_mut(); + let mut matched = 0; + let mut new_tracks: std::collections::BTreeMap = Default::default(); + for (t, cid) in clips { + let Some(c) = s.find_clip(cid).map(|(_, c)| c.clone()) else { continue }; + let ClipContent::Midi { sequence } = &c.content else { continue }; + let (hit, rest) = soundcraft_midi::ops::split_notes(&sequence.notes, from, to); + matched += hit.len(); + if !split || hit.is_empty() { + continue; + } + if let Some(cl) = s.find_clip_mut(cid) + && let ClipContent::Midi { sequence } = &mut cl.content + { + sequence.notes = rest; + } + let nt = match new_tracks.get(&t.0) { + Some(x) => *x, + None => { + let name = s.track(t).map_or_else(|| "MIDI".to_string(), |x| format!("{} Split", x.name)); + let nt = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some(&name)); + new_tracks.insert(t.0, nt); + nt + } + }; + let mut nc = c.clone(); + nc.id = s.new_clip_id(); + nc.content = ClipContent::Midi { sequence: soundcraft_midi::Sequence { notes: hit, ctrls: Vec::new() } }; + crate::edit::place_clip(s, nt, nc); + } + Ok(json!({"matched": matched, "split": split, "tracks": new_tracks.values().collect::>()})) +} + +fn input_quantize_state(s: &Session) -> Value { + json!({ + "enabled": s.edit.flag("midi.input_quantize"), + "grid_ticks": s.edit.value("midi.input_quantize.grid_ticks", (TICKS_PER_QUARTER / 4) as f64), + "strength": s.edit.value("midi.input_quantize.strength", 100.0), + "swing": s.edit.value("midi.input_quantize.swing", 0.0), + }) +} + +fn input_quantize(e: &mut Engine, p: &Value) -> Result { + let s = e.session_mut(); + if let Some(on) = p.get("enabled").and_then(Value::as_bool) { + s.edit.set_flag("midi.input_quantize", on); + } + if p.get("grid").is_some() { + let g = note_ticks(p.get("grid"), TICKS_PER_QUARTER / 4); + s.edit.values.insert("midi.input_quantize.grid_ticks".into(), g as f64); + } + store_value(&mut s.edit, p, "strength", "midi.input_quantize.strength", 0.0, 100.0); + store_value(&mut s.edit, p, "swing", "midi.input_quantize.swing", 0.0, 100.0); + Ok(input_quantize_state(s)) +} + +fn quantize_opts(s: &Session) -> QuantizeOptions { + QuantizeOptions { + grid_ticks: (s.edit.value("midi.input_quantize.grid_ticks", (TICKS_PER_QUARTER / 4) as f64) as i64).clamp(1, TICKS_PER_QUARTER * 16), + strength: (s.edit.value("midi.input_quantize.strength", 100.0) / 100.0).clamp(0.0, 1.0) as f32, + swing: (s.edit.value("midi.input_quantize.swing", 0.0) / 100.0).clamp(0.0, 1.0) as f32, + ..QuantizeOptions::default() + } +} + +fn midi_target(e: &Engine, p: &Value, id: &str) -> Result { + if let Some(t) = track_param(e, id, p, "track")? { + return if e.session().track(t).is_some_and(|x| x.kind.is_midi()) { + Ok(t) + } else { + Err(bad(id, "`track` is not a MIDI or instrument track")) + }; + } + let s = e.session(); + s.edit + .selected_tracks + .iter() + .copied() + .find(|t| s.track(*t).is_some_and(|x| x.kind.is_midi())) + .ok_or_else(|| bad(id, "select a MIDI or instrument track")) +} + +fn step_input(e: &mut Engine, p: &Value) -> Result { + let id = "event.step_input"; + let t = midi_target(e, p, id)?; + let dur = note_ticks(p.get("duration"), TICKS_PER_QUARTER / 2); + let vel = i64_or(p, "velocity", 100).clamp(1, 127) as u8; + let rest = bool_or(p, "rest", false); + let pitches: Vec = match p.get("pitches").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_i64).map(|x| x.clamp(0, 127) as u8).take(16).collect(), + None => vec![i64_or(p, "pitch", 60).clamp(0, 127) as u8], + }; + let s = e.session_mut(); + let sr = s.sample_rate; + let at = s.edit.selection.start.max(0); + let tick = s.tempo.samples_to_ticks(at, sr); + let end_tick = tick.saturating_add(dur); + let end = s.tempo.tick_to_samples(end_tick, sr); + // Continue the clip under (or ending at) the insertion point, else start a new one. + let host = s.track(t).and_then(|tr| { + tr.clips().iter().filter(|c| matches!(c.content, ClipContent::Midi { .. })).find(|c| c.range().contains(at) || c.end() == at).map(|c| c.id) + }); + let host = match host { + Some(h) => h, + None => { + let cid = s.new_clip_id(); + let name = s.track(t).map(|x| x.name.clone()).unwrap_or_default(); + crate::edit::place_clip(s, t, Clip::midi(cid, name, at, (end - at).max(1), soundcraft_midi::Sequence::default())); + cid + } + }; + let next_start = s.track(t).and_then(|tr| tr.clips().iter().filter(|c| c.start > at && c.id != host).map(|c| c.start).min()); + let tempo = s.tempo.clone(); + let mut added = 0; + if let Some(c) = s.find_clip_mut(host) { + let base = tempo.samples_to_ticks(c.start, sr); + if let ClipContent::Midi { sequence } = &mut c.content { + if !rest { + for pitch in &pitches { + sequence.notes.push(Note { pitch: *pitch, velocity: vel, release_velocity: 64, channel: 0, start: tick - base, length: dur }); + added += 1; + } + } + sequence.sort(); + } + if end > c.end() { + c.trim_end_to(end.min(next_start.unwrap_or(Samples::MAX))); + } + } + if bool_or(p, "advance", true) { + s.edit.selection = Range::point(end); + } + Ok(json!({"clip": host, "notes": added, "next": end})) +} + +fn performance(e: &mut Engine, p: &Value, flatten: bool) -> Result { + let clips = midi_clip_ids(e, p); + let s = e.session_mut(); + let mut n = 0; + for (_, cid) in clips { + if flatten { + if let Some(ClipContent::Midi { sequence }) = s.find_clip(cid).map(|(_, c)| c.content.clone()) { + s.midi_originals.insert(cid.0, sequence); + n += 1; + } + } else if let Some(orig) = s.midi_originals.get(&cid.0).cloned() + && let Some(c) = s.find_clip_mut(cid) + && let ClipContent::Midi { sequence } = &mut c.content + && *sequence != orig + { + *sequence = orig; + n += 1; + } + } + Ok(json!({"clips": n})) +} + +fn midi_offsets(e: &mut Engine, p: &Value) -> Result { + let id = "event.midi_track_offsets"; + let t = track_param(e, id, p, "track")?; + let off = position_param(e, id, p, "offset")?; + let s = e.session_mut(); + if let (Some(t), Some(o)) = (t, off) { + let lim = s.sample_rate.samples(10.0); + s.edit.values.insert(format!("midi.offset.{}", t.0), o.clamp(-lim, lim) as f64); + } + let all: Vec = s + .tracks + .iter() + .filter(|tr| tr.kind.is_midi()) + .map(|tr| json!({"track": tr.id, "name": tr.name, "offset": s.edit.value(&format!("midi.offset.{}", tr.id.0), 0.0)})) + .collect(); + Ok(json!({"offsets": all})) +} + +fn midi_rtp(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_param(e, "event.midi_rtp", p)?; + let s = e.session_mut(); + let clear = bool_or(p, "clear", false); + for t in &tracks { + if clear { + let pre = format!("rtp.{}.", t.0); + s.edit.values.retain(|k, _| !k.starts_with(&pre)); + continue; + } + store_value(&mut s.edit, p, "velocity", &track_key("rtp", *t, "velocity"), -127.0, 127.0); + store_value(&mut s.edit, p, "transpose", &track_key("rtp", *t, "transpose"), -127.0, 127.0); + store_value(&mut s.edit, p, "duration", &track_key("rtp", *t, "duration"), 1.0, 1000.0); + store_value(&mut s.edit, p, "delay", &track_key("rtp", *t, "delay"), -1e7, 1e7); + } + Ok(json!({"rtp": values_with_prefix(&s.edit, "rtp.")})) +} + +const NOTE_NAMES: [&str; 12] = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"]; +const CHORDS: [(&str, &[usize]); 12] = [ + ("", &[0, 4, 7]), + ("m", &[0, 3, 7]), + ("dim", &[0, 3, 6]), + ("aug", &[0, 4, 8]), + ("sus2", &[0, 2, 7]), + ("sus4", &[0, 5, 7]), + ("7", &[0, 4, 7, 10]), + ("maj7", &[0, 4, 7, 11]), + ("m7", &[0, 3, 7, 10]), + ("m7b5", &[0, 3, 6, 10]), + ("6", &[0, 4, 7, 9]), + ("m6", &[0, 3, 7, 9]), +]; + +/// Name the chord formed by duration-weighted pitch classes (`bass` = lowest pitch class). +pub fn chord_name(weights: &[f64; 12], bass: usize) -> Option { + let present = weights.iter().filter(|w| **w > 0.0).count(); + if present < 3 { + return None; + } + let total: f64 = weights.iter().sum(); + let maxw = weights.iter().copied().fold(0.0, f64::max); + let mut best: Option<(f64, usize, &str)> = None; + for root in 0..12 { + for (suffix, tones) in CHORDS { + let w = |i: usize| weights.get((root + i) % 12).copied().unwrap_or(0.0); + let inside: f64 = tones.iter().map(|i| w(*i)).sum(); + let missing = tones.iter().filter(|i| w(**i) <= 0.0).count() as f64; + let mut score = inside - (total - inside) - missing * 0.6 * maxw - tones.len() as f64 * 0.01 * maxw; + if root == bass { + score += 0.1 * maxw; + } + if best.is_none_or(|(b, _, _)| score > b) { + best = Some((score, root, suffix)); + } + } + } + let (_, root, suffix) = best?; + let mut name = format!("{}{suffix}", NOTE_NAMES.get(root).copied().unwrap_or("C")); + if bass != root { + name.push('/'); + name.push_str(NOTE_NAMES.get(bass % 12).copied().unwrap_or("C")); + } + Some(name) +} + +fn extract_chords(e: &mut Engine, p: &Value) -> Result { + 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 = 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 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); + let end = s.tempo.samples_to_ticks(c.end(), s.sample_rate); + for n in &sequence.notes { + let a = base + n.start; + let b = (base + n.end()).min(end); + if b > a { + notes.push((a, b, n.pitch)); + } + } + } + } + let first_bar = s.tempo.bar_beat_at_tick(t0).bar; + let mut bar = first_bar; + let mut chords: Vec = Vec::new(); + let mut marks: Vec<(Samples, String)> = Vec::new(); + let mut last = String::new(); + while chords.len() < 2000 { + let a = s.tempo.bar_start_tick(bar).max(t0); + let b = s.tempo.bar_start_tick(bar + 1).min(t1); + if a >= t1 { + break; + } + let mut w = [0.0f64; 12]; + let mut bass: Option = None; + for (na, nb, pitch) in ¬es { + 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})); + if name != last { + marks.push((at, name.clone())); + last = name; + } + } + bar += 1; + } + let s = e.session_mut(); + for (at, name) in &marks { + let mid = s.add_marker(name, MarkerKind::Marker, *at, *at); + if let Some(m) = s.markers.iter_mut().find(|m| m.id == mid) { + m.ruler = 2; + } + } + Ok(json!({"chords": chords, "markers": marks.len()})) +} + +fn beat_detective(e: &mut Engine, p: &Value) -> Result { + let id = "event.beat_detective"; + let tracks = tracks_required(e, id, p)?; + let r = range_param(e, id, p)?; + if r.is_empty() { + return Err(bad(id, "select a range to analyse")); + } + if r.len() > e.session().sample_rate.samples(20.0 * 60.0) { + return Err(bad(id, "analyse at most 20 minutes at a time")); + } + let sens = f32_or(p, "sensitivity", 0.5).clamp(0.0, 1.0); + let s = e.session(); + let sr = s.sample_rate; + let mut hits: Vec = Vec::new(); + for t in &tracks { + if s.track(*t).is_some_and(|x| x.kind.has_playlist() && !x.kind.is_midi()) { + hits.extend(crate::io::transients_in(s, *t, r, sens)); + } + } + hits.sort_unstable(); + let min_gap = sr.samples(0.03); + let mut beats: Vec = Vec::new(); + for h in hits { + if beats.last().is_none_or(|l| h - l >= min_gap) { + beats.push(h); + } + } + let mut iois: Vec = beats.windows(2).filter_map(|w| Some((w.get(1)? - w.first()?) as f64)).filter(|d| *d > 0.0).collect(); + iois.sort_by(f64::total_cmp); + let bpm = iois.get(iois.len() / 2).map(|d| { + let mut b = 60.0 * sr.as_f64() / d; + let mut guard = 0; + while b < 70.0 && guard < 16 { + b *= 2.0; + guard += 1; + } + while b >= 180.0 && guard < 32 { + b /= 2.0; + guard += 1; + } + (b * 100.0).round() / 100.0 + }); + let separate = bool_or(p, "separate", false); + let set_tempo = bool_or(p, "set_tempo", false); + let s = e.session_mut(); + let mut separated = 0; + if separate { + for t in &tracks { + for b in &beats { + separated += crate::edit::separate_at(s, *t, *b); + } + } + } + if set_tempo && let Some(b) = bpm { + let tick = s.tempo.samples_to_ticks(r.start, sr); + s.tempo.set_tempo(tick, b).map_err(|err| bad(id, err.to_string()))?; + } + let shown: Vec = beats.iter().take(2000).copied().collect(); + Ok(json!({"beats": shown, "count": beats.len(), "bpm": bpm, "separated": separated})) +} + +fn identify_beat(e: &mut Engine, p: &Value) -> Result { + let id = "event.identify_beat"; + let (r, t0, _) = selection_ticks(e, p, id)?; + let s = e.session(); + let m = s.tempo.meter_at_tick(t0); + let bars = f64_or(p, "bars", 1.0).max(0.0); + let beats = f64_or(p, "beats", 0.0).max(0.0); + let total_beats = bars * f64::from(m.numerator.max(1)) + beats; + if total_beats <= 0.0 { + return Err(bad(id, "`bars`/`beats` must describe a positive length")); + } + let quarters = total_beats * 4.0 / f64::from(m.denominator.max(1)); + let secs = s.sample_rate.seconds(r.len()); + let bpm = quarters * 60.0 / secs; + let s = e.session_mut(); + s.tempo.set_tempo(t0, bpm).map_err(|err| bad(id, err.to_string()))?; + Ok(json!({"bpm": bpm, "tick": t0})) +} + +fn retro_record(e: &mut Engine, p: &Value) -> Result { + let id = "event.retrospective_record"; + let Some(list) = p.get("notes").and_then(Value::as_array).filter(|a| !a.is_empty()) else { + return Ok(json!({"recorded": 0})); + }; + let t = midi_target(e, p, id)?; + let s = e.session(); + let sr = s.sample_rate; + let mut raw: Vec<(i64, Note)> = Vec::new(); + for v in list.iter().take(100_000) { + let tick = match (v.get("start_ticks").and_then(Value::as_i64), v.get("start")) { + (Some(tk), _) => tk.max(0), + (None, Some(_)) => s.tempo.samples_to_ticks(position_param(e, id, v, "start")?.unwrap_or(0).max(0), sr), + _ => return Err(bad(id, "each note needs `start` or `start_ticks`")), + }; + let note = Note { + pitch: i64_or(v, "pitch", 60).clamp(0, 127) as u8, + velocity: i64_or(v, "velocity", 100).clamp(1, 127) as u8, + release_velocity: 64, + channel: i64_or(v, "channel", 0).clamp(0, 15) as u8, + start: 0, + length: i64_or(v, "length_ticks", TICKS_PER_QUARTER / 4).clamp(1, TICKS_PER_QUARTER * 1024), + }; + raw.push((tick, note)); + } + let first = raw.iter().map(|x| x.0).min().unwrap_or(0); + // Start the clip on the bar line so the quantize grid lines up with the music. + let clip_tick = s.tempo.bar_start_tick(s.tempo.bar_beat_at_tick(first).bar).min(first).max(0); + let end_tick = raw.iter().map(|(tk, n)| tk.saturating_add(n.length)).max().unwrap_or(first + 1); + let mut seq = soundcraft_midi::Sequence::default(); + for (tk, mut n) in raw { + n.start = tk - clip_tick; + seq.notes.push(n); + } + seq.sort(); + let performance = seq.clone(); + let quantized = s.edit.flag("midi.input_quantize"); + if quantized { + soundcraft_midi::ops::quantize(&mut seq.notes, &quantize_opts(s)); + seq.sort(); + } + let start = s.tempo.tick_to_samples(clip_tick, sr); + let end = s.tempo.tick_to_samples(end_tick, sr); + let n = seq.notes.len(); + let s = e.session_mut(); + let cid = s.new_clip_id(); + let name = s.track(t).map(|x| x.name.clone()).unwrap_or_default(); + crate::edit::place_clip(s, t, Clip::midi(cid, name, start, (end - start).max(1), seq)); + s.midi_originals.insert(cid.0, performance); + Ok(json!({"recorded": n, "clip": cid, "quantized": quantized})) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn midi_engine() -> (Engine, TrackId) { + let mut e = Engine::default(); + e.execute("track.new", &json!({"kind": "midi"})).unwrap(); + let t = e.session().tracks[0].id; + (e, t) + } + + #[test] + fn chord_names() { + let mut w = [0.0; 12]; + for pc in [0, 4, 7] { + w[pc] = 1.0; + } + assert_eq!(chord_name(&w, 0).as_deref(), Some("C")); + let mut w = [0.0; 12]; + for pc in [9, 0, 4] { + w[pc] = 1.0; + } + assert_eq!(chord_name(&w, 9).as_deref(), Some("Am")); + let mut w = [0.0; 12]; + for pc in [7, 11, 2, 5] { + w[pc] = 1.0; + } + assert_eq!(chord_name(&w, 7).as_deref(), Some("G7")); + assert_eq!(chord_name(&[0.0; 12], 0), None); + } + + #[test] + fn retro_record_quantizes_and_restores() { + let (mut e, t) = midi_engine(); + e.execute("event.input_quantize", &json!({"enabled": true, "grid": "1/4"})).unwrap(); + let r = e + .execute( + "event.retrospective_record", + &json!({"track": t.0, "notes": [{"pitch": 60, "start_ticks": 1010}, {"pitch": 64, "start_ticks": 1930}]}), + ) + .unwrap(); + assert_eq!(r["recorded"], 2); + let cid = ClipId(r["clip"].as_u64().unwrap()); + let notes = |e: &Engine| match &e.session().find_clip(cid).unwrap().1.content { + ClipContent::Midi { sequence } => sequence.notes.iter().map(|n| n.start).collect::>(), + _ => vec![], + }; + assert_eq!(notes(&e), vec![960, 1920]); + e.execute("edit.select", &json!({"clips": [cid.0]})).unwrap(); + e.execute("event.restore_performance", &json!({})).unwrap(); + assert_eq!(notes(&e), vec![1010, 1930]); + assert_eq!(e.execute("event.retrospective_record", &json!({})).unwrap()["recorded"], 0); + } + + #[test] + fn flatten_then_restore() { + let (mut e, t) = midi_engine(); + let r = e.execute("event.retrospective_record", &json!({"track": t.0, "notes": [{"pitch": 60, "start_ticks": 100}]})).unwrap(); + let cid = r["clip"].as_u64().unwrap(); + e.execute("edit.select", &json!({"clips": [cid]})).unwrap(); + e.execute("event.transpose", &json!({"semitones": 5})).unwrap(); + e.execute("event.flatten_performance", &json!({})).unwrap(); + e.execute("event.transpose", &json!({"semitones": 5})).unwrap(); + e.execute("event.restore_performance", &json!({})).unwrap(); + match &e.session().find_clip(ClipId(cid)).unwrap().1.content { + ClipContent::Midi { sequence } => assert_eq!(sequence.notes[0].pitch, 65), + _ => panic!(), + } + } + + #[test] + fn step_input_appends_and_advances() { + let (mut e, t) = midi_engine(); + e.execute("edit.select", &json!({"tracks": [t.0], "start": 0})).unwrap(); + e.execute("event.step_input", &json!({"pitch": 60, "duration": "1/4"})).unwrap(); + e.execute("event.step_input", &json!({"pitches": [64, 67], "duration": "1/4"})).unwrap(); + e.execute("event.step_input", &json!({"rest": true, "duration": "1/4"})).unwrap(); + let tr = e.session().track(t).unwrap(); + assert_eq!(tr.clips().len(), 1); + let c = &tr.clips()[0]; + assert_eq!(c.length, 3 * 24_000); + match &c.content { + ClipContent::Midi { sequence } => assert_eq!(sequence.notes.iter().map(|n| n.start).collect::>(), vec![0, 960, 960]), + _ => panic!(), + } + assert_eq!(e.session().edit.selection.start, 3 * 24_000); + } + + #[test] + fn tempo_curves() { + let mut e = Engine::default(); + e.execute("track.new", &json!({})).unwrap(); + e.execute("event.tempo_linear", &json!({"start": 0, "end": 96_000, "start_bpm": 120.0, "end_bpm": 160.0, "resolution": "1/4"})).unwrap(); + let tempos = e.session().tempo.tempos().to_vec(); + assert_eq!(tempos.first().unwrap().bpm, 120.0); + assert!(tempos.windows(2).all(|w| w[1].bpm >= w[0].bpm || w[1].tick >= 3840)); + assert_eq!(e.session().tempo.tempo_at_tick(3840), 120.0, "tempo after the selection is kept"); + assert_eq!(tempos.len(), 5); + let mut e = Engine::default(); + e.execute("event.tempo_stretch", &json!({"start": 0, "end": 96_000, "factor": 2.0})).unwrap(); + assert_eq!(e.session().tempo.tempo_at_tick(0), 60.0); + assert_eq!(e.session().tempo.tick_to_samples(3840, soundcraft_time::SampleRate::HZ_48000), 192_000); + } + + #[test] + fn identify_beat_sets_tempo() { + let mut e = Engine::default(); + e.execute("event.identify_beat", &json!({"start": 0, "end": 96_000, "bars": 1})).unwrap(); + assert!((e.session().tempo.tempo_at_tick(0) - 120.0).abs() < 1e-9); + e.execute("event.identify_beat", &json!({"start": 0, "end": 192_000, "bars": 1})).unwrap(); + assert!((e.session().tempo.tempo_at_tick(0) - 60.0).abs() < 1e-9); + } + + #[test] + fn split_notes_to_new_track() { + let (mut e, t) = midi_engine(); + let r = e + .execute( + "event.retrospective_record", + &json!({"track": t.0, "notes": [{"pitch": 36, "start_ticks": 0}, {"pitch": 72, "start_ticks": 0}]}), + ) + .unwrap(); + let cid = r["clip"].as_u64().unwrap(); + e.execute("edit.select", &json!({"clips": [cid]})).unwrap(); + let r = e.execute("event.select_split_notes", &json!({"pitch_from": 60, "pitch_to": 127, "action": "split"})).unwrap(); + assert_eq!(r["matched"], 1); + assert_eq!(e.session().tracks.len(), 2); + let nt = e.session().tracks.iter().find(|x| x.id != t).unwrap(); + match &nt.clips()[0].content { + ClipContent::Midi { sequence } => assert_eq!(sequence.notes[0].pitch, 72), + _ => panic!(), + } + } + + #[test] + fn chords_from_demo_become_markers() { + let mut e = crate::demo::demo_engine(); + let keys = e.session().track_by_name("Keys").unwrap().clone(); + let c = keys.clips()[0].clone(); + let before = e.session().markers.len(); + e.execute("edit.select", &json!({"tracks": [keys.id.0], "start": c.start, "end": c.end()})).unwrap(); + let r = e.execute("event.extract_chords", &json!({})).unwrap(); + assert!(!r["chords"].as_array().unwrap().is_empty(), "{r}"); + assert!(e.session().markers.len() > before); + assert!(e.session().markers.iter().any(|m| m.ruler == 2)); + } + + #[test] + fn beat_detective_finds_demo_tempo() { + let mut e = crate::demo::demo_engine(); + let kick = e.session().track_by_name("Kick").unwrap().clone(); + let c = kick.clips()[0].clone(); + let r = e.execute("event.beat_detective", &json!({"tracks": [kick.id.0], "start": c.start, "end": c.start + 48_000 * 8})).unwrap(); + assert!(r["count"].as_u64().unwrap() > 4, "{r}"); + assert!(r["bpm"].as_f64().is_some()); + } + + #[test] + fn move_song_start_moves_tick_material() { + let (mut e, t) = midi_engine(); + e.execute("event.retrospective_record", &json!({"track": t.0, "notes": [{"pitch": 60, "start_ticks": 0}]})).unwrap(); + e.execute("event.move_song_start", &json!({"to": 48_000})).unwrap(); + assert_eq!(e.session().track(t).unwrap().clips()[0].start, 48_000); + assert_eq!(e.session().edit.value("song.start", 0.0), 48_000.0); + } + + #[test] + fn rtp_and_offsets_are_settings() { + let (mut e, t) = midi_engine(); + let undo = e.undo_history().len(); + e.execute("event.midi_rtp", &json!({"track": t.0, "transpose": 7})).unwrap(); + assert_eq!(e.session().edit.value(&track_key("rtp", t, "transpose"), 0.0), 7.0); + let r = e.execute("event.midi_track_offsets", &json!({"track": t.0, "offset": 480})).unwrap(); + assert_eq!(r["offsets"][0]["offset"], 480.0); + assert_eq!(e.undo_history().len(), undo, "settings are not undo steps"); + } +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d6bdcd2..7c6c09b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -154,5 +154,7 @@ fn bounce(e: &mut Engine, p: &Value) -> Result { let normalize = bool_or(p, "normalize", false); let (bytes, stats) = crate::io::bounce_bytes(e, r, &opts, normalize)?; std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; - Ok(json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1})) + Ok( + json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1, "true_peak_db": stats.2}), + ) } diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs index 61c92b1..fe9fdc2 100644 --- a/crates/engine/src/cmd/midi.rs +++ b/crates/engine/src/cmd/midi.rs @@ -10,7 +10,15 @@ use soundcraft_time::TICKS_PER_QUARTER; pub fn specs() -> Vec { vec![ cmd!(query "midi.notes", "List MIDI Notes", [], None, "{clip}", always, notes), - cmd!("midi.new_clip", "New MIDI Clip", [], None, "{track, start?, end?|length?} (default: the edit selection, or 1 bar)", has_tracks, new_clip), + cmd!( + "midi.new_clip", + "New MIDI Clip", + [], + None, + "{track, start?, end?|length?} (default: the edit selection, or 1 bar)", + has_tracks, + new_clip + ), cmd!("midi.note_add", "Add Note", [], None, "{clip, pitch, start_ticks, length_ticks?: 480, velocity?: 100, channel?: 0}", always, note_add), cmd!("midi.note_edit", "Edit Note", [], None, "{clip, index, pitch?, start_ticks?, length_ticks?, velocity?}", always, note_edit), cmd!("midi.note_delete", "Delete Notes", [], None, "{clip, indices: [n]}", always, note_delete), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 9726d2b..e28365b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -8,16 +8,23 @@ use std::sync::OnceLock; mod audiosuite; mod clip; -mod midi; +mod clip_more; mod edit; +mod edit_more; mod event; +mod event_more; mod file; +mod midi; mod mix; +mod more_util; mod options; mod query; +mod setup_more; mod track; +mod track_more; mod transport; mod view; +mod view_more; pub type Run = fn(&mut Engine, &Value) -> Result; /// `Err(reason)` = disabled. @@ -85,6 +92,7 @@ pub fn command_specs() -> &'static [CommandSpec] { let mut v = Vec::new(); v.extend(file::specs()); v.extend(edit::specs()); + v.extend(edit_more::specs()); v.extend(view::specs()); v.extend(track::specs()); v.extend(clip::specs()); @@ -95,6 +103,11 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(query::specs()); v.extend(audiosuite::specs()); v.extend(midi::specs()); + v.extend(view_more::specs()); + v.extend(track_more::specs()); + v.extend(clip_more::specs()); + v.extend(event_more::specs()); + v.extend(setup_more::specs()); v }) } diff --git a/crates/engine/src/cmd/more_util.rs b/crates/engine/src/cmd/more_util.rs new file mode 100644 index 0000000..756106b --- /dev/null +++ b/crates/engine/src/cmd/more_util.rs @@ -0,0 +1,205 @@ +//! Helpers shared by the newer command modules: settings kept in `EditState.flags`/`values`, +//! per-track settings, target playlists, clip-gain envelopes and stretch rendering. + +use super::*; +use serde_json::json; +use soundcraft_audio_io::{AudioBuffer, FileFormat}; +use soundcraft_model::{Clip, ClipContent, EditState, Session, SourceId, TrackId}; +use soundcraft_time::Samples; + +/// Toggle (or set from `value`) a flag in `edit.flags`; returns the new state. +pub fn toggle_flag(e: &mut Engine, p: &Value, id: &str) -> Result { + let s = e.session_mut(); + let on = p.get("value").and_then(Value::as_bool).unwrap_or(!s.edit.flag(id)); + s.edit.set_flag(id, on); + Ok(json!({"flag": id, "value": on})) +} + +/// Turn `id` on and every other flag of `group` off (radio-style menu items). +pub fn exclusive_flag(e: &mut Engine, id: &str, group: &[&str]) -> Result { + let s = e.session_mut(); + for g in group { + s.edit.set_flag(g, false); + } + s.edit.set_flag(id, true); + Ok(json!({"flag": id, "value": true})) +} + +/// Set several flags at once. +pub fn set_flags(e: &mut Engine, ids: &[&str], on: bool) -> Value { + let s = e.session_mut(); + for id in ids { + s.edit.set_flag(id, on); + } + json!({"flags": ids, "value": on}) +} + +/// String settings live in `edit.flags` as `key=value` (one per key). +pub fn set_text(edit: &mut EditState, key: &str, value: Option<&str>) { + let prefix = format!("{key}="); + edit.flags.retain(|f| !f.starts_with(&prefix)); + if let Some(v) = value.map(str::trim).filter(|v| !v.is_empty()) { + let v: String = v.chars().take(200).collect(); + edit.flags.insert(format!("{prefix}{v}")); + } +} + +pub fn text(edit: &EditState, key: &str) -> Option { + let prefix = format!("{key}="); + edit.flags.iter().find_map(|f| f.strip_prefix(&prefix).map(str::to_string)) +} + +/// All `values` whose key starts with `prefix`, as a JSON object (prefix stripped). +pub fn values_with_prefix(edit: &EditState, prefix: &str) -> Value { + let m: serde_json::Map = + edit.values.iter().filter_map(|(k, v)| k.strip_prefix(prefix).map(|rest| (rest.to_string(), json!(v)))).collect(); + Value::Object(m) +} + +/// Store a finite number from `p[key]` into `values[target]`, clamped. Returns whether it was given. +pub fn store_value(edit: &mut EditState, p: &Value, key: &str, target: &str, lo: f64, hi: f64) -> bool { + match p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()) { + Some(v) => { + edit.values.insert(target.to_string(), v.clamp(lo, hi)); + true + } + None => false, + } +} + +/// Key for a per-track value, e.g. `rtp.12.velocity`. +pub fn track_key(prefix: &str, t: TrackId, name: &str) -> String { + format!("{prefix}.{}.{name}", t.0) +} + +/// The track's designated target playlist index, if valid. +pub fn target_playlist(s: &Session, t: TrackId) -> Option { + let v = s.edit.values.get(&format!("playlist.target.{}", t.0)).copied()?; + if !v.is_finite() || v < 0.0 { + return None; + } + let i = v as usize; + s.track(t).filter(|tr| i < tr.playlists.len()).map(|_| i) +} + +/// Make `idx` the active playlist, remembering the previous one for Toggle Recent Playlist. +pub fn activate_playlist(s: &mut Session, t: TrackId, idx: usize) -> bool { + let Some(tr) = s.track_mut(t) else { return false }; + if idx >= tr.playlists.len() || idx == tr.active_playlist { + return false; + } + let prev = tr.active_playlist; + tr.active_playlist = idx; + s.edit.values.insert(format!("playlist.recent.{}", t.0), prev as f64); + true +} + +/// Interpolated value of a `(offset, dB)` envelope (0 when empty), like the model's clip gain. +pub fn env_db(env: &[(Samples, f32)], at: Samples) -> f32 { + let mut prev: Option<(Samples, f32)> = None; + for &(t, v) in env { + if t >= at { + return match prev { + Some((pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32), + _ => v, + }; + } + prev = Some((t, v)); + } + prev.map_or(0.0, |(_, v)| v) +} + +/// Absolute clip gain (static gain + envelope) in dB at `rel` samples from the clip start. +pub fn clip_db(c: &Clip, rel: Samples) -> f32 { + c.gain_db + if c.gain_env.is_empty() { 0.0 } else { env_db(&c.gain_env, rel) } +} + +/// Replace a clip's gain with an absolute envelope (sorted, deduplicated, clamped). +pub fn set_clip_env(c: &mut Clip, mut pts: Vec<(Samples, f32)>) { + pts.retain(|(o, v)| *o >= 0 && *o <= c.length && v.is_finite()); + pts.sort_by_key(|p| p.0); + pts.dedup_by_key(|p| p.0); + for p in &mut pts { + p.1 = p.1.clamp(-144.0, 36.0); + } + c.gain_db = 0.0; + let flat = pts.windows(2).all(|w| w.first().map(|a| a.1) == w.get(1).map(|b| b.1)); + if flat { + c.gain_db = pts.first().map_or(0.0, |p| p.1); + c.gain_env.clear(); + } else { + c.gain_env = pts; + } +} + +/// Longest audio we render in one stretch (one hour at the session rate). +fn max_render(s: &Session) -> Samples { + s.sample_rate.samples(3600.0) +} + +/// Render the audio a clip plays, time-stretched (pitch preserved) to `out_len` samples, into a new +/// source. Returns `Ok(None)` for MIDI clips or missing audio. +pub fn render_stretched(s: &mut Session, cmd: &str, c: &Clip, out_len: Samples) -> Result> { + let ClipContent::Audio { source, offset } = c.content else { return Ok(None) }; + let Some(audio) = s.pool.get(source).cloned() else { return Ok(None) }; + if out_len <= 0 || out_len > max_render(s) { + return Err(bad(cmd, "the target length must be between 1 sample and one hour")); + } + let stretch = if c.stretch.is_finite() && c.stretch > 0.0 { c.stretch } else { 1.0 }; + let src_len = ((c.length as f64) / stretch).round().max(1.0); + let ratio = out_len as f64 / src_len; + if !(0.1..=10.0).contains(&ratio) { + return Err(bad(cmd, format!("stretch ratio {ratio:.3} is outside 0.1..10"))); + } + let a = usize::try_from(offset.max(0)).unwrap_or(0); + let n = src_len as usize; + let ch: Vec> = audio + .buffer + .channels + .iter() + .map(|x| { + let mut v: Vec = x.iter().skip(a).take(n).copied().collect(); + v.resize(n, 0.0); + v + }) + .collect(); + let sr = s.sample_rate; + let mut out = soundcraft_dsp::offline::time_stretch(&ch, ratio, sr.as_f64() as f32); + let want = usize::try_from(out_len).unwrap_or(0); + for o in &mut out { + o.resize(want, 0.0); + } + let buf = AudioBuffer { sample_rate: sr.hz(), channels: out }; + Ok(Some(crate::io::add_source(s, &format!("{}-TCE", c.name), buf, None, FileFormat::Wav))) +} + +/// Replace clip `old` on track `t` with `new` (overwriting whatever `new` covers). +pub fn replace_clip(s: &mut Session, t: TrackId, old: soundcraft_model::ClipId, new: Clip) { + if let Some(pl) = s.track_mut(t).and_then(|tr| tr.playlist_mut()) { + pl.clips.retain(|c| c.id != old); + } + crate::edit::place_clip(s, t, new); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_settings_round_trip() { + let mut ed = EditState::default(); + set_text(&mut ed, "engine.device", Some("Built-in")); + set_text(&mut ed, "engine.device", Some("Interface")); + assert_eq!(text(&ed, "engine.device").as_deref(), Some("Interface")); + set_text(&mut ed, "engine.device", None); + assert_eq!(text(&ed, "engine.device"), None); + } + + #[test] + fn envelope_interpolates() { + let env = [(0, 0.0f32), (100, -10.0)]; + assert!((env_db(&env, 50) + 5.0).abs() < 1e-6); + assert_eq!(env_db(&[], 50), 0.0); + assert_eq!(env_db(&env, 500), -10.0); + } +} diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs new file mode 100644 index 0000000..7a936af --- /dev/null +++ b/crates/engine/src/cmd/setup_more.rs @@ -0,0 +1,606 @@ +//! More Options toggles, the Setup menu (hardware, playback engine, I/O, timecode, click, +//! shortcuts, MIDI setup) and File › Import › Session Data. + +use super::more_util::{set_text, store_value, text, toggle_flag, values_with_prefix}; +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, Source, SourceAudio, SourceId, TrackId, TrackKind}; +use soundcraft_time::Timecode; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +macro_rules! option { + ($id:literal, $label:literal, $flag:literal) => { + cmd!(noundo $id, $label, ["Options"], None, "{value?: bool} (toggles when omitted)", always, |e, p| toggle_flag(e, p, $flag)) + }; +} + +const MIDI_FILTER_KINDS: [&str; 8] = ["notes", "pitch_bend", "mono_aftertouch", "poly_aftertouch", "program", "cc", "sysex", "realtime"]; + +pub fn specs() -> Vec { + vec![ + option!("options.destructive_punch", "DestructivePunch", "options.destructive_punch"), + option!("options.transport_online", "Transport Online", "options.transport_online"), + option!("options.dynamic_transport", "Dynamic Transport", "options.dynamic_transport"), + option!( + "options.marker_target_follows_selection", + "Marker Target Follows First Selected Track", + "options.marker_target_follows_first_selected_track" + ), + option!("options.midi_thru", "MIDI Thru", "options.midi_thru"), + option!("options.auto_spot_clips", "Auto-Spot Clips", "options.auto_spot_clips"), + option!("options.keyboard_lock", "Edit/Tool Mode Keyboard Lock", "options.edit_tool_mode_keyboard_lock"), + option!("options.calibration_mode", "Calibration Mode", "options.calibration_mode"), + option!("options.midi_live_mode", "MIDI Live Mode", "options.midi_live_mode"), + option!("options.low_latency_monitoring", "Low Latency Monitoring", "options.low_latency_monitoring"), + cmd!( + "options.prepare_dpe_tracks", + "Prepare DPE Tracks", + ["Options"], + None, + "{tracks?} — consolidates each record-armed (or given) audio track into one continuous clip for destructive punch", + always, + prepare_dpe + ), + cmd!(noundo "setup.hardware", "Hardware...", ["Setup"], None, "{device?, buffer_size?: 16..8192} — audio interface settings (sample rate is the session's)", always, |e, p| engine_settings(e, p)), + cmd!(noundo "setup.playback_engine", "Playback Engine...", ["Setup"], None, "{device?, buffer_size?: 16..8192, delay_compensation?: bool}", always, |e, p| { + if let Some(v) = p.get("delay_compensation").and_then(Value::as_bool) { + e.session_mut().edit.delay_compensation = v; + } + engine_settings(e, p) + }), + cmd!(noundo "setup.disk_allocation", "Disk Allocation...", ["Setup"], None, "{path?, track?} — the record folder for the session, or for one track", always, disk_allocation), + cmd!(noundo "setup.peripherals", "Peripherals...", ["Setup"], None, "{settings?: {name: number|bool|string}} — control surface / sync device settings", always, peripherals), + cmd!( + "setup.io", + "I/O...", + ["Setup"], + None, + "{action?: list|create_bus|rename_bus|delete_bus|create_output|rename_output|delete_output, name?, new_name?, format?: stereo, first_channel?: 0}", + always, + io_setup + ), + cmd!( + "setup.current_timecode", + "Current Timecode Position...", + ["Setup"], + None, + "{timecode: 'HH:MM:SS:FF'} — sets the session start so the insertion point reads this timecode", + always, + current_timecode + ), + cmd!(noundo "setup.current_feet_frames", "Current Feet+Frames Position...", ["Setup"], None, "{feet_frames: 'F+FF'} — the feet+frames reading at the insertion point", always, current_feet_frames), + cmd!(noundo "setup.external_timecode_offset", "External Timecode Offset...", ["Setup"], None, "{frames?: ±, samples?: ±} — offset applied to incoming timecode", always, |e, p| offset_setting(e, p, "sync.external_offset")), + cmd!(noundo "setup.video_sync_offset", "Video Sync Offset...", ["Setup"], None, "{frames?: ±, samples?: ±} — offset between audio and video playback", always, |e, p| offset_setting(e, p, "video.sync_offset")), + cmd!(noundo "setup.click_countoff", "Click/Countoff...", ["Setup"], None, "{volume_db?: -60..12, accent_note?: 0..127, normal_note?: 0..127, accent_velocity?, normal_velocity?, countoff?: bool, countoff_bars?: 1..16, only_during_record?: bool}", always, click_countoff), + cmd!(query "setup.keyboard_shortcuts", "Keyboard Shortcuts...", ["Setup"], None, "{filter?} — every command shortcut", always, |_, p| { + let f = str_param(p, "filter").map(str::to_ascii_lowercase); + let rows: Vec = command_specs() + .iter() + .filter_map(|c| c.shortcut.map(|sc| (c, sc))) + .filter(|(c, _)| f.as_ref().is_none_or(|f| c.id.contains(f.as_str()) || c.label.to_ascii_lowercase().contains(f.as_str()))) + .map(|(c, sc)| json!({"id": c.id, "label": c.label, "menu": c.menu.join(" > "), "shortcut": sc})) + .collect(); + Ok(json!({"shortcuts": rows})) + }), + cmd!(noundo "setup.midi_studio", "MIDI Studio...", ["Setup", "MIDI"], None, "{devices?: [names]} — the MIDI devices in the studio", always, |e, p| { + let s = e.session_mut(); + if let Some(a) = p.get("devices").and_then(Value::as_array) { + let names: Vec<&str> = a.iter().filter_map(Value::as_str).take(64).collect(); + set_text(&mut s.edit, "midi.studio.devices", Some(&names.join("|"))); + } + let devices: Vec = text(&s.edit, "midi.studio.devices").map(|t| t.split('|').map(str::to_string).collect()).unwrap_or_default(); + Ok(json!({"devices": devices})) + }), + cmd!(noundo "setup.midi_beat_clock", "MIDI Beat Clock...", ["Setup", "MIDI"], None, "{enabled?: bool, offset?: samples, destination?: name}", always, |e, p| { + let s = e.session_mut(); + if let Some(v) = p.get("enabled").and_then(Value::as_bool) { + s.edit.set_flag("midi.beat_clock", v); + } + store_value(&mut s.edit, p, "offset", "midi.beat_clock.offset", -1e7, 1e7); + if let Some(d) = str_param(p, "destination") { + set_text(&mut s.edit, "midi.beat_clock.destination", Some(d)); + } + Ok(json!({"enabled": s.edit.flag("midi.beat_clock"), "offset": s.edit.value("midi.beat_clock.offset", 0.0), "destination": text(&s.edit, "midi.beat_clock.destination")})) + }), + cmd!(noundo "setup.midi_input_filter", "MIDI Input Filter...", ["Setup", "MIDI"], None, "{filter?: [notes|pitch_bend|mono_aftertouch|poly_aftertouch|program|cc|sysex|realtime], allow?: [...]} — filtered kinds are not recorded", always, midi_filter), + cmd!(noundo "setup.midi_input_devices", "MIDI Input Devices...", ["Setup", "MIDI"], None, "{enable?: [names], disable?: [names]}", always, midi_inputs), + cmd!(noundo "setup.transcription_settings", "Transcription Settings...", ["Setup"], None, "{enabled?: bool, language?: 'en'}", always, |e, p| { + let s = e.session_mut(); + if let Some(v) = p.get("enabled").and_then(Value::as_bool) { + s.edit.set_flag("transcription.enabled", v); + } + if let Some(l) = str_param(p, "language") { + set_text(&mut s.edit, "transcription.language", Some(l)); + } + Ok(json!({"enabled": s.edit.flag("transcription.enabled"), "language": text(&s.edit, "transcription.language").unwrap_or_else(|| "en".into())})) + }), + cmd!( + "file.import_session_data", + "Session Data...", + ["File", "Import"], + Some("Alt+Shift+I"), + "{path, tracks?: [names]} — imports tracks (clips, playlists, mixer, automation, audio) from another session", + always, + import_session_data + ), + ] +} + +fn prepare_dpe(e: &mut Engine, p: &Value) -> Result { + let id = "options.prepare_dpe_tracks"; + let s = e.session(); + let tracks: Vec = if p.get("tracks").is_some() || p.get("track").is_some() { + tracks_param(e, id, p)? + } else { + s.tracks.iter().filter(|t| t.mixer.record_arm).map(|t| t.id).collect() + }; + let tracks: Vec = tracks.into_iter().filter(|t| s.track(*t).is_some_and(|x| x.kind == TrackKind::Audio)).collect(); + if tracks.is_empty() { + return Err(bad(id, "record-enable (or pass) the audio tracks to prepare")); + } + let end = s.content_end(); + if end <= 0 { + return Err(bad(id, "there is no audio to prepare")); + } + let r = soundcraft_time::Range::new(0, end); + let mut n = 0; + for t in tracks { + if crate::io::consolidate(e, t, r)? { + n += 1; + } + } + e.session_mut().edit.set_flag("options.dpe_prepared", true); + Ok(json!({"prepared": n})) +} + +fn engine_settings(e: &mut Engine, p: &Value) -> Result { + let s = e.session_mut(); + if let Some(b) = p.get("buffer_size").and_then(Value::as_u64) { + let b = b.clamp(16, 8192).next_power_of_two().min(8192); + s.edit.values.insert("engine.buffer_size".into(), b as f64); + } + if let Some(d) = str_param(p, "device") { + set_text(&mut s.edit, "engine.device", Some(d)); + } + Ok(json!({ + "device": text(&s.edit, "engine.device"), + "buffer_size": s.edit.value("engine.buffer_size", 1024.0), + "sample_rate": s.sample_rate.hz(), + "bit_depth": s.bit_depth, + "delay_compensation": s.edit.delay_compensation, + })) +} + +fn disk_allocation(e: &mut Engine, p: &Value) -> Result { + let id = "setup.disk_allocation"; + let t = track_param(e, id, p, "track")?; + let s = e.session_mut(); + if let Some(path) = str_param(p, "path") { + let key = t.map_or_else(|| "disk.root".to_string(), |t| format!("disk.track.{}", t.0)); + set_text(&mut s.edit, &key, Some(path)); + } + let root = text(&s.edit, "disk.root").unwrap_or_else(|| "Audio Files".into()); + let rows: Vec = s + .tracks + .iter() + .filter(|t| t.kind == TrackKind::Audio) + .map(|t| json!({"track": t.id, "name": t.name, "path": text(&s.edit, &format!("disk.track.{}", t.id.0)).unwrap_or_else(|| root.clone())})) + .collect(); + Ok(json!({"root": root, "tracks": rows})) +} + +fn peripherals(e: &mut Engine, p: &Value) -> Result { + let s = e.session_mut(); + if let Some(m) = p.get("settings").and_then(Value::as_object) { + for (k, v) in m.iter().take(256) { + let key: String = k.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')).take(64).collect(); + if key.is_empty() { + continue; + } + let key = format!("peripherals.{key}"); + match v { + Value::Bool(b) => s.edit.set_flag(&key, *b), + Value::Number(n) => { + if let Some(x) = n.as_f64().filter(|x| x.is_finite()) { + s.edit.values.insert(key, x); + } + } + Value::String(t) => set_text(&mut s.edit, &key, Some(t)), + _ => {} + } + } + } + let flags: Vec<&String> = s.edit.flags.iter().filter(|f| f.starts_with("peripherals.")).collect(); + Ok(json!({"values": values_with_prefix(&s.edit, "peripherals."), "flags": flags})) +} + +fn io_state(s: &Session) -> Value { + json!({"busses": s.busses, "outputs": s.outputs}) +} + +fn io_setup(e: &mut Engine, p: &Value) -> Result { + let id = "setup.io"; + let action = str_param(p, "action").unwrap_or("list"); + if action == "list" { + return Ok(io_state(e.session())); + } + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(id, "`name` required"))?.to_string(); + let new_name = str_param(p, "new_name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + let format = match str_param(p, "format") { + Some(f) => ChannelFormat::from_id(f).ok_or_else(|| bad(id, format!("unknown format `{f}`")))?, + None => ChannelFormat::Stereo, + }; + let s = e.session_mut(); + match action { + "create_bus" => { + if s.bus_by_name(&name).is_some() { + return Err(bad(id, format!("a bus named `{name}` exists"))); + } + s.add_bus(&name, format); + } + "rename_bus" => { + let to = new_name.ok_or_else(|| bad(id, "`new_name` required"))?; + if s.bus_by_name(&to).is_some() { + return Err(bad(id, format!("a bus named `{to}` exists"))); + } + let b = s.busses.iter_mut().find(|b| b.name.eq_ignore_ascii_case(&name)).ok_or_else(|| bad(id, format!("no bus `{name}`")))?; + b.name = to; + } + "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); + 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)) { + return Err(bad(id, format!("an output named `{name}` exists"))); + } + let first = i64_or(p, "first_channel", 0).clamp(0, 1023) as u16; + s.outputs.push(OutputPath { name, first_channel: first, format }); + } + "rename_output" => { + let to = new_name.ok_or_else(|| bad(id, "`new_name` required"))?; + let o = s.outputs.iter_mut().find(|o| o.name.eq_ignore_ascii_case(&name)).ok_or_else(|| bad(id, format!("no output `{name}`")))?; + let old = std::mem::replace(&mut o.name, to.clone()); + for t in &mut s.tracks { + if t.mixer.output == Route::Hardware(old.clone()) { + t.mixer.output = Route::Hardware(to.clone()); + } + } + } + "delete_output" => { + let before = s.outputs.len(); + s.outputs.retain(|o| !o.name.eq_ignore_ascii_case(&name)); + if s.outputs.len() == before { + return Err(bad(id, format!("no output `{name}`"))); + } + } + other => return Err(bad(id, format!("unknown action `{other}`"))), + } + Ok(io_state(s)) +} + +fn current_timecode(e: &mut Engine, p: &Value) -> Result { + let id = "setup.current_timecode"; + let tc = str_param(p, "timecode").ok_or_else(|| bad(id, "`timecode` required (HH:MM:SS:FF)"))?; + let tc = Timecode::parse(tc).map_err(|err| bad(id, err.to_string()))?; + let s = e.session_mut(); + let at = s.edit.selection.start.max(0); + let abs = tc.to_samples(s.frame_rate, s.sample_rate); + s.timecode_start = abs - at; + Ok(json!({"timecode_start": s.timecode_start, "at": at})) +} + +fn current_feet_frames(e: &mut Engine, p: &Value) -> Result { + let id = "setup.current_feet_frames"; + let text_v = str_param(p, "feet_frames").ok_or_else(|| bad(id, "`feet_frames` required (F+FF)"))?; + let (ft, fr) = text_v.split_once('+').ok_or_else(|| bad(id, "use F+FF, e.g. 12+08"))?; + let ft: i64 = ft.trim().parse().map_err(|_| bad(id, "bad feet"))?; + let fr: i64 = fr.trim().parse().map_err(|_| bad(id, "bad frames"))?; + if !(0..=10_000_000).contains(&ft) || !(0..16).contains(&fr) { + return Err(bad(id, "feet must be 0.. and frames 0..15")); + } + let s = e.session_mut(); + let at = s.edit.selection.start.max(0); + let frames_at = s.frame_rate.frames_at(at, s.sample_rate); + let start = ft * 16 + fr - frames_at; + s.edit.values.insert("feet_frames.start".into(), start as f64); + Ok(json!({"feet_frames_start": start})) +} + +fn offset_setting(e: &mut Engine, p: &Value, key: &str) -> Result { + let s = e.session_mut(); + let sr = s.sample_rate; + let per_frame = s.frame_rate.samples_per_frame(sr); + if let Some(f) = p.get("frames").and_then(Value::as_f64).filter(|v| v.is_finite()) { + s.edit.values.insert(key.to_string(), (f.clamp(-10_000.0, 10_000.0) * per_frame).round()); + } else if let Some(n) = p.get("samples").and_then(Value::as_f64).filter(|v| v.is_finite()) { + let lim = sr.as_f64() * 3600.0; + s.edit.values.insert(key.to_string(), n.clamp(-lim, lim).round()); + } + let samples = s.edit.value(key, 0.0); + Ok(json!({"samples": samples, "frames": samples / per_frame.max(1e-9)})) +} + +fn click_countoff(e: &mut Engine, p: &Value) -> Result { + let s = e.session_mut(); + store_value(&mut s.edit, p, "volume_db", "click.volume_db", -60.0, 12.0); + store_value(&mut s.edit, p, "accent_note", "click.accent_note", 0.0, 127.0); + store_value(&mut s.edit, p, "normal_note", "click.normal_note", 0.0, 127.0); + store_value(&mut s.edit, p, "accent_velocity", "click.accent_velocity", 1.0, 127.0); + store_value(&mut s.edit, p, "normal_velocity", "click.normal_velocity", 1.0, 127.0); + if let Some(v) = p.get("countoff").and_then(Value::as_bool) { + s.edit.countoff = v; + } + if let Some(b) = p.get("countoff_bars").and_then(Value::as_u64) { + s.edit.countoff_bars = b.clamp(1, 16) as u32; + } + if let Some(v) = p.get("only_during_record").and_then(Value::as_bool) { + s.edit.set_flag("click.only_during_record", v); + } + Ok(json!({ + "volume_db": s.edit.value("click.volume_db", -6.0), + "accent_note": s.edit.value("click.accent_note", 84.0), + "normal_note": s.edit.value("click.normal_note", 72.0), + "accent_velocity": s.edit.value("click.accent_velocity", 127.0), + "normal_velocity": s.edit.value("click.normal_velocity", 100.0), + "countoff": s.edit.countoff, + "countoff_bars": s.edit.countoff_bars, + "only_during_record": s.edit.flag("click.only_during_record"), + })) +} + +fn kinds(p: &Value, key: &str) -> Vec<&'static str> { + p.get(key) + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).filter_map(|k| MIDI_FILTER_KINDS.iter().copied().find(|x| *x == k)).collect()) + .unwrap_or_default() +} + +fn midi_filter(e: &mut Engine, p: &Value) -> Result { + let s = e.session_mut(); + for k in kinds(p, "filter") { + s.edit.set_flag(&format!("midi.filter.{k}"), true); + } + for k in kinds(p, "allow") { + s.edit.set_flag(&format!("midi.filter.{k}"), false); + } + let filtered: Vec<&str> = MIDI_FILTER_KINDS.iter().copied().filter(|k| s.edit.flag(&format!("midi.filter.{k}"))).collect(); + Ok(json!({"filtered": filtered})) +} + +fn device_key(name: &str) -> String { + let n: String = name.chars().filter(|c| !c.is_control() && *c != '=').take(64).collect(); + format!("midi.input.{}", n.trim()) +} + +fn midi_inputs(e: &mut Engine, p: &Value) -> Result { + let s = e.session_mut(); + for (key, on) in [("enable", true), ("disable", false)] { + for n in p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).take(64).collect::>()).unwrap_or_default() { + if !n.trim().is_empty() { + s.edit.set_flag(&device_key(n), on); + } + } + } + let enabled: Vec<&str> = s.edit.flags.iter().filter_map(|f| f.strip_prefix("midi.input.")).collect(); + Ok(json!({"enabled": enabled})) +} + +/// Decode a source file of another session (paths relative to that session's folder). +fn load_source(dir: &Path, src: &Source, sr: u32) -> Option { + let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) }; + let bytes = std::fs::read(&p).ok()?; + let (_, mut buf) = soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())).ok()?; + if buf.sample_rate != sr && buf.sample_rate > 0 { + buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, sr); + buf.sample_rate = sr; + } + Some(buf) +} + +fn import_session_data(e: &mut Engine, p: &Value) -> Result { + let id = "file.import_session_data"; + let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(id, "`path` required"))?.to_string(); + let text_v = std::fs::read_to_string(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let other = Session::from_json(&text_v).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let dir = Path::new(&path).parent().map(Path::to_path_buf).unwrap_or_default(); + let names: Option> = + p.get("tracks").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + let picked: Vec = match &names { + Some(ns) => { + let mut v = Vec::new(); + for n in ns { + v.push(other.track_by_name(n).cloned().ok_or_else(|| bad(id, format!("`{path}` has no track named `{n}`")))?); + } + v + } + None => other.tracks.iter().filter(|t| t.kind != TrackKind::Master).cloned().collect(), + }; + if picked.is_empty() { + return Err(bad(id, "nothing to import")); + } + let mut needed: Vec = + picked.iter().flat_map(|t| t.playlists.iter().flat_map(|pl| pl.clips.iter().filter_map(|c| c.source()))).collect(); + needed.sort_by_key(|x| x.0); + needed.dedup(); + let sr = e.session().sample_rate.hz(); + let decoded: Vec<(Source, Option)> = + needed.iter().filter_map(|sid| other.source(*sid).cloned()).map(|src| (src.clone(), load_source(&dir, &src, sr))).collect(); + let s = e.session_mut(); + let mut map: std::collections::BTreeMap = Default::default(); + let mut missing = Vec::new(); + for (src, buf) in decoded { + let nid = SourceId(s.alloc()); + let abs = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) }; + let mut ns = src.clone(); + ns.id = nid; + ns.path = abs.to_string_lossy().into_owned(); + match buf { + Some(b) => { + ns.frames = b.frames() as u64; + ns.sample_rate = sr; + ns.unsaved = true; + s.pool.insert(nid, Arc::new(SourceAudio::new(b))); + } + None => { + ns.unsaved = false; + missing.push(ns.path.clone()); + } + } + s.sources.push(ns); + map.insert(src.id.0, nid); + } + let mut out = Vec::new(); + for mut tr in picked { + tr.id = TrackId(s.alloc()); + tr.name = s.unique_track_name(&tr.name); + tr.folder = None; + tr.mixer.vca = None; + for pl in &mut tr.playlists { + for c in &mut pl.clips { + c.id = s.new_clip_id(); + if let ClipContent::Audio { source, .. } = &mut c.content + && let Some(n) = map.get(&source.0) + { + *source = *n; + } + } + } + // Busses are matched by name (created when missing). + let remap = |s: &mut Session, r: &Route| -> Route { + match r { + Route::Bus(b) => other.bus(*b).map_or(Route::None, |ob| Route::Bus(s.add_bus(&ob.name, ob.format))), + x => x.clone(), + } + }; + tr.mixer.input = remap(s, &tr.mixer.input); + tr.mixer.output = remap(s, &tr.mixer.output); + for slot in tr.mixer.sends.iter_mut().flatten() { + slot.target = remap(s, &slot.target); + } + let pos = s.tracks.iter().position(|t| t.kind == TrackKind::Master).unwrap_or(s.tracks.len()); + out.push(tr.id); + s.tracks.insert(pos, tr); + } + s.edit.selected_tracks = out.clone(); + Ok(json!({"tracks": out, "sources": map.len(), "missing": missing})) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn option_flags_toggle() { + let mut e = Engine::default(); + assert_eq!(e.execute("options.midi_thru", &json!({})).unwrap()["value"], true); + assert!(e.session().edit.flag("options.midi_thru")); + e.execute("options.midi_thru", &json!({"value": false})).unwrap(); + assert!(!e.session().edit.flag("options.midi_thru")); + } + + #[test] + fn io_bus_lifecycle() { + let mut e = crate::demo::demo_engine(); + e.execute("setup.io", &json!({"action": "create_bus", "name": "Drums"})).unwrap(); + e.execute("setup.io", &json!({"action": "rename_bus", "name": "Verb", "new_name": "Hall"})).unwrap(); + assert!(e.session().bus_by_name("Hall").is_some()); + e.execute("setup.io", &json!({"action": "delete_bus", "name": "Hall"})).unwrap(); + assert!( + e.session().tracks.iter().all(|t| t + .mixer + .sends + .iter() + .flatten() + .all(|s| matches!(s.target, Route::None | Route::Main | Route::Hardware(_)))) + ); + assert!(e.session().tracks.iter().all(|t| { + !matches!(t.mixer.input, Route::Bus(_)) + || e.session() + .bus(match t.mixer.input { + Route::Bus(b) => b, + _ => BusId(0), + }) + .is_some() + })); + e.execute("setup.io", &json!({"action": "create_output", "name": "Out 3-4", "first_channel": 2})).unwrap(); + assert_eq!(e.session().outputs.len(), 2); + assert!(e.execute("setup.io", &json!({"action": "nope", "name": "x"})).is_err()); + assert!(e.can_undo()); + } + + #[test] + fn current_timecode_sets_start() { + let mut e = Engine::default(); + e.execute("edit.select", &json!({"start": 48_000})).unwrap(); + e.execute("setup.current_timecode", &json!({"timecode": "01:00:00:00"})).unwrap(); + let s = e.session(); + let shown = + soundcraft_time::format_position(48_000, soundcraft_time::TimeFormat::Timecode, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + assert_eq!(shown, "01:00:00:00"); + } + + #[test] + fn click_countoff_settings() { + let mut e = Engine::default(); + let r = e.execute("setup.click_countoff", &json!({"volume_db": -12.0, "countoff_bars": 4, "countoff": true})).unwrap(); + assert_eq!(r["volume_db"], -12.0); + assert_eq!(e.session().edit.countoff_bars, 4); + assert!(e.session().edit.countoff); + } + + #[test] + fn shortcuts_listed() { + let mut e = Engine::default(); + let r = e.execute("setup.keyboard_shortcuts", &json!({})).unwrap(); + assert!(r["shortcuts"].as_array().unwrap().iter().any(|x| x["id"] == "edit.undo")); + } + + #[test] + fn engine_and_midi_settings() { + let mut e = Engine::default(); + let r = e.execute("setup.playback_engine", &json!({"buffer_size": 300, "device": "Interface"})).unwrap(); + assert_eq!(r["buffer_size"], 512.0); + assert_eq!(r["device"], "Interface"); + let r = e.execute("setup.midi_input_filter", &json!({"filter": ["sysex", "bogus"]})).unwrap(); + assert_eq!(r["filtered"], json!(["sysex"])); + let r = e.execute("setup.midi_input_devices", &json!({"enable": ["Keys"]})).unwrap(); + assert_eq!(r["enabled"], json!(["Keys"])); + let r = e.execute("setup.video_sync_offset", &json!({"frames": 2})).unwrap(); + assert_eq!(r["samples"], 3200.0); + } + + #[test] + fn import_session_data_brings_tracks_and_audio() { + let dir = std::env::temp_dir().join(format!("sc-import-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("donor.scraft").to_string_lossy().into_owned(); + let mut donor = crate::demo::demo_engine(); + crate::io::save_session(&mut donor, &path).unwrap(); + let mut e = Engine::default(); + let r = e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Kick", "Keys"]})).unwrap(); + assert_eq!(r["tracks"].as_array().unwrap().len(), 2); + assert_eq!(r["missing"], json!([])); + let kick = e.session().track_by_name("Kick").unwrap(); + let src = kick.clips()[0].source().unwrap(); + assert!(e.session().pool.get(src).is_some()); + assert_eq!(e.session().track_by_name("Keys").unwrap().clips().len(), 1); + assert!(e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Nope"]})).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index ede6ae2..2f231f4 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -126,6 +126,58 @@ pub fn specs() -> Vec { tr.folder_open = v.unwrap_or(!tr.folder_open); Ok(json!({"open": tr.folder_open})) }), + cmd!("track.vca", "Assign to VCA", [], None, "{tracks?, vca: VCA track id|name | null}", has_selection, |e, p| { + let tracks = tracks_required(e, "track.vca", p)?; + let vca = track_param(e, "track.vca", p, "vca")?; + if let Some(v) = vca + && e.session().track(v).is_none_or(|t| t.kind != TrackKind::Vca) + { + return Err(bad("track.vca", "`vca` must be a VCA Master track")); + } + let s = e.session_mut(); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + tr.mixer.vca = vca.map(|v| v.0); + } + } + Ok(json!({"vca": vca})) + }), + cmd!( + "track.playlist_promote", + "Promote to Main Playlist", + [], + None, + "{track, playlist: index, start?, end?} (copy that range from an alternate playlist into the active one)", + has_tracks, + |e, p| { + let t = track_param(e, "track.playlist_promote", p, "track")?.ok_or_else(|| bad("track.playlist_promote", "`track` required"))?; + let idx = + p.get("playlist").and_then(Value::as_u64).ok_or_else(|| bad("track.playlist_promote", "`playlist` index required"))? as usize; + let r = range_param(e, "track.playlist_promote", p)?; + if r.is_empty() { + return Err(bad("track.playlist_promote", "give `start` and `end`")); + } + let s = e.session_mut(); + let (src, active) = { + let tr = s.track(t).ok_or_else(|| bad("track.playlist_promote", "no track"))?; + (tr.playlists.get(idx).cloned().ok_or_else(|| bad("track.playlist_promote", "no such playlist"))?, tr.active_playlist) + }; + if idx == active { + return Err(bad("track.playlist_promote", "that playlist is already active")); + } + // Copy the clips of the source playlist inside the range (via a temporary active switch). + if let Some(tr) = s.track_mut(t) { + tr.active_playlist = idx; + } + let clips = crate::edit::copy_range(s, t, r); + if let Some(tr) = s.track_mut(t) { + tr.active_playlist = active; + } + let _ = src; + let ids = crate::edit::paste(s, t, r.start, &clips, r.len(), false); + Ok(json!({"clips": ids})) + } + ), cmd!("track.input", "Track Input", [], None, "{tracks?, input: none|bus name|hardware name}", has_selection, |e, p| route(e, p, true)), cmd!("track.output", "Track Output", [], None, "{tracks?, output: main|none|bus name}", has_selection, |e, p| route(e, p, false)), cmd!("track.timebase", "Track Timebase", [], None, "{tracks?, ticks: bool}", has_selection, |e, p| flag( @@ -344,12 +396,21 @@ fn move_to_folder(e: &mut Engine, p: &Value) -> Result { let f = s.tracks.remove(i); s.tracks.insert(first_idx.min(s.tracks.len()), f); } + // A routing folder sums its members through its own bus. + let fname = s.track(fid).map(|x| x.name.clone()).unwrap_or_else(|| "Folder".into()); + let bus = s.add_bus(&format!("{fname} Bus"), ChannelFormat::Stereo); + if let Some(f) = s.track_mut(fid) { + f.mixer.input = Route::Bus(bus); + } for t in &tracks { if let Some(tr) = s.track_mut(*t) { tr.folder = Some(fid); + if tr.mixer.output == Route::Main { + tr.mixer.output = Route::Bus(bus); + } } } - Ok(json!({"folder": fid})) + Ok(json!({"folder": fid, "bus": bus})) } fn change_width(e: &mut Engine, p: &Value) -> Result { @@ -437,7 +498,8 @@ fn height(e: &mut Engine, p: &Value) -> Result { fn view(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "track.view", p)?; let v = str_param(p, "view").ok_or_else(|| bad("track.view", "`view` required"))?.to_string(); - let ok = matches!(v.as_str(), "waveform" | "blocks" | "notes" | "regions" | "clip_gain") || soundcraft_model::AutoParam::parse(&v).is_some(); + let ok = matches!(v.as_str(), "waveform" | "blocks" | "notes" | "regions" | "clip_gain" | "playlists") + || soundcraft_model::AutoParam::parse(&v).is_some(); if !ok { return Err(bad("track.view", format!("unknown view `{v}`"))); } @@ -587,3 +649,35 @@ fn route(e: &mut Engine, p: &Value, input: bool) -> Result { } Ok(json!({key: r})) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn comp_from_alternate_playlist() { + let mut e = crate::demo::demo_engine(); + let kick = e.session().track_by_name("Kick").map(|t| t.id.0).unwrap(); + // Duplicate the playlist, then clear a range on the new active playlist and promote it back from the first. + e.execute("track.playlist_duplicate", &json!({"track": kick})).unwrap(); + e.execute("edit.clear", &json!({"tracks": [kick], "start": {"seconds": 10.0}, "end": {"seconds": 12.0}})).unwrap(); + let gap = |e: &Engine| { + e.session().track(soundcraft_model::TrackId(kick)).unwrap().playlist().unwrap().clip_at(e.session().sample_rate.samples(11.0)).is_none() + }; + assert!(gap(&e)); + e.execute("track.playlist_promote", &json!({"track": kick, "playlist": 0, "start": {"seconds": 10.0}, "end": {"seconds": 12.0}})).unwrap(); + assert!(!gap(&e)); + assert!(e.execute("track.playlist_promote", &json!({"track": kick, "playlist": 1, "start": 0, "end": 10})).is_err()); + } + + #[test] + fn folder_routes_members_through_its_bus() { + let mut e = crate::demo::demo_engine(); + let r = e.execute("track.move_to_new_folder", &json!({"tracks": ["Kick", "Snare"], "name": "Drums"})).unwrap(); + assert!(r["bus"].is_u64()); + let s = e.session(); + let kick = s.track_by_name("Kick").unwrap(); + assert!(matches!(kick.mixer.output, Route::Bus(_))); + assert!(s.tracks.iter().any(|t| t.kind == TrackKind::Folder && t.mixer.input == kick.mixer.output)); + } +} diff --git a/crates/engine/src/cmd/track_more.rs b/crates/engine/src/cmd/track_more.rs new file mode 100644 index 0000000..eac11ef --- /dev/null +++ b/crates/engine/src/cmd/track_more.rs @@ -0,0 +1,645 @@ +//! More Track menu commands: Convert Aux to Routing Folder, Extract MIDI, track presets, insert / +//! send bypass subsets, MIDI real-time properties, trim/VCA coalescing and target playlists. + +use super::more_util::{activate_playlist, target_playlist, track_key}; +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_dsp::Category; +use soundcraft_model::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipContent, Insert, Route, Session, Track, TrackId, TrackKind}; +use soundcraft_time::Samples; + +/// Trim automation lives in a lane addressed past the last insert slot, so the mixer ignores it +/// until it is coalesced into the volume lane. +pub fn trim_param() -> AutoParam { + AutoParam::Plugin { slot: u8::MAX, param: "trim".into() } +} + +const PRESET_FORMAT: &str = "soundcraft-track-preset"; + +pub fn specs() -> Vec { + vec![ + cmd!( + "track.convert_aux_to_folder", + "Convert Aux to Routing Folder", + ["Track"], + None, + "{tracks?} — aux inputs become routing folders; tracks feeding their input bus become members", + has_selection, + convert_aux + ), + cmd!( + "track.extract_midi", + "Extract MIDI to New Track", + ["Track"], + None, + "{tracks?} — copies the MIDI clips of each MIDI/instrument track to a new MIDI track", + has_selection, + extract_midi + ), + cmd!(noundo "track.save_preset", "Save Track Preset...", ["Track"], None, "{path, track?} — writes the track's type, width, mixer, inserts, sends and instrument as JSON", has_selection, save_preset), + cmd!( + "track.load_preset", + "Load Track Preset", + [], + None, + "{path, tracks?} — applies a saved track preset's mixer, inserts, sends and instrument", + has_selection, + load_preset + ), + cmd!("track.bypass_inserts_ae", "Inserts A-E", ["Track", "Bypass Inserts"], None, "{tracks?, bypass?: bool}", has_selection, |e, p| { + bypass_where(e, p, |i, _| i < 5) + }), + cmd!("track.bypass_inserts_fj", "Inserts F-J", ["Track", "Bypass Inserts"], None, "{tracks?, bypass?: bool}", has_selection, |e, p| { + bypass_where(e, p, |i, _| (5..10).contains(&i)) + }), + cmd!("track.bypass_eq", "EQ", ["Track", "Bypass Inserts"], None, "{tracks?, bypass?: bool} — EQ plug-ins only", has_selection, |e, p| { + bypass_where(e, p, |_, c| c == Some(Category::Eq)) + }), + cmd!("track.bypass_dynamics", "Dynamics", ["Track", "Bypass Inserts"], None, "{tracks?, bypass?: bool}", has_selection, |e, p| bypass_where( + e, + p, + |_, c| c == Some(Category::Dynamics) + )), + cmd!("track.bypass_reverb", "Reverb", ["Track", "Bypass Inserts"], None, "{tracks?, bypass?: bool}", has_selection, |e, p| bypass_where( + e, + p, + |_, c| c == Some(Category::Reverb) + )), + cmd!("track.bypass_delay", "Delay", ["Track", "Bypass Inserts"], None, "{tracks?, bypass?: bool}", has_selection, |e, p| bypass_where( + e, + p, + |_, c| c == Some(Category::Delay) + )), + cmd!("track.bypass_modulation", "Modulation", ["Track", "Bypass Inserts"], None, "{tracks?, bypass?: bool}", has_selection, |e, p| { + bypass_where(e, p, |_, c| c == Some(Category::Modulation)) + }), + cmd!( + "track.mute_sends_ae", + "Sends A-E", + ["Track", "Mute Sends"], + None, + "{tracks?, mute?: bool} (toggles when omitted)", + has_selection, + |e, p| mute_sends_where(e, p, 0, 5) + ), + cmd!( + "track.mute_sends_fj", + "Sends F-J", + ["Track", "Mute Sends"], + None, + "{tracks?, mute?: bool} (toggles when omitted)", + has_selection, + |e, p| mute_sends_where(e, p, 5, 10) + ), + cmd!( + "track.write_midi_rtp", + "Write MIDI Real-Time Properties", + ["Track"], + None, + "{tracks?} — writes the track's real-time properties (velocity, transpose, duration, delay) into its notes and resets them", + has_selection, + write_rtp + ), + cmd!( + "track.trim_automation_write", + "Write Trim Automation", + [], + None, + "{tracks?, start?, end?, db} — writes a relative volume trim (dB) over a range", + has_selection, + trim_write + ), + cmd!( + "track.coalesce_trim", + "Coalesce Trim Automation", + ["Track"], + None, + "{tracks?} — adds trim automation into volume automation and removes the trim", + has_selection, + coalesce_trim + ), + cmd!( + "track.coalesce_vca", + "Coalesce VCA Controller Automation", + ["Track"], + None, + "{tracks?} — for selected VCA masters: folds VCA level/automation into every member's volume automation, then resets the VCA", + has_tracks, + coalesce_vca + ), + cmd!( + "track.designate_target_playlist", + "Designate as Target Playlist", + ["Track"], + None, + "{tracks?, index?} — the active playlist (or `index`) becomes the target for Copy/Move Selection to", + has_selection, + designate_target + ), + cmd!( + "track.show_target_playlist", + "Show Target Playlist", + ["Track"], + None, + "{tracks?} — makes the target playlist the main playlist", + has_selection, + show_target + ), + cmd!( + "track.toggle_recent_playlist", + "Toggle Recent Playlist", + ["Track"], + Some("Ctrl+Alt+Shift+Left"), + "{tracks?} — swaps to the previously shown playlist", + has_selection, + toggle_recent + ), + ] +} + +fn convert_aux(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.convert_aux_to_folder", p)?; + let s = e.session_mut(); + let mut out = Vec::new(); + for t in &tracks { + let Some(tr) = s.track(*t) else { continue }; + if tr.kind != TrackKind::Aux { + continue; + } + let input = tr.mixer.input.clone(); + if let Some(tr) = s.track_mut(*t) { + tr.kind = TrackKind::Folder; + tr.folder_open = true; + } + let mut members = 0; + if let Route::Bus(_) = input { + for m in s.tracks.iter_mut().filter(|m| m.id != *t && m.mixer.output == input) { + m.folder = Some(*t); + members += 1; + } + } + out.push(json!({"track": t, "members": members})); + } + if out.is_empty() { + return Err(bad("track.convert_aux_to_folder", "select one or more aux input tracks")); + } + Ok(json!({"converted": out})) +} + +fn extract_midi(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.extract_midi", p)?; + let s = e.session_mut(); + let mut out = Vec::new(); + for t in &tracks { + let Some(src) = s.track(*t).cloned() else { continue }; + if !src.kind.is_midi() { + continue; + } + let midi: Vec = src.clips().iter().filter(|c| matches!(c.content, ClipContent::Midi { .. })).cloned().collect(); + let nt = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some(&format!("{} MIDI", src.name))); + if let (Some(i), Some(ni)) = (s.track_index(*t), s.track_index(nt)) { + let tr = s.tracks.remove(ni); + s.tracks.insert((i + 1).min(s.tracks.len()), tr); + } + let mut clips = Vec::new(); + for mut c in midi { + c.id = s.new_clip_id(); + clips.push(c); + } + let n = clips.len(); + if let Some(pl) = s.track_mut(nt).and_then(Track::playlist_mut) { + pl.clips = clips; + pl.sort(); + } + if let Some(tr) = s.track_mut(nt) { + tr.color = src.color; + tr.ticks_timebase = src.ticks_timebase; + } + out.push(json!({"from": t, "track": nt, "clips": n})); + } + if out.is_empty() { + return Err(bad("track.extract_midi", "select a MIDI or instrument track")); + } + Ok(json!({"tracks": out})) +} + +fn save_preset(e: &mut Engine, p: &Value) -> Result { + let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad("track.save_preset", "`path` required"))?.to_string(); + let t = tracks_required(e, "track.save_preset", p)?; + let first = *t.first().ok_or_else(|| bad("track.save_preset", "no track"))?; + let tr = e.session().track(first).ok_or_else(|| bad("track.save_preset", "no track"))?; + let preset = json!({ + "format": PRESET_FORMAT, + "version": 1, + "name": tr.name, + "kind": tr.kind.id(), + "width": tr.format.label(), + "color": tr.color, + "mixer": tr.mixer, + "instrument": tr.instrument, + "elastic": tr.elastic, + "comments": tr.comments, + }); + let text = serde_json::to_string_pretty(&preset).map_err(|err| EngineError::Io(err.to_string()))?; + std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + Ok(json!({"path": path, "track": first})) +} + +fn load_preset(e: &mut Engine, p: &Value) -> Result { + let id = "track.load_preset"; + let path = str_param(p, "path").ok_or_else(|| bad(id, "`path` required"))?.to_string(); + let tracks = tracks_required(e, id, p)?; + let text = std::fs::read_to_string(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let v: Value = serde_json::from_str(&text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + if v.get("format").and_then(Value::as_str) != Some(PRESET_FORMAT) { + return Err(EngineError::Io(format!("{path}: not a SoundCraft track preset"))); + } + let mut mixer: soundcraft_model::Mixer = + serde_json::from_value(v.get("mixer").cloned().unwrap_or(Value::Null)).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + mixer.sanitize(); + let instrument: Option = v.get("instrument").cloned().and_then(|x| serde_json::from_value(x).ok()); + let s = e.session_mut(); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + // Routing is session-specific: keep the track's own input/output, VCA and arm state. + let keep = (tr.mixer.input.clone(), tr.mixer.output.clone(), tr.mixer.vca, tr.mixer.record_arm, tr.mixer.pan.clone()); + tr.mixer = mixer.clone(); + tr.mixer.input = keep.0; + tr.mixer.output = keep.1; + tr.mixer.vca = keep.2; + tr.mixer.record_arm = keep.3; + if tr.mixer.pan.len() != keep.4.len() { + tr.mixer.pan = keep.4; + } + if tr.kind == TrackKind::Instrument && instrument.is_some() { + tr.instrument = instrument.clone(); + } + } + } + Ok(json!({"tracks": tracks})) +} + +fn bypass_where(e: &mut Engine, p: &Value, pick: fn(usize, Option) -> bool) -> Result { + let tracks = tracks_required(e, "track.bypass_inserts", p)?; + let s = e.session_mut(); + let hits = |tr: &Track| -> Vec { + tr.mixer + .inserts + .iter() + .enumerate() + .filter_map(|(i, x)| x.as_ref().map(|ins| (i, ins))) + .filter(|(i, ins)| pick(*i, soundcraft_dsp::plugin_info(&ins.plugin).map(|info| info.category))) + .map(|(i, _)| i) + .collect() + }; + let all_bypassed = tracks + .iter() + .filter_map(|t| s.track(*t)) + .all(|tr| hits(tr).iter().all(|i| tr.mixer.inserts.get(*i).and_then(Option::as_ref).is_some_and(|x| x.bypass))); + let target = p.get("bypass").and_then(Value::as_bool).unwrap_or(!all_bypassed); + let mut n = 0; + for t in &tracks { + let Some(tr) = s.track_mut(*t) else { continue }; + for i in hits(tr) { + if let Some(Some(ins)) = tr.mixer.inserts.get_mut(i) { + ins.bypass = target; + n += 1; + } + } + } + Ok(json!({"bypass": target, "inserts": n})) +} + +fn mute_sends_where(e: &mut Engine, p: &Value, from: usize, to: usize) -> Result { + let tracks = tracks_required(e, "track.mute_sends", p)?; + let s = e.session_mut(); + let all_muted = tracks.iter().filter_map(|t| s.track(*t)).all(|tr| tr.mixer.sends.iter().skip(from).take(to - from).flatten().all(|x| x.mute)); + let target = p.get("mute").and_then(Value::as_bool).unwrap_or(!all_muted); + let mut n = 0; + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + for snd in tr.mixer.sends.iter_mut().skip(from).take(to - from).flatten() { + snd.mute = target; + n += 1; + } + } + } + Ok(json!({"mute": target, "sends": n})) +} + +/// Real-time properties for a track (stored by Event › MIDI Real-Time Properties). +pub struct Rtp { + pub velocity: i64, + pub transpose: i64, + pub duration_pct: f64, + pub delay_ticks: i64, +} + +pub fn rtp_of(s: &Session, t: TrackId) -> Rtp { + let v = |name: &str, d: f64| s.edit.value(&track_key("rtp", t, name), d); + Rtp { + velocity: v("velocity", 0.0).clamp(-127.0, 127.0) as i64, + transpose: v("transpose", 0.0).clamp(-127.0, 127.0) as i64, + duration_pct: v("duration", 100.0).clamp(1.0, 1000.0), + delay_ticks: v("delay", 0.0).clamp(-1e7, 1e7) as i64, + } +} + +fn write_rtp(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.write_midi_rtp", p)?; + let s = e.session_mut(); + let mut notes = 0; + for t in &tracks { + let r = rtp_of(s, *t); + let Some(pl) = s.track_mut(*t).and_then(Track::playlist_mut) else { continue }; + for c in &mut pl.clips { + let ClipContent::Midi { sequence } = &mut c.content else { continue }; + for n in &mut sequence.notes { + n.velocity = (i64::from(n.velocity) + r.velocity).clamp(1, 127) as u8; + n.pitch = (i64::from(n.pitch) + r.transpose).clamp(0, 127) as u8; + n.length = ((n.length as f64 * r.duration_pct / 100.0).round() as i64).max(1); + n.start = n.start.saturating_add(r.delay_ticks).max(0); + notes += 1; + } + sequence.sort(); + } + let pre = format!("rtp.{}.", t.0); + s.edit.values.retain(|k, _| !k.starts_with(&pre)); + } + Ok(json!({"notes": notes})) +} + +fn trim_write(e: &mut Engine, p: &Value) -> Result { + let id = "track.trim_automation_write"; + let tracks = tracks_required(e, id, p)?; + let r = range_param(e, id, p)?; + if r.is_empty() { + return Err(bad(id, "the selection is empty")); + } + let db = p.get("db").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(id, "`db` required"))?.clamp(-144.0, 24.0) as f32; + let s = e.session_mut(); + for t in &tracks { + if let Some(tr) = s.track_mut(*t) { + tr.lane_mut(&trim_param()).write_range(r.start, r.end, db, 0.0); + } + } + Ok(json!({"db": db})) +} + +/// Add an offset curve (dB) to a track's volume automation (creating it from the fader level). +fn add_to_volume(tr: &mut Track, offset: &AutomationLane, offset_default: f32) { + let fader = tr.mixer.volume_db; + let vol = tr.lane(&AutoParam::Volume).filter(|l| !l.points.is_empty()).cloned(); + let mut times: Vec = offset.points.iter().map(|p| p.at).collect(); + if let Some(v) = &vol { + times.extend(v.points.iter().map(|p| p.at)); + } + if times.is_empty() { + tr.mixer.volume_db = (fader + offset_default).clamp(-144.0, 12.0); + return; + } + times.sort_unstable(); + times.dedup(); + let base = |at: Samples| vol.as_ref().map_or(fader, |l| l.value_at(at, fader)); + let pts: Vec<(Samples, f32)> = times.iter().map(|&at| (at, base(at) + offset.value_at(at, offset_default))).collect(); + let lane = tr.lane_mut(&AutoParam::Volume); + lane.points.clear(); + for (at, v) in pts { + lane.set_point(at, v); + } +} + +fn coalesce_trim(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.coalesce_trim", p)?; + let s = e.session_mut(); + let mut n = 0; + for t in &tracks { + let Some(tr) = s.track_mut(*t) else { continue }; + let Some(trim) = tr.lane(&trim_param()).cloned() else { continue }; + if !trim.points.is_empty() { + add_to_volume(tr, &trim, 0.0); + n += 1; + } + tr.automation.retain(|l| l.param != trim_param()); + } + Ok(json!({"tracks": n})) +} + +fn coalesce_vca(e: &mut Engine, p: &Value) -> Result { + let id = "track.coalesce_vca"; + let picked = tracks_param(e, id, p)?; + let s = e.session_mut(); + let vcas: Vec = if picked.is_empty() { + s.tracks.iter().filter(|t| t.kind == TrackKind::Vca).map(|t| t.id).collect() + } else { + picked.into_iter().filter(|t| s.track(*t).is_some_and(|x| x.kind == TrackKind::Vca)).collect() + }; + if vcas.is_empty() { + return Err(bad(id, "select a VCA master")); + } + let mut out = Vec::new(); + for v in vcas { + let Some(vca) = s.track(v).cloned() else { continue }; + let lane = vca.lane(&AutoParam::Volume).cloned().unwrap_or_else(|| AutomationLane::new(AutoParam::Volume)); + let level = vca.mixer.volume_db; + let members: Vec = s.tracks.iter().filter(|t| t.mixer.vca == Some(v.0)).map(|t| t.id).collect(); + for m in &members { + if let Some(tr) = s.track_mut(*m) { + add_to_volume(tr, &lane, level); + } + } + if let Some(tr) = s.track_mut(v) { + tr.automation.retain(|l| l.param != AutoParam::Volume); + tr.mixer.volume_db = 0.0; + } + out.push(json!({"vca": v, "members": members})); + } + Ok(json!({"coalesced": out})) +} + +fn designate_target(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.designate_target_playlist", p)?; + let idx = p.get("index").and_then(Value::as_u64); + let s = e.session_mut(); + let mut out = Vec::new(); + for t in &tracks { + let Some(tr) = s.track(*t) else { continue }; + let i = idx.map_or(tr.active_playlist, |x| usize::try_from(x).unwrap_or(usize::MAX)); + if i >= tr.playlists.len() { + return Err(bad("track.designate_target_playlist", "no such playlist")); + } + s.edit.values.insert(format!("playlist.target.{}", t.0), i as f64); + out.push(json!({"track": t, "target": i})); + } + Ok(json!({"tracks": out})) +} + +fn show_target(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.show_target_playlist", p)?; + let s = e.session_mut(); + let mut n = 0; + for t in &tracks { + if let Some(i) = target_playlist(s, *t) + && activate_playlist(s, *t, i) + { + n += 1; + } + } + Ok(json!({"shown": n})) +} + +fn toggle_recent(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.toggle_recent_playlist", p)?; + let s = e.session_mut(); + let mut n = 0; + for t in &tracks { + let recent = s.edit.values.get(&format!("playlist.recent.{}", t.0)).copied().filter(|v| v.is_finite() && *v >= 0.0); + if let Some(i) = recent + && activate_playlist(s, *t, i as usize) + { + n += 1; + } + } + Ok(json!({"toggled": n})) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bypass_by_category_and_range() { + let mut e = crate::demo::demo_engine(); + let kick = e.session().track_by_name("Kick").unwrap().id; + e.execute("edit.select", &json!({"tracks": [kick.0]})).unwrap(); + e.execute("track.bypass_dynamics", &json!({})).unwrap(); + let tr = e.session().track(kick).unwrap(); + assert!(tr.mixer.inserts[1].as_ref().unwrap().bypass, "compressor bypassed"); + assert!(!tr.mixer.inserts[0].as_ref().unwrap().bypass, "EQ untouched"); + e.execute("track.bypass_eq", &json!({})).unwrap(); + assert!(e.session().track(kick).unwrap().mixer.inserts[0].as_ref().unwrap().bypass); + e.execute("track.bypass_inserts_ae", &json!({})).unwrap(); + let tr = e.session().track(kick).unwrap(); + assert!(tr.mixer.inserts.iter().flatten().all(|i| !i.bypass), "A-E toggles back off when all are bypassed"); + } + + #[test] + fn mute_sends_subset() { + let mut e = crate::demo::demo_engine(); + e.execute("edit.select", &json!({"tracks": ["Snare"]})).unwrap(); + e.execute("track.mute_sends_fj", &json!({})).unwrap(); + assert!(!e.session().track_by_name("Snare").unwrap().mixer.sends[0].as_ref().unwrap().mute); + e.execute("track.mute_sends_ae", &json!({})).unwrap(); + assert!(e.session().track_by_name("Snare").unwrap().mixer.sends[0].as_ref().unwrap().mute); + } + + #[test] + fn extract_midi_copies_clips() { + let mut e = crate::demo::demo_engine(); + e.execute("edit.select", &json!({"tracks": ["Keys"]})).unwrap(); + let r = e.execute("track.extract_midi", &json!({})).unwrap(); + let nt = TrackId(r["tracks"][0]["track"].as_u64().unwrap()); + let tr = e.session().track(nt).unwrap(); + assert_eq!(tr.kind, TrackKind::Midi); + assert_eq!(tr.clips().len(), e.session().track_by_name("Keys").unwrap().clips().len()); + assert_ne!(tr.clips()[0].id, e.session().track_by_name("Keys").unwrap().clips()[0].id); + } + + #[test] + fn convert_aux_adopts_members() { + let mut e = crate::demo::demo_engine(); + let s = e.session_mut(); + let verb = s.track_by_name("Verb").unwrap().id; + let bus = match s.track(verb).unwrap().mixer.input.clone() { + Route::Bus(b) => b, + _ => panic!("demo verb is fed by a bus"), + }; + let pad = s.track_by_name("Pad").unwrap().id; + s.track_mut(pad).unwrap().mixer.output = Route::Bus(bus); + e.execute("edit.select", &json!({"tracks": [verb.0]})).unwrap(); + e.execute("track.convert_aux_to_folder", &json!({})).unwrap(); + assert_eq!(e.session().track(verb).unwrap().kind, TrackKind::Folder); + assert_eq!(e.session().track(pad).unwrap().folder, Some(verb)); + } + + #[test] + fn preset_round_trip() { + let dir = std::env::temp_dir().join(format!("sc-preset-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("bass.json").to_string_lossy().into_owned(); + let mut e = crate::demo::demo_engine(); + e.execute("edit.select", &json!({"tracks": ["Bass"]})).unwrap(); + e.execute("track.save_preset", &json!({"path": path})).unwrap(); + e.execute("edit.select", &json!({"tracks": ["Hats"]})).unwrap(); + e.execute("track.load_preset", &json!({"path": path})).unwrap(); + let hats = e.session().track_by_name("Hats").unwrap(); + assert_eq!(hats.mixer.inserts[0].as_ref().map(|i| i.plugin.as_str()), Some("compressor")); + assert_eq!(hats.mixer.volume_db, -5.0); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn rtp_written_into_notes() { + let mut e = crate::demo::demo_engine(); + let keys = e.session().track_by_name("Keys").unwrap().id; + let first = |e: &Engine| match &e.session().track(keys).unwrap().clips()[0].content { + ClipContent::Midi { sequence } => sequence.notes[0], + _ => panic!(), + }; + let before = first(&e); + let s = e.session_mut(); + s.edit.values.insert(track_key("rtp", keys, "transpose"), 12.0); + s.edit.values.insert(track_key("rtp", keys, "velocity"), -10.0); + e.execute("edit.select", &json!({"tracks": [keys.0]})).unwrap(); + e.execute("track.write_midi_rtp", &json!({})).unwrap(); + let after = first(&e); + assert_eq!(after.pitch, before.pitch + 12); + assert_eq!(after.velocity, before.velocity - 10); + assert!(!e.session().edit.values.keys().any(|k| k.starts_with(&format!("rtp.{}.", keys.0)))); + } + + #[test] + fn trim_and_vca_coalesce() { + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 2})).unwrap(); + e.execute("track.new", &json!({"kind": "vca"})).unwrap(); + let ids: Vec = e.session().tracks.iter().map(|t| t.id).collect(); + let (a, b, v) = (ids[0], ids[1], ids[2]); + let s = e.session_mut(); + s.track_mut(a).unwrap().mixer.vca = Some(v.0); + s.track_mut(b).unwrap().mixer.vca = Some(v.0); + s.track_mut(b).unwrap().mixer.volume_db = -3.0; + let l = s.track_mut(v).unwrap().lane_mut(&AutoParam::Volume); + l.set_point(0, 0.0); + l.set_point(1000, -6.0); + e.execute("edit.select", &json!({"tracks": [v.0]})).unwrap(); + e.execute("track.coalesce_vca", &json!({})).unwrap(); + let s = e.session(); + assert!((s.track(a).unwrap().lane(&AutoParam::Volume).unwrap().value_at(1000, 0.0) + 6.0).abs() < 1e-4); + assert!((s.track(b).unwrap().lane(&AutoParam::Volume).unwrap().value_at(500, 0.0) + 6.0).abs() < 1e-4); + assert!(s.track(v).unwrap().lane(&AutoParam::Volume).is_none()); + // Trim. + e.execute("edit.select", &json!({"tracks": [a.0]})).unwrap(); + e.execute("track.trim_automation_write", &json!({"start": 2000, "end": 3000, "db": -4.0})).unwrap(); + e.execute("track.coalesce_trim", &json!({})).unwrap(); + let tr = e.session().track(a).unwrap(); + assert!((tr.lane(&AutoParam::Volume).unwrap().value_at(2500, 0.0) + 10.0).abs() < 1e-4); + assert!(tr.lane(&trim_param()).is_none()); + } + + #[test] + fn target_and_recent_playlists() { + let mut e = crate::demo::demo_engine(); + e.execute("edit.select", &json!({"tracks": ["Kick"]})).unwrap(); + e.execute("track.playlist_new", &json!({})).unwrap(); + e.execute("track.designate_target_playlist", &json!({})).unwrap(); + e.execute("track.playlist_select", &json!({"index": 0})).unwrap(); + e.execute("track.show_target_playlist", &json!({})).unwrap(); + assert_eq!(e.session().track_by_name("Kick").unwrap().active_playlist, 1); + e.execute("track.toggle_recent_playlist", &json!({})).unwrap(); + assert_eq!(e.session().track_by_name("Kick").unwrap().active_playlist, 0); + e.execute("track.toggle_recent_playlist", &json!({})).unwrap(); + assert_eq!(e.session().track_by_name("Kick").unwrap().active_playlist, 1); + } +} diff --git a/crates/engine/src/cmd/view_more.rs b/crates/engine/src/cmd/view_more.rs new file mode 100644 index 0000000..79997ed --- /dev/null +++ b/crates/engine/src/cmd/view_more.rs @@ -0,0 +1,276 @@ +//! More View menu commands: Edit-window column views, extra rulers, marker displays, clip +//! display options, waveform styles, expanded sends, transport views and other view toggles. +//! Every setting is saved with the session in `edit.flags` (or `edit.rulers` for rulers). + +use super::more_util::{exclusive_flag, set_flags, toggle_flag}; +use super::*; +use crate::cmd; +use serde_json::json; + +/// A view toggle: `{value?: bool}` sets it, otherwise it flips. +macro_rules! flag { + ($id:literal, $label:literal, [$($m:literal),*], $flag:literal) => { + cmd!(noundo $id, $label, [$($m),*], None, "{value?: bool} (toggles when omitted)", always, |e, p| toggle_flag(e, p, $flag)) + }; +} + +/// A radio item: turns its flag on and the rest of its group off. +macro_rules! radio { + ($id:literal, $label:literal, [$($m:literal),*], $flag:literal, $group:expr) => { + cmd!(noundo $id, $label, [$($m),*], None, "{}", always, |e, _| exclusive_flag(e, $flag, $group)) + }; +} + +/// Edit window column views (View › Edit Window Views). +pub const EDIT_VIEWS: [&str; 14] = [ + "edit_view.comments", + "edit_view.mic_preamps", + "edit_view.instruments", + "edit_view.inserts_ae", + "edit_view.inserts_fj", + "edit_view.sends_ae", + "edit_view.sends_fj", + "edit_view.io", + "edit_view.object", + "edit_view.realtime_properties", + "edit_view.track_color", + "edit_view.marker_controls", + "edit_view.pinned_tracks", + "edit_view.track_number", +]; + +const WAVEFORM_STYLE: &[&str] = &["waveform.peak", "waveform.power"]; +const CLIP_TIME: &[&str] = &["view.clip.time.current", "view.clip.time.original", "view.clip.time.user", "view.clip.time.none"]; +const SENDS: [&str; 10] = [ + "expanded_send.a", + "expanded_send.b", + "expanded_send.c", + "expanded_send.d", + "expanded_send.e", + "expanded_send.f", + "expanded_send.g", + "expanded_send.h", + "expanded_send.i", + "expanded_send.j", +]; + +/// Every ruler id, in display order (the first ten are also served by `view.ruler`). +pub const RULERS: [&str; 15] = [ + "bars_beats", + "min_secs", + "timecode", + "timecode2", + "feet_frames", + "samples", + "tempo", + "meter", + "key", + "chords", + "markers", + "markers2", + "markers3", + "markers4", + "markers5", +]; +const MARKER_RULERS: [&str; 5] = ["markers", "markers2", "markers3", "markers4", "markers5"]; + +pub fn specs() -> Vec { + vec![ + // Edit Window Views. + flag!("view.edit_comments", "Comments", ["View", "Edit Window Views"], "edit_view.comments"), + flag!("view.edit_mic_preamps", "Mic Preamps", ["View", "Edit Window Views"], "edit_view.mic_preamps"), + flag!("view.edit_instruments", "Instruments", ["View", "Edit Window Views"], "edit_view.instruments"), + flag!("view.edit_inserts_ae", "Inserts A-E", ["View", "Edit Window Views"], "edit_view.inserts_ae"), + flag!("view.edit_inserts_fj", "Inserts F-J", ["View", "Edit Window Views"], "edit_view.inserts_fj"), + flag!("view.edit_sends_ae", "Sends A-E", ["View", "Edit Window Views"], "edit_view.sends_ae"), + flag!("view.edit_sends_fj", "Sends F-J", ["View", "Edit Window Views"], "edit_view.sends_fj"), + flag!("view.edit_io", "I/O", ["View", "Edit Window Views"], "edit_view.io"), + flag!("view.edit_object", "Object", ["View", "Edit Window Views"], "edit_view.object"), + flag!("view.edit_realtime_properties", "Real-Time Properties", ["View", "Edit Window Views"], "edit_view.realtime_properties"), + flag!("view.edit_track_color", "Track Color", ["View", "Edit Window Views"], "edit_view.track_color"), + flag!("view.edit_marker_controls", "Marker Controls", ["View", "Edit Window Views"], "edit_view.marker_controls"), + flag!("view.edit_pinned_tracks", "Pinned Tracks", ["View", "Edit Window Views"], "edit_view.pinned_tracks"), + cmd!(noundo "view.edit_all", "All", ["View", "Edit Window Views"], None, "{} — shows every Edit window column", always, |e, _| Ok(set_flags(e, &EDIT_VIEWS, true))), + cmd!(noundo "view.edit_minimal", "Minimal", ["View", "Edit Window Views"], None, "{} — hides every optional Edit window column", always, |e, _| Ok(set_flags(e, &EDIT_VIEWS, false))), + // Rulers. + cmd!(noundo "view.ruler_timecode2", "Timecode 2", ["View", "Rulers"], None, "{visible?: bool}", always, |e, p| toggle_ruler(e, p, "timecode2")), + cmd!(noundo "view.ruler_markers2", "Markers 2", ["View", "Rulers"], None, "{visible?: bool}", always, |e, p| toggle_ruler(e, p, "markers2")), + cmd!(noundo "view.ruler_markers3", "Markers 3", ["View", "Rulers"], None, "{visible?: bool}", always, |e, p| toggle_ruler(e, p, "markers3")), + cmd!(noundo "view.ruler_markers4", "Markers 4", ["View", "Rulers"], None, "{visible?: bool}", always, |e, p| toggle_ruler(e, p, "markers4")), + cmd!(noundo "view.ruler_markers5", "Markers 5", ["View", "Rulers"], None, "{visible?: bool}", always, |e, p| toggle_ruler(e, p, "markers5")), + cmd!(noundo "view.ruler_all_markers", "All Marker Rulers", ["View", "Rulers"], None, "{visible?: bool} — shows (or hides) all five marker rulers", always, |e, p| { + let s = e.session_mut(); + let vis = p.get("visible").and_then(Value::as_bool).unwrap_or(!MARKER_RULERS.iter().all(|r| s.edit.rulers.iter().any(|x| x == r))); + for r in MARKER_RULERS { + set_ruler(&mut s.edit.rulers, r, vis); + } + Ok(json!({"rulers": s.edit.rulers})) + }), + cmd!(noundo "view.ruler_all", "All", ["View", "Rulers"], None, "{} — shows every ruler", always, |e, _| { + let s = e.session_mut(); + s.edit.rulers = RULERS.iter().map(|r| r.to_string()).collect(); + Ok(json!({"rulers": s.edit.rulers})) + }), + cmd!(noundo "view.ruler_minimal", "Minimal", ["View", "Rulers"], None, "{} — only the main time ruler and markers", always, |e, _| { + let s = e.session_mut(); + let main = s.edit.main_counter.id().to_string(); + s.edit.rulers = vec![main, "markers".into()]; + sort_rulers(&mut s.edit.rulers); + Ok(json!({"rulers": s.edit.rulers})) + }), + flag!("view.ruler_tempo_editor", "Tempo Editor", ["View", "Rulers", "Tempo"], "view.ruler.tempo_editor"), + flag!("view.ruler_key_staff", "Key Signature Staff", ["View", "Rulers", "Key Signature"], "view.ruler.key_staff"), + // Marker displays. + flag!("view.marker_track_lane", "Track Lane", ["View", "Marker Displays"], "view.marker.track_lane"), + flag!("view.marker_ruler_lines", "Ruler Lines", ["View", "Marker Displays"], "view.marker.ruler_lines"), + flag!("view.marker_folder_lines", "Folder Lines", ["View", "Marker Displays"], "view.marker.folder_lines"), + flag!("view.marker_ruler_lane_color", "Ruler Lane Color", ["View", "Marker Displays"], "view.marker.ruler_lane_color"), + flag!("view.marker_track_lane_color", "Track Lane Color", ["View", "Marker Displays"], "view.marker.track_lane_color"), + // Other displays. + flag!("view.midi_input_display", "MIDI Input Display", ["View", "Other Displays"], "view.midi_input_display"), + flag!( + "view.midi_editor_clip_effects", + "Clip Effects", + ["View", "Other Displays", "Lower Dock", "MIDI Editor"], + "view.lower_dock.midi_editor.clip_effects" + ), + // Clip display. + flag!("view.clip_sync_point", "Sync Point", ["View", "Clip"], "view.clip.sync_point"), + flag!("view.clip_processing_state", "Processing State", ["View", "Clip"], "view.clip.processing_state"), + flag!("view.clip_name", "Name", ["View", "Clip"], "view.clip.name"), + flag!("view.clip_channel_name", "Channel Name", ["View", "Clip"], "view.clip.channel_name"), + flag!("view.clip_scene_take", "Scene And Take", ["View", "Clip"], "view.clip.scene_take"), + flag!("view.clip_rating", "Rating", ["View", "Clip"], "view.clip.rating"), + flag!("view.clip_overlap_shadows", "Overlap Shadows", ["View", "Clip"], "view.clip.overlap_shadows"), + flag!("view.clip_transparency", "Transparency", ["View", "Clip"], "view.clip.transparency"), + flag!("view.clip_overwrite_indicator", "Overwrite Indicator", ["View", "Clip"], "view.clip.overwrite_indicator"), + flag!("view.clip_copy_move_indicators", "Copy/Move Indicators", ["View", "Clip"], "view.clip.copy_move_indicators"), + flag!("view.clip_gain_line", "Clip Gain Line", ["View", "Clip"], "view.clip.gain_line"), + flag!("view.clip_gain_info", "Clip Gain Info", ["View", "Clip"], "view.clip.gain_info"), + flag!("view.clip_effects_status", "Clip Effects Status", ["View", "Clip"], "view.clip.effects_status"), + flag!("view.clip_ara_note_overlay", "ARA Note Overlay", ["View", "Clip"], "view.clip.ara_note_overlay"), + radio!("view.clip_time_current", "Current Time", ["View", "Clip"], "view.clip.time.current", CLIP_TIME), + radio!("view.clip_time_original", "Original Time Stamp", ["View", "Clip"], "view.clip.time.original", CLIP_TIME), + radio!("view.clip_time_user", "User Time Stamp", ["View", "Clip"], "view.clip.time.user", CLIP_TIME), + radio!("view.clip_time_none", "No Time", ["View", "Clip"], "view.clip.time.none", CLIP_TIME), + flag!("view.clip_all_channels", "Display on All Channels", ["View", "Clip"], "view.clip.all_channels"), + // Waveforms. + radio!("view.waveform_peak", "Peak", ["View", "Waveforms"], "waveform.peak", WAVEFORM_STYLE), + radio!("view.waveform_power", "Power", ["View", "Waveforms"], "waveform.power", WAVEFORM_STYLE), + flag!("view.waveform_rectified", "Rectified", ["View", "Waveforms"], "waveform.rectified"), + flag!("view.waveform_outlines", "Outlines", ["View", "Waveforms"], "waveform.outlines"), + flag!("view.waveform_overlapped_crossfades", "Overlapped Crossfades", ["View", "Waveforms"], "waveform.overlapped_crossfades"), + // Automation playlists. + flag!("view.automation_trim_playlist", "Trim Playlist", ["View", "Automation"], "view.automation.trim_playlist"), + flag!("view.automation_composite_playlist", "Composite Playlist", ["View", "Automation"], "view.automation.composite_playlist"), + // Expanded sends. + cmd!(noundo "view.expanded_sends_all", "All", ["View", "Expanded Sends"], None, "{} — expands every send in the sends view", always, |e, _| Ok(set_flags(e, &SENDS, true))), + cmd!(noundo "view.expanded_sends_none", "None", ["View", "Expanded Sends"], None, "{}", always, |e, _| Ok(set_flags(e, &SENDS, false))), + flag!("view.expanded_send_a", "Send A", ["View", "Expanded Sends"], "expanded_send.a"), + flag!("view.expanded_send_b", "Send B", ["View", "Expanded Sends"], "expanded_send.b"), + flag!("view.expanded_send_c", "Send C", ["View", "Expanded Sends"], "expanded_send.c"), + flag!("view.expanded_send_d", "Send D", ["View", "Expanded Sends"], "expanded_send.d"), + flag!("view.expanded_send_e", "Send E", ["View", "Expanded Sends"], "expanded_send.e"), + flag!("view.expanded_send_f", "Send F", ["View", "Expanded Sends"], "expanded_send.f"), + flag!("view.expanded_send_g", "Send G", ["View", "Expanded Sends"], "expanded_send.g"), + flag!("view.expanded_send_h", "Send H", ["View", "Expanded Sends"], "expanded_send.h"), + flag!("view.expanded_send_i", "Send I", ["View", "Expanded Sends"], "expanded_send.i"), + flag!("view.expanded_send_j", "Send J", ["View", "Expanded Sends"], "expanded_send.j"), + // Misc. + flag!("view.track_number", "Track Number", ["View"], "edit_view.track_number"), + flag!("view.track_transcription_lane", "Track Transcription Lane", ["View"], "view.track_transcription_lane"), + flag!("view.dropped_video_frame_indicator", "Dropped Video Frame Indicator", ["View"], "view.dropped_video_frame_indicator"), + // Transport window views. + flag!("view.transport_counters", "Counters", ["View", "Transport"], "view.transport.counters"), + flag!("view.transport_midi_controls", "MIDI Controls", ["View", "Transport"], "view.transport.midi_controls"), + flag!("view.transport_synchronization", "Synchronization", ["View", "Transport"], "view.transport.synchronization"), + flag!("view.transport_ableton_link", "Ableton Link", ["View", "Transport"], "view.transport.ableton_link"), + flag!("view.transport_output_meters", "Output Meters", ["View", "Transport"], "view.transport.output_meters"), + flag!("view.transport_expanded", "Expanded", ["View", "Transport"], "view.transport.expanded"), + cmd!(query "view.flags", "View Flags", [], None, "{prefix?} — lists the view/option flags that are on", always, |e, p| { + let pre = str_param(p, "prefix").unwrap_or(""); + let f: Vec<&String> = e.session().edit.flags.iter().filter(|f| f.starts_with(pre)).collect(); + Ok(json!({"flags": f, "rulers": e.session().edit.rulers})) + }), + ] +} + +fn sort_rulers(r: &mut Vec) { + r.sort_by_key(|x| RULERS.iter().position(|a| a == x).unwrap_or(usize::MAX)); + r.dedup(); +} + +fn set_ruler(rulers: &mut Vec, id: &str, vis: bool) { + rulers.retain(|x| x != id); + if vis { + rulers.push(id.to_string()); + sort_rulers(rulers); + } +} + +fn toggle_ruler(e: &mut Engine, p: &Value, id: &str) -> Result { + let s = e.session_mut(); + let shown = s.edit.rulers.iter().any(|x| x == id); + let vis = p.get("visible").and_then(Value::as_bool).unwrap_or(!shown); + set_ruler(&mut s.edit.rulers, id, vis); + Ok(json!({"ruler": id, "visible": vis})) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flags_toggle_and_persist() { + let mut e = Engine::default(); + let r = e.execute("view.clip_rating", &json!({})).unwrap(); + assert_eq!(r["value"], true); + assert!(e.session().edit.flag("view.clip.rating")); + e.execute("view.clip_rating", &json!({})).unwrap(); + assert!(!e.session().edit.flag("view.clip.rating")); + e.execute("view.edit_inserts_ae", &json!({"value": true})).unwrap(); + let text = e.session().to_json().unwrap(); + let back = soundcraft_model::Session::from_json(&text).unwrap(); + assert!(back.edit.flag("edit_view.inserts_ae")); + assert!(!e.can_undo(), "view toggles are not undo steps"); + } + + #[test] + fn radio_groups_are_exclusive() { + let mut e = Engine::default(); + assert!(e.session().edit.flag("waveform.peak")); + e.execute("view.waveform_power", &json!({})).unwrap(); + assert!(e.session().edit.flag("waveform.power")); + assert!(!e.session().edit.flag("waveform.peak")); + e.execute("view.clip_time_user", &json!({})).unwrap(); + e.execute("view.clip_time_none", &json!({})).unwrap(); + assert!(!e.session().edit.flag("view.clip.time.user")); + assert!(e.session().edit.flag("view.clip.time.none")); + } + + #[test] + fn all_and_minimal() { + let mut e = Engine::default(); + e.execute("view.edit_all", &json!({})).unwrap(); + assert!(EDIT_VIEWS.iter().all(|f| e.session().edit.flag(f))); + e.execute("view.edit_minimal", &json!({})).unwrap(); + assert!(EDIT_VIEWS.iter().all(|f| !e.session().edit.flag(f))); + e.execute("view.expanded_sends_all", &json!({})).unwrap(); + assert!(e.session().edit.flag("expanded_send.j")); + e.execute("view.expanded_sends_none", &json!({})).unwrap(); + assert!(!e.session().edit.flag("expanded_send.a")); + } + + #[test] + fn rulers() { + let mut e = Engine::default(); + e.execute("view.ruler_markers3", &json!({})).unwrap(); + assert!(e.session().edit.rulers.iter().any(|r| r == "markers3")); + e.execute("view.ruler_all_markers", &json!({})).unwrap(); + assert!(MARKER_RULERS.iter().all(|m| e.session().edit.rulers.iter().any(|r| r == m))); + e.execute("view.ruler_all", &json!({})).unwrap(); + assert_eq!(e.session().edit.rulers.len(), RULERS.len()); + e.execute("view.ruler_minimal", &json!({})).unwrap(); + assert_eq!(e.session().edit.rulers, vec!["min_secs".to_string(), "markers".to_string()]); + } +} diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index f0242db..afd6967 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -32,7 +32,7 @@ fn insert(id: &str) -> Insert { } fn beat_len(sr: f64) -> usize { - (sr * 60.0 / BPM) as usize + (sr * 60.0 / BPM).round() as usize } fn kick(sr: f64, frames: usize, out: &mut [f32], at: usize, vel: f32) { @@ -308,8 +308,9 @@ pub fn demo_session() -> Session { tr.mixer.inserts[0] = Some(insert("maximizer")); } // Markers. - for (name, at) in [("Intro", 0usize), ("Verse", 4), ("Chorus", 12), ("Outro", 20)] { - s.add_marker(name, MarkerKind::Marker, (at * bar) as i64, (at * bar) as i64); + for (name, at) in [("Intro", 1i64), ("Verse", 5), ("Chorus", 13), ("Outro", 21)] { + let pos = s.tempo.samples_at_bar_beat(soundcraft_time::BarBeat { bar: at, beat: 1, tick: 0 }, sr); + s.add_marker(name, MarkerKind::Marker, pos, pos); } s.edit.selected_tracks = vec![tk]; s.edit.selection = soundcraft_time::Range::point((4 * bar) as i64); diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index fd248de..4c8b434 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -328,6 +328,7 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 }; pl.sort(); let mut made = 0; + let mut xfaded: Vec = Vec::new(); let n = pl.clips.len(); // Crossfades between adjacent clips whose boundary is inside the range. for i in 0..n.saturating_sub(1) { @@ -351,13 +352,15 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh cb.fade_in = Fade { len: (ext_b + after).min(cb.length), shape }; } made += 1; + xfaded.push(a.id); + xfaded.push(b.id); } } - for c in &mut pl.clips { - if range.start <= c.start && range.end > c.start && range.end < c.end() && c.fade_in.len == 0 { + for c in pl.clips.iter_mut().filter(|c| !xfaded.contains(&c.id)) { + if range.start <= c.start && range.end > c.start && range.end < c.end() { c.fade_in = Fade { len: range.end - c.start, shape }; made += 1; - } else if range.end >= c.end() && range.start > c.start && range.start < c.end() && c.fade_out.len == 0 { + } else if range.end >= c.end() && range.start > c.start && range.start < c.end() { c.fade_out = Fade { len: c.end() - range.start, shape }; made += 1; } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index adf645b..1608f6d 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -128,7 +128,7 @@ pub fn export_midi(e: &Engine) -> Result> { } /// Render the main mix. Returns the encoded file and (peak dBFS, integrated LUFS). -pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool) -> Result<(Vec, (f32, f32))> { +pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool) -> Result<(Vec, (f32, f32, f32))> { let s = e.session(); if r.len() > s.sample_rate.samples(4.0 * 3600.0) { return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into())); @@ -142,9 +142,10 @@ pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool) let frames = ch.first().map_or(0, Vec::len); lm.process(&ch, frames); let lufs = lm.integrated_lufs(); + let true_peak = lm.true_peak_db(); let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch }; let bytes = soundcraft_audio_io::encode(&buf, opts).map_err(|err| EngineError::Io(err.to_string()))?; - Ok((bytes, (peak, lufs))) + Ok((bytes, (peak, lufs, true_peak))) } /// Transient positions (absolute) in a track's clips within `r`. @@ -392,7 +393,9 @@ pub fn add_recording(e: &mut Engine, start: Samples, channels: Vec>, ra .filter(|t| t.mixer.record_arm && t.kind == TrackKind::Audio && !t.inactive) .map(|t| { let first = match &t.mixer.input { - soundcraft_model::Route::Hardware(h) => h.trim_start_matches("In ").split(['-', ' ']).next().and_then(|n| n.parse::().ok()).map_or(0, |n| n.saturating_sub(1)), + soundcraft_model::Route::Hardware(h) => { + h.trim_start_matches("In ").split(['-', ' ']).next().and_then(|n| n.parse::().ok()).map_or(0, |n| n.saturating_sub(1)) + } _ => 0, }; (t.id, first, t.channels(), t.name.clone()) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0dadc9c..cf9117f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod catalog; +pub mod catalog_more; pub mod cmd; pub mod demo; pub mod edit; @@ -69,6 +70,10 @@ pub struct Clipboard { pub length: Samples, pub tracks: Vec>, pub automation: Vec>, + /// Copy Special › Clip Gain: per track, (offset from the copied range start, dB) breakpoints. + pub clip_gain: Vec>, + /// Copy Special › Clip Effects: clip-effect settings (`param` → value) of the first copied clip. + pub clip_effects: Vec<(String, f64)>, } #[derive(Debug, Clone)] @@ -93,6 +98,8 @@ pub struct Engine { /// Revision counter; bumps on every document change (UI caches key on it). pub revision: u64, dirty: bool, + /// Gesture key for undo coalescing (see `execute_merged`). + merge_key: Option, /// Messages for the UI status line / agents. pub messages: Vec, } @@ -118,6 +125,7 @@ impl Engine { path: None, revision: 1, dirty: false, + merge_key: None, messages: Vec::new(), } } @@ -203,9 +211,15 @@ impl Engine { /// Run a command by id. Never panics: escaped panics become `EngineError::Internal` and the /// document is restored. pub fn execute(&mut self, id: &str, params: &Value) -> Result { + self.merge_key = None; let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; if let Err(reason) = (spec.enabled)(self) { - return Err(EngineError::Disabled(id.to_string(), reason)); + // A call that names its targets (tracks, clips, a range) does not need a selection; + // the command itself validates them. + let names_targets = ["track", "tracks", "clip", "clips", "start", "end", "at"].iter().any(|k| params.get(k).is_some()); + if !names_targets { + return Err(EngineError::Disabled(id.to_string(), reason)); + } } let before = Arc::clone(&self.doc); let rev = self.revision; @@ -244,6 +258,25 @@ impl Engine { } } + /// Execute as part of a continuous gesture (fader drag, knob turn, slider): consecutive calls + /// with the same `key` collapse into a single undo step. + pub fn execute_merged(&mut self, id: &str, params: &Value, key: &str) -> Result { + let continuing = self.merge_key.as_deref() == Some(key); + let before_len = self.undo.len(); + let r = self.execute(id, params)?; + if continuing && self.undo.len() == before_len + 1 && before_len > 0 { + // Drop the step just pushed; the earlier one still holds the pre-gesture document. + self.undo.pop(); + } + self.merge_key = Some(key.to_string()); + Ok(r) + } + + /// End a gesture started with `execute_merged`. + pub fn end_merge(&mut self) { + self.merge_key = None; + } + /// Convenience: execute with `{}` params. pub fn run(&mut self, id: &str) -> Result { self.execute(id, &json!({})) @@ -279,3 +312,21 @@ fn install_panic_hook() { })); }); } + +#[cfg(test)] +mod merge_tests { + use super::*; + + #[test] + fn merged_gesture_is_one_undo_step() { + let mut e = Engine::default(); + let t = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None); + for db in [-1.0, -2.0, -3.0, -4.0] { + e.execute_merged("mix.volume", &json!({"track": t.0, "db": db}), "fader").unwrap(); + } + e.end_merge(); + assert_eq!(e.undo_history().len(), 1); + e.undo(); + assert_eq!(e.session().track(t).unwrap().mixer.volume_db, 0.0); + } +} diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 54794d4..effffa7 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -386,9 +386,19 @@ fn process_strip(s: &Session, t: &Track, strip: &mut Strip, busses: &HashMap f32 { if t.mixer.automation_mode.reads() + && t.mixer.automation_mode != soundcraft_model::AutomationMode::Write && let Some(l) = t.lane(&AutoParam::Volume) && !l.points.is_empty() { diff --git a/crates/model/src/mixer.rs b/crates/model/src/mixer.rs index 3ee0068..af11a36 100644 --- a/crates/model/src/mixer.rs +++ b/crates/model/src/mixer.rs @@ -145,10 +145,19 @@ impl ChannelFormat { } /// Best format for a channel count. pub fn for_channels(n: usize) -> ChannelFormat { - [ChannelFormat::Mono, ChannelFormat::Stereo, ChannelFormat::Lcr, ChannelFormat::Quad, ChannelFormat::Surround50, ChannelFormat::Surround51, ChannelFormat::Surround70, ChannelFormat::Surround71] - .into_iter() - .find(|f| f.channels() == n) - .unwrap_or(if n <= 1 { ChannelFormat::Mono } else { ChannelFormat::Stereo }) + [ + ChannelFormat::Mono, + ChannelFormat::Stereo, + ChannelFormat::Lcr, + ChannelFormat::Quad, + ChannelFormat::Surround50, + ChannelFormat::Surround51, + ChannelFormat::Surround70, + ChannelFormat::Surround71, + ] + .into_iter() + .find(|f| f.channels() == n) + .unwrap_or(if n <= 1 { ChannelFormat::Mono } else { ChannelFormat::Stereo }) } } diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index 3641aa1..ec94b1f 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -267,7 +267,10 @@ impl Default for EditState { delay_compensation: true, keyboard_focus: "commands".into(), timeline_selection: Range::default(), - flags: ["view.clip.name", "view.clip.gain_line", "waveform.peak", "view.clip.overlap_shadows", "view.marker.ruler_lines"].iter().map(|s| s.to_string()).collect(), + flags: ["view.clip.name", "view.clip.gain_line", "waveform.peak", "view.clip.overlap_shadows", "view.marker.ruler_lines"] + .iter() + .map(|s| s.to_string()) + .collect(), values: std::collections::BTreeMap::new(), } } @@ -296,6 +299,10 @@ pub struct Session { pub next_id: u64, #[serde(default)] pub comments: String, + /// Original MIDI performances keyed by clip id (Event › MIDI Operations › Restore/Flatten + /// Performance). Independent of undo so a restore survives later edits. + #[serde(default)] + pub midi_originals: std::collections::BTreeMap, #[serde(skip)] pub pool: SourcePool, } @@ -328,6 +335,7 @@ impl Session { edit: EditState::default(), next_id: 1, comments: String::new(), + midi_originals: std::collections::BTreeMap::new(), pool: SourcePool::default(), }; s.edit.pre_roll = sample_rate.samples(2.0); @@ -522,6 +530,10 @@ impl Session { l.points.sort_by_key(|p| p.at); } } + // Drop performances whose clip no longer exists anywhere (any playlist). + let clip_ids: std::collections::BTreeSet = + self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0))).collect(); + self.midi_originals.retain(|id, _| clip_ids.contains(id)); if !self.edit.zoom.samples_per_px.is_finite() || self.edit.zoom.samples_per_px <= 0.0 { self.edit.zoom.samples_per_px = 1024.0; } diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index 13befaa..e6154eb 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -72,7 +72,13 @@ impl Recorder { ) .map_err(|e| e.to_string())?; stream.play().map_err(|e| e.to_string())?; - Ok(Recorder { shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels: usize::from(config.channels).max(1) }) + Ok(Recorder { + shared, + _stream: Some(stream), + device_name: name, + sample_rate: config.sample_rate.0, + channels: usize::from(config.channels).max(1), + }) } #[cfg(target_os = "freebsd")] diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 785fe1f..541c49d 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -42,8 +42,11 @@ pub fn sample_at(s: &Session, tl: Rect, x: f32) -> Samples { s.edit.zoom.scroll + soundcraft_time::to_samples(f64::from(x - tl.min.x) * s.edit.zoom.samples_per_px) } +pub const LANE_H: f32 = 46.0; + pub fn track_height(t: &Track) -> f32 { - t.height.points() + let alt = if t.view == "playlists" { t.playlists.len().saturating_sub(1) as f32 * LANE_H } else { 0.0 }; + t.height.points() + alt } pub fn show(app: &mut SoundApp, ui: &mut Ui) { @@ -59,6 +62,9 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { .frame(egui::Frame::NONE.fill(t.panel_bg)) .show(ui, |ui| crate::midi_editor::show(app, ui)); } + if app.ui.show_universe { + egui::Panel::top("universe").exact_size(64.0).frame(egui::Frame::NONE.fill(Color32::from_rgb(18, 18, 19))).show(ui, |ui| universe(app, ui)); + } if app.ui.show_tracks_list { egui::Panel::left("tracks_list") .exact_size(168.0) @@ -71,6 +77,45 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.window_bg)).show(ui, |ui| main_area(app, ui)); } +/// Universe: the whole session in miniature; the box is the visible area (drag or click to move). +fn universe(app: &mut SoundApp, ui: &mut Ui) { + let r = ui.max_rect().shrink2(vec2(8.0, 4.0)); + let s = app.engine.session(); + let total = (s.content_end() + s.sample_rate.samples(10.0)).max(1) as f32; + let tracks: Vec<&Track> = s.tracks.iter().filter(|t| !t.hidden).collect(); + let n = tracks.len().max(1) as f32; + let rh = (r.height() / n).max(1.0); + for (i, t) in tracks.iter().enumerate() { + let y = r.min.y + i as f32 * rh; + for c in t.clips() { + let x0 = r.min.x + c.start as f32 / total * r.width(); + let x1 = r.min.x + c.end() as f32 / total * r.width(); + ui.painter().rect_filled( + Rect::from_min_max(pos2(x0, y + 0.5), pos2(x1.max(x0 + 1.0), y + rh - 0.5)), + 0.0, + rgb(c.color.unwrap_or(t.color)), + ); + } + } + let vis0 = s.edit.zoom.scroll as f32 / total; + let tlw = app.edit_layout.timeline[2] - app.edit_layout.timeline[0]; + let vis1 = vis0 + (f64::from(tlw.max(100.0)) * s.edit.zoom.samples_per_px) as f32 / total; + let vr = Rect::from_min_max(pos2(r.min.x + vis0 * r.width(), r.min.y - 2.0), pos2(r.min.x + vis1.min(1.0) * r.width(), r.max.y + 2.0)); + ui.painter().rect_stroke(vr, 2.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside); + if app.is_playing() { + let x = r.min.x + app.position() as f32 / total * r.width(); + ui.painter().line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, Tokens::DARK.playhead)); + } + let resp = ui.interact(r, ui.id().with("universe"), Sense::click_and_drag()); + if let Some(p) = resp.interact_pointer_pos() + && (resp.clicked() || resp.dragged()) + { + let half = (vis1 - vis0) * 0.5; + let k = ((p.x - r.min.x) / r.width() - half).clamp(0.0, 1.0); + let _ = app.engine.execute("view.scroll", &json!({"to": (k * total) as i64})); + } +} + fn dock_tabs(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::DARK; let r = ui.max_rect(); @@ -114,7 +159,7 @@ fn status_bar(app: &mut SoundApp, ui: &mut Ui) { ui.painter().text( pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, - format!("{undo} {:.1} ms/frame", app.frame_ms), + if app.frame_ms < 50.0 { format!("{undo} {:.1} ms/frame", app.frame_ms) } else { undo }, regular(11.0), t.text_dim, ); @@ -475,7 +520,8 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) let cr = Rect::from_min_size(pos2(head.max.x + i as f32 * COLUMN_W, row.min.y), vec2(COLUMN_W, row.height())); header_column(app, ui, &track, c, cr); } - let lane = Rect::from_min_max(pos2(tl.min.x, row.min.y), pos2(tl.max.x, row.max.y)); + let main_h = track.height.points(); + let lane = Rect::from_min_max(pos2(tl.min.x, row.min.y), pos2(tl.max.x, row.min.y + main_h)); // Header background. ui.painter().rect_filled(head, 0.0, if selected { Color32::from_rgb(52, 58, 66) } else { t.panel_bg2 }); ui.painter().rect_filled(Rect::from_min_size(head.min, vec2(5.0, head.height())), 0.0, rgb(track.color)); @@ -511,6 +557,48 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) } } lane_interaction(app, ui, &track, lane, tl); + if track.view == "playlists" { + playlist_lanes(app, ui, &track, row, main_h, tl); + } +} + +/// Alternate playlists as lanes under the main one; clicking a clip promotes it (comping). +fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, main_h: f32, tl: Rect) { + let t = Tokens::DARK; + let s = app.engine.session().clone(); + let mut y = row.min.y + main_h; + for (i, pl) in track.playlists.iter().enumerate() { + if i == track.active_playlist { + continue; + } + let lane = Rect::from_min_max(pos2(tl.min.x, y), pos2(tl.max.x, y + LANE_H)); + let head = Rect::from_min_max(pos2(row.min.x + 12.0, y), pos2(tl.min.x, y + LANE_H)); + ui.painter().rect_filled(lane, 0.0, Color32::from_rgb(30, 30, 31)); + ui.painter().text(pos2(head.min.x + 4.0, head.center().y), Align2::LEFT_CENTER, &pl.name, regular(10.5), t.text_dim); + let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect())); + for c in &pl.clips { + let r = Rect::from_min_max(pos2(x_of(&s, tl, c.start), lane.min.y + 3.0), pos2(x_of(&s, tl, c.end()), lane.max.y - 3.0)); + painter.rect(r, 2.0, rgb(track.color).gamma_multiply(0.45), Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); + painter.text(pos2(r.min.x + 4.0, r.min.y + 7.0), Align2::LEFT_CENTER, &c.name, regular(9.5), t.text); + } + let resp = ui.interact(lane, ui.id().with(("pl_lane", track.id.0, i)), Sense::click()); + if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + let at = sample_at(&s, tl, p.x); + let sel = s.edit.selection; + let range = if !sel.is_empty() && sel.contains(at) { + Some((sel.start, sel.end)) + } else { + pl.clips.iter().find(|c| c.range().contains(at)).map(|c| (c.start, c.end())) + }; + if let Some((a, b)) = range { + let _ = app.run("track.playlist_promote", json!({"track": track.id.0, "playlist": i, "start": a, "end": b})); + } + } + resp.on_hover_text("Click a take (or the selection) to promote it to the main playlist"); + y += LANE_H; + } } fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, selected: bool) { @@ -735,7 +823,8 @@ fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: R let mut c = track.comments.clone(); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink(3.0))); if child.add(egui::TextEdit::multiline(&mut c).desired_width(r.width() - 6.0).font(regular(10.0))).changed() { - let _ = app.run("track.comments", json!({"track": track.id.0, "comments": c})); + let _ = + app.engine.execute_merged("track.comments", &json!({"track": track.id.0, "comments": c}), &format!("comments:{}", track.id.0)); } } } @@ -754,7 +843,7 @@ fn view_menu(app: &mut SoundApp, ui: &mut Ui, track: &Track) { if track.kind.is_midi() { opts.extend(["notes", "regions", "blocks"]); } else if track.kind.has_playlist() { - opts.extend(["waveform", "blocks", "clip_gain"]); + opts.extend(["waveform", "blocks", "clip_gain", "playlists"]); } opts.extend(["volume", "pan", "mute"]); for o in opts { @@ -1127,7 +1216,32 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, ui.ctx().set_cursor_icon(icon); } let auto_view = AutoParam::parse(&track.view); - if resp.drag_started() { + let fade_corner = if tool == Tool::Smart { + hit.as_ref().and_then(|c| { + let xs = x_of(&s, tl, c.start); + let xe = x_of(&s, tl, c.end()); + let top = p.y < lane.min.y + lane.height() * 0.28; + if !top { + None + } else if (p.x - xs) > 0.0 && (p.x - xs) < 14.0 { + Some(true) + } else if (xe - p.x) > 0.0 && (xe - p.x) < 14.0 { + Some(false) + } else { + None + } + }) + } else { + None + }; + if fade_corner.is_some() && resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe); + } + if resp.drag_started() + && let (Some(fade_in), Some(c)) = (fade_corner, hit.as_ref()) + { + app.gesture = Some(Gesture::Fade { clip: c.id, track: track.id, fade_in, to: at }); + } else if resp.drag_started() { app.gesture = match eff { Tool::Selector | Tool::Smart => Some(Gesture::Select { track: track.id, anchor: snap(&s, at), tracks: vec![track.id] }), Tool::Grabber => hit.as_ref().map(|c| { @@ -1172,6 +1286,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, } } Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at), + Some(Gesture::Fade { to, .. }) => *to = at, Some(Gesture::Scrub { last }) => { *last = at; let _ = app.engine.execute("transport.locate", &json!({"at": at})); @@ -1215,6 +1330,12 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, let _ = app.run("edit.move_clips", params); } } + Some(Gesture::Fade { clip, track, fade_in, to }) => { + if let Some((_, c)) = app.engine.session().find_clip(clip).map(|(t, c)| (t, c.clone())) { + let (a, b) = if fade_in { (c.start, to.clamp(c.start + 1, c.end())) } else { (to.clamp(c.start, c.end() - 1), c.end()) }; + let _ = app.run("edit.fades_create", json!({"tracks": [track.0], "start": a, "end": b, "shape": "s-curve"})); + } + } Some(Gesture::TrimStart { clip, track, to }) => trim_clip(app, clip, track, to, true), Some(Gesture::TrimEnd { clip, track, to }) => trim_clip(app, clip, track, to, false), Some(Gesture::Pencil { track, points }) => { @@ -1403,6 +1524,16 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { let x = x_of(s, tl, *to); painter.line_segment([pos2(x, r[1]), pos2(x, r[3])], Stroke::new(2.0, Color32::WHITE)); } + if let Some(Gesture::Fade { clip, fade_in, to, .. }) = &app.gesture + && let Some((tid, c)) = s.find_clip(*clip) + && let Some((_, r)) = app.edit_layout.rows.iter().find(|(i, _)| *i == tid.0) + { + let (a, b) = if *fade_in { (c.start, *to) } else { (*to, c.end()) }; + let (x0, x1) = (x_of(s, tl, a), x_of(s, tl, b)); + let (y0, y1) = (r[1] + 2.0, r[3] - 2.0); + let line = if *fade_in { [pos2(x0, y1), pos2(x1, y0)] } else { [pos2(x0, y0), pos2(x1, y1)] }; + painter.line_segment(line, Stroke::new(2.0, Color32::WHITE)); + } if let Some(at) = scroll_to { let _ = app.engine.execute("view.scroll", &json!({"to": at})); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d907637..ec90201 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -172,6 +172,9 @@ pub struct SoundApp { pub quit_requested: bool, /// Reset floating-window positions next frame (Window › Arrange). pub arrange_request: bool, + /// Tracks whose automation was touched during this pass (Touch/Latch writing). + touched: std::collections::HashSet, + last_write_at: Samples, pub frame_ms: f32, last_frame: Option, } @@ -200,6 +203,8 @@ impl SoundApp { sim: None, quit_requested: false, arrange_request: false, + touched: std::collections::HashSet::new(), + last_write_at: i64::MIN, frame_ms: 0.0, last_frame: None, } @@ -289,9 +294,13 @@ impl SoundApp { self.engine.transport.recording = true; if !self.is_playing() { let sel = self.engine.session().edit.selection; - // Record into the selection when there is one (punch), else open-ended. - let end = (!sel.is_empty()).then_some(sel.end); - self.start_play(sel.start, end, None); + if self.engine.session().edit.loop_record && !sel.is_empty() { + self.start_play(sel.start, None, Some(sel)); + } else { + // Record into the selection when there is one (punch), else open-ended. + let end = (!sel.is_empty()).then_some(sel.end); + self.start_play(sel.start, end, None); + } } } @@ -300,12 +309,88 @@ impl SoundApp { let Some(r) = &self.recorder else { return }; let take = r.take(); let rate = take.sample_rate; + let sel = self.engine.session().edit.selection; + if self.engine.session().edit.loop_record && !sel.is_empty() { + // Split the capture into one take per loop pass; each pass gets its own playlist. + let pass = usize::try_from(soundcraft_time::to_samples(sel.len() as f64 * f64::from(rate) / self.engine.session().sample_rate.as_f64())) + .unwrap_or(0) + .max(1); + let total = take.channels.first().map_or(0, Vec::len); + let passes = total.div_ceil(pass).max(1); + let mut made = 0; + for k in 0..passes { + let chunk: Vec> = + take.channels.iter().map(|c| c.get(k * pass..((k + 1) * pass).min(c.len())).map(<[f32]>::to_vec).unwrap_or_default()).collect(); + if chunk.first().is_none_or(|c| c.len() < pass / 8) { + continue; + } + if made > 0 { + let armed: Vec = self.engine.session().tracks.iter().filter(|t| t.mixer.record_arm).map(|t| t.id.0).collect(); + let _ = self.engine.execute("track.playlist_new", &serde_json::json!({"tracks": armed})); + } + if soundcraft_engine::io::add_recording(&mut self.engine, sel.start, chunk, rate).is_ok() { + made += 1; + } + } + self.ui.status = format!("Loop-recorded {made} take(s)"); + return; + } match soundcraft_engine::io::add_recording(&mut self.engine, self.record_start, take.channels, rate) { Ok(ids) => self.ui.status = format!("Recorded {} clip(s)", ids.len()), Err(e) => self.ui.status = e.to_string(), } } + /// While playing, record volume automation from fader positions for tracks in Write mode, and + /// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. One undo step per pass. + fn write_automation(&mut self, ctx: &egui::Context) { + use soundcraft_model::AutomationMode as M; + if !self.is_playing() { + if !self.touched.is_empty() { + self.touched.clear(); + self.engine.end_merge(); + } + self.last_write_at = i64::MIN; + return; + } + let pos = self.position(); + let step = self.engine.session().sample_rate.samples(0.02); + if (pos - self.last_write_at).abs() < step { + return; + } + self.last_write_at = pos; + let dragging = ctx.input(|i| i.pointer.any_down()); + let fader_track = match &self.gesture { + Some(Gesture::Fader { track, .. }) => Some(*track), + _ => None, + }; + let writes: Vec<(TrackId, f32)> = self + .engine + .session() + .tracks + .iter() + .filter_map(|t| { + let m = t.mixer.automation_mode; + let touched_now = dragging && fader_track == Some(t.id); + let write = match m { + M::Write => true, + M::Touch => touched_now, + M::Latch | M::TouchLatch => touched_now || self.touched.contains(&t.id), + _ => false, + }; + write.then_some((t.id, t.mixer.volume_db)) + }) + .collect(); + for (t, v) in writes { + self.touched.insert(t); + let _ = self.engine.execute_merged( + "automation.set_point", + &serde_json::json!({"track": t.0, "param": "volume", "at": pos, "value": v}), + "automation_pass", + ); + } + } + /// Play from the selection, honouring loop playback and selection end. pub fn play_from_selection(&mut self) { let s = self.engine.session(); @@ -430,6 +515,15 @@ impl SoundApp { self.last_frame = Some(now); self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1; control::drain(self, ctx); + if !ctx.input(|i| i.pointer.any_down()) { + if matches!(self.gesture, Some(Gesture::Fader { .. })) { + self.gesture = None; + } + // Keep a running automation pass merged until playback stops. + if !ctx.egui_wants_keyboard_input() && self.touched.is_empty() { + self.engine.end_merge(); + } + } if self.engine.revision != self.last_rev { self.last_rev = self.engine.revision; if let Some(p) = &self.player { @@ -437,6 +531,7 @@ impl SoundApp { } } self.handle_transport(dt); + self.write_automation(ctx); if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() { ctx.request_repaint(); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index df0790d..50e3015 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -314,11 +314,11 @@ const MENU_WINDOWS: &[(&str, &str)] = &[ /// Menu-style invocation: may open a dialog. pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) { - if let Some((_, w)) = MENU_WINDOWS.iter().find(|(p, _)| *p == path) { - if w.starts_with("window.") { - let _ = app.run(w, json!({"value": true})); - return; - } + if let Some((_, w)) = MENU_WINDOWS.iter().find(|(p, _)| *p == path) + && w.starts_with("window.") + { + let _ = app.run(w, json!({"value": true})); + return; } if id == "audiosuite.process" { let p = params_for(path, id); diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index 44f140a..d2216df 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -258,6 +258,6 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { && (x_of(n.start) - p.x).abs() < 8.0 { let v = (((vel.max.y - 2.0 - p.y) / (vel.height() - 4.0)) * 127.0).round().clamp(1.0, 127.0) as i64; - let _ = app.engine.execute("midi.note_edit", &json!({"clip": cid.0, "index": i, "velocity": v})); + let _ = app.engine.execute_merged("midi.note_edit", &json!({"clip": cid.0, "index": i, "velocity": v}), &format!("vel:{}", cid.0)); } } diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 2539cd6..d0fb44e 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -116,7 +116,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { let mut c = track.comments.clone(); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(sec)); if child.add(egui::TextEdit::multiline(&mut c).desired_width(inner_w).desired_rows(2).hint_text("comments").font(regular(10.0))).changed() { - let _ = app.engine.execute("track.comments", &json!({"track": id.0, "comments": c})); + let _ = app.engine.execute_merged("track.comments", &json!({"track": id.0, "comments": c}), &format!("comments:{}", id.0)); } y = sec.max.y + 4.0; } @@ -174,11 +174,9 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { let before = v; let resp = pan_knob(&mut c, ks, &mut v, "Pan (drag; double-click centres)"); if (v - before).abs() > f32::EPSILON { - let _ = app.engine.execute("mix.pan", &json!({"track": id.0, "pan": v, "index": i})); - } - if resp.drag_stopped() { - app.engine.push_undo("Pan", app.engine.session_arc()); + let _ = app.engine.execute_merged("mix.pan", &json!({"track": id.0, "pan": v, "index": i}), &format!("pan:{}:{i}", id.0)); } + let _ = resp; let pr = Rect::from_center_size(pos2(cx, y + ks + 8.0), vec2(ks + 8.0, 13.0)); ui.painter().rect_filled(pr, 1.0, t.counter_bg); ui.painter().text(pr.center(), Align2::CENTER_CENTER, pan_text(v), regular(10.0), t.counter_text); @@ -218,25 +216,8 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { let fh = (r.max.y - bottom_h - y).max(120.0); let fr = Rect::from_min_size(pos2(x0, y), vec2(inner_w * 0.6, fh)); if let Some(db) = fader(ui, fr, track.mixer.volume_db, ui.id().with(("fader", id.0))) { - if app.gesture.is_none() { - app.gesture = Some(crate::Gesture::Fader { track: id, start_db: track.mixer.volume_db }); - app.ui.status = String::new(); - } - let before = app.engine.session_arc(); - let _ = app.engine.execute("mix.volume", &json!({"track": id.0, "db": db})); - let _ = before; - } - if !ui.input(|i| i.pointer.any_down()) - && let Some(crate::Gesture::Fader { track: ft, start_db }) = app.gesture.clone() - && ft == id - { - app.gesture = None; - // Collapse the drag into a single undo step. - let end = app.engine.session().track(id).map_or(start_db, |t| t.mixer.volume_db); - if (end - start_db).abs() > 0.001 { - let _ = app.engine.execute("mix.volume", &json!({"track": id.0, "db": start_db})); - let _ = app.run("mix.volume", json!({"track": id.0, "db": end})); - } + app.gesture = Some(crate::Gesture::Fader { track: id, start_db: track.mixer.volume_db }); + let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &format!("fader:{}", id.0)); } let md = app.meters.get(&id).copied().unwrap_or_default(); let mr = Rect::from_min_max(pos2(fr.max.x + 4.0, fr.min.y + 6.0), pos2(x0 + inner_w - 2.0, fr.max.y - 6.0)); @@ -371,7 +352,8 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec ); if resp.dragged() { let db = (s.level_db.max(-60.0) - resp.drag_delta().y * 0.3).clamp(-144.0, 12.0); - let _ = app.engine.execute("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db})); + let _ = + app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0)); } if resp.double_clicked() { let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0})); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 01aa672..93949b2 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -254,6 +254,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { .id(egui::Id::new(("plugin", tid.0, slot))) .open(&mut open) .default_width(340.0) + .default_pos(egui::pos2(ctx.content_rect().width() - 380.0, 120.0)) .show(ctx, |ui| { ui.horizontal(|ui| { ui.label(egui::RichText::new(&ins.preset).color(Tokens::DARK.text_dim)); @@ -289,7 +290,11 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { ui.add(sl) }; if (x - v).abs() > f32::EPSILON { - let _ = app.engine.execute("mix.insert_param", &json!({"track": tid.0, "slot": slot, "param": p.id, "value": x})); + let _ = app.engine.execute_merged( + "mix.insert_param", + &json!({"track": tid.0, "slot": slot, "param": p.id, "value": x}), + &format!("param:{}:{slot}:{}", tid.0, p.id), + ); } let _ = resp; ui.label(egui::RichText::new(p.format(x)).font(mono(11.0))); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index bddb1ce..eef407e 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -127,6 +127,16 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool { let s = app.engine.session(); let at = s.edit.selection.start; let back = m.alt; + if s.edit.tab_to_transient + && !back + && let Some(t) = s.edit.selected_tracks.first().copied() + { + let r = soundcraft_time::Range::new(at + 1, s.content_end()); + if let Some(n) = soundcraft_engine::io::transients_in(s, t, r, 0.5).into_iter().find(|x| *x > at + 64) { + let _ = app.run("transport.locate", json!({"at": n})); + return true; + } + } let mut edges: Vec = s .tracks .iter() diff --git a/docs/images/automation.png b/docs/images/automation.png new file mode 100644 index 0000000..0471aea Binary files /dev/null and b/docs/images/automation.png differ diff --git a/docs/images/edit.png b/docs/images/edit.png new file mode 100644 index 0000000..fac2ce0 Binary files /dev/null and b/docs/images/edit.png differ diff --git a/docs/images/midi.png b/docs/images/midi.png new file mode 100644 index 0000000..ed6ec55 Binary files /dev/null and b/docs/images/midi.png differ diff --git a/docs/images/mix.png b/docs/images/mix.png new file mode 100644 index 0000000..9f8be06 Binary files /dev/null and b/docs/images/mix.png differ diff --git a/docs/parity.md b/docs/parity.md index e5a484f..54a8fa9 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -4,19 +4,19 @@ Generated by `soundcraft-cli parity --write docs/parity.md` (also `cargo xtask p 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). -**Menu items implemented by the engine: 171 / 512 (33.4%)** +**Menu items implemented by the engine: 388 / 512 (75.8%)** | Menu | Implemented | Total | |---|---:|---:| | AudioSuite | 0 | 28 | -| Clip | 22 | 29 | -| Edit | 60 | 98 | -| Event | 13 | 32 | -| File | 15 | 30 | -| Options | 28 | 41 | -| Setup | 2 | 19 | -| Track | 17 | 71 | -| View | 13 | 112 | +| Clip | 28 | 29 | +| Edit | 93 | 98 | +| Event | 32 | 32 | +| File | 16 | 30 | +| Options | 39 | 41 | +| Setup | 18 | 19 | +| Track | 69 | 71 | +| View | 92 | 112 | | Window | 1 | 52 | ## Not yet implemented @@ -26,7 +26,6 @@ UI-layer commands (windows, AudioSuite) are counted by the app's own report (`en - File > Dashboard... - File > Start Collaboration... - File > Send To... > Sibelius -- File > Import > Session Data... - File > Import > Video... - File > Import > Clip Groups... - File > Import > VENUE Channel Names as Track Names @@ -36,41 +35,8 @@ UI-layer commands (windows, AudioSuite) are counted by the app's own report (`en - File > Score Setup... - File > Print Score... - File > Sign In -- Edit > Cut Special > Clip Effects -- Edit > Cut Special > Pan Automation -- Edit > Cut Special > Plugin Automation -- Edit > Copy Special > Clip Gain -- Edit > Copy Special > Clip Effects -- Edit > Copy Special > All Automation -- Edit > Copy Special > Pan Automation -- Edit > Copy Special > Plugin Automation - Edit > Copy Special > Audio as MIDI -- Edit > Paste Special > Merge MIDI - Edit > Paste Special > Merge Markers -- Edit > Paste Special > To Current Automation Type -- Edit > Clear Special > Clip Effects -- Edit > Selection > Play Timeline -- Edit > Selection > Duplicate and Extend Edit -- Edit > Selection > Extend Edit Up to Members -- Edit > Selection > Extend Edit Down to Members -- Edit > Selection > Move Edit Selection To Marker Lane Above -- Edit > Selection > Move Edit Selection To Marker Lane Below -- Edit > Selection > Extend Edit Selection To Marker Lane Above -- Edit > Selection > Extend Edit Selection To Marker Lane Below -- Edit > Space Clips… -- Edit > Snap to > Next -- Edit > Snap to > Previous -- Edit > Copy Selection to... > Target Playlist -- Edit > Copy Selection to... > Duplicate of Main Playlist -- Edit > Move Selection to... > Target Playlist -- Edit > Move Selection to... > New Playlist -- Edit > Move Selection to... > Duplicate of Main Playlist -- Edit > TCE Edit to Timeline Selection -- Edit > Automation > Coalesce Volume Automation to Clip Gain -- Edit > Automation > Coalesce Clip Gain to Volume Automation -- Edit > Automation > Trim to All Enabled -- Edit > Automation > Glide to All Enabled -- Edit > Automation > Glide Pan Only - Edit > Automation > Duplicate Dolby Atmos Plugin Automation to Pan Automation - Edit > Automation > Duplicate Pan Automation to Dolby Atmos Plugin Automation - Edit > Automation > Coalesce Pan Guide Automation @@ -91,168 +57,12 @@ UI-layer commands (windows, AudioSuite) are counted by the app's own report (`en - View > Mix Window Views > All - View > Mix Window Views > Minimal - View > Narrow Mix -- View > Edit Window Views > Comments -- View > Edit Window Views > Mic Preamps -- View > Edit Window Views > Instruments -- View > Edit Window Views > Inserts A-E -- View > Edit Window Views > Inserts F-J -- View > Edit Window Views > Sends A-E -- View > Edit Window Views > Sends F-J -- View > Edit Window Views > I/O -- View > Edit Window Views > Object -- View > Edit Window Views > Real-Time Properties -- View > Edit Window Views > Track Color -- View > Edit Window Views > Marker Controls -- View > Edit Window Views > All -- View > Edit Window Views > Minimal -- View > Edit Window Views > Pinned Tracks -- View > Rulers > Timecode 2 -- View > Rulers > Tempo > Tempo Editor -- View > Rulers > Key Signature > Key Signature Staff -- View > Rulers > Markers 2 -- View > Rulers > Markers 3 -- View > Rulers > Markers 4 -- View > Rulers > Markers 5 -- View > Rulers > All Marker Rulers -- View > Rulers > All -- View > Rulers > Minimal -- View > Marker Displays > Track Lane -- View > Marker Displays > Ruler Lines -- View > Marker Displays > Folder Lines -- View > Marker Displays > Ruler Lane Color -- View > Marker Displays > Track Lane Color - View > Other Displays > Universe - View > Other Displays > Track List - View > Other Displays > Clip List -- View > Other Displays > MIDI Input Display -- View > Other Displays > Lower Dock > MIDI Editor > Clip Effects -- View > Clip > Sync Point -- View > Clip > Processing State -- View > Clip > Name -- View > Clip > Channel Name -- View > Clip > Scene And Take -- View > Clip > Rating -- View > Clip > Overlap Shadows -- View > Clip > Transparency -- View > Clip > Overwrite Indicator -- View > Clip > Copy/Move Indicators -- View > Clip > Clip Gain Line -- View > Clip > Clip Gain Info -- View > Clip > Clip Effects Status -- View > Clip > ARA Note Overlay -- View > Clip > Current Time -- View > Clip > Original Time Stamp -- View > Clip > User Time Stamp -- View > Clip > No Time -- View > Clip > Display on All Channels -- View > Waveforms > Peak -- View > Waveforms > Power -- View > Waveforms > Rectified -- View > Waveforms > Outlines -- View > Waveforms > Overlapped Crossfades -- View > Automation > Trim Playlist -- View > Automation > Composite Playlist -- View > Expanded Sends > All -- View > Expanded Sends > None -- View > Expanded Sends > Send A -- View > Expanded Sends > Send B -- View > Expanded Sends > Send C -- View > Expanded Sends > Send D -- View > Expanded Sends > Send E -- View > Expanded Sends > Send F -- View > Expanded Sends > Send G -- View > Expanded Sends > Send H -- View > Expanded Sends > Send I -- View > Expanded Sends > Send J -- View > Track Number -- View > Track Transcription Lane -- View > Dropped Video Frame Indicator -- View > Transport > Counters -- View > Transport > MIDI Controls -- View > Transport > Synchronization -- View > Transport > Ableton Link -- View > Transport > Output Meters -- View > Transport > Expanded -- Track > Convert Aux to Routing Folder -- Track > Change Track Width > Mono -- Track > Change Track Width > Stereo -- Track > Change Track Width > LCR -- Track > Change Track Width > Quad -- Track > Change Track Width > LCRS -- Track > Change Track Width > 5.0 -- Track > Change Track Width > 5.1 -- Track > Change Track Width > 6.0 -- Track > Change Track Width > 6.1 -- Track > Change Track Width > 7.0 SDDS -- Track > Change Track Width > 7.1 SDDS -- Track > Change Track Width > 7.0 -- Track > Change Track Width > 7.1 -- Track > Change Track Width > 7.0.2 -- Track > Change Track Width > 7.1.2 -- Track > Change Track Width > 1st Order Ambisonics -- Track > Change Track Width > 2nd Order Ambisonics -- Track > Change Track Width > 3rd Order Ambisonics -- Track > Change Track Width > 4th Order Ambisonics -- Track > Change Track Width > 5th Order Ambisonics -- Track > Change Track Width > 6th Order Ambisonics -- Track > Change Track Width > 7th Order Ambisonics -- Track > Change Track Width > 5.0.2 -- Track > Change Track Width > 5.1.2 -- Track > Change Track Width > 5.0.4 -- Track > Change Track Width > 5.1.4 -- Track > Change Track Width > 7.0.4 -- Track > Change Track Width > 7.1.4 -- Track > Change Track Width > 7.0.6 -- Track > Change Track Width > 7.1.6 -- Track > Change Track Width > 9.0.4 -- Track > Change Track Width > 9.1.4 -- Track > Change Track Width > 9.0.6 -- Track > Change Track Width > 9.1.6 - Track > Change Track Width > Show / Hide... -- Track > Extract MIDI to New Track - Track > ARA Plugins > Melodyne -- Track > Save Track Preset... -- Track > Bypass Inserts > Inserts A-E -- Track > Bypass Inserts > Inserts F-J -- Track > Bypass Inserts > EQ -- Track > Bypass Inserts > Dynamics -- Track > Bypass Inserts > Reverb -- Track > Bypass Inserts > Delay -- Track > Bypass Inserts > Modulation -- Track > Mute Sends > Sends A-E -- Track > Mute Sends > Sends F-J -- Track > Write MIDI Real-Time Properties -- Track > Coalesce VCA Controller Automation -- Track > Coalesce Trim Automation -- Track > Designate as Target Playlist -- Track > Show Target Playlist -- Track > Toggle Recent Playlist -- Clip > Regroup -- Clip > Unloop... -- Clip > Clip Effects > Bypass -- Clip > Clip Effects > Render - Clip > ARA Plugins > Melodyne -- Clip > Conform to Tempo -- Clip > Remove Pitch Shift -- Event > Time Operations > Time Operations Window -- Event > Time Operations > Move Song Start... -- Event > Tempo Operations > Tempo Operations Window -- Event > Tempo Operations > Linear... -- Event > Tempo Operations > Parabolic... -- Event > Tempo Operations > S-Curve... -- Event > Tempo Operations > Stretch... -- Event > MIDI Operations > MIDI Operations Window -- Event > MIDI Operations > Select/Split Notes... -- Event > MIDI Operations > Input Quantize... -- Event > MIDI Operations > Step Input... -- Event > MIDI Operations > Restore Performance... -- Event > MIDI Operations > Flatten Performance... -- Event > MIDI Track Offsets -- Event > MIDI Real-Time Properties -- Event > Extract Chords from Selection -- Event > Beat Detective -- Event > Identify Beat... -- Event > Retrospective Record - AudioSuite > EQ > Channel Strip - AudioSuite > EQ > EQ3 1-Band - AudioSuite > EQ > EQ3 7-Band @@ -281,36 +91,9 @@ UI-layer commands (windows, AudioSuite) are counted by the app's own report (`en - AudioSuite > Other > Reverse - AudioSuite > Other > Signal Generator - AudioSuite > Other > Time Compression Expansion -- Options > DestructivePunch -- Options > Prepare DPE Tracks -- Options > Transport Online - Options > Video Track Online -- Options > Dynamic Transport -- Options > Marker Target Follows First Selected Track -- Options > MIDI Thru -- Options > Auto-Spot Clips -- Options > Edit/Tool Mode Keyboard Lock -- Options > Calibration Mode -- Options > MIDI Live Mode - Options > Activate HEAT -- Options > Low Latency Monitoring -- Setup > Hardware... -- Setup > Playback Engine... -- Setup > Disk Allocation... -- Setup > Peripherals... -- Setup > I/O... - Setup > Machine Track Arming Profiles... -- Setup > Video Sync Offset... -- Setup > Current Feet+Frames Position... -- Setup > Current Timecode Position... -- Setup > External Timecode Offset... -- Setup > Transcription Settings... -- Setup > MIDI > MIDI Studio... -- Setup > MIDI > MIDI Beat Clock... -- Setup > MIDI > MIDI Input Filter... -- Setup > MIDI > MIDI Input Devices... -- Setup > Click/Countoff... -- Setup > Keyboard Shortcuts... - Window > Configurations > Window Configuration List - Window > Configurations > New Configuration... - Window > Configurations > Update Active Configuration diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 37b3ea5..8269405 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -7,3 +7,6 @@ publish = false [dependencies] serde_json.workspace = true + +[lints] +workspace = true diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 60dd5bb..c604516 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -11,7 +11,8 @@ fn root() -> PathBuf { } fn cargo(args: &[&str]) -> Result<(), String> { - let st = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())).args(args).current_dir(root()).status().map_err(|e| e.to_string())?; + let st = + Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())).args(args).current_dir(root()).status().map_err(|e| e.to_string())?; if st.success() { Ok(()) } else { Err(format!("cargo {} failed", args.join(" "))) } } @@ -34,7 +35,8 @@ const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", " const EXEMPT: &[&str] = &["soundcraft", "soundcraft-cli", "soundcraft-web", "xtask"]; fn layers() -> Result<(), String> { - let out = Command::new("cargo").args(["metadata", "--format-version", "1", "--no-deps"]).current_dir(root()).output().map_err(|e| e.to_string())?; + let out = + Command::new("cargo").args(["metadata", "--format-version", "1", "--no-deps"]).current_dir(root()).output().map_err(|e| e.to_string())?; let v: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|e| e.to_string())?; let mut problems = Vec::new(); for p in v["packages"].as_array().cloned().unwrap_or_default() { @@ -74,10 +76,15 @@ fn layers() -> Result<(), String> { } } -const ASSET_EXT: &[&str] = &["png", "jpg", "jpeg", "svg", "ico", "icns", "ttf", "otf", "ttc", "woff", "woff2", "wav", "aif", "aiff", "flac", "mp3", "ogg", "gif", "webp"]; +const ASSET_EXT: &[&str] = + &["png", "jpg", "jpeg", "svg", "ico", "icns", "ttf", "otf", "ttc", "woff", "woff2", "wav", "aif", "aiff", "flac", "mp3", "ogg", "gif", "webp"]; fn assets() -> Result<(), String> { - let out = Command::new("git").args(["ls-files", "--cached", "--others", "--exclude-standard"]).current_dir(root()).output().map_err(|e| e.to_string())?; + let out = Command::new("git") + .args(["ls-files", "--cached", "--others", "--exclude-standard"]) + .current_dir(root()) + .output() + .map_err(|e| e.to_string())?; let files = String::from_utf8_lossy(&out.stdout).to_string(); let attribution = std::fs::read_to_string(root().join("ATTRIBUTION.md")).map_err(|e| format!("ATTRIBUTION.md: {e}"))?; let mut missing = Vec::new(); @@ -87,7 +94,8 @@ fn assets() -> Result<(), String> { continue; } // Generated icon trees are covered by their directory row. - let covered = attribution.contains(&format!("`{f}`")) || Path::new(f).ancestors().skip(1).any(|a| !a.as_os_str().is_empty() && attribution.contains(&format!("`{}/`", a.display()))); + let covered = attribution.contains(&format!("`{f}`")) + || Path::new(f).ancestors().skip(1).any(|a| !a.as_os_str().is_empty() && attribution.contains(&format!("`{}/`", a.display()))); if !covered { missing.push(f.to_string()); } @@ -105,7 +113,18 @@ fn parity() -> Result<(), String> { } /// Crates that must compile for the web. -const WASM: &[&str] = &["soundcraft-time", "soundcraft-audio-io", "soundcraft-midi", "soundcraft-dsp", "soundcraft-model", "soundcraft-mix", "soundcraft-engine", "soundcraft-playback", "soundcraft-automation", "soundcraft-ui-egui"]; +const WASM: &[&str] = &[ + "soundcraft-time", + "soundcraft-audio-io", + "soundcraft-midi", + "soundcraft-dsp", + "soundcraft-model", + "soundcraft-mix", + "soundcraft-engine", + "soundcraft-playback", + "soundcraft-automation", + "soundcraft-ui-egui", +]; fn wasm() -> Result<(), String> { let mut args = vec!["check", "--target", "wasm32-unknown-unknown"];