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:
parent
04d222069d
commit
720373c0c6
|
|
@ -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.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/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 |
|
| `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
256
README.md
|
|
@ -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 & 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>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ the honest status: what works today, what is missing, and how far we are.
|
||||||
|
|
||||||
| Measure | Value | How |
|
| 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 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 |
|
| 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
|
## Current focus
|
||||||
|
|
||||||
1. Land the engine parity batch (Edit/View/Track/Clip/Event/Options/Setup menu items).
|
1. Play back the model-only state the engine already stores: clip effects, trim automation, MIDI real-time properties.
|
||||||
2. Automation write modes during playback (touch/latch/write from fader moves).
|
2. QuickPunch and input monitoring (loop record and Write/Touch/Latch automation passes have landed).
|
||||||
3. Loop record and QuickPunch; input monitoring.
|
3. Pitch-preserving realtime Elastic Audio.
|
||||||
4. Plugin hosting (CLAP first, it is MIT-licensed and Rust-friendly).
|
4. Plugin hosting (CLAP first, it is MIT-licensed and Rust-friendly).
|
||||||
5. Elastic Audio in realtime; Beat Detective workflow window.
|
5. Elastic Audio in realtime; Beat Detective workflow window.
|
||||||
6. README screenshots and the first alpha release.
|
6. README screenshots and the first alpha release.
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ pub fn catalog() -> Vec<&'static str> {
|
||||||
|
|
||||||
/// Command id implementing a catalog entry, if any.
|
/// Command id implementing a catalog entry, if any.
|
||||||
pub fn implemented_by(entry: &str, extra: &[(&str, &str)]) -> Option<String> {
|
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());
|
return Some((*id).to_string());
|
||||||
}
|
}
|
||||||
let parts: Vec<&str> = entry.split(" > ").collect();
|
let parts: Vec<&str> = entry.split(" > ").collect();
|
||||||
|
|
@ -116,7 +116,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aliases_point_at_real_commands() {
|
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!(crate::find_command(id).is_some(), "{path} → unknown {id}");
|
||||||
assert!(catalog().contains(path), "alias for unknown catalog entry {path}");
|
assert!(catalog().contains(path), "alias for unknown catalog entry {path}");
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +126,9 @@ mod tests {
|
||||||
fn parity_floor() {
|
fn parity_floor() {
|
||||||
let p = parity_json();
|
let p = parity_json();
|
||||||
let pct = p["percent"].as_f64().unwrap_or(0.0);
|
let pct = p["percent"].as_f64().unwrap_or(0.0);
|
||||||
|
let done = p["implemented"].as_u64().unwrap_or(0);
|
||||||
// The floor only ever rises.
|
// The floor only ever rises.
|
||||||
assert!(pct >= 25.0, "engine parity regressed: {pct}%");
|
assert!(pct >= 25.0, "engine parity regressed: {pct}%");
|
||||||
|
assert!(done >= 386, "engine parity regressed: {done} menu items");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
repeat_to_fill
|
repeat_to_fill
|
||||||
),
|
),
|
||||||
cmd!(noundo "edit.select_all", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, select_all),
|
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, _| {
|
cmd!(noundo "edit.select_none", "Deselect All", [], None, "{}", always, |e, _| {
|
||||||
let s = e.session_mut();
|
let s = e.session_mut();
|
||||||
s.edit.selected_tracks.clear();
|
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 en = position_param(e, "edit.select", p, "end")?;
|
||||||
let clips =
|
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<_>>());
|
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();
|
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;
|
s.edit.selected_tracks = t;
|
||||||
}
|
}
|
||||||
match (st, en) {
|
match (st, en) {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 ¬es {
|
||||||
|
let ov = (*nb).min(b) - (*na).max(a);
|
||||||
|
if ov > 0 {
|
||||||
|
if let Some(x) = w.get_mut(usize::from(*pitch) % 12) {
|
||||||
|
*x += ov as f64;
|
||||||
|
}
|
||||||
|
bass = Some(bass.map_or(*pitch, |bp| bp.min(*pitch)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(name) = bass.and_then(|bp| chord_name(&w, usize::from(bp) % 12)) {
|
||||||
|
let at = s.tempo.tick_to_samples(a, s.sample_rate);
|
||||||
|
chords.push(json!({"bar": bar, "chord": name, "at": at}));
|
||||||
|
if name != last {
|
||||||
|
marks.push((at, name.clone()));
|
||||||
|
last = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bar += 1;
|
||||||
|
}
|
||||||
|
let s = e.session_mut();
|
||||||
|
for (at, name) in &marks {
|
||||||
|
let mid = s.add_marker(name, MarkerKind::Marker, *at, *at);
|
||||||
|
if let Some(m) = s.markers.iter_mut().find(|m| m.id == mid) {
|
||||||
|
m.ruler = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(json!({"chords": chords, "markers": marks.len()}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn beat_detective(e: &mut Engine, p: &Value) -> Result<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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -154,5 +154,7 @@ fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||||
let normalize = bool_or(p, "normalize", false);
|
let normalize = bool_or(p, "normalize", false);
|
||||||
let (bytes, stats) = crate::io::bounce_bytes(e, r, &opts, normalize)?;
|
let (bytes, stats) = crate::io::bounce_bytes(e, r, &opts, normalize)?;
|
||||||
std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
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}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,15 @@ use soundcraft_time::TICKS_PER_QUARTER;
|
||||||
pub fn specs() -> Vec<CommandSpec> {
|
pub fn specs() -> Vec<CommandSpec> {
|
||||||
vec![
|
vec![
|
||||||
cmd!(query "midi.notes", "List MIDI Notes", [], None, "{clip}", always, notes),
|
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_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_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),
|
cmd!("midi.note_delete", "Delete Notes", [], None, "{clip, indices: [n]}", always, note_delete),
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,23 @@ use std::sync::OnceLock;
|
||||||
|
|
||||||
mod audiosuite;
|
mod audiosuite;
|
||||||
mod clip;
|
mod clip;
|
||||||
mod midi;
|
mod clip_more;
|
||||||
mod edit;
|
mod edit;
|
||||||
|
mod edit_more;
|
||||||
mod event;
|
mod event;
|
||||||
|
mod event_more;
|
||||||
mod file;
|
mod file;
|
||||||
|
mod midi;
|
||||||
mod mix;
|
mod mix;
|
||||||
|
mod more_util;
|
||||||
mod options;
|
mod options;
|
||||||
mod query;
|
mod query;
|
||||||
|
mod setup_more;
|
||||||
mod track;
|
mod track;
|
||||||
|
mod track_more;
|
||||||
mod transport;
|
mod transport;
|
||||||
mod view;
|
mod view;
|
||||||
|
mod view_more;
|
||||||
|
|
||||||
pub type Run = fn(&mut Engine, &Value) -> Result<Value>;
|
pub type Run = fn(&mut Engine, &Value) -> Result<Value>;
|
||||||
/// `Err(reason)` = disabled.
|
/// `Err(reason)` = disabled.
|
||||||
|
|
@ -85,6 +92,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||||
let mut v = Vec::new();
|
let mut v = Vec::new();
|
||||||
v.extend(file::specs());
|
v.extend(file::specs());
|
||||||
v.extend(edit::specs());
|
v.extend(edit::specs());
|
||||||
|
v.extend(edit_more::specs());
|
||||||
v.extend(view::specs());
|
v.extend(view::specs());
|
||||||
v.extend(track::specs());
|
v.extend(track::specs());
|
||||||
v.extend(clip::specs());
|
v.extend(clip::specs());
|
||||||
|
|
@ -95,6 +103,11 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||||
v.extend(query::specs());
|
v.extend(query::specs());
|
||||||
v.extend(audiosuite::specs());
|
v.extend(audiosuite::specs());
|
||||||
v.extend(midi::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
|
v
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -126,6 +126,58 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
tr.folder_open = v.unwrap_or(!tr.folder_open);
|
tr.folder_open = v.unwrap_or(!tr.folder_open);
|
||||||
Ok(json!({"open": 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.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.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(
|
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);
|
let f = s.tracks.remove(i);
|
||||||
s.tracks.insert(first_idx.min(s.tracks.len()), f);
|
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 {
|
for t in &tracks {
|
||||||
if let Some(tr) = s.track_mut(*t) {
|
if let Some(tr) = s.track_mut(*t) {
|
||||||
tr.folder = Some(fid);
|
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> {
|
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> {
|
fn view(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||||
let tracks = tracks_required(e, "track.view", p)?;
|
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 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 {
|
if !ok {
|
||||||
return Err(bad("track.view", format!("unknown view `{v}`")));
|
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}))
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,7 +32,7 @@ fn insert(id: &str) -> Insert {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn beat_len(sr: f64) -> usize {
|
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) {
|
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"));
|
tr.mixer.inserts[0] = Some(insert("maximizer"));
|
||||||
}
|
}
|
||||||
// Markers.
|
// Markers.
|
||||||
for (name, at) in [("Intro", 0usize), ("Verse", 4), ("Chorus", 12), ("Outro", 20)] {
|
for (name, at) in [("Intro", 1i64), ("Verse", 5), ("Chorus", 13), ("Outro", 21)] {
|
||||||
s.add_marker(name, MarkerKind::Marker, (at * bar) as i64, (at * bar) as i64);
|
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.selected_tracks = vec![tk];
|
||||||
s.edit.selection = soundcraft_time::Range::point((4 * bar) as i64);
|
s.edit.selection = soundcraft_time::Range::point((4 * bar) as i64);
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||||
pl.sort();
|
pl.sort();
|
||||||
let mut made = 0;
|
let mut made = 0;
|
||||||
|
let mut xfaded: Vec<ClipId> = Vec::new();
|
||||||
let n = pl.clips.len();
|
let n = pl.clips.len();
|
||||||
// Crossfades between adjacent clips whose boundary is inside the range.
|
// Crossfades between adjacent clips whose boundary is inside the range.
|
||||||
for i in 0..n.saturating_sub(1) {
|
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 };
|
cb.fade_in = Fade { len: (ext_b + after).min(cb.length), shape };
|
||||||
}
|
}
|
||||||
made += 1;
|
made += 1;
|
||||||
|
xfaded.push(a.id);
|
||||||
|
xfaded.push(b.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for c in &mut pl.clips {
|
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.len == 0 {
|
if range.start <= c.start && range.end > c.start && range.end < c.end() {
|
||||||
c.fade_in = Fade { len: range.end - c.start, shape };
|
c.fade_in = Fade { len: range.end - c.start, shape };
|
||||||
made += 1;
|
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 };
|
c.fade_out = Fade { len: c.end() - range.start, shape };
|
||||||
made += 1;
|
made += 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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).
|
/// 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();
|
let s = e.session();
|
||||||
if r.len() > s.sample_rate.samples(4.0 * 3600.0) {
|
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()));
|
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);
|
let frames = ch.first().map_or(0, Vec::len);
|
||||||
lm.process(&ch, frames);
|
lm.process(&ch, frames);
|
||||||
let lufs = lm.integrated_lufs();
|
let lufs = lm.integrated_lufs();
|
||||||
|
let true_peak = lm.true_peak_db();
|
||||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch };
|
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()))?;
|
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`.
|
/// 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)
|
.filter(|t| t.mixer.record_arm && t.kind == TrackKind::Audio && !t.inactive)
|
||||||
.map(|t| {
|
.map(|t| {
|
||||||
let first = match &t.mixer.input {
|
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,
|
_ => 0,
|
||||||
};
|
};
|
||||||
(t.id, first, t.channels(), t.name.clone())
|
(t.id, first, t.channels(), t.name.clone())
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||||
|
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
|
pub mod catalog_more;
|
||||||
pub mod cmd;
|
pub mod cmd;
|
||||||
pub mod demo;
|
pub mod demo;
|
||||||
pub mod edit;
|
pub mod edit;
|
||||||
|
|
@ -69,6 +70,10 @@ pub struct Clipboard {
|
||||||
pub length: Samples,
|
pub length: Samples,
|
||||||
pub tracks: Vec<Vec<Clip>>,
|
pub tracks: Vec<Vec<Clip>>,
|
||||||
pub automation: Vec<Vec<soundcraft_model::AutomationLane>>,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -93,6 +98,8 @@ pub struct Engine {
|
||||||
/// Revision counter; bumps on every document change (UI caches key on it).
|
/// Revision counter; bumps on every document change (UI caches key on it).
|
||||||
pub revision: u64,
|
pub revision: u64,
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
|
/// Gesture key for undo coalescing (see `execute_merged`).
|
||||||
|
merge_key: Option<String>,
|
||||||
/// Messages for the UI status line / agents.
|
/// Messages for the UI status line / agents.
|
||||||
pub messages: Vec<String>,
|
pub messages: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +125,7 @@ impl Engine {
|
||||||
path: None,
|
path: None,
|
||||||
revision: 1,
|
revision: 1,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
|
merge_key: None,
|
||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -203,9 +211,15 @@ impl Engine {
|
||||||
/// Run a command by id. Never panics: escaped panics become `EngineError::Internal` and the
|
/// Run a command by id. Never panics: escaped panics become `EngineError::Internal` and the
|
||||||
/// document is restored.
|
/// document is restored.
|
||||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
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()))?;
|
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||||
if let Err(reason) = (spec.enabled)(self) {
|
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 before = Arc::clone(&self.doc);
|
||||||
let rev = self.revision;
|
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.
|
/// Convenience: execute with `{}` params.
|
||||||
pub fn run(&mut self, id: &str) -> Result<Value> {
|
pub fn run(&mut self, id: &str) -> Result<Value> {
|
||||||
self.execute(id, &json!({}))
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(&[]));
|
d.extend_from_slice(src.get(..frames).unwrap_or(&[]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let v0 = volume_db_at(t, pos);
|
let mut v0 = volume_db_at(t, pos);
|
||||||
let v1 = volume_db_at(t, pos + frames as i64);
|
let mut v1 = volume_db_at(t, pos + frames as i64);
|
||||||
let (g0, g1) = if strip.muted { (-144.0, -144.0) } else { (v0, v1) };
|
// 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 c in strip.buf.iter_mut() {
|
||||||
for i in 0..frames {
|
for i in 0..frames {
|
||||||
if let Some(x) = c.get_mut(i) {
|
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 {
|
fn volume_db_at(t: &Track, pos: Samples) -> f32 {
|
||||||
if t.mixer.automation_mode.reads()
|
if t.mixer.automation_mode.reads()
|
||||||
|
&& t.mixer.automation_mode != soundcraft_model::AutomationMode::Write
|
||||||
&& let Some(l) = t.lane(&AutoParam::Volume)
|
&& let Some(l) = t.lane(&AutoParam::Volume)
|
||||||
&& !l.points.is_empty()
|
&& !l.points.is_empty()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -145,10 +145,19 @@ impl ChannelFormat {
|
||||||
}
|
}
|
||||||
/// Best format for a channel count.
|
/// Best format for a channel count.
|
||||||
pub fn for_channels(n: usize) -> ChannelFormat {
|
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()
|
ChannelFormat::Mono,
|
||||||
.find(|f| f.channels() == n)
|
ChannelFormat::Stereo,
|
||||||
.unwrap_or(if n <= 1 { ChannelFormat::Mono } else { 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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -267,7 +267,10 @@ impl Default for EditState {
|
||||||
delay_compensation: true,
|
delay_compensation: true,
|
||||||
keyboard_focus: "commands".into(),
|
keyboard_focus: "commands".into(),
|
||||||
timeline_selection: Range::default(),
|
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(),
|
values: std::collections::BTreeMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -296,6 +299,10 @@ pub struct Session {
|
||||||
pub next_id: u64,
|
pub next_id: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub comments: String,
|
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)]
|
#[serde(skip)]
|
||||||
pub pool: SourcePool,
|
pub pool: SourcePool,
|
||||||
}
|
}
|
||||||
|
|
@ -328,6 +335,7 @@ impl Session {
|
||||||
edit: EditState::default(),
|
edit: EditState::default(),
|
||||||
next_id: 1,
|
next_id: 1,
|
||||||
comments: String::new(),
|
comments: String::new(),
|
||||||
|
midi_originals: std::collections::BTreeMap::new(),
|
||||||
pool: SourcePool::default(),
|
pool: SourcePool::default(),
|
||||||
};
|
};
|
||||||
s.edit.pre_roll = sample_rate.samples(2.0);
|
s.edit.pre_roll = sample_rate.samples(2.0);
|
||||||
|
|
@ -522,6 +530,10 @@ impl Session {
|
||||||
l.points.sort_by_key(|p| p.at);
|
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 {
|
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;
|
self.edit.zoom.samples_per_px = 1024.0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,13 @@ impl Recorder {
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
stream.play().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")]
|
#[cfg(target_os = "freebsd")]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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 {
|
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) {
|
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))
|
.frame(egui::Frame::NONE.fill(t.panel_bg))
|
||||||
.show(ui, |ui| crate::midi_editor::show(app, ui));
|
.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 {
|
if app.ui.show_tracks_list {
|
||||||
egui::Panel::left("tracks_list")
|
egui::Panel::left("tracks_list")
|
||||||
.exact_size(168.0)
|
.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));
|
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) {
|
fn dock_tabs(app: &mut SoundApp, ui: &mut Ui) {
|
||||||
let t = Tokens::DARK;
|
let t = Tokens::DARK;
|
||||||
let r = ui.max_rect();
|
let r = ui.max_rect();
|
||||||
|
|
@ -114,7 +159,7 @@ fn status_bar(app: &mut SoundApp, ui: &mut Ui) {
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
pos2(r.max.x - 10.0, r.center().y),
|
pos2(r.max.x - 10.0, r.center().y),
|
||||||
Align2::RIGHT_CENTER,
|
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),
|
regular(11.0),
|
||||||
t.text_dim,
|
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()));
|
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);
|
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.
|
// 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(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));
|
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);
|
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) {
|
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 c = track.comments.clone();
|
||||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink(3.0)));
|
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() {
|
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() {
|
if track.kind.is_midi() {
|
||||||
opts.extend(["notes", "regions", "blocks"]);
|
opts.extend(["notes", "regions", "blocks"]);
|
||||||
} else if track.kind.has_playlist() {
|
} else if track.kind.has_playlist() {
|
||||||
opts.extend(["waveform", "blocks", "clip_gain"]);
|
opts.extend(["waveform", "blocks", "clip_gain", "playlists"]);
|
||||||
}
|
}
|
||||||
opts.extend(["volume", "pan", "mute"]);
|
opts.extend(["volume", "pan", "mute"]);
|
||||||
for o in opts {
|
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);
|
ui.ctx().set_cursor_icon(icon);
|
||||||
}
|
}
|
||||||
let auto_view = AutoParam::parse(&track.view);
|
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 {
|
app.gesture = match eff {
|
||||||
Tool::Selector | Tool::Smart => Some(Gesture::Select { track: track.id, anchor: snap(&s, at), tracks: vec![track.id] }),
|
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| {
|
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::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at),
|
||||||
|
Some(Gesture::Fade { to, .. }) => *to = at,
|
||||||
Some(Gesture::Scrub { last }) => {
|
Some(Gesture::Scrub { last }) => {
|
||||||
*last = at;
|
*last = at;
|
||||||
let _ = app.engine.execute("transport.locate", &json!({"at": 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);
|
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::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::TrimEnd { clip, track, to }) => trim_clip(app, clip, track, to, false),
|
||||||
Some(Gesture::Pencil { track, points }) => {
|
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);
|
let x = x_of(s, tl, *to);
|
||||||
painter.line_segment([pos2(x, r[1]), pos2(x, r[3])], Stroke::new(2.0, Color32::WHITE));
|
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 {
|
if let Some(at) = scroll_to {
|
||||||
let _ = app.engine.execute("view.scroll", &json!({"to": at}));
|
let _ = app.engine.execute("view.scroll", &json!({"to": at}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,9 @@ pub struct SoundApp {
|
||||||
pub quit_requested: bool,
|
pub quit_requested: bool,
|
||||||
/// Reset floating-window positions next frame (Window › Arrange).
|
/// Reset floating-window positions next frame (Window › Arrange).
|
||||||
pub arrange_request: bool,
|
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,
|
pub frame_ms: f32,
|
||||||
last_frame: Option<f64>,
|
last_frame: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
@ -200,6 +203,8 @@ impl SoundApp {
|
||||||
sim: None,
|
sim: None,
|
||||||
quit_requested: false,
|
quit_requested: false,
|
||||||
arrange_request: false,
|
arrange_request: false,
|
||||||
|
touched: std::collections::HashSet::new(),
|
||||||
|
last_write_at: i64::MIN,
|
||||||
frame_ms: 0.0,
|
frame_ms: 0.0,
|
||||||
last_frame: None,
|
last_frame: None,
|
||||||
}
|
}
|
||||||
|
|
@ -289,9 +294,13 @@ impl SoundApp {
|
||||||
self.engine.transport.recording = true;
|
self.engine.transport.recording = true;
|
||||||
if !self.is_playing() {
|
if !self.is_playing() {
|
||||||
let sel = self.engine.session().edit.selection;
|
let sel = self.engine.session().edit.selection;
|
||||||
// Record into the selection when there is one (punch), else open-ended.
|
if self.engine.session().edit.loop_record && !sel.is_empty() {
|
||||||
let end = (!sel.is_empty()).then_some(sel.end);
|
self.start_play(sel.start, None, Some(sel));
|
||||||
self.start_play(sel.start, end, None);
|
} 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 Some(r) = &self.recorder else { return };
|
||||||
let take = r.take();
|
let take = r.take();
|
||||||
let rate = take.sample_rate;
|
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) {
|
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()),
|
Ok(ids) => self.ui.status = format!("Recorded {} clip(s)", ids.len()),
|
||||||
Err(e) => self.ui.status = e.to_string(),
|
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.
|
/// Play from the selection, honouring loop playback and selection end.
|
||||||
pub fn play_from_selection(&mut self) {
|
pub fn play_from_selection(&mut self) {
|
||||||
let s = self.engine.session();
|
let s = self.engine.session();
|
||||||
|
|
@ -430,6 +515,15 @@ impl SoundApp {
|
||||||
self.last_frame = Some(now);
|
self.last_frame = Some(now);
|
||||||
self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1;
|
self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1;
|
||||||
control::drain(self, ctx);
|
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 {
|
if self.engine.revision != self.last_rev {
|
||||||
self.last_rev = self.engine.revision;
|
self.last_rev = self.engine.revision;
|
||||||
if let Some(p) = &self.player {
|
if let Some(p) = &self.player {
|
||||||
|
|
@ -437,6 +531,7 @@ impl SoundApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.handle_transport(dt);
|
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() {
|
if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() {
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -314,11 +314,11 @@ const MENU_WINDOWS: &[(&str, &str)] = &[
|
||||||
|
|
||||||
/// Menu-style invocation: may open a dialog.
|
/// Menu-style invocation: may open a dialog.
|
||||||
pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
|
pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
|
||||||
if let Some((_, w)) = MENU_WINDOWS.iter().find(|(p, _)| *p == path) {
|
if let Some((_, w)) = MENU_WINDOWS.iter().find(|(p, _)| *p == path)
|
||||||
if w.starts_with("window.") {
|
&& w.starts_with("window.")
|
||||||
let _ = app.run(w, json!({"value": true}));
|
{
|
||||||
return;
|
let _ = app.run(w, json!({"value": true}));
|
||||||
}
|
return;
|
||||||
}
|
}
|
||||||
if id == "audiosuite.process" {
|
if id == "audiosuite.process" {
|
||||||
let p = params_for(path, id);
|
let p = params_for(path, id);
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,6 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
||||||
&& (x_of(n.start) - p.x).abs() < 8.0
|
&& (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 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
|
||||||
let mut c = track.comments.clone();
|
let mut c = track.comments.clone();
|
||||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(sec));
|
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() {
|
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;
|
y = sec.max.y + 4.0;
|
||||||
}
|
}
|
||||||
|
|
@ -174,11 +174,9 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
|
||||||
let before = v;
|
let before = v;
|
||||||
let resp = pan_knob(&mut c, ks, &mut v, "Pan (drag; double-click centres)");
|
let resp = pan_knob(&mut c, ks, &mut v, "Pan (drag; double-click centres)");
|
||||||
if (v - before).abs() > f32::EPSILON {
|
if (v - before).abs() > f32::EPSILON {
|
||||||
let _ = app.engine.execute("mix.pan", &json!({"track": id.0, "pan": v, "index": i}));
|
let _ = app.engine.execute_merged("mix.pan", &json!({"track": id.0, "pan": v, "index": i}), &format!("pan:{}:{i}", id.0));
|
||||||
}
|
|
||||||
if resp.drag_stopped() {
|
|
||||||
app.engine.push_undo("Pan", app.engine.session_arc());
|
|
||||||
}
|
}
|
||||||
|
let _ = resp;
|
||||||
let pr = Rect::from_center_size(pos2(cx, y + ks + 8.0), vec2(ks + 8.0, 13.0));
|
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().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);
|
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 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));
|
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 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.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));
|
||||||
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}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let md = app.meters.get(&id).copied().unwrap_or_default();
|
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));
|
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() {
|
if resp.dragged() {
|
||||||
let db = (s.level_db.max(-60.0) - resp.drag_delta().y * 0.3).clamp(-144.0, 12.0);
|
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() {
|
if resp.double_clicked() {
|
||||||
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0}));
|
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0}));
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
|
||||||
.id(egui::Id::new(("plugin", tid.0, slot)))
|
.id(egui::Id::new(("plugin", tid.0, slot)))
|
||||||
.open(&mut open)
|
.open(&mut open)
|
||||||
.default_width(340.0)
|
.default_width(340.0)
|
||||||
|
.default_pos(egui::pos2(ctx.content_rect().width() - 380.0, 120.0))
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label(egui::RichText::new(&ins.preset).color(Tokens::DARK.text_dim));
|
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)
|
ui.add(sl)
|
||||||
};
|
};
|
||||||
if (x - v).abs() > f32::EPSILON {
|
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;
|
let _ = resp;
|
||||||
ui.label(egui::RichText::new(p.format(x)).font(mono(11.0)));
|
ui.label(egui::RichText::new(p.format(x)).font(mono(11.0)));
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,16 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool {
|
||||||
let s = app.engine.session();
|
let s = app.engine.session();
|
||||||
let at = s.edit.selection.start;
|
let at = s.edit.selection.start;
|
||||||
let back = m.alt;
|
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
|
let mut edges: Vec<i64> = s
|
||||||
.tracks
|
.tracks
|
||||||
.iter()
|
.iter()
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 537 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 559 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 551 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 540 KiB |
235
docs/parity.md
235
docs/parity.md
|
|
@ -4,19 +4,19 @@ Generated by `soundcraft-cli parity --write docs/parity.md` (also `cargo xtask p
|
||||||
It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry.
|
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).
|
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 |
|
| Menu | Implemented | Total |
|
||||||
|---|---:|---:|
|
|---|---:|---:|
|
||||||
| AudioSuite | 0 | 28 |
|
| AudioSuite | 0 | 28 |
|
||||||
| Clip | 22 | 29 |
|
| Clip | 28 | 29 |
|
||||||
| Edit | 60 | 98 |
|
| Edit | 93 | 98 |
|
||||||
| Event | 13 | 32 |
|
| Event | 32 | 32 |
|
||||||
| File | 15 | 30 |
|
| File | 16 | 30 |
|
||||||
| Options | 28 | 41 |
|
| Options | 39 | 41 |
|
||||||
| Setup | 2 | 19 |
|
| Setup | 18 | 19 |
|
||||||
| Track | 17 | 71 |
|
| Track | 69 | 71 |
|
||||||
| View | 13 | 112 |
|
| View | 92 | 112 |
|
||||||
| Window | 1 | 52 |
|
| Window | 1 | 52 |
|
||||||
|
|
||||||
## Not yet implemented
|
## Not yet implemented
|
||||||
|
|
@ -26,7 +26,6 @@ UI-layer commands (windows, AudioSuite) are counted by the app's own report (`en
|
||||||
- File > Dashboard...
|
- File > Dashboard...
|
||||||
- File > Start Collaboration...
|
- File > Start Collaboration...
|
||||||
- File > Send To... > Sibelius
|
- File > Send To... > Sibelius
|
||||||
- File > Import > Session Data...
|
|
||||||
- File > Import > Video...
|
- File > Import > Video...
|
||||||
- File > Import > Clip Groups...
|
- File > Import > Clip Groups...
|
||||||
- File > Import > VENUE Channel Names as Track Names
|
- 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 > Score Setup...
|
||||||
- File > Print Score...
|
- File > Print Score...
|
||||||
- File > Sign In
|
- 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 > Copy Special > Audio as MIDI
|
||||||
- Edit > Paste Special > Merge MIDI
|
|
||||||
- Edit > Paste Special > Merge Markers
|
- 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 Dolby Atmos Plugin Automation to Pan Automation
|
||||||
- Edit > Automation > Duplicate Pan Automation to Dolby Atmos Plugin Automation
|
- Edit > Automation > Duplicate Pan Automation to Dolby Atmos Plugin Automation
|
||||||
- Edit > Automation > Coalesce Pan Guide 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 > All
|
||||||
- View > Mix Window Views > Minimal
|
- View > Mix Window Views > Minimal
|
||||||
- View > Narrow Mix
|
- 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 > Universe
|
||||||
- View > Other Displays > Track List
|
- View > Other Displays > Track List
|
||||||
- View > Other Displays > Clip 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 > Change Track Width > Show / Hide...
|
||||||
- Track > Extract MIDI to New Track
|
|
||||||
- Track > ARA Plugins > Melodyne
|
- 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 > 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 > Channel Strip
|
||||||
- AudioSuite > EQ > EQ3 1-Band
|
- AudioSuite > EQ > EQ3 1-Band
|
||||||
- AudioSuite > EQ > EQ3 7-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 > Reverse
|
||||||
- AudioSuite > Other > Signal Generator
|
- AudioSuite > Other > Signal Generator
|
||||||
- AudioSuite > Other > Time Compression Expansion
|
- AudioSuite > Other > Time Compression Expansion
|
||||||
- Options > DestructivePunch
|
|
||||||
- Options > Prepare DPE Tracks
|
|
||||||
- Options > Transport Online
|
|
||||||
- Options > Video Track 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 > 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 > 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 > Window Configuration List
|
||||||
- Window > Configurations > New Configuration...
|
- Window > Configurations > New Configuration...
|
||||||
- Window > Configurations > Update Active Configuration
|
- Window > Configurations > Update Active Configuration
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,6 @@ publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ fn root() -> PathBuf {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cargo(args: &[&str]) -> Result<(), String> {
|
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(" "))) }
|
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"];
|
const EXEMPT: &[&str] = &["soundcraft", "soundcraft-cli", "soundcraft-web", "xtask"];
|
||||||
|
|
||||||
fn layers() -> Result<(), String> {
|
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 v: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|e| e.to_string())?;
|
||||||
let mut problems = Vec::new();
|
let mut problems = Vec::new();
|
||||||
for p in v["packages"].as_array().cloned().unwrap_or_default() {
|
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> {
|
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 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 attribution = std::fs::read_to_string(root().join("ATTRIBUTION.md")).map_err(|e| format!("ATTRIBUTION.md: {e}"))?;
|
||||||
let mut missing = Vec::new();
|
let mut missing = Vec::new();
|
||||||
|
|
@ -87,7 +94,8 @@ fn assets() -> Result<(), String> {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Generated icon trees are covered by their directory row.
|
// 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 {
|
if !covered {
|
||||||
missing.push(f.to_string());
|
missing.push(f.to_string());
|
||||||
}
|
}
|
||||||
|
|
@ -105,7 +113,18 @@ fn parity() -> Result<(), String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Crates that must compile for the web.
|
/// 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> {
|
fn wasm() -> Result<(), String> {
|
||||||
let mut args = vec!["check", "--target", "wasm32-unknown-unknown"];
|
let mut args = vec!["check", "--target", "wasm32-unknown-unknown"];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue