Engine parity batch (76% engine, 90% with UI), comping, fades, universe, automation writing, loop record

- ~200 new commands across Edit/View/Track/Clip/Event/Options/Setup/File
  (copy/paste special, playlists, TCE, tempo operations, beat detective,
  chords, MIDI performance, view flags, setup settings, import session data)
- explicit targets bypass selection-based enablement; undo coalescing for
  gestures (faders, knobs, sliders)
- live Write/Touch/Latch volume automation passes; loop recording into
  playlists; playlist lanes with promote-to-main comping; smart-tool fade
  handles; Universe overview; VCA masters; routing-folder busses; edit groups
- bounce reports true peak; README with screenshots; parity report

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-05 23:25:09 -04:00
parent 04d222069d
commit 720373c0c6
39 changed files with 4984 additions and 308 deletions

View File

@ -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 |

256
README.md
View File

@ -1,4 +1,254 @@
SoundCraft
===========
<p align="center">
<a href="https://getartcraft.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
</picture>
</a>
</p>
By ArtCraft
<h1 align="center">SoundCraft</h1>
<p align="center">
<b>Recording, editing and mixing audio; an open-source, clean-room reimplementation of Avid Pro Tools, rebuilt in pure Rust.</b>
</p>
<p align="center">
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.
</p>
<p align="center">
<img alt="Rust" src="https://img.shields.io/badge/100%25-Rust-b7410e?style=flat-square&logo=rust">
<img alt="egui" src="https://img.shields.io/badge/UI-egui-14a9c4?style=flat-square">
<img alt="Platforms" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-0b7385?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue?style=flat-square">
<img alt="Status: pre-alpha" src="https://img.shields.io/badge/status-pre--alpha-orange?style=flat-square">
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<p align="center">
<a href="https://getartcraft.com/apps/soundcraft"><b>SoundCraft on getartcraft.com</b></a> ·
<a href="https://getartcraft.com/">ArtCraft</a> ·
<a href="https://getartcraft.com/apps">All Crafting Apps</a>
</p>
<p align="center">
<img alt="SoundCraft's Edit window with a nine-track session: drums, bass, pad, keys and lead, a reverb aux and a master fader" src="docs/images/edit.png" width="100%">
<br>
<sub>The Edit window: tracks, waveforms, MIDI, rulers, markers and the transport, playing the built-in demo song (every sound in it is synthesised by SoundCraft itself).</sub>
</p>
> [!NOTE]
> **ArtCraft is a community of artists from all walks of life.** Painters, photographers,
> filmmakers, illustrators, designers, animators, hobbyists, and people who picked up a pencil
> last week. If you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
<p align="center">
<a href="#what-it-does">What it does</a> ·
<a href="#getting-started">Getting started</a> ·
<a href="#for-agents-and-scripts">Agents &amp; scripts</a> ·
<a href="#how-its-built">How it's built</a> ·
<a href="#status-and-roadmap">Status</a> ·
<a href="#the-crafting-apps">The Crafting Apps</a> ·
<a href="#license-and-credits">License</a>
</p>
## 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.
<table>
<tr>
<td width="50%"><img alt="The Mix window with channel strips, inserts, sends, I/O, pan knobs, faders and meters, and an EQ plugin window" src="docs/images/mix.png"></td>
<td width="50%"><img alt="The MIDI editor showing a lead melody as a piano roll with a velocity lane" src="docs/images/midi.png"></td>
</tr>
<tr>
<td><b>Mix window.</b> 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.</td>
<td><b>MIDI editor.</b> Instrument tracks with built-in synths, a piano roll with a velocity lane, step input, quantize, transpose, and Standard MIDI File import and export.</td>
</tr>
<tr>
<td width="50%"><img alt="Volume automation drawn on the Pad track, with the Memory Locations and Big Counter windows open" src="docs/images/automation.png"></td>
<td width="50%"><img alt="The SoundCraft app icon: a singing nightingale on a teal field" src="assets/app-icon/soundcraft-1024.png" width="70%"></td>
</tr>
<tr>
<td><b>Automation and markers.</b> 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.</td>
<td><b>Everywhere.</b> Signed builds for macOS (universal), Windows (x64, x86, Arm), Linux (AppImage, deb, rpm, Flatpak), FreeBSD, and a WebAssembly build that runs in the browser.</td>
</tr>
</table>
### 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: <kbd>Space</kbd> play/stop, <kbd>⌘</kbd><kbd>=</kbd> Mix/Edit,
<kbd>F1</kbd>–<kbd>F4</kbd> edit modes, <kbd>F5</kbd>–<kbd>F10</kbd> tools, <kbd>⌘</kbd><kbd>E</kbd>
separate, <kbd>⌘</kbd><kbd>D</kbd> duplicate, <kbd>Enter</kbd> new marker, <kbd>⌘</kbd><kbd>⇧</kbd><kbd>N</kbd>
new tracks. **Setup › Keyboard Shortcuts** lists them all.
## 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 |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | **Recording, editing and mixing audio · you are here** | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<h3 align="center">Come make things with us</h3>
<p align="center">
Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film,
set type, and people still figuring out what they like to make. Share what you're working on,
ask for help, tell us what's broken, or tell us what you wish these tools could do.
Whatever your medium and however long you've been at it, you're welcome here.
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
<a href="https://getartcraft.com/">getartcraft.com</a> ·
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
<a href="https://getartcraft.com/apps/soundcraft">SoundCraft</a>
</p>
## License and credits
SoundCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
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.
<sub>Avid and Pro Tools are trademarks or registered trademarks of Avid Technology, Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Avid Technology, Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>

View File

@ -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.

View File

@ -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<String> {
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");
}
}

View File

@ -0,0 +1,53 @@
//! More catalog → command mappings, for catalog entries served by one parameterised command
//! (e.g. every `Track > Change Track Width > <format>` 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}`");
}
}
}

View File

@ -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<CommandSpec> {
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<Value> {
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<u64, usize> = 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<ClipId> {
let Some(tr) = s.track(t) else { return Vec::new() };
let clips = tr.clips();
let Some(mut cur) = clips.iter().find(|c| c.id == id) else { return Vec::new() };
let mut guard = 0;
while let Some(prev) = clips.iter().find(|x| x.id != cur.id && x.end() == cur.start && same_material(x, cur)) {
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<Value> {
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<ClipId> = Vec::new();
let mut gone: Vec<ClipId> = 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<ClipId> = 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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<ClipId>) {
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<ClipId> = 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<u64> = 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<f32> = (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));
}
}

View File

@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
repeat_to_fill
),
cmd!(noundo "edit.select_all", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, select_all),
cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id]}", 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<Value> {
let en = position_param(e, "edit.select", p, "end")?;
let clips =
p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::ClipId).collect::<Vec<_>>());
let 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<soundcraft_model::TrackId> =
s.groups.iter().filter(|g| g.active && g.edit && g.members.iter().any(|m| t.contains(m))).flat_map(|g| g.members.clone()).collect();
for m in extra {
if !t.contains(&m) {
t.push(m);
}
}
let order: Vec<soundcraft_model::TrackId> = s.tracks.iter().map(|x| x.id).collect();
t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
}
s.edit.selected_tracks = t;
}
match (st, en) {

File diff suppressed because it is too large Load Diff

View File

@ -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<CommandSpec> {
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<Value> {
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<Value> {
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<i64> = 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<Value> {
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<Value> {
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<u64, TrackId> = 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::<Vec<_>>()}))
}
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<Value> {
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<TrackId> {
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<Value> {
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<u8> = 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<Value> {
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<Value> {
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<Value> = 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<Value> {
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<String> {
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<Value> {
let id = "event.extract_chords";
let (_, t0, t1) = selection_ticks(e, p, id)?;
let s = e.session();
let picked = tracks_param(e, id, p)?;
let mut tracks: Vec<TrackId> = picked.into_iter().filter(|t| s.track(*t).is_some_and(|x| x.kind.is_midi())).collect();
if tracks.is_empty() {
tracks = s.tracks.iter().filter(|t| t.kind.is_midi()).map(|t| t.id).collect();
}
// Absolute (tick) notes from every MIDI clip on those tracks, cut to their clip.
let mut notes: Vec<(i64, i64, u8)> = Vec::new();
for t in &tracks {
for 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<Value> = 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<u8> = None;
for (na, nb, pitch) in &notes {
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<Value> {
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<Samples> = 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<Samples> = Vec::new();
for h in hits {
if beats.last().is_none_or(|l| h - l >= min_gap) {
beats.push(h);
}
}
let mut iois: Vec<f64> = 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<Samples> = 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<Value> {
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<Value> {
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<_>>(),
_ => 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<_>>(), 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");
}
}

View File

@ -154,5 +154,7 @@ fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
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}),
)
}

View File

@ -10,7 +10,15 @@ use soundcraft_time::TICKS_PER_QUARTER;
pub fn specs() -> Vec<CommandSpec> {
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),

View File

@ -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<Value>;
/// `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
})
}

View File

@ -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<Value> {
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<Value> {
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<String> {
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<String, Value> =
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<usize> {
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<Option<SourceId>> {
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<Vec<f32>> = audio
.buffer
.channels
.iter()
.map(|x| {
let mut v: Vec<f32> = 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);
}
}

View File

@ -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<CommandSpec> {
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<Value> = 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<String> = 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<Value> {
let id = "options.prepare_dpe_tracks";
let s = e.session();
let tracks: Vec<TrackId> = 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<TrackId> = 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<Value> {
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<Value> {
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<Value> = 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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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::<Vec<_>>()).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<soundcraft_audio_io::AudioBuffer> {
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<Value> {
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<Vec<String>> =
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<soundcraft_model::Track> = 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<SourceId> =
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<soundcraft_audio_io::AudioBuffer>)> =
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<u64, SourceId> = 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);
}
}

View File

@ -126,6 +126,58 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<Value> {
@ -437,7 +498,8 @@ fn height(e: &mut Engine, p: &Value) -> Result<Value> {
fn view(e: &mut Engine, p: &Value) -> Result<Value> {
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<Value> {
}
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));
}
}

View File

@ -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<CommandSpec> {
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<Value> {
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<Value> {
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<Clip> = 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<Value> {
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<Value> {
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<Insert> = 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<Category>) -> bool) -> Result<Value> {
let tracks = tracks_required(e, "track.bypass_inserts", p)?;
let s = e.session_mut();
let hits = |tr: &Track| -> Vec<usize> {
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<Value> {
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<Value> {
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<Value> {
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<Samples> = 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<Value> {
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<Value> {
let id = "track.coalesce_vca";
let picked = tracks_param(e, id, p)?;
let s = e.session_mut();
let vcas: Vec<TrackId> = 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<TrackId> = 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<Value> {
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<Value> {
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<Value> {
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<TrackId> = 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);
}
}

View File

@ -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<CommandSpec> {
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<String>) {
r.sort_by_key(|x| RULERS.iter().position(|a| a == x).unwrap_or(usize::MAX));
r.dedup();
}
fn set_ruler(rulers: &mut Vec<String>, 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<Value> {
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()]);
}
}

View File

@ -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);

View File

@ -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<ClipId> = 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;
}

View File

@ -128,7 +128,7 @@ pub fn export_midi(e: &Engine) -> Result<Vec<u8>> {
}
/// 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<u8>, (f32, f32))> {
pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool) -> Result<(Vec<u8>, (f32, f32, f32))> {
let s = e.session();
if r.len() > s.sample_rate.samples(4.0 * 3600.0) {
return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into()));
@ -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<Vec<f32>>, 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::<usize>().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::<usize>().ok()).map_or(0, |n| n.saturating_sub(1))
}
_ => 0,
};
(t.id, first, t.channels(), t.name.clone())

View File

@ -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<Vec<Clip>>,
pub automation: Vec<Vec<soundcraft_model::AutomationLane>>,
/// Copy Special › Clip Gain: per track, (offset from the copied range start, dB) breakpoints.
pub clip_gain: Vec<Vec<(Samples, f32)>>,
/// 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<String>,
/// Messages for the UI status line / agents.
pub messages: Vec<String>,
}
@ -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<Value> {
self.merge_key = None;
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if let Err(reason) = (spec.enabled)(self) {
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<Value> {
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<Value> {
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);
}
}

View File

@ -386,9 +386,19 @@ fn process_strip(s: &Session, t: &Track, strip: &mut Strip, busses: &HashMap<Bus
d.extend_from_slice(src.get(..frames).unwrap_or(&[]));
}
}
let v0 = volume_db_at(t, pos);
let v1 = volume_db_at(t, pos + frames as i64);
let (g0, g1) = if strip.muted { (-144.0, -144.0) } else { (v0, v1) };
let mut v0 = volume_db_at(t, pos);
let mut v1 = volume_db_at(t, pos + frames as i64);
// VCA master: its fader offsets the member's, and its mute mutes the member.
if let Some(vid) = t.mixer.vca
&& let Some(vca) = s.tracks.iter().find(|x| x.id.0 == vid && x.kind == TrackKind::Vca)
{
v0 += volume_db_at(vca, pos);
v1 += volume_db_at(vca, pos + frames as i64);
if vca.mixer.mute {
strip.muted = true;
}
}
let (g0, g1) = if strip.muted { (-144.0, -144.0) } else { (v0.min(12.0), v1.min(12.0)) };
for c in strip.buf.iter_mut() {
for i in 0..frames {
if let Some(x) = c.get_mut(i) {
@ -596,6 +606,7 @@ fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track
fn volume_db_at(t: &Track, pos: Samples) -> 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()
{

View File

@ -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 })
}
}

View File

@ -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<u64, soundcraft_midi::Sequence>,
#[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<u64> =
self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0))).collect();
self.midi_originals.retain(|id, _| clip_ids.contains(id));
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;
}

View File

@ -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")]

View File

@ -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}));
}

View File

@ -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<TrackId>,
last_write_at: Samples,
pub frame_ms: f32,
last_frame: Option<f64>,
}
@ -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<Vec<f32>> =
take.channels.iter().map(|c| c.get(k * pass..((k + 1) * pass).min(c.len())).map(<[f32]>::to_vec).unwrap_or_default()).collect();
if chunk.first().is_none_or(|c| c.len() < pass / 8) {
continue;
}
if made > 0 {
let armed: Vec<u64> = self.engine.session().tracks.iter().filter(|t| t.mixer.record_arm).map(|t| t.id.0).collect();
let _ = self.engine.execute("track.playlist_new", &serde_json::json!({"tracks": armed}));
}
if soundcraft_engine::io::add_recording(&mut self.engine, sel.start, chunk, rate).is_ok() {
made += 1;
}
}
self.ui.status = format!("Loop-recorded {made} take(s)");
return;
}
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();
}

View File

@ -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);

View File

@ -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));
}
}

View File

@ -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}));

View File

@ -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)));

View File

@ -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<i64> = s
.tracks
.iter()

BIN
docs/images/automation.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

BIN
docs/images/edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

BIN
docs/images/midi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

BIN
docs/images/mix.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

View File

@ -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

View File

@ -7,3 +7,6 @@ publish = false
[dependencies]
serde_json.workspace = true
[lints]
workspace = true

View File

@ -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"];