mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-02 18:07:31 +00:00
Foundation: time, audio-io, midi, dsp, model, mix, engine, playback crates
Workspace for SoundCraft, a clean-room pure-Rust Pro Tools-class DAW: timebases and tempo maps, WAV/AIFF/FLAC/symphonia audio IO with peaks, SMF and MIDI ops, 26 original DSP plugins, the session model, the mix engine, ~180 registered commands with undo, a cpal player, and the agent rules (AGENTS.md, ATTRIBUTION.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
[alias]
|
||||
xtask = "run --package xtask --"
|
||||
@@ -1,3 +1,11 @@
|
||||
*~
|
||||
plan/
|
||||
/target
|
||||
/dist
|
||||
/corpus
|
||||
/log
|
||||
.DS_Store
|
||||
/.claude/
|
||||
/target-*/
|
||||
/craft-fonts/
|
||||
plans/
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# SoundCraft — instructions for agents
|
||||
|
||||
SoundCraft is a clean-room, open-source, pure-Rust digital audio workstation targeting Avid Pro Tools parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../printcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft-apps folder, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed).
|
||||
2. Read `ROADMAP.md` (committed) for status, milestones and the parity estimate.
|
||||
3. Work autonomously; don't stop to ask unless a decision is genuinely the user's.
|
||||
|
||||
## ⚠️ Assets: the absolute rule
|
||||
**No Avid, Pro Tools, Adobe or other proprietary iconography, images, sounds, presets or artwork — ever.** This is not negotiable.
|
||||
- Every icon in SoundCraft is drawn in code (`crates/ui-egui/src/icons.rs`) or is an original/open asset.
|
||||
- Every bundled asset (image, icon, sound, font, preset) must be original, public domain (CC0), or under a permissive open license (MIT, Apache-2.0, BSD, OFL, CC-BY with attribution), or contributed by someone who made it and licenses it openly.
|
||||
- **Every bundled asset gets a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (path, author, source, license) in the same commit that adds it. `cargo xtask assets` enforces this; CI fails otherwise.
|
||||
- Never copy files out of the Pro Tools bundle (`/Applications/Pro Tools.app`): no plugins, presets, icons, sounds, session templates or text. Observing the running app (screenshots kept under `plan/`, menu names) is fine; copying its resources is not.
|
||||
- Demo audio is synthesised in code (`crates/engine/src/demo.rs`). Demo and README media must be ours or public domain.
|
||||
- The ArtCraft trademarks in `docs/brand/` are the only exception, under `docs/brand/LICENSE-brand.txt`.
|
||||
|
||||
## Clean-room
|
||||
Pro Tools is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic sessions, take screenshots by window id (`screencapture -l <id>`), stored only under `plan/protools/screenshots/`. Never read, disassemble or copy anything inside the Pro Tools bundle; never copy Avid icons, artwork, plugin names, presets, or wording beyond feature names. Never copy GPL/AGPL/LGPL code (Ardour, Audacity, LMMS, Freeverb sources…). Our plugins have our own names.
|
||||
|
||||
## Never crash
|
||||
People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
- No panics in non-test code: no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- Errors are `Result<T, E>` + `?`. An unfinished feature returns an "unsupported" error.
|
||||
- Input-derived numbers are hostile (files, commands, MCP/control params): `get()` not `[i]`, checked/saturating arithmetic, no NaN casts, cap allocations.
|
||||
- The audio callback never blocks, allocates in steady state, or panics.
|
||||
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
- `Engine::execute` catches escaped panics and restores the document. Every crash fix lands with a regression test.
|
||||
|
||||
## Architecture (layers enforced by `cargo xtask layers`)
|
||||
| Layer | Crate | Role |
|
||||
|---|---|---|
|
||||
| L0 | `soundcraft-time`, `soundcraft-audio-io`, `soundcraft-midi` | Timebases/tempo/grid; audio file formats + peaks; Standard MIDI Files + MIDI ops |
|
||||
| L1 | `soundcraft-dsp` | Plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utility, instruments), offline processing, meters, FFT |
|
||||
| L2 | `soundcraft-model` | The session document (tracks, playlists, clips, mixer, routing, automation, markers, groups) |
|
||||
| L3 | `soundcraft-mix` | Mix engine shared by realtime playback and offline bounce |
|
||||
| L4 | `soundcraft-engine`, `soundcraft-playback` | Command registry, undo, editing, import/export/bounce, parity catalog; audio device I/O (cpal) |
|
||||
| L5 | `soundcraft-automation` | MCP server + control-channel client |
|
||||
| L6 | `soundcraft-ui-egui` | The swappable UI (egui) |
|
||||
| apps | `soundcraft`, `soundcraft-cli`, `soundcraft-web` | Entry points |
|
||||
|
||||
Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin: panels read engine state and act through `engine.execute(id, params)`.
|
||||
|
||||
## Everything is a command
|
||||
User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (windows, dialogs) live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). Menus, shortcuts, the CLI, the control channel and MCP all dispatch the same ids. Programmatic calls never open dialogs; only menu-style invocation (`ui.menu.invoke`) does. New commands go in a new module with its own `specs()`, registered by one line in `cmd/mod.rs`.
|
||||
|
||||
Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the incumbent's menu leaves (names only). `cargo xtask parity` writes `docs/parity.md`; a test enforces a floor that only rises.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p soundcraft -- --demo --control 0` (demo session + control channel on a free port; the port is printed).
|
||||
- Drive it: JSON lines over TCP, e.g. `{"id":1,"method":"engine.execute","params":{"command":"track.new","params":{"count":2,"format":"stereo"}}}`, then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- Offscreen UI render (no window, no focus stealing): `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl]`.
|
||||
- Headless: `soundcraft-cli run --demo --cmd 'mix.volume={"track":"Kick","db":-6}' --bounce out.wav`; MCP: `soundcraft-cli mcp [--connect PORT]`.
|
||||
- **For UI work, look at the result** (render, read the PNG) and compare with `plan/protools/screenshots/`.
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically; delete your target dir when done (disk).
|
||||
|
||||
## Quality gates (before every commit)
|
||||
`cargo xtask ci` = fmt check, `clippy --workspace --all-targets -D warnings`, tests, assets, layers, wasm check. Commit after each arc of work that builds; one topic per commit.
|
||||
|
||||
## Release
|
||||
Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak manifest), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone lands.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Attribution
|
||||
|
||||
Every asset bundled with or committed to SoundCraft is listed here with its author, source and
|
||||
license. `cargo xtask assets` fails CI when a tracked asset file has no row.
|
||||
|
||||
**Rule:** no Avid, Pro Tools, Adobe or other proprietary icons, images, sounds or presets — ever.
|
||||
Assets must be original, public domain (CC0) or permissively licensed. See `AGENTS.md`.
|
||||
|
||||
## Icons
|
||||
|
||||
All UI icons (tools, transport, track buttons) are drawn procedurally in
|
||||
`crates/ui-egui/src/icons.rs` by the SoundCraft contributors (MIT OR Apache-2.0). No icon image
|
||||
files are used.
|
||||
|
||||
## Audio
|
||||
|
||||
The demo session's audio (drums, bass, pad) and its MIDI parts are synthesised in code in
|
||||
`crates/engine/src/demo.rs` by the SoundCraft contributors (MIT OR Apache-2.0). No sample files
|
||||
are bundled.
|
||||
|
||||
## Fonts
|
||||
|
||||
SoundCraft bundles no font files. It uses the default fonts shipped inside the `egui` crate
|
||||
(Ubuntu-Light: Ubuntu Font Licence 1.0; Hack: MIT; Noto Emoji: OFL-1.1; emoji-icon-font: OFL-1.1/MIT)
|
||||
and, at runtime, the operating system's own UI fonts when available (never redistributed).
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Author | Source | License |
|
||||
|---|---|---|---|
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
|
||||
@@ -0,0 +1 @@
|
||||
Read `AGENTS.md`: it is the entry point for agents in this repo.
|
||||
Generated
+1562
File diff suppressed because it is too large
Load Diff
+65
@@ -0,0 +1,65 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "xtask"]
|
||||
default-members = ["crates/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
repository = "https://github.com/storytold/soundcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
soundcraft-time = { path = "crates/time" }
|
||||
soundcraft-audio-io = { path = "crates/audio-io" }
|
||||
soundcraft-midi = { path = "crates/midi" }
|
||||
soundcraft-dsp = { path = "crates/dsp" }
|
||||
soundcraft-model = { path = "crates/model" }
|
||||
soundcraft-mix = { path = "crates/mix" }
|
||||
soundcraft-engine = { path = "crates/engine" }
|
||||
soundcraft-playback = { path = "crates/playback" }
|
||||
soundcraft-automation = { path = "crates/automation" }
|
||||
soundcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
rayon = "1"
|
||||
proptest = "1"
|
||||
|
||||
# audio
|
||||
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
|
||||
rustfft = "6"
|
||||
cpal = "0.16"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ArtCraft Team and the SoundCraft contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,17 @@
|
||||
SoundCraft
|
||||
Copyright (c) 2026 ArtCraft Team and the SoundCraft contributors.
|
||||
Licensed under the MIT License or the Apache License, Version 2.0, at your option
|
||||
(LICENSE-MIT, LICENSE-APACHE).
|
||||
|
||||
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
|
||||
not open source and are not covered by the license above; their terms are in
|
||||
docs/brand/LICENSE-brand.txt.
|
||||
|
||||
Third-party material
|
||||
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
list (author, source, license) is in ATTRIBUTION.md.
|
||||
|
||||
None: every bundled asset is original to SoundCraft. Icons are drawn in code; demo audio is
|
||||
synthesised in code. The egui default fonts (Ubuntu-Light, Hack, Noto Emoji, Emoji Icon Font) ship
|
||||
inside the egui crate under their own open licenses (see ATTRIBUTION.md).
|
||||
@@ -0,0 +1,4 @@
|
||||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "soundcraft-audio-io"
|
||||
description = "SoundCraft audio file I/O: WAV/BWF/RF64, AIFF/AIFF-C native readers and writers, FLAC encoder, symphonia-backed decode of compressed formats, waveform peaks"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
symphonia.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Native AIFF / AIFF-C reader and writer (Apple "Audio Interchange File Format" 1.3 and the
|
||||
//! AIFF-C draft: big-endian IFF chunks, 80-bit IEEE extended sample rate).
|
||||
|
||||
use crate::pcm::{PcmKind, Quantizer, be_u16, be_u32, clamp_float, deinterleave, tag, validate_buffer};
|
||||
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat};
|
||||
|
||||
/// AIFF-C version 1 timestamp for the FVER chunk.
|
||||
const AIFC_VERSION_1: u32 = 0xA280_5140;
|
||||
const MAX_SAMPLE_RATE: f64 = 10_000_000.0;
|
||||
|
||||
pub(crate) struct AiffLayout {
|
||||
pub info: AudioInfo,
|
||||
pub kind: PcmKind,
|
||||
pub data_start: usize,
|
||||
pub data_len: usize,
|
||||
}
|
||||
|
||||
pub(crate) enum AiffParse {
|
||||
Native(AiffLayout),
|
||||
/// Compressed AIFF-C (ulaw, alaw, ima4, ...): handed to symphonia.
|
||||
Foreign,
|
||||
}
|
||||
|
||||
/// True when the bytes start like an AIFF or AIFF-C file.
|
||||
pub(crate) fn is_aiff(b: &[u8]) -> bool {
|
||||
tag(b, 0).is_some_and(|t| &t == b"FORM") && tag(b, 8).is_some_and(|t| &t == b"AIFF" || &t == b"AIFC")
|
||||
}
|
||||
|
||||
/// Decode an 80-bit IEEE 754 extended-precision float.
|
||||
pub(crate) fn extended_to_f64(b: &[u8]) -> Option<f64> {
|
||||
let se = be_u16(b, 0)?;
|
||||
let hi = u64::from(be_u32(b, 2)?);
|
||||
let lo = u64::from(be_u32(b, 6)?);
|
||||
let mant = (hi << 32) | lo;
|
||||
if mant == 0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let exp = i32::from(se & 0x7FFF);
|
||||
if exp == 0x7FFF {
|
||||
return None; // inf / NaN
|
||||
}
|
||||
let v = mant as f64 * 2f64.powi(exp - 16383 - 63);
|
||||
Some(if se & 0x8000 != 0 { -v } else { v })
|
||||
}
|
||||
|
||||
/// Encode a positive integer as an 80-bit IEEE 754 extended float (exact).
|
||||
pub(crate) fn u32_to_extended(r: u32) -> [u8; 10] {
|
||||
let mut out = [0u8; 10];
|
||||
if r == 0 {
|
||||
return out;
|
||||
}
|
||||
let e = 31 - r.leading_zeros();
|
||||
let exp = (16383 + e) as u16;
|
||||
let mant = u64::from(r) << (63 - e);
|
||||
out[..2].copy_from_slice(&exp.to_be_bytes());
|
||||
out[2..].copy_from_slice(&mant.to_be_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn parse(b: &[u8]) -> Result<AiffParse> {
|
||||
if !is_aiff(b) {
|
||||
return Err(AudioError::Malformed("not an AIFF/AIFF-C file".into()));
|
||||
}
|
||||
let is_aifc = tag(b, 8).is_some_and(|t| &t == b"AIFC");
|
||||
let mut comm: Option<(i16, u32, i16, f64, [u8; 4])> = None;
|
||||
let mut ssnd: Option<(usize, usize)> = None;
|
||||
|
||||
let mut pos: usize = 12;
|
||||
let mut chunks = 0usize;
|
||||
while let (Some(id), Some(size32)) = (tag(b, pos), be_u32(b, pos + 4)) {
|
||||
chunks += 1;
|
||||
if chunks > 100_000 {
|
||||
break;
|
||||
}
|
||||
let body = pos.checked_add(8).ok_or_else(|| AudioError::Malformed("chunk offset overflow".into()))?;
|
||||
let remaining = b.len().saturating_sub(body);
|
||||
let declared = usize::try_from(size32).unwrap_or(usize::MAX);
|
||||
let len = declared.min(remaining);
|
||||
let c = b.get(body..body + len).unwrap_or_default();
|
||||
match &id {
|
||||
b"COMM" => {
|
||||
let short = || AudioError::Malformed("COMM chunk too short".into());
|
||||
let channels = be_u16(c, 0).ok_or_else(short)? as i16;
|
||||
let frames = be_u32(c, 2).ok_or_else(short)?;
|
||||
let bits = be_u16(c, 6).ok_or_else(short)? as i16;
|
||||
let rate = extended_to_f64(c.get(8..18).ok_or_else(short)?).ok_or_else(|| AudioError::Malformed("invalid sample rate".into()))?;
|
||||
let compression = if is_aifc { tag(c, 18).ok_or_else(short)? } else { *b"NONE" };
|
||||
comm = Some((channels, frames, bits, rate, compression));
|
||||
}
|
||||
b"SSND" if ssnd.is_none() => {
|
||||
let offset = usize::try_from(be_u32(c, 0).unwrap_or(0)).unwrap_or(usize::MAX);
|
||||
let start = body.saturating_add(8).saturating_add(offset).min(b.len());
|
||||
let data_len = len.saturating_sub(8).saturating_sub(offset);
|
||||
ssnd = Some((start, data_len));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if declared > remaining {
|
||||
break;
|
||||
}
|
||||
pos = match body.checked_add(declared).and_then(|p| p.checked_add(declared & 1)) {
|
||||
Some(p) => p,
|
||||
None => break,
|
||||
};
|
||||
}
|
||||
|
||||
let (channels, frames, bits, rate, compression) = comm.ok_or_else(|| AudioError::Malformed("AIFF has no COMM chunk".into()))?;
|
||||
if channels <= 0 {
|
||||
return Err(AudioError::Malformed("AIFF declares zero or negative channels".into()));
|
||||
}
|
||||
if !(1.0..=MAX_SAMPLE_RATE).contains(&rate) {
|
||||
return Err(AudioError::Malformed(format!("AIFF sample rate {rate} out of range")));
|
||||
}
|
||||
let sample_rate = rate.round() as u32;
|
||||
let int_kind = |big_endian: bool| -> Result<(PcmKind, SampleFormat)> {
|
||||
if !(1..=32).contains(&bits) {
|
||||
return Err(AudioError::Unsupported(format!("AIFF sample size {bits}")));
|
||||
}
|
||||
let bytes = (bits as usize).div_ceil(8);
|
||||
let sf = match bits {
|
||||
1..=8 => SampleFormat::Int8,
|
||||
9..=16 => SampleFormat::Int16,
|
||||
17..=24 => SampleFormat::Int24,
|
||||
_ => SampleFormat::Int32,
|
||||
};
|
||||
Ok((PcmKind::Int { bytes, big_endian, unsigned: false }, sf))
|
||||
};
|
||||
let (kind, sample_format) = match &compression {
|
||||
b"NONE" | b"twos" => int_kind(true)?,
|
||||
b"sowt" => int_kind(false)?,
|
||||
b"fl32" | b"FL32" => (PcmKind::Float { bytes: 4, big_endian: true }, SampleFormat::Float32),
|
||||
b"fl64" | b"FL64" => (PcmKind::Float { bytes: 8, big_endian: true }, SampleFormat::Float64),
|
||||
_ => return Ok(AiffParse::Foreign),
|
||||
};
|
||||
let (data_start, data_len) = ssnd.unwrap_or((b.len(), 0));
|
||||
let block = kind.bytes().saturating_mul(channels as usize).max(1);
|
||||
let frames = u64::from(frames).min((data_len / block) as u64);
|
||||
Ok(AiffParse::Native(AiffLayout {
|
||||
info: AudioInfo { format: FileFormat::Aiff, sample_format, sample_rate, channels: channels as u16, frames, bwf: None },
|
||||
kind,
|
||||
data_start,
|
||||
data_len,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn decode(b: &[u8], layout: &AiffLayout) -> Result<AudioBuffer> {
|
||||
let end = layout.data_start.saturating_add(layout.data_len).min(b.len());
|
||||
let data = b.get(layout.data_start..end).unwrap_or_default();
|
||||
let channels = deinterleave(data, layout.kind, usize::from(layout.info.channels), layout.info.frames)?;
|
||||
Ok(AudioBuffer { sample_rate: layout.info.sample_rate, channels })
|
||||
}
|
||||
|
||||
fn push_chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
|
||||
out.extend_from_slice(id);
|
||||
out.extend_from_slice(&u32::try_from(body.len()).unwrap_or(u32::MAX).to_be_bytes());
|
||||
out.extend_from_slice(body);
|
||||
if body.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode AIFF (integer depths) or AIFF-C `fl32` (Float32).
|
||||
pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result<Vec<u8>> {
|
||||
validate_buffer(buf)?;
|
||||
let channels = buf.channels.len();
|
||||
let channels_i16 = i16::try_from(channels).map_err(|_| AudioError::Encode("too many channels for AIFF".into()))?;
|
||||
let frames = buf.frames();
|
||||
let frames_u32 = u32::try_from(frames).map_err(|_| AudioError::TooLarge("too many frames for AIFF".into()))?;
|
||||
let (bits, is_float): (u16, bool) = match depth {
|
||||
BitDepth::Int16 => (16, false),
|
||||
BitDepth::Int24 => (24, false),
|
||||
BitDepth::Int32 => (32, false),
|
||||
BitDepth::Float32 => (32, true),
|
||||
};
|
||||
let bytes = usize::from(bits / 8);
|
||||
let data_len =
|
||||
bytes.checked_mul(channels).and_then(|b| b.checked_mul(frames)).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
|
||||
let mut comm = Vec::with_capacity(40);
|
||||
comm.extend_from_slice(&channels_i16.to_be_bytes());
|
||||
comm.extend_from_slice(&frames_u32.to_be_bytes());
|
||||
comm.extend_from_slice(&bits.to_be_bytes());
|
||||
comm.extend_from_slice(&u32_to_extended(buf.sample_rate));
|
||||
if is_float {
|
||||
comm.extend_from_slice(b"fl32");
|
||||
let name = b"32-bit float";
|
||||
comm.push(name.len() as u8);
|
||||
comm.extend_from_slice(name);
|
||||
if (name.len() + 1) % 2 == 1 {
|
||||
comm.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
let mut head = Vec::with_capacity(128);
|
||||
head.extend_from_slice(b"FORM");
|
||||
head.extend_from_slice(&[0, 0, 0, 0]); // patched below
|
||||
head.extend_from_slice(if is_float { b"AIFC" } else { b"AIFF" });
|
||||
if is_float {
|
||||
push_chunk(&mut head, b"FVER", &AIFC_VERSION_1.to_be_bytes());
|
||||
}
|
||||
push_chunk(&mut head, b"COMM", &comm);
|
||||
head.extend_from_slice(b"SSND");
|
||||
let ssnd_len = u32::try_from(data_len.saturating_add(8)).map_err(|_| AudioError::TooLarge("AIFF data exceeds 4 GiB".into()))?;
|
||||
head.extend_from_slice(&ssnd_len.to_be_bytes());
|
||||
head.extend_from_slice(&[0u8; 8]); // offset, blockSize
|
||||
|
||||
let total = head.len().saturating_add(data_len).saturating_add(data_len & 1);
|
||||
let form_len = u32::try_from(total - 8).map_err(|_| AudioError::TooLarge("AIFF file exceeds 4 GiB".into()))?;
|
||||
if let Some(slot) = head.get_mut(4..8) {
|
||||
slot.copy_from_slice(&form_len.to_be_bytes());
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(total).map_err(|_| AudioError::TooLarge(format!("cannot allocate {total} bytes for AIFF output")))?;
|
||||
out.extend_from_slice(&head);
|
||||
let mut qs = Quantizer::per_channel(u32::from(bits), dither, channels);
|
||||
for f in 0..frames {
|
||||
for (ch, q) in buf.channels.iter().zip(qs.iter_mut()) {
|
||||
let s = ch.get(f).copied().unwrap_or(0.0);
|
||||
if is_float {
|
||||
out.extend_from_slice(&clamp_float(s).to_be_bytes());
|
||||
} else {
|
||||
let v = q.quantize(s).to_be_bytes();
|
||||
out.extend_from_slice(v.get(4 - bytes..).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
if data_len % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Planar `f32` audio buffer.
|
||||
|
||||
/// Planar f32 audio.
|
||||
///
|
||||
/// Every channel is expected to hold the same number of frames; [`AudioBuffer::frames`] reports the
|
||||
/// shortest channel so a ragged buffer never causes out-of-bounds access.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct AudioBuffer {
|
||||
/// Sample rate in Hz.
|
||||
pub sample_rate: u32,
|
||||
/// One `Vec` of samples per channel.
|
||||
pub channels: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
impl AudioBuffer {
|
||||
/// A silent buffer with `channels` channels of `frames` frames each.
|
||||
pub fn new(sample_rate: u32, channels: usize, frames: usize) -> Self {
|
||||
Self { sample_rate, channels: vec![vec![0.0; frames]; channels] }
|
||||
}
|
||||
|
||||
/// Number of frames (the length of the shortest channel; 0 when there are no channels).
|
||||
pub fn frames(&self) -> usize {
|
||||
self.channels.iter().map(Vec::len).min().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Number of channels.
|
||||
pub fn num_channels(&self) -> usize {
|
||||
self.channels.len()
|
||||
}
|
||||
|
||||
/// Duration in seconds (0 when the sample rate is 0).
|
||||
pub fn duration_secs(&self) -> f64 {
|
||||
if self.sample_rate == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.frames() as f64 / f64::from(self.sample_rate)
|
||||
}
|
||||
|
||||
/// Interleaved copy of the samples (frame-major: `L0 R0 L1 R1 ...`).
|
||||
pub fn interleaved(&self) -> Vec<f32> {
|
||||
let frames = self.frames();
|
||||
let mut out = Vec::with_capacity(frames.saturating_mul(self.channels.len()));
|
||||
for f in 0..frames {
|
||||
for ch in &self.channels {
|
||||
out.push(ch.get(f).copied().unwrap_or(0.0));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a planar buffer from interleaved samples. A trailing partial frame is dropped;
|
||||
/// `channels == 0` yields an empty buffer.
|
||||
pub fn from_interleaved(sr: u32, channels: usize, data: &[f32]) -> Self {
|
||||
if channels == 0 {
|
||||
return Self { sample_rate: sr, channels: Vec::new() };
|
||||
}
|
||||
let frames = data.len() / channels;
|
||||
let mut out: Vec<Vec<f32>> = (0..channels).map(|_| Vec::with_capacity(frames)).collect();
|
||||
for frame in data.chunks_exact(channels) {
|
||||
for (ch, &s) in out.iter_mut().zip(frame) {
|
||||
ch.push(s);
|
||||
}
|
||||
}
|
||||
Self { sample_rate: sr, channels: out }
|
||||
}
|
||||
|
||||
/// Absolute peak over all channels (NaN samples are ignored).
|
||||
pub fn peak(&self) -> f32 {
|
||||
self.channels.iter().flat_map(|c| c.iter()).fold(0.0f32, |acc, &s| acc.max(s.abs()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
//! A small pure-Rust FLAC encoder (RFC 9639): fixed blocksize, independent channels, and per
|
||||
//! subframe the cheapest of CONSTANT, VERBATIM or FIXED (orders 0–4) prediction with
|
||||
//! partitioned Rice-coded residuals.
|
||||
|
||||
use crate::pcm::{Quantizer, validate_buffer};
|
||||
use crate::{AudioBuffer, AudioError, BitDepth, Result};
|
||||
|
||||
const BLOCK_SIZE: usize = 4096;
|
||||
const MAX_PARTITION_ORDER: u32 = 6;
|
||||
|
||||
struct BitWriter {
|
||||
out: Vec<u8>,
|
||||
acc: u64,
|
||||
nbits: u32,
|
||||
}
|
||||
|
||||
impl BitWriter {
|
||||
fn new() -> Self {
|
||||
Self { out: Vec::new(), acc: 0, nbits: 0 }
|
||||
}
|
||||
|
||||
/// Write the low `n` (≤ 32) bits of `v`, MSB first.
|
||||
fn write(&mut self, v: u64, n: u32) {
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let n = n.min(32);
|
||||
let mask = (1u64 << n) - 1;
|
||||
self.acc = (self.acc << n) | (v & mask);
|
||||
self.nbits += n;
|
||||
while self.nbits >= 8 {
|
||||
self.nbits -= 8;
|
||||
self.out.push((self.acc >> self.nbits) as u8);
|
||||
}
|
||||
self.acc &= (1u64 << self.nbits) - 1;
|
||||
}
|
||||
|
||||
fn write_signed(&mut self, v: i64, n: u32) {
|
||||
self.write(v as u64, n);
|
||||
}
|
||||
|
||||
/// `q` zero bits followed by a one bit.
|
||||
fn write_unary(&mut self, mut q: u64) {
|
||||
while q >= 32 {
|
||||
self.write(0, 32);
|
||||
q -= 32;
|
||||
}
|
||||
self.write(1, (q as u32) + 1);
|
||||
}
|
||||
|
||||
fn align(&mut self) {
|
||||
if self.nbits > 0 {
|
||||
let pad = 8 - self.nbits;
|
||||
self.write(0, pad);
|
||||
}
|
||||
}
|
||||
|
||||
fn into_bytes(mut self) -> Vec<u8> {
|
||||
self.align();
|
||||
self.out
|
||||
}
|
||||
}
|
||||
|
||||
fn crc8(data: &[u8]) -> u8 {
|
||||
let mut crc: u8 = 0;
|
||||
for &b in data {
|
||||
crc ^= b;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x80 != 0 { (crc << 1) ^ 0x07 } else { crc << 1 };
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
fn crc16(data: &[u8]) -> u16 {
|
||||
let mut crc: u16 = 0;
|
||||
for &b in data {
|
||||
crc ^= u16::from(b) << 8;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x8000 != 0 { (crc << 1) ^ 0x8005 } else { crc << 1 };
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// FLAC's UTF-8-like variable-length integer coding (up to 36 bits).
|
||||
fn write_utf8_number(w: &mut BitWriter, v: u64) {
|
||||
if v < 0x80 {
|
||||
w.write(v, 8);
|
||||
return;
|
||||
}
|
||||
// Payload bits available for an n-byte coding: 5n + 1 (n = 2..=6), 36 for n = 7.
|
||||
let n: u32 = (2..=7).find(|&n| if n == 7 { true } else { v < (1u64 << (5 * n + 1)) }).unwrap_or(7);
|
||||
let cont_bits = 6 * (n - 1);
|
||||
let lead_payload = if n == 7 { 0 } else { 7 - n };
|
||||
let prefix: u64 = (0xFFu64 << (8 - n)) & 0xFF;
|
||||
let lead = prefix | ((v >> cont_bits) & ((1u64 << lead_payload) - 1));
|
||||
w.write(lead, 8);
|
||||
for i in (0..n - 1).rev() {
|
||||
w.write(0x80 | ((v >> (6 * i)) & 0x3F), 8);
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_rate_code(r: u32) -> (u64, Option<(u64, u32)>) {
|
||||
match r {
|
||||
88_200 => (1, None),
|
||||
176_400 => (2, None),
|
||||
192_000 => (3, None),
|
||||
8_000 => (4, None),
|
||||
16_000 => (5, None),
|
||||
22_050 => (6, None),
|
||||
24_000 => (7, None),
|
||||
32_000 => (8, None),
|
||||
44_100 => (9, None),
|
||||
48_000 => (10, None),
|
||||
96_000 => (11, None),
|
||||
r if r % 1000 == 0 && r / 1000 <= 255 => (12, Some((u64::from(r / 1000), 8))),
|
||||
r if r <= 65_535 => (13, Some((u64::from(r), 16))),
|
||||
r if r % 10 == 0 && r / 10 <= 65_535 => (14, Some((u64::from(r / 10), 16))),
|
||||
_ => (0, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn bps_code(bps: u32) -> u64 {
|
||||
match bps {
|
||||
8 => 1,
|
||||
12 => 2,
|
||||
16 => 4,
|
||||
20 => 5,
|
||||
24 => 6,
|
||||
_ => 0, // from STREAMINFO
|
||||
}
|
||||
}
|
||||
|
||||
fn zigzag(r: i64) -> u64 {
|
||||
((r << 1) ^ (r >> 63)) as u64
|
||||
}
|
||||
|
||||
/// Fixed-predictor residuals for `order`, or `None` when any residual leaves the i32 range.
|
||||
fn fixed_residual(x: &[i64], order: usize) -> Option<Vec<i64>> {
|
||||
let mut res = Vec::with_capacity(x.len().saturating_sub(order));
|
||||
for n in order..x.len() {
|
||||
let g = |k: usize| x.get(n - k).copied().unwrap_or(0);
|
||||
let r = match order {
|
||||
0 => g(0),
|
||||
1 => g(0) - g(1),
|
||||
2 => g(0) - 2 * g(1) + g(2),
|
||||
3 => g(0) - 3 * g(1) + 3 * g(2) - g(3),
|
||||
_ => g(0) - 4 * g(1) + 6 * g(2) - 4 * g(3) + g(4),
|
||||
};
|
||||
if r < i64::from(i32::MIN) || r > i64::from(i32::MAX) {
|
||||
return None;
|
||||
}
|
||||
res.push(r);
|
||||
}
|
||||
Some(res)
|
||||
}
|
||||
|
||||
/// Best Rice parameter and its cost in bits for one partition.
|
||||
fn best_rice(part: &[u64]) -> (u32, u64) {
|
||||
if part.is_empty() {
|
||||
return (0, 0);
|
||||
}
|
||||
let sum: u64 = part.iter().sum();
|
||||
let mean = sum / part.len() as u64;
|
||||
let guess = if mean == 0 { 0 } else { 63 - mean.leading_zeros() };
|
||||
let mut best = (0u32, u64::MAX);
|
||||
for k in guess.saturating_sub(1)..=(guess + 1).min(30) {
|
||||
let cost = part.len() as u64 * u64::from(k + 1) + part.iter().map(|&u| u >> k).sum::<u64>();
|
||||
if cost < best.1 {
|
||||
best = (k, cost);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Residual coding plan: partition order, per-partition parameters, total bits.
|
||||
struct RicePlan {
|
||||
order: u32,
|
||||
params: Vec<u32>,
|
||||
bits: u64,
|
||||
wide: bool,
|
||||
}
|
||||
|
||||
fn plan_residual(u: &[u64], block_len: usize, pred_order: usize) -> RicePlan {
|
||||
let mut best: Option<RicePlan> = None;
|
||||
for p in 0..=MAX_PARTITION_ORDER {
|
||||
let parts = 1usize << p;
|
||||
if !block_len.is_multiple_of(parts) || block_len / parts <= pred_order {
|
||||
break;
|
||||
}
|
||||
let per = block_len / parts;
|
||||
let mut params = Vec::with_capacity(parts);
|
||||
let mut bits = 0u64;
|
||||
let mut start = 0usize;
|
||||
for i in 0..parts {
|
||||
let len = if i == 0 { per - pred_order } else { per };
|
||||
let part = u.get(start..start + len).unwrap_or_default();
|
||||
start += len;
|
||||
let (k, cost) = best_rice(part);
|
||||
params.push(k);
|
||||
bits += cost;
|
||||
}
|
||||
let wide = params.iter().any(|&k| k > 14);
|
||||
bits += 6 + parts as u64 * if wide { 5 } else { 4 };
|
||||
if best.as_ref().is_none_or(|b| bits < b.bits) {
|
||||
best = Some(RicePlan { order: p, params, bits, wide });
|
||||
}
|
||||
}
|
||||
best.unwrap_or(RicePlan { order: 0, params: vec![0], bits: u64::MAX, wide: false })
|
||||
}
|
||||
|
||||
fn write_residual(w: &mut BitWriter, u: &[u64], block_len: usize, pred_order: usize, plan: &RicePlan) {
|
||||
w.write(u64::from(plan.wide), 2);
|
||||
w.write(u64::from(plan.order), 4);
|
||||
let parts = 1usize << plan.order;
|
||||
let per = block_len / parts;
|
||||
let mut start = 0usize;
|
||||
for (i, &k) in plan.params.iter().enumerate() {
|
||||
let len = if i == 0 { per - pred_order } else { per };
|
||||
w.write(u64::from(k), if plan.wide { 5 } else { 4 });
|
||||
for &v in u.get(start..start + len).unwrap_or_default() {
|
||||
w.write_unary(v >> k);
|
||||
w.write(v, k);
|
||||
}
|
||||
start += len;
|
||||
}
|
||||
}
|
||||
|
||||
fn write_subframe(w: &mut BitWriter, x: &[i64], bps: u32) {
|
||||
let n = x.len();
|
||||
let first = x.first().copied().unwrap_or(0);
|
||||
if x.iter().all(|&s| s == first) {
|
||||
w.write(0, 1);
|
||||
w.write(0, 6);
|
||||
w.write(0, 1);
|
||||
w.write_signed(first, bps);
|
||||
return;
|
||||
}
|
||||
let verbatim_bits = n as u64 * u64::from(bps);
|
||||
let mut best: Option<(usize, Vec<u64>, RicePlan)> = None;
|
||||
for order in 0..=4usize.min(n.saturating_sub(1)) {
|
||||
let Some(res) = fixed_residual(x, order) else { continue };
|
||||
let u: Vec<u64> = res.into_iter().map(zigzag).collect();
|
||||
let plan = plan_residual(&u, n, order);
|
||||
let bits = plan.bits.saturating_add(order as u64 * u64::from(bps));
|
||||
if bits < verbatim_bits && best.as_ref().is_none_or(|b| bits < b.2.bits.saturating_add(b.0 as u64 * u64::from(bps))) {
|
||||
best = Some((order, u, plan));
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some((order, u, plan)) => {
|
||||
w.write(0, 1);
|
||||
w.write(8 + order as u64, 6);
|
||||
w.write(0, 1);
|
||||
for &s in x.iter().take(order) {
|
||||
w.write_signed(s, bps);
|
||||
}
|
||||
write_residual(w, &u, n, order, &plan);
|
||||
}
|
||||
None => {
|
||||
w.write(0, 1);
|
||||
w.write(1, 6);
|
||||
w.write(0, 1);
|
||||
for &s in x {
|
||||
w.write_signed(s, bps);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result<Vec<u8>> {
|
||||
validate_buffer(buf)?;
|
||||
let channels = buf.channels.len();
|
||||
if channels > 8 {
|
||||
return Err(AudioError::Unsupported(format!("FLAC supports at most 8 channels, got {channels}")));
|
||||
}
|
||||
if buf.sample_rate > 655_350 {
|
||||
return Err(AudioError::Unsupported(format!("FLAC sample rate {} exceeds 655350 Hz", buf.sample_rate)));
|
||||
}
|
||||
let bps: u32 = match depth {
|
||||
BitDepth::Int16 => 16,
|
||||
BitDepth::Int24 => 24,
|
||||
BitDepth::Int32 => 32,
|
||||
BitDepth::Float32 => return Err(AudioError::Unsupported("FLAC cannot store 32-bit float samples".into())),
|
||||
};
|
||||
let frames = buf.frames();
|
||||
let total = frames as u64;
|
||||
if total >= 1u64 << 36 {
|
||||
return Err(AudioError::TooLarge("too many frames for FLAC".into()));
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve(frames.saturating_mul(channels).saturating_mul(bps as usize / 8) / 2)
|
||||
.map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?;
|
||||
out.extend_from_slice(b"fLaC");
|
||||
let mut si = BitWriter::new();
|
||||
si.write(1, 1); // last metadata block
|
||||
si.write(0, 7); // STREAMINFO
|
||||
si.write(34, 24);
|
||||
si.write(BLOCK_SIZE as u64, 16);
|
||||
si.write(BLOCK_SIZE as u64, 16);
|
||||
si.write(0, 24);
|
||||
si.write(0, 24);
|
||||
si.write(u64::from(buf.sample_rate), 20);
|
||||
si.write(channels as u64 - 1, 3);
|
||||
si.write(u64::from(bps - 1), 5);
|
||||
si.write(total >> 32, 4);
|
||||
si.write(total & 0xFFFF_FFFF, 32);
|
||||
for _ in 0..4 {
|
||||
si.write(0, 32); // MD5 unknown
|
||||
}
|
||||
out.extend_from_slice(&si.into_bytes());
|
||||
|
||||
let (sr_code, sr_extra) = sample_rate_code(buf.sample_rate);
|
||||
let mut quantizers = Quantizer::per_channel(bps, dither, channels);
|
||||
let mut block: Vec<i64> = Vec::with_capacity(BLOCK_SIZE);
|
||||
let mut frame_no: u64 = 0;
|
||||
let mut start = 0usize;
|
||||
while start < frames {
|
||||
let len = (frames - start).min(BLOCK_SIZE);
|
||||
let mut w = BitWriter::new();
|
||||
w.write(0x3FFE, 14);
|
||||
w.write(0, 1);
|
||||
w.write(0, 1); // fixed blocksize stream
|
||||
w.write(7, 4); // 16-bit (blocksize-1) follows
|
||||
w.write(sr_code, 4);
|
||||
w.write(channels as u64 - 1, 4);
|
||||
w.write(bps_code(bps), 3);
|
||||
w.write(0, 1);
|
||||
write_utf8_number(&mut w, frame_no);
|
||||
w.write(len as u64 - 1, 16);
|
||||
if let Some((v, n)) = sr_extra {
|
||||
w.write(v, n);
|
||||
}
|
||||
let header = w.into_bytes();
|
||||
let mut w = BitWriter::new();
|
||||
for &b in &header {
|
||||
w.write(u64::from(b), 8);
|
||||
}
|
||||
w.write(u64::from(crc8(&header)), 8);
|
||||
for (ch, q) in buf.channels.iter().zip(quantizers.iter_mut()) {
|
||||
block.clear();
|
||||
block.extend(ch.get(start..start + len).unwrap_or_default().iter().map(|&s| i64::from(q.quantize(s))));
|
||||
write_subframe(&mut w, &block, bps);
|
||||
}
|
||||
let mut frame = w.into_bytes();
|
||||
let crc = crc16(&frame);
|
||||
frame.extend_from_slice(&crc.to_be_bytes());
|
||||
out.extend_from_slice(&frame);
|
||||
start += len;
|
||||
frame_no += 1;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn crc_known_values() {
|
||||
// CRC-8/SMBUS and CRC-16/UMTS (FLAC's polynomials) of "123456789".
|
||||
assert_eq!(crc8(b"123456789"), 0xF4);
|
||||
assert_eq!(crc16(b"123456789"), 0xFEE8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_numbers() {
|
||||
let enc = |v| {
|
||||
let mut w = BitWriter::new();
|
||||
write_utf8_number(&mut w, v);
|
||||
w.into_bytes()
|
||||
};
|
||||
assert_eq!(enc(0x7F), vec![0x7F]);
|
||||
assert_eq!(enc(0x80), vec![0xC2, 0x80]);
|
||||
assert_eq!(enc(0x800), vec![0xE0, 0xA0, 0x80]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! SoundCraft audio file I/O.
|
||||
//!
|
||||
//! Bytes in, bytes out (no filesystem access, so the crate also builds for `wasm32`):
|
||||
//!
|
||||
//! * [`detect_format`] / [`probe`] / [`decode`] read WAV (PCM 8/16/24/32, float 32/64,
|
||||
//! `WAVE_FORMAT_EXTENSIBLE`, RF64/BW64, BWF `bext`) and AIFF/AIFF-C (big-endian, `sowt`,
|
||||
//! `fl32`/`fl64`) with native readers, and everything else (FLAC, MP3, Ogg Vorbis, AAC/ALAC in
|
||||
//! MP4, CAF, Matroska, ADPCM/A-law/µ-law WAV, ...) through `symphonia`.
|
||||
//! * [`encode`] writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32`, and FLAC (built-in
|
||||
//! encoder), with optional TPDF dither.
|
||||
//! * [`peaks`] builds multi-resolution min/max overviews for waveform drawing.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod aiff;
|
||||
mod buffer;
|
||||
mod flac;
|
||||
mod pcm;
|
||||
pub mod peaks;
|
||||
mod symph;
|
||||
mod wav;
|
||||
|
||||
pub use buffer::AudioBuffer;
|
||||
|
||||
/// Errors from probing, decoding and encoding.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AudioError {
|
||||
/// The format, codec or option is valid but not supported.
|
||||
#[error("unsupported audio: {0}")]
|
||||
Unsupported(String),
|
||||
/// The input is corrupt or not what it claims to be.
|
||||
#[error("malformed audio: {0}")]
|
||||
Malformed(String),
|
||||
/// The input (or requested output) exceeds a size limit.
|
||||
#[error("audio too large: {0}")]
|
||||
TooLarge(String),
|
||||
/// Encoding failed (e.g. an empty buffer).
|
||||
#[error("audio encode failed: {0}")]
|
||||
Encode(String),
|
||||
}
|
||||
|
||||
/// Result alias for this crate.
|
||||
pub type Result<T> = std::result::Result<T, AudioError>;
|
||||
|
||||
/// Container / codec family of an audio file.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FileFormat {
|
||||
Wav,
|
||||
Aiff,
|
||||
Flac,
|
||||
Mp3,
|
||||
Ogg,
|
||||
Aac,
|
||||
Alac,
|
||||
Caf,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Stored sample representation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum SampleFormat {
|
||||
Int8,
|
||||
Int16,
|
||||
Int24,
|
||||
Int32,
|
||||
Float32,
|
||||
Float64,
|
||||
/// A lossy codec (MP3, AAC, Vorbis, ...): no fixed bit depth.
|
||||
Compressed,
|
||||
}
|
||||
|
||||
/// Broadcast Wave (`bext`) metadata.
|
||||
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BwfInfo {
|
||||
pub description: String,
|
||||
pub originator: String,
|
||||
pub originator_reference: String,
|
||||
/// `yyyy-mm-dd`.
|
||||
pub origination_date: String,
|
||||
/// `hh:mm:ss`.
|
||||
pub origination_time: String,
|
||||
/// Sample count since midnight of the first sample (the timestamp Pro Tools spots to).
|
||||
pub time_reference: u64,
|
||||
}
|
||||
|
||||
/// What [`probe`] learns about a file without decoding all of it.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AudioInfo {
|
||||
pub format: FileFormat,
|
||||
pub sample_format: SampleFormat,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
pub frames: u64,
|
||||
pub bwf: Option<BwfInfo>,
|
||||
}
|
||||
|
||||
impl AudioInfo {
|
||||
/// Duration in seconds.
|
||||
pub fn duration_secs(&self) -> f64 {
|
||||
if self.sample_rate == 0 { 0.0 } else { self.frames as f64 / f64::from(self.sample_rate) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Bit depth for [`encode`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum BitDepth {
|
||||
Int16,
|
||||
Int24,
|
||||
Int32,
|
||||
Float32,
|
||||
}
|
||||
|
||||
/// Options for [`encode`].
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct EncodeOptions {
|
||||
/// `Wav`, `Aiff` or `Flac`; anything else is [`AudioError::Unsupported`].
|
||||
pub format: FileFormat,
|
||||
pub bit_depth: BitDepth,
|
||||
/// Add TPDF dither when quantizing to an integer depth.
|
||||
pub dither: bool,
|
||||
/// Write a BWF `bext` chunk (WAV only; ignored for other formats).
|
||||
pub bwf: Option<BwfInfo>,
|
||||
}
|
||||
|
||||
impl Default for EncodeOptions {
|
||||
fn default() -> Self {
|
||||
Self { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Size of an ID3v2 tag at the start of `b`, if there is one.
|
||||
fn id3v2_len(b: &[u8]) -> Option<usize> {
|
||||
if b.get(..3)? != b"ID3" {
|
||||
return None;
|
||||
}
|
||||
let flags = *b.get(5)?;
|
||||
let size = b.get(6..10)?.iter().fold(0usize, |acc, &x| (acc << 7) | usize::from(x & 0x7F));
|
||||
Some(10 + size + if flags & 0x10 != 0 { 10 } else { 0 })
|
||||
}
|
||||
|
||||
fn detect_by_magic(b: &[u8], depth: u32) -> Option<FileFormat> {
|
||||
let head = |n: usize| b.get(..n);
|
||||
if wav::is_wav(b) {
|
||||
return Some(FileFormat::Wav);
|
||||
}
|
||||
if aiff::is_aiff(b) {
|
||||
return Some(FileFormat::Aiff);
|
||||
}
|
||||
match head(4)? {
|
||||
b"fLaC" => return Some(FileFormat::Flac),
|
||||
b"OggS" => return Some(FileFormat::Ogg),
|
||||
b"caff" => return Some(FileFormat::Caf),
|
||||
[0x1A, 0x45, 0xDF, 0xA3] => return Some(FileFormat::Other),
|
||||
_ => {}
|
||||
}
|
||||
if b.get(4..8) == Some(b"ftyp") {
|
||||
// MP4 / M4A: the sample entry inside `stsd` names the codec.
|
||||
let found = b.windows(4).position(|w| w == b"stsd");
|
||||
let codec = found.and_then(|p| b.get(p.checked_add(16)?..p.checked_add(20)?));
|
||||
return Some(if codec == Some(b"alac") { FileFormat::Alac } else { FileFormat::Aac });
|
||||
}
|
||||
if let Some(len) = id3v2_len(b) {
|
||||
if depth == 0
|
||||
&& let Some(f) = b.get(len..).and_then(|rest| detect_by_magic(rest, 1))
|
||||
{
|
||||
return Some(f);
|
||||
}
|
||||
return Some(FileFormat::Mp3);
|
||||
}
|
||||
let (b0, b1) = (*b.first()?, *b.get(1)?);
|
||||
if b0 == 0xFF && b1 & 0xE0 == 0xE0 {
|
||||
let layer = (b1 >> 1) & 0x3;
|
||||
if b1 & 0xF0 == 0xF0 && layer == 0 {
|
||||
return Some(FileFormat::Aac); // ADTS
|
||||
}
|
||||
if layer != 0 {
|
||||
return Some(FileFormat::Mp3);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn detect_by_ext(ext: &str) -> FileFormat {
|
||||
match ext.trim_start_matches('.').to_ascii_lowercase().as_str() {
|
||||
"wav" | "wave" | "bwf" | "rf64" => FileFormat::Wav,
|
||||
"aif" | "aiff" | "aifc" => FileFormat::Aiff,
|
||||
"flac" => FileFormat::Flac,
|
||||
"mp3" | "mp2" | "mp1" => FileFormat::Mp3,
|
||||
"ogg" | "oga" => FileFormat::Ogg,
|
||||
"aac" | "m4a" | "mp4" | "adts" => FileFormat::Aac,
|
||||
"caf" => FileFormat::Caf,
|
||||
_ => FileFormat::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Identify a file by its magic bytes, falling back to the extension hint (`"wav"`, `".flac"`).
|
||||
pub fn detect_format(bytes: &[u8], ext_hint: Option<&str>) -> FileFormat {
|
||||
detect_by_magic(bytes, 0).unwrap_or_else(|| ext_hint.map(detect_by_ext).unwrap_or(FileFormat::Other))
|
||||
}
|
||||
|
||||
/// Read a file's format, sample rate, channel count, length and BWF metadata.
|
||||
pub fn probe(bytes: &[u8], ext_hint: Option<&str>) -> Result<AudioInfo> {
|
||||
let format = detect_format(bytes, ext_hint);
|
||||
match format {
|
||||
FileFormat::Wav if wav::is_wav(bytes) => match wav::parse(bytes)? {
|
||||
wav::WavParse::Native(l) => Ok(l.info),
|
||||
wav::WavParse::Foreign { bwf } => {
|
||||
let mut info = symph::probe(bytes, Some("wav"), format)?;
|
||||
info.bwf = bwf;
|
||||
Ok(info)
|
||||
}
|
||||
},
|
||||
FileFormat::Aiff if aiff::is_aiff(bytes) => match aiff::parse(bytes)? {
|
||||
aiff::AiffParse::Native(l) => Ok(l.info),
|
||||
aiff::AiffParse::Foreign => symph::probe(bytes, Some("aiff"), format),
|
||||
},
|
||||
_ => symph::probe(bytes, ext_hint, format),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a whole file to planar `f32`.
|
||||
pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioBuffer)> {
|
||||
let format = detect_format(bytes, ext_hint);
|
||||
match format {
|
||||
FileFormat::Wav if wav::is_wav(bytes) => match wav::parse(bytes)? {
|
||||
wav::WavParse::Native(l) => {
|
||||
let buf = wav::decode(bytes, &l)?;
|
||||
let mut info = l.info;
|
||||
info.frames = buf.frames() as u64;
|
||||
Ok((info, buf))
|
||||
}
|
||||
wav::WavParse::Foreign { bwf } => {
|
||||
let (mut info, buf) = symph::decode(bytes, Some("wav"), format)?;
|
||||
info.bwf = bwf;
|
||||
Ok((info, buf))
|
||||
}
|
||||
},
|
||||
FileFormat::Aiff if aiff::is_aiff(bytes) => match aiff::parse(bytes)? {
|
||||
aiff::AiffParse::Native(l) => {
|
||||
let buf = aiff::decode(bytes, &l)?;
|
||||
let mut info = l.info;
|
||||
info.frames = buf.frames() as u64;
|
||||
Ok((info, buf))
|
||||
}
|
||||
aiff::AiffParse::Foreign => symph::decode(bytes, Some("aiff"), format),
|
||||
},
|
||||
_ => symph::decode(bytes, ext_hint, format),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode `buf` as WAV/BWF, AIFF/AIFF-C or FLAC. Samples are clamped to [-1, 1].
|
||||
pub fn encode(buf: &AudioBuffer, opts: &EncodeOptions) -> Result<Vec<u8>> {
|
||||
match opts.format {
|
||||
FileFormat::Wav => wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), false),
|
||||
FileFormat::Aiff => aiff::encode(buf, opts.bit_depth, opts.dither),
|
||||
FileFormat::Flac => flac::encode(buf, opts.bit_depth, opts.dither),
|
||||
other => Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a WAV file in the RF64 layout regardless of size (normally used only above 4 GiB).
|
||||
pub fn encode_wav_rf64(buf: &AudioBuffer, opts: &EncodeOptions) -> Result<Vec<u8>> {
|
||||
wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), true)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Byte-level PCM helpers shared by the WAV, AIFF and FLAC code: bounded readers, sample
|
||||
//! decoding to `f32`, and quantization (with optional TPDF dither) for encoding.
|
||||
|
||||
use crate::{AudioError, Result};
|
||||
|
||||
/// Maximum number of decoded `f32` samples (all channels) we will produce: 4 GiB of output.
|
||||
pub(crate) const MAX_DECODE_SAMPLES: u64 = (4u64 << 30) / 4;
|
||||
|
||||
/// Little-endian `u16` at `pos`.
|
||||
pub(crate) fn le_u16(b: &[u8], pos: usize) -> Option<u16> {
|
||||
let s = b.get(pos..pos.checked_add(2)?)?;
|
||||
Some(u16::from_le_bytes([*s.first()?, *s.get(1)?]))
|
||||
}
|
||||
|
||||
/// Little-endian `u32` at `pos`.
|
||||
pub(crate) fn le_u32(b: &[u8], pos: usize) -> Option<u32> {
|
||||
let s: [u8; 4] = b.get(pos..pos.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(u32::from_le_bytes(s))
|
||||
}
|
||||
|
||||
/// Little-endian `u64` at `pos`.
|
||||
pub(crate) fn le_u64(b: &[u8], pos: usize) -> Option<u64> {
|
||||
let s: [u8; 8] = b.get(pos..pos.checked_add(8)?)?.try_into().ok()?;
|
||||
Some(u64::from_le_bytes(s))
|
||||
}
|
||||
|
||||
/// Big-endian `u16` at `pos`.
|
||||
pub(crate) fn be_u16(b: &[u8], pos: usize) -> Option<u16> {
|
||||
let s: [u8; 2] = b.get(pos..pos.checked_add(2)?)?.try_into().ok()?;
|
||||
Some(u16::from_be_bytes(s))
|
||||
}
|
||||
|
||||
/// Big-endian `u32` at `pos`.
|
||||
pub(crate) fn be_u32(b: &[u8], pos: usize) -> Option<u32> {
|
||||
let s: [u8; 4] = b.get(pos..pos.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(u32::from_be_bytes(s))
|
||||
}
|
||||
|
||||
/// Four-byte tag at `pos`.
|
||||
pub(crate) fn tag(b: &[u8], pos: usize) -> Option<[u8; 4]> {
|
||||
b.get(pos..pos.checked_add(4)?)?.try_into().ok()
|
||||
}
|
||||
|
||||
/// How the samples in a PCM byte stream are encoded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PcmKind {
|
||||
/// Signed (or, for 1-byte WAV, unsigned) integers occupying `bytes` bytes each, left-justified.
|
||||
Int { bytes: usize, big_endian: bool, unsigned: bool },
|
||||
/// IEEE float, 4 or 8 bytes.
|
||||
Float { bytes: usize, big_endian: bool },
|
||||
}
|
||||
|
||||
impl PcmKind {
|
||||
pub(crate) fn bytes(self) -> usize {
|
||||
match self {
|
||||
PcmKind::Int { bytes, .. } | PcmKind::Float { bytes, .. } => bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode one sample from exactly `kind.bytes()` bytes. Returns 0 for a short slice.
|
||||
fn decode_sample(s: &[u8], kind: PcmKind) -> f32 {
|
||||
match kind {
|
||||
PcmKind::Int { bytes, big_endian, unsigned } => {
|
||||
if bytes == 0 || bytes > 4 || s.len() < bytes {
|
||||
return 0.0;
|
||||
}
|
||||
let mut acc: u32 = 0;
|
||||
for i in 0..bytes {
|
||||
let idx = if big_endian { i } else { bytes - 1 - i };
|
||||
let mut byte = s.get(idx).copied().unwrap_or(0);
|
||||
if unsigned && i == 0 {
|
||||
byte ^= 0x80;
|
||||
}
|
||||
acc = (acc << 8) | u32::from(byte);
|
||||
}
|
||||
let shifted = acc << (32 - 8 * bytes as u32);
|
||||
(f64::from(shifted as i32) / 2_147_483_648.0) as f32
|
||||
}
|
||||
PcmKind::Float { bytes, big_endian } => {
|
||||
let v = match bytes {
|
||||
4 => {
|
||||
let Some(a) = s.get(..4).and_then(|x| <[u8; 4]>::try_from(x).ok()) else { return 0.0 };
|
||||
if big_endian { f32::from_be_bytes(a) } else { f32::from_le_bytes(a) }
|
||||
}
|
||||
8 => {
|
||||
let Some(a) = s.get(..8).and_then(|x| <[u8; 8]>::try_from(x).ok()) else { return 0.0 };
|
||||
(if big_endian { f64::from_be_bytes(a) } else { f64::from_le_bytes(a) }) as f32
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
if v.is_finite() { v } else { 0.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deinterleave `frames` frames of `channels`-channel PCM from `data` into planar `f32`.
|
||||
pub(crate) fn deinterleave(data: &[u8], kind: PcmKind, channels: usize, frames: u64) -> Result<Vec<Vec<f32>>> {
|
||||
let bytes = kind.bytes();
|
||||
let block = bytes.checked_mul(channels).filter(|&b| b > 0).ok_or_else(|| AudioError::Malformed("zero-sized sample frame".into()))?;
|
||||
let available = (data.len() / block) as u64;
|
||||
let frames = frames.min(available);
|
||||
let total = frames.checked_mul(channels as u64).ok_or_else(|| AudioError::TooLarge("sample count overflows".into()))?;
|
||||
if total > MAX_DECODE_SAMPLES {
|
||||
return Err(AudioError::TooLarge(format!("{total} samples exceeds the {MAX_DECODE_SAMPLES}-sample decode limit")));
|
||||
}
|
||||
let frames = usize::try_from(frames).map_err(|_| AudioError::TooLarge("frame count exceeds address space".into()))?;
|
||||
let mut out: Vec<Vec<f32>> = (0..channels).map(|_| Vec::with_capacity(frames)).collect();
|
||||
for frame in data.chunks_exact(block).take(frames) {
|
||||
for (ch, s) in out.iter_mut().zip(frame.chunks_exact(bytes)) {
|
||||
ch.push(decode_sample(s, kind));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Converts `f32` samples to integers of a given bit depth, clamping to [-1, 1] and optionally
|
||||
/// adding triangular (TPDF) dither of ±1 LSB.
|
||||
pub(crate) struct Quantizer {
|
||||
bits: u32,
|
||||
dither: bool,
|
||||
rng: u64,
|
||||
}
|
||||
|
||||
impl Quantizer {
|
||||
/// A quantizer for one channel; each channel gets an independent dither sequence.
|
||||
pub(crate) fn new(bits: u32, dither: bool, channel: usize) -> Self {
|
||||
let rng = 0x9E37_79B9_7F4A_7C15u64 ^ (channel as u64).wrapping_add(1).wrapping_mul(0xD1B5_4A32_D192_ED03);
|
||||
Self { bits: bits.clamp(2, 32), dither, rng: if rng == 0 { 1 } else { rng } }
|
||||
}
|
||||
|
||||
/// One quantizer per channel.
|
||||
pub(crate) fn per_channel(bits: u32, dither: bool, channels: usize) -> Vec<Self> {
|
||||
(0..channels).map(|c| Self::new(bits, dither, c)).collect()
|
||||
}
|
||||
|
||||
/// Uniform in [0, 1) from a xorshift64* generator (deterministic, no OS entropy needed).
|
||||
fn uniform(&mut self) -> f64 {
|
||||
let mut x = self.rng;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.rng = x;
|
||||
(x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
pub(crate) fn quantize(&mut self, x: f32) -> i32 {
|
||||
let x = if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) };
|
||||
let scale = (1u64 << (self.bits - 1)) as f64;
|
||||
let mut v = f64::from(x) * scale;
|
||||
if self.dither {
|
||||
v += self.uniform() - self.uniform();
|
||||
}
|
||||
let max = scale - 1.0;
|
||||
v.round().clamp(-scale, max) as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp a float sample to [-1, 1], mapping NaN to 0.
|
||||
pub(crate) fn clamp_float(x: f32) -> f32 {
|
||||
if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) }
|
||||
}
|
||||
|
||||
/// Check that a buffer is encodable: at least one channel and a non-zero sample rate.
|
||||
pub(crate) fn validate_buffer(buf: &crate::AudioBuffer) -> Result<()> {
|
||||
if buf.channels.is_empty() {
|
||||
return Err(AudioError::Encode("buffer has no channels".into()));
|
||||
}
|
||||
if buf.channels.len() > usize::from(u16::MAX) {
|
||||
return Err(AudioError::Encode("too many channels".into()));
|
||||
}
|
||||
if buf.sample_rate == 0 {
|
||||
return Err(AudioError::Encode("sample rate is 0".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Multi-resolution min/max overview for waveform drawing.
|
||||
//!
|
||||
//! Level 0 holds the (min, max) of each block of [`BLOCK`] samples; each following level merges
|
||||
//! [`FANOUT`] blocks of the level below, until a level has a single block. Range queries combine
|
||||
//! the coarsest whole blocks that fit inside the range, so a query costs O(log n) regardless of
|
||||
//! zoom.
|
||||
|
||||
/// Samples per level-0 block.
|
||||
pub const BLOCK: usize = 32;
|
||||
/// Blocks of level `n` merged into one block of level `n + 1`.
|
||||
pub const FANOUT: usize = 4;
|
||||
/// Upper bound on the number of columns [`Peaks::columns`] will produce.
|
||||
pub const MAX_COLUMNS: usize = 1 << 20;
|
||||
|
||||
/// Multi-resolution min/max overview for waveform drawing. Level 0 = blocks of 32 samples; each next level merges 4.
|
||||
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Peaks {
|
||||
len: usize,
|
||||
levels: Vec<Vec<(f32, f32)>>,
|
||||
}
|
||||
|
||||
const EMPTY: (f32, f32) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||
|
||||
fn merge(a: (f32, f32), b: (f32, f32)) -> (f32, f32) {
|
||||
(a.0.min(b.0), a.1.max(b.1))
|
||||
}
|
||||
|
||||
fn finish(acc: (f32, f32)) -> (f32, f32) {
|
||||
if acc.0 > acc.1 { (0.0, 0.0) } else { acc }
|
||||
}
|
||||
|
||||
impl Peaks {
|
||||
/// Build the overview of `samples` (one channel). NaN samples are ignored.
|
||||
pub fn build(samples: &[f32]) -> Peaks {
|
||||
let mut levels = Vec::new();
|
||||
let level0: Vec<(f32, f32)> = samples.chunks(BLOCK).map(|c| finish(c.iter().fold(EMPTY, |acc, &s| merge(acc, (s, s))))).collect();
|
||||
if level0.is_empty() {
|
||||
return Peaks { len: 0, levels };
|
||||
}
|
||||
levels.push(level0);
|
||||
while let Some(last) = levels.last() {
|
||||
if last.len() <= 1 {
|
||||
break;
|
||||
}
|
||||
let next: Vec<(f32, f32)> = last.chunks(FANOUT).map(|c| c.iter().fold(EMPTY, |acc, &b| merge(acc, b))).collect();
|
||||
levels.push(next);
|
||||
}
|
||||
Peaks { len: samples.len(), levels }
|
||||
}
|
||||
|
||||
/// Number of samples the overview was built from.
|
||||
pub fn len_samples(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
/// Number of resolution levels (0 for an empty overview).
|
||||
pub fn num_levels(&self) -> usize {
|
||||
self.levels.len()
|
||||
}
|
||||
|
||||
/// (min, max) over the sample range `[start, end)`, clamped to the data. Exact when both ends
|
||||
/// are multiples of 32 (or `end` is the data length); otherwise the partial blocks at the edges
|
||||
/// contribute their whole block's extremes, so the result always contains the true range.
|
||||
/// Returns `(0.0, 0.0)` for an empty range.
|
||||
pub fn minmax(&self, start: usize, end: usize) -> (f32, f32) {
|
||||
let end = end.min(self.len);
|
||||
if start >= end {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let mut lo = start / BLOCK;
|
||||
let mut hi = end.div_ceil(BLOCK);
|
||||
let mut acc = EMPTY;
|
||||
let mut lvl = 0usize;
|
||||
while lo < hi {
|
||||
let Some(level) = self.levels.get(lvl) else { break };
|
||||
let has_parent = lvl + 1 < self.levels.len();
|
||||
if !has_parent || hi - lo < 2 * FANOUT {
|
||||
for b in level.get(lo..hi.min(level.len())).unwrap_or_default() {
|
||||
acc = merge(acc, *b);
|
||||
}
|
||||
break;
|
||||
}
|
||||
while !lo.is_multiple_of(FANOUT) && lo < hi {
|
||||
acc = merge(acc, level.get(lo).copied().unwrap_or(EMPTY));
|
||||
lo += 1;
|
||||
}
|
||||
while !hi.is_multiple_of(FANOUT) && hi > lo {
|
||||
hi -= 1;
|
||||
acc = merge(acc, level.get(hi).copied().unwrap_or(EMPTY));
|
||||
}
|
||||
lo /= FANOUT;
|
||||
hi /= FANOUT;
|
||||
lvl += 1;
|
||||
}
|
||||
finish(acc)
|
||||
}
|
||||
|
||||
/// Per-column (min, max) for drawing `cols` pixel columns spanning the sample range
|
||||
/// `[start, end)`. Columns that fall outside the data are `(0.0, 0.0)`. Each column covers at
|
||||
/// least one sample, so zoomed-in views repeat the containing sample's block.
|
||||
pub fn columns(&self, start: f64, end: f64, cols: usize) -> Vec<(f32, f32)> {
|
||||
let cols = cols.min(MAX_COLUMNS);
|
||||
let mut out = vec![(0.0, 0.0); cols];
|
||||
if cols == 0 || !start.is_finite() || !end.is_finite() || end <= start || self.len == 0 {
|
||||
return out;
|
||||
}
|
||||
let span = (end - start) / cols as f64;
|
||||
let len = self.len as f64;
|
||||
for (c, slot) in out.iter_mut().enumerate() {
|
||||
let s = start + span * c as f64;
|
||||
let e = start + span * (c + 1) as f64;
|
||||
if e <= 0.0 || s >= len {
|
||||
continue;
|
||||
}
|
||||
let si = s.max(0.0).floor();
|
||||
let ei = e.min(len).ceil().max(si + 1.0);
|
||||
*slot = self.minmax(si as usize, ei as usize);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn brute(samples: &[f32], start: usize, end: usize) -> (f32, f32) {
|
||||
let s = &samples[start..end];
|
||||
(s.iter().copied().fold(f32::INFINITY, f32::min), s.iter().copied().fold(f32::NEG_INFINITY, f32::max))
|
||||
}
|
||||
|
||||
fn signal(n: usize) -> Vec<f32> {
|
||||
(0..n).map(|i| ((i as f32 * 0.037).sin() * (i as f32 * 0.0011).cos()) + if i % 997 == 0 { 0.5 } else { 0.0 }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aligned_ranges_are_exact() {
|
||||
let x = signal(100_003);
|
||||
let p = Peaks::build(&x);
|
||||
for &(a, b) in &[(0, 32), (0, 100_003), (32, 64_000), (3200, 3232), (96, 99_968), (64, 100_003)] {
|
||||
assert_eq!(p.minmax(a, b), brute(&x, a, b), "range {a}..{b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unaligned_ranges_contain_truth_and_match_block_cover() {
|
||||
let x = signal(50_000);
|
||||
let p = Peaks::build(&x);
|
||||
let mut seed = 12345u64;
|
||||
for _ in 0..2000 {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
let a = (seed >> 33) as usize % 50_000;
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
let b = a + 1 + (seed >> 33) as usize % (50_000 - a);
|
||||
let got = p.minmax(a, b);
|
||||
let truth = brute(&x, a, b);
|
||||
assert!(got.0 <= truth.0 && got.1 >= truth.1, "{a}..{b}: {got:?} vs {truth:?}");
|
||||
let cover = brute(&x, a / BLOCK * BLOCK, (b.div_ceil(BLOCK) * BLOCK).min(x.len()));
|
||||
assert_eq!(got, cover, "{a}..{b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_inputs() {
|
||||
let p = Peaks::build(&[]);
|
||||
assert_eq!(p.len_samples(), 0);
|
||||
assert_eq!(p.minmax(0, 10), (0.0, 0.0));
|
||||
assert_eq!(p.columns(0.0, 10.0, 4), vec![(0.0, 0.0); 4]);
|
||||
let p = Peaks::build(&[0.5, -0.25]);
|
||||
assert_eq!(p.minmax(0, 2), (-0.25, 0.5));
|
||||
assert_eq!(p.minmax(5, 3), (0.0, 0.0));
|
||||
assert_eq!(p.minmax(0, usize::MAX), (-0.25, 0.5));
|
||||
assert!(p.columns(f64::NAN, 1.0, 3).iter().all(|&c| c == (0.0, 0.0)));
|
||||
assert_eq!(p.columns(0.0, 1.0, 0).len(), 0);
|
||||
let p = Peaks::build(&[f32::NAN, 1.0]);
|
||||
assert_eq!(p.minmax(0, 2), (1.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_cover_range() {
|
||||
let x = signal(10_000);
|
||||
let p = Peaks::build(&x);
|
||||
let cols = p.columns(0.0, 10_000.0, 100);
|
||||
assert_eq!(cols.len(), 100);
|
||||
for (c, &(lo, hi)) in cols.iter().enumerate() {
|
||||
let truth = brute(&x, c * 100, c * 100 + 100);
|
||||
assert!(lo <= truth.0 && hi >= truth.1);
|
||||
}
|
||||
let all = cols.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |a, &c| (a.0.min(c.0), a.1.max(c.1)));
|
||||
assert_eq!(all, brute(&x, 0, 10_000));
|
||||
// Past the end of the data.
|
||||
let cols = p.columns(9_000.0, 11_000.0, 2);
|
||||
assert_eq!(cols[1], (0.0, 0.0));
|
||||
// Zoomed in further than one sample per column.
|
||||
assert_eq!(p.columns(0.0, 4.0, 8).len(), 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Decode path for everything that isn't native PCM WAV/AIFF, backed by `symphonia`.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::{
|
||||
CODEC_TYPE_AAC, CODEC_TYPE_ADPCM_G722, CODEC_TYPE_ADPCM_G726, CODEC_TYPE_ADPCM_G726LE, CODEC_TYPE_ADPCM_IMA_QT, CODEC_TYPE_ADPCM_IMA_WAV,
|
||||
CODEC_TYPE_ADPCM_MS, CODEC_TYPE_ALAC, CODEC_TYPE_FLAC, CODEC_TYPE_MP1, CODEC_TYPE_MP2, CODEC_TYPE_MP3, CODEC_TYPE_NULL, CODEC_TYPE_OPUS,
|
||||
CODEC_TYPE_PCM_ALAW, CODEC_TYPE_PCM_F32BE, CODEC_TYPE_PCM_F32LE, CODEC_TYPE_PCM_F64BE, CODEC_TYPE_PCM_F64LE, CODEC_TYPE_PCM_MULAW,
|
||||
CODEC_TYPE_VORBIS, CodecParameters, CodecType, DecoderOptions,
|
||||
};
|
||||
use symphonia::core::errors::Error as SymError;
|
||||
use symphonia::core::formats::{FormatOptions, FormatReader};
|
||||
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions};
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
|
||||
use crate::pcm::MAX_DECODE_SAMPLES;
|
||||
use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat};
|
||||
|
||||
fn map_err(e: SymError) -> AudioError {
|
||||
match e {
|
||||
SymError::Unsupported(m) => AudioError::Unsupported(m.to_string()),
|
||||
SymError::LimitError(m) => AudioError::TooLarge(m.to_string()),
|
||||
other => AudioError::Malformed(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f`, turning a panic inside the third-party decoder into an error.
|
||||
fn guarded<T>(f: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into())))
|
||||
}
|
||||
|
||||
fn open(bytes: &[u8], ext_hint: Option<&str>) -> Result<Box<dyn FormatReader>> {
|
||||
let mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = ext_hint {
|
||||
hint.with_extension(ext.trim_start_matches('.'));
|
||||
}
|
||||
let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() };
|
||||
let probed = symphonia::default::get_probe().format(&hint, mss, &fmt_opts, &MetadataOptions::default()).map_err(map_err)?;
|
||||
Ok(probed.format)
|
||||
}
|
||||
|
||||
fn format_from_codec(codec: CodecType, detected: FileFormat) -> FileFormat {
|
||||
if detected != FileFormat::Other {
|
||||
return detected;
|
||||
}
|
||||
match codec {
|
||||
c if c == CODEC_TYPE_FLAC => FileFormat::Flac,
|
||||
c if c == CODEC_TYPE_MP3 || c == CODEC_TYPE_MP2 || c == CODEC_TYPE_MP1 => FileFormat::Mp3,
|
||||
c if c == CODEC_TYPE_AAC => FileFormat::Aac,
|
||||
c if c == CODEC_TYPE_ALAC => FileFormat::Alac,
|
||||
c if c == CODEC_TYPE_VORBIS => FileFormat::Ogg,
|
||||
_ => FileFormat::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_format_of(p: &CodecParameters) -> SampleFormat {
|
||||
let c = p.codec;
|
||||
if c == CODEC_TYPE_PCM_F32LE || c == CODEC_TYPE_PCM_F32BE {
|
||||
return SampleFormat::Float32;
|
||||
}
|
||||
if c == CODEC_TYPE_PCM_F64LE || c == CODEC_TYPE_PCM_F64BE {
|
||||
return SampleFormat::Float64;
|
||||
}
|
||||
let lossy = [
|
||||
CODEC_TYPE_MP1,
|
||||
CODEC_TYPE_MP2,
|
||||
CODEC_TYPE_MP3,
|
||||
CODEC_TYPE_AAC,
|
||||
CODEC_TYPE_VORBIS,
|
||||
CODEC_TYPE_OPUS,
|
||||
CODEC_TYPE_ADPCM_MS,
|
||||
CODEC_TYPE_ADPCM_IMA_WAV,
|
||||
CODEC_TYPE_ADPCM_IMA_QT,
|
||||
CODEC_TYPE_ADPCM_G722,
|
||||
CODEC_TYPE_ADPCM_G726,
|
||||
CODEC_TYPE_ADPCM_G726LE,
|
||||
CODEC_TYPE_PCM_ALAW,
|
||||
CODEC_TYPE_PCM_MULAW,
|
||||
];
|
||||
let lossless = !lossy.contains(&c);
|
||||
match (lossless, p.bits_per_sample) {
|
||||
(true, Some(1..=8)) => SampleFormat::Int8,
|
||||
(true, Some(9..=16)) => SampleFormat::Int16,
|
||||
(true, Some(17..=24)) => SampleFormat::Int24,
|
||||
(true, Some(25..=32)) => SampleFormat::Int32,
|
||||
_ => SampleFormat::Compressed,
|
||||
}
|
||||
}
|
||||
|
||||
fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, AudioInfo)> {
|
||||
let track = reader
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?;
|
||||
let p = &track.codec_params;
|
||||
let sample_rate = p.sample_rate.filter(|&r| r > 0).ok_or_else(|| AudioError::Malformed("unknown sample rate".into()))?;
|
||||
let channels = p.channels.map(|c| c.count()).unwrap_or(0);
|
||||
let channels = u16::try_from(channels).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
|
||||
Ok((
|
||||
track.id,
|
||||
AudioInfo {
|
||||
format: format_from_codec(p.codec, detected),
|
||||
sample_format: sample_format_of(p),
|
||||
sample_rate,
|
||||
channels,
|
||||
frames: p.n_frames.unwrap_or(0),
|
||||
bwf: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Decode the whole first audio track. When `count_only`, samples are counted but not stored.
|
||||
fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
|
||||
let mut reader = open(bytes, ext_hint)?;
|
||||
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
|
||||
if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.codec_params.n_frames) {
|
||||
let total = n.saturating_mul(u64::from(info.channels.max(1)));
|
||||
if !count_only && total > MAX_DECODE_SAMPLES {
|
||||
return Err(AudioError::TooLarge(format!("{total} samples exceeds the decode limit")));
|
||||
}
|
||||
}
|
||||
let params = reader
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.id == track_id)
|
||||
.map(|t| t.codec_params.clone())
|
||||
.ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
|
||||
let mut decoder = symphonia::default::get_codecs().make(¶ms, &DecoderOptions::default()).map_err(map_err)?;
|
||||
|
||||
let mut planar: Vec<Vec<f32>> = Vec::new();
|
||||
let mut frames: u64 = 0;
|
||||
let mut sbuf: Option<SampleBuffer<f32>> = None;
|
||||
let mut errors = 0usize;
|
||||
loop {
|
||||
let packet = match reader.next_packet() {
|
||||
Ok(p) => p,
|
||||
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
|
||||
Err(SymError::ResetRequired) => break,
|
||||
Err(e) => {
|
||||
if frames == 0 {
|
||||
return Err(map_err(e));
|
||||
}
|
||||
log::warn!("stopping decode after demux error: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
Ok(d) => d,
|
||||
Err(SymError::DecodeError(m)) => {
|
||||
errors += 1;
|
||||
log::debug!("skipping corrupt packet: {m}");
|
||||
if errors > 1000 && frames == 0 {
|
||||
return Err(AudioError::Malformed(format!("no decodable packets: {m}")));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(SymError::IoError(_)) => break,
|
||||
Err(e) => return Err(map_err(e)),
|
||||
};
|
||||
let spec = *decoded.spec();
|
||||
let ch = spec.channels.count();
|
||||
if ch == 0 {
|
||||
continue;
|
||||
}
|
||||
if planar.is_empty() {
|
||||
// The decoded stream is authoritative for the channel count.
|
||||
info.channels = u16::try_from(ch).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
|
||||
planar = (0..ch).map(|_| Vec::new()).collect();
|
||||
}
|
||||
let n = decoded.frames() as u64;
|
||||
frames = frames.saturating_add(n);
|
||||
if frames.saturating_mul(ch as u64) > MAX_DECODE_SAMPLES {
|
||||
return Err(AudioError::TooLarge("decoded audio exceeds the 4 GiB limit".into()));
|
||||
}
|
||||
if count_only {
|
||||
continue;
|
||||
}
|
||||
let needed = decoded.capacity() as u64;
|
||||
let sb = match &mut sbuf {
|
||||
Some(sb) if sb.capacity() >= decoded.capacity() * ch => sb,
|
||||
_ => sbuf.insert(SampleBuffer::<f32>::new(needed, spec)),
|
||||
};
|
||||
sb.copy_interleaved_ref(decoded);
|
||||
for frame in sb.samples().chunks_exact(ch) {
|
||||
for (dst, &s) in planar.iter_mut().zip(frame) {
|
||||
dst.push(if s.is_finite() { s } else { 0.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
if info.channels == 0 {
|
||||
return Err(AudioError::Malformed("stream contains no audio".into()));
|
||||
}
|
||||
if planar.is_empty() {
|
||||
planar = (0..usize::from(info.channels)).map(|_| Vec::new()).collect();
|
||||
}
|
||||
info.frames = if count_only { frames } else { planar.iter().map(Vec::len).min().unwrap_or(0) as u64 };
|
||||
let sample_rate = info.sample_rate;
|
||||
Ok((info, AudioBuffer { sample_rate, channels: planar }))
|
||||
}
|
||||
|
||||
pub(crate) fn probe(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat) -> Result<AudioInfo> {
|
||||
guarded(|| {
|
||||
let reader = open(bytes, ext_hint)?;
|
||||
let (_, info) = info_of(reader.as_ref(), detected)?;
|
||||
if info.frames > 0 && info.channels > 0 {
|
||||
return Ok(info);
|
||||
}
|
||||
// Length not in the header (e.g. MP3 without a Xing frame): count by decoding.
|
||||
run(bytes, ext_hint, detected, true).map(|(i, _)| i)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> {
|
||||
guarded(|| run(bytes, ext_hint, detected, false))
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Native WAV / BWF / RF64 reader and writer (RIFF chunk layout per the Microsoft multimedia
|
||||
//! specification, `bext` per EBU Tech 3285, `ds64` per EBU Tech 3306).
|
||||
|
||||
use crate::pcm::{PcmKind, Quantizer, clamp_float, deinterleave, le_u16, le_u32, le_u64, tag, validate_buffer};
|
||||
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, BwfInfo, FileFormat, Result, SampleFormat};
|
||||
|
||||
const WAVE_FORMAT_PCM: u16 = 1;
|
||||
const WAVE_FORMAT_IEEE_FLOAT: u16 = 3;
|
||||
const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE;
|
||||
/// Tail of the KSDATAFORMAT_SUBTYPE_* GUIDs (bytes 2..16); bytes 0..2 hold the format tag.
|
||||
const GUID_TAIL: [u8; 14] = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71];
|
||||
const BEXT_MIN_LEN: usize = 602;
|
||||
|
||||
/// Parsed WAV layout (no sample data decoded yet).
|
||||
pub(crate) struct WavLayout {
|
||||
pub info: AudioInfo,
|
||||
pub kind: PcmKind,
|
||||
pub data_start: usize,
|
||||
pub data_len: usize,
|
||||
}
|
||||
|
||||
/// Outcome of parsing a WAV header.
|
||||
pub(crate) enum WavParse {
|
||||
/// A PCM / float layout we decode natively.
|
||||
Native(WavLayout),
|
||||
/// A valid WAV whose codec we hand to symphonia (ADPCM, A-law, ...).
|
||||
Foreign { bwf: Option<BwfInfo> },
|
||||
}
|
||||
|
||||
struct Fmt {
|
||||
tag: u16,
|
||||
channels: u16,
|
||||
sample_rate: u32,
|
||||
block_align: u16,
|
||||
bits: u16,
|
||||
valid_bits: u16,
|
||||
}
|
||||
|
||||
fn parse_fmt(c: &[u8]) -> Result<Fmt> {
|
||||
let short = || AudioError::Malformed("fmt chunk too short".into());
|
||||
let mut fmt = Fmt {
|
||||
tag: le_u16(c, 0).ok_or_else(short)?,
|
||||
channels: le_u16(c, 2).ok_or_else(short)?,
|
||||
sample_rate: le_u32(c, 4).ok_or_else(short)?,
|
||||
block_align: le_u16(c, 12).ok_or_else(short)?,
|
||||
bits: le_u16(c, 14).unwrap_or(0),
|
||||
valid_bits: 0,
|
||||
};
|
||||
fmt.valid_bits = fmt.bits;
|
||||
if fmt.tag == WAVE_FORMAT_EXTENSIBLE {
|
||||
// cbSize(2) validBits(2) channelMask(4) subFormat GUID(16)
|
||||
if let (Some(valid), Some(sub)) = (le_u16(c, 18), le_u16(c, 24)) {
|
||||
if valid != 0 {
|
||||
fmt.valid_bits = valid;
|
||||
}
|
||||
fmt.tag = sub;
|
||||
} else {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into()));
|
||||
}
|
||||
}
|
||||
Ok(fmt)
|
||||
}
|
||||
|
||||
/// Read a NUL-padded ASCII field.
|
||||
fn fixed_str(b: &[u8]) -> String {
|
||||
let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
|
||||
String::from_utf8_lossy(b.get(..end).unwrap_or_default()).trim_end().to_string()
|
||||
}
|
||||
|
||||
fn parse_bext(c: &[u8]) -> Option<BwfInfo> {
|
||||
if c.len() < 346 {
|
||||
return None;
|
||||
}
|
||||
let lo = u64::from(le_u32(c, 338)?);
|
||||
let hi = u64::from(le_u32(c, 342)?);
|
||||
Some(BwfInfo {
|
||||
description: fixed_str(c.get(0..256)?),
|
||||
originator: fixed_str(c.get(256..288)?),
|
||||
originator_reference: fixed_str(c.get(288..320)?),
|
||||
origination_date: fixed_str(c.get(320..330)?),
|
||||
origination_time: fixed_str(c.get(330..338)?),
|
||||
time_reference: (hi << 32) | lo,
|
||||
})
|
||||
}
|
||||
|
||||
/// True when the bytes start like a RIFF/RF64/BW64 WAVE file.
|
||||
pub(crate) fn is_wav(b: &[u8]) -> bool {
|
||||
matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE")
|
||||
}
|
||||
|
||||
pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
|
||||
if !is_wav(b) {
|
||||
return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into()));
|
||||
}
|
||||
let is_rf64 = tag(b, 0).is_some_and(|t| &t != b"RIFF");
|
||||
let mut ds64_data: Option<u64> = None;
|
||||
let mut fmt: Option<Fmt> = None;
|
||||
let mut bwf: Option<BwfInfo> = None;
|
||||
let mut data: Option<(usize, usize)> = None;
|
||||
|
||||
let mut pos: usize = 12;
|
||||
// Bound the number of chunks we look at so a file of tiny chunks can't spin forever.
|
||||
let mut chunks = 0usize;
|
||||
while let (Some(id), Some(size32)) = (tag(b, pos), le_u32(b, pos + 4)) {
|
||||
chunks += 1;
|
||||
if chunks > 100_000 {
|
||||
break;
|
||||
}
|
||||
let body = pos.checked_add(8).ok_or_else(|| AudioError::Malformed("chunk offset overflow".into()))?;
|
||||
let remaining = b.len().saturating_sub(body);
|
||||
let mut size = u64::from(size32);
|
||||
if &id == b"data" && is_rf64 && size32 == u32::MAX {
|
||||
size = ds64_data.unwrap_or(remaining as u64);
|
||||
}
|
||||
let declared = usize::try_from(size).unwrap_or(usize::MAX);
|
||||
if &id == b"data" {
|
||||
// Lenient: a truncated recording (or a streaming writer's 0 / 0xFFFFFFFF size) still
|
||||
// yields whatever audio is actually present.
|
||||
let len = if size32 == 0 && !is_rf64 { remaining } else { declared.min(remaining) };
|
||||
if data.is_none() {
|
||||
data = Some((body, len));
|
||||
}
|
||||
if declared > remaining {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if declared > remaining {
|
||||
// A truncated non-audio chunk at the end of the file: stop scanning.
|
||||
break;
|
||||
}
|
||||
let c = b.get(body..body + declared).unwrap_or_default();
|
||||
match &id {
|
||||
b"fmt " => fmt = Some(parse_fmt(c)?),
|
||||
b"bext" => bwf = parse_bext(c),
|
||||
b"ds64" => ds64_data = le_u64(c, 8),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let padded = declared.saturating_add(declared & 1);
|
||||
pos = match body.checked_add(padded) {
|
||||
Some(p) => p,
|
||||
None => break,
|
||||
};
|
||||
}
|
||||
|
||||
let fmt = fmt.ok_or_else(|| AudioError::Malformed("WAV has no fmt chunk".into()))?;
|
||||
if fmt.channels == 0 {
|
||||
return Err(AudioError::Malformed("WAV declares zero channels".into()));
|
||||
}
|
||||
if fmt.sample_rate == 0 {
|
||||
return Err(AudioError::Malformed("WAV declares a zero sample rate".into()));
|
||||
}
|
||||
let channels = usize::from(fmt.channels);
|
||||
let (kind, sample_format) = match (fmt.tag, fmt.bits) {
|
||||
(WAVE_FORMAT_PCM, bits @ 1..=32) => {
|
||||
let bytes = usize::from(bits.div_ceil(8));
|
||||
let sf = match fmt.valid_bits.min(bits) {
|
||||
0..=8 => SampleFormat::Int8,
|
||||
9..=16 => SampleFormat::Int16,
|
||||
17..=24 => SampleFormat::Int24,
|
||||
_ => SampleFormat::Int32,
|
||||
};
|
||||
(PcmKind::Int { bytes, big_endian: false, unsigned: bytes == 1 }, sf)
|
||||
}
|
||||
(WAVE_FORMAT_IEEE_FLOAT, 32) => (PcmKind::Float { bytes: 4, big_endian: false }, SampleFormat::Float32),
|
||||
(WAVE_FORMAT_IEEE_FLOAT, 64) => (PcmKind::Float { bytes: 8, big_endian: false }, SampleFormat::Float64),
|
||||
(WAVE_FORMAT_PCM | WAVE_FORMAT_IEEE_FLOAT, bits) => {
|
||||
return Err(AudioError::Unsupported(format!("WAV format {} with {bits} bits per sample", fmt.tag)));
|
||||
}
|
||||
_ => return Ok(WavParse::Foreign { bwf }),
|
||||
};
|
||||
let block = kind.bytes().checked_mul(channels).ok_or_else(|| AudioError::Malformed("block size overflow".into()))?;
|
||||
if usize::from(fmt.block_align) != block {
|
||||
log::debug!("WAV block_align {} disagrees with computed {block}; using computed", fmt.block_align);
|
||||
}
|
||||
let (data_start, data_len) = data.ok_or_else(|| AudioError::Malformed("WAV has no data chunk".into()))?;
|
||||
let frames = (data_len / block) as u64;
|
||||
Ok(WavParse::Native(WavLayout {
|
||||
info: AudioInfo { format: FileFormat::Wav, sample_format, sample_rate: fmt.sample_rate, channels: fmt.channels, frames, bwf },
|
||||
kind,
|
||||
data_start,
|
||||
data_len,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn decode(b: &[u8], layout: &WavLayout) -> Result<AudioBuffer> {
|
||||
let end = layout.data_start.saturating_add(layout.data_len).min(b.len());
|
||||
let data = b.get(layout.data_start..end).unwrap_or_default();
|
||||
let channels = deinterleave(data, layout.kind, usize::from(layout.info.channels), layout.info.frames)?;
|
||||
Ok(AudioBuffer { sample_rate: layout.info.sample_rate, channels })
|
||||
}
|
||||
|
||||
fn channel_mask(channels: usize) -> u32 {
|
||||
match channels {
|
||||
1 => 0x4,
|
||||
2 => 0x3,
|
||||
3 => 0x7,
|
||||
4 => 0x33,
|
||||
5 => 0x37,
|
||||
6 => 0x3F,
|
||||
7 => 0x13F,
|
||||
8 => 0x63F,
|
||||
n if n <= 18 => (1u32 << n) - 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn put_fixed_str(out: &mut Vec<u8>, s: &str, len: usize) {
|
||||
let bytes = s.as_bytes();
|
||||
let n = bytes.len().min(len);
|
||||
out.extend_from_slice(bytes.get(..n).unwrap_or_default());
|
||||
out.resize(out.len() + (len - n), 0);
|
||||
}
|
||||
|
||||
fn bext_chunk(bwf: &BwfInfo) -> Vec<u8> {
|
||||
let mut c = Vec::with_capacity(BEXT_MIN_LEN);
|
||||
put_fixed_str(&mut c, &bwf.description, 256);
|
||||
put_fixed_str(&mut c, &bwf.originator, 32);
|
||||
put_fixed_str(&mut c, &bwf.originator_reference, 32);
|
||||
put_fixed_str(&mut c, &bwf.origination_date, 10);
|
||||
put_fixed_str(&mut c, &bwf.origination_time, 8);
|
||||
c.extend_from_slice(&((bwf.time_reference & 0xFFFF_FFFF) as u32).to_le_bytes());
|
||||
c.extend_from_slice(&((bwf.time_reference >> 32) as u32).to_le_bytes());
|
||||
c.extend_from_slice(&1u16.to_le_bytes()); // version 1
|
||||
c.resize(BEXT_MIN_LEN, 0); // UMID, loudness, reserved; no coding history
|
||||
c
|
||||
}
|
||||
|
||||
fn push_chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
|
||||
out.extend_from_slice(id);
|
||||
out.extend_from_slice(&u32::try_from(body.len()).unwrap_or(u32::MAX).to_le_bytes());
|
||||
out.extend_from_slice(body);
|
||||
if body.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a WAV file. `force_rf64` writes the RF64 layout even for small files (for tests);
|
||||
/// it is used automatically when the file would exceed 4 GiB.
|
||||
pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool, bwf: Option<&BwfInfo>, force_rf64: bool) -> Result<Vec<u8>> {
|
||||
validate_buffer(buf)?;
|
||||
let channels = buf.channels.len();
|
||||
let frames = buf.frames();
|
||||
let (bits, is_float): (u16, bool) = match depth {
|
||||
BitDepth::Int16 => (16, false),
|
||||
BitDepth::Int24 => (24, false),
|
||||
BitDepth::Int32 => (32, false),
|
||||
BitDepth::Float32 => (32, true),
|
||||
};
|
||||
let bytes = usize::from(bits / 8);
|
||||
let block = bytes.checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
|
||||
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
|
||||
let data_len = block.checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
let extensible = channels > 2 || bits > 16;
|
||||
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
|
||||
|
||||
let mut fmt = Vec::with_capacity(40);
|
||||
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
|
||||
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
|
||||
fmt.extend_from_slice(&buf.sample_rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&buf.sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
|
||||
fmt.extend_from_slice(&block_align.to_le_bytes());
|
||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||
if extensible {
|
||||
fmt.extend_from_slice(&22u16.to_le_bytes());
|
||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||
fmt.extend_from_slice(&channel_mask(channels).to_le_bytes());
|
||||
fmt.extend_from_slice(&fmt_tag.to_le_bytes());
|
||||
fmt.extend_from_slice(&GUID_TAIL);
|
||||
}
|
||||
|
||||
let mut head = Vec::with_capacity(1024);
|
||||
let rf64_body_len = 28usize;
|
||||
// Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place).
|
||||
let mut meta = Vec::new();
|
||||
if let Some(bwf) = bwf {
|
||||
push_chunk(&mut meta, b"bext", &bext_chunk(bwf));
|
||||
}
|
||||
push_chunk(&mut meta, b"fmt ", &fmt);
|
||||
if is_float {
|
||||
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
|
||||
}
|
||||
let total = 4 + (8 + rf64_body_len) + meta.len() + 8 + data_len + (data_len & 1);
|
||||
let rf64 = force_rf64 || u32::try_from(total).is_err();
|
||||
|
||||
if rf64 {
|
||||
head.extend_from_slice(b"RF64");
|
||||
head.extend_from_slice(&u32::MAX.to_le_bytes());
|
||||
head.extend_from_slice(b"WAVE");
|
||||
let mut ds64 = Vec::with_capacity(rf64_body_len);
|
||||
ds64.extend_from_slice(&(total as u64).to_le_bytes());
|
||||
ds64.extend_from_slice(&(data_len as u64).to_le_bytes());
|
||||
ds64.extend_from_slice(&(frames as u64).to_le_bytes());
|
||||
ds64.extend_from_slice(&0u32.to_le_bytes());
|
||||
push_chunk(&mut head, b"ds64", &ds64);
|
||||
} else {
|
||||
head.extend_from_slice(b"RIFF");
|
||||
head.extend_from_slice(&(total as u32).to_le_bytes());
|
||||
head.extend_from_slice(b"WAVE");
|
||||
push_chunk(&mut head, b"JUNK", &[0u8; 28]);
|
||||
}
|
||||
head.extend_from_slice(&meta);
|
||||
head.extend_from_slice(b"data");
|
||||
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
|
||||
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1))
|
||||
.map_err(|_| AudioError::TooLarge(format!("cannot allocate {data_len} bytes for WAV output")))?;
|
||||
out.extend_from_slice(&head);
|
||||
let mut qs = Quantizer::per_channel(u32::from(bits), dither, channels);
|
||||
for f in 0..frames {
|
||||
for (ch, q) in buf.channels.iter().zip(qs.iter_mut()) {
|
||||
let s = ch.get(f).copied().unwrap_or(0.0);
|
||||
if is_float {
|
||||
out.extend_from_slice(&clamp_float(s).to_le_bytes());
|
||||
} else {
|
||||
let v = q.quantize(s).to_le_bytes();
|
||||
out.extend_from_slice(v.get(..bytes).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
if data_len % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Hostile inputs must produce `Err` (or a best-effort `Ok`), never a panic.
|
||||
|
||||
use proptest::prelude::*;
|
||||
use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, probe};
|
||||
|
||||
fn small(format: FileFormat, depth: BitDepth, channels: usize) -> Vec<u8> {
|
||||
let mut b = AudioBuffer::new(44_100, channels, 300);
|
||||
for ch in &mut b.channels {
|
||||
for (i, s) in ch.iter_mut().enumerate() {
|
||||
*s = ((i as f32) * 0.1).sin() * 0.5;
|
||||
}
|
||||
}
|
||||
encode(&b, &EncodeOptions { format, bit_depth: depth, dither: false, bwf: None }).unwrap()
|
||||
}
|
||||
|
||||
/// Little helper for building RIFF files by hand.
|
||||
fn riff(chunks: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
|
||||
let mut body = b"WAVE".to_vec();
|
||||
for (id, data) in chunks {
|
||||
body.extend_from_slice(*id);
|
||||
body.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
body.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
body.push(0);
|
||||
}
|
||||
}
|
||||
let mut out = b"RIFF".to_vec();
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out
|
||||
}
|
||||
|
||||
fn fmt(tag: u16, channels: u16, sr: u32, bits: u16) -> Vec<u8> {
|
||||
let align = channels * bits.div_ceil(8);
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(&tag.to_le_bytes());
|
||||
f.extend_from_slice(&channels.to_le_bytes());
|
||||
f.extend_from_slice(&sr.to_le_bytes());
|
||||
f.extend_from_slice(&(sr * u32::from(align)).to_le_bytes());
|
||||
f.extend_from_slice(&align.to_le_bytes());
|
||||
f.extend_from_slice(&bits.to_le_bytes());
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_truncation_is_handled() {
|
||||
let files = [
|
||||
small(FileFormat::Wav, BitDepth::Int16, 2),
|
||||
small(FileFormat::Wav, BitDepth::Float32, 6),
|
||||
small(FileFormat::Aiff, BitDepth::Int24, 1),
|
||||
small(FileFormat::Aiff, BitDepth::Float32, 2),
|
||||
small(FileFormat::Flac, BitDepth::Int16, 2),
|
||||
small(FileFormat::Flac, BitDepth::Int24, 1),
|
||||
];
|
||||
for file in &files {
|
||||
for n in 0..file.len() {
|
||||
let cut = &file[..n];
|
||||
let _ = probe(cut, None);
|
||||
if let Ok((info, buf)) = decode(cut, None) {
|
||||
assert_eq!(buf.num_channels(), info.channels as usize);
|
||||
assert_eq!(buf.frames() as u64, info.frames);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_data_chunk_yields_partial_audio() {
|
||||
let file = small(FileFormat::Wav, BitDepth::Int16, 2);
|
||||
let (info, buf) = decode(&file[..file.len() - 400], None).unwrap();
|
||||
assert_eq!(info.frames, 200);
|
||||
assert_eq!(buf.frames(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_single_byte_corruption_is_handled() {
|
||||
for file in [small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Aiff, BitDepth::Int16, 2), small(FileFormat::Flac, BitDepth::Int16, 1)]
|
||||
{
|
||||
for i in 0..file.len().min(200) {
|
||||
for v in [0x00, 0xFF, 0x7F, 0x80] {
|
||||
let mut f = file.clone();
|
||||
f[i] = v;
|
||||
let _ = probe(&f, None);
|
||||
let _ = decode(&f, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_channels_is_malformed() {
|
||||
let wav = riff(&[(b"fmt ", fmt(1, 0, 44_100, 16)), (b"data", vec![0; 16])]);
|
||||
assert!(matches!(decode(&wav, None), Err(AudioError::Malformed(_))));
|
||||
assert!(probe(&wav, None).is_err());
|
||||
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
|
||||
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
aiff[comm + 8] = 0;
|
||||
aiff[comm + 9] = 0;
|
||||
assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_sample_rate_is_malformed() {
|
||||
let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]);
|
||||
assert!(decode(&wav, None).is_err());
|
||||
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
|
||||
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
for b in &mut aiff[comm + 16..comm + 26] {
|
||||
*b = 0;
|
||||
}
|
||||
assert!(decode(&aiff, None).is_err());
|
||||
// Infinite exponent.
|
||||
aiff[comm + 16] = 0x7F;
|
||||
aiff[comm + 17] = 0xFF;
|
||||
assert!(decode(&aiff, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absurd_chunk_sizes() {
|
||||
// fmt chunk claims 4 GiB.
|
||||
let mut wav = riff(&[(b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
|
||||
wav[16..20].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(decode(&wav, None).is_err());
|
||||
// data chunk claims 4 GiB: lenient read of what's there.
|
||||
let mut wav = riff(&[(b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
|
||||
let d = wav.windows(4).position(|w| w == b"data").unwrap();
|
||||
wav[d + 4..d + 8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
let (info, buf) = decode(&wav, None).unwrap();
|
||||
assert_eq!((info.frames, buf.frames()), (4, 4));
|
||||
// An unknown chunk with an absurd size before fmt.
|
||||
let mut wav = riff(&[(b"junk", vec![0; 4]), (b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
|
||||
wav[16..20].copy_from_slice(&0xFFFF_FFF0u32.to_le_bytes());
|
||||
assert!(decode(&wav, None).is_err());
|
||||
// RF64 with a ds64 claiming an exabyte of data.
|
||||
let mut ds64 = Vec::new();
|
||||
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
|
||||
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
|
||||
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
|
||||
ds64.extend_from_slice(&0u32.to_le_bytes());
|
||||
let mut rf = riff(&[(b"ds64", ds64), (b"fmt ", fmt(1, 1, 8_000, 8)), (b"data", vec![0x80; 10])]);
|
||||
rf[..4].copy_from_slice(b"RF64");
|
||||
let d = rf.windows(4).rposition(|w| w == b"data").unwrap();
|
||||
rf[d + 4..d + 8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
let (info, buf) = decode(&rf, None).unwrap();
|
||||
assert_eq!((info.frames, buf.frames()), (10, 10));
|
||||
// AIFF COMM claims more frames than present; SSND offset beyond the end.
|
||||
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
|
||||
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
aiff[comm + 10..comm + 14].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!(decode(&aiff, None).unwrap().1.frames(), 300);
|
||||
let ssnd = aiff.windows(4).position(|w| w == b"SSND").unwrap();
|
||||
aiff[ssnd + 8..ssnd + 12].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!(decode(&aiff, None).unwrap().1.frames(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_tiny_chunks_terminate() {
|
||||
let mut chunks: Vec<(&[u8; 4], Vec<u8>)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))];
|
||||
for _ in 0..200_000 {
|
||||
chunks.push((b"pad ", Vec::new()));
|
||||
}
|
||||
let wav = riff(&chunks);
|
||||
assert!(decode(&wav, None).is_err()); // no data chunk within the scan limit
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_built_wav_variants() {
|
||||
// 8-bit unsigned.
|
||||
let wav = riff(&[(b"fmt ", fmt(1, 1, 8_000, 8)), (b"data", vec![0x80, 0xFF, 0x00])]);
|
||||
let (info, buf) = decode(&wav, None).unwrap();
|
||||
assert_eq!(info.sample_format, SampleFormat::Int8);
|
||||
assert_eq!(buf.channels[0], vec![0.0, 127.0 / 128.0, -1.0]);
|
||||
// 64-bit float, with NaN replaced by silence.
|
||||
let mut data = Vec::new();
|
||||
for v in [0.25f64, -0.5, f64::NAN] {
|
||||
data.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
let wav = riff(&[(b"fmt ", fmt(3, 1, 8_000, 64)), (b"data", data)]);
|
||||
let (info, buf) = decode(&wav, None).unwrap();
|
||||
assert_eq!(info.sample_format, SampleFormat::Float64);
|
||||
assert_eq!(buf.channels[0], vec![0.25, -0.5, 0.0]);
|
||||
// Unsupported PCM width.
|
||||
let wav = riff(&[(b"fmt ", fmt(3, 1, 8_000, 16)), (b"data", vec![0; 4])]);
|
||||
assert!(matches!(decode(&wav, None), Err(AudioError::Unsupported(_))));
|
||||
// No data chunk / no fmt chunk.
|
||||
assert!(decode(&riff(&[(b"fmt ", fmt(1, 1, 8_000, 16))]), None).is_err());
|
||||
assert!(decode(&riff(&[(b"data", vec![0; 4])]), None).is_err());
|
||||
// Short fmt chunk.
|
||||
assert!(decode(&riff(&[(b"fmt ", vec![1, 0, 1]), (b"data", vec![0; 4])]), None).is_err());
|
||||
// Unknown codec tag goes to symphonia and fails cleanly.
|
||||
let wav = riff(&[(b"fmt ", fmt(0x1234, 1, 8_000, 16)), (b"data", vec![0; 4])]);
|
||||
assert!(decode(&wav, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiff_sowt_little_endian() {
|
||||
let be = small(FileFormat::Aiff, BitDepth::Int16, 2);
|
||||
let (_, expect) = decode(&be, None).unwrap();
|
||||
// Rebuild as AIFC/sowt by hand.
|
||||
let comm = be.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
let ssnd = be.windows(4).position(|w| w == b"SSND").unwrap();
|
||||
let mut comm_body = be[comm + 8..comm + 26].to_vec();
|
||||
comm_body.extend_from_slice(b"sowt");
|
||||
comm_body.extend_from_slice(&[0, 0]); // empty pstring, padded
|
||||
let mut ssnd_body = be[ssnd + 8..].to_vec();
|
||||
for pair in ssnd_body[8..].as_chunks_mut::<2>().0 {
|
||||
pair.swap(0, 1);
|
||||
}
|
||||
let mut body = b"AIFC".to_vec();
|
||||
for (id, data) in [(b"COMM", &comm_body), (b"SSND", &ssnd_body)] {
|
||||
body.extend_from_slice(id);
|
||||
body.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
body.extend_from_slice(data);
|
||||
}
|
||||
let mut f = b"FORM".to_vec();
|
||||
f.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
f.extend_from_slice(&body);
|
||||
let (info, got) = decode(&f, None).unwrap();
|
||||
assert_eq!(info.sample_format, SampleFormat::Int16);
|
||||
assert_eq!(got, expect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_format_by_magic_and_hint() {
|
||||
assert_eq!(detect_format(&small(FileFormat::Wav, BitDepth::Int16, 1), Some("mp3")), FileFormat::Wav);
|
||||
assert_eq!(detect_format(&small(FileFormat::Aiff, BitDepth::Int16, 1), None), FileFormat::Aiff);
|
||||
assert_eq!(detect_format(&small(FileFormat::Flac, BitDepth::Int16, 1), None), FileFormat::Flac);
|
||||
assert_eq!(detect_format(b"OggS\0\x02rest", None), FileFormat::Ogg);
|
||||
assert_eq!(detect_format(b"caff\0\x01\0\0", None), FileFormat::Caf);
|
||||
assert_eq!(detect_format(b"ID3\x04\0\0\0\0\0\x00", None), FileFormat::Mp3);
|
||||
let mut id3_flac = b"ID3\x04\0\0\0\0\0\x02xx".to_vec();
|
||||
id3_flac.extend_from_slice(b"fLaC");
|
||||
assert_eq!(detect_format(&id3_flac, None), FileFormat::Flac);
|
||||
assert_eq!(detect_format(&[0xFF, 0xFB, 0x90, 0x00], None), FileFormat::Mp3);
|
||||
assert_eq!(detect_format(&[0xFF, 0xF1, 0x50, 0x80], None), FileFormat::Aac);
|
||||
let mut m4a = b"\0\0\0\x20ftypM4A ".to_vec();
|
||||
m4a.extend_from_slice(b"....stsd\0\0\0\0\0\0\0\x01\0\0\0\x24alac");
|
||||
assert_eq!(detect_format(&m4a, None), FileFormat::Alac);
|
||||
assert_eq!(detect_format(b"\0\0\0\x20ftypM4A ", None), FileFormat::Aac);
|
||||
assert_eq!(detect_format(b"", Some(".WAV")), FileFormat::Wav);
|
||||
assert_eq!(detect_format(b"zz", Some("aif")), FileFormat::Aiff);
|
||||
assert_eq!(detect_format(b"zz", Some("m4a")), FileFormat::Aac);
|
||||
assert_eq!(detect_format(b"zz", Some("flac")), FileFormat::Flac);
|
||||
assert_eq!(detect_format(b"zz", Some("xyz")), FileFormat::Other);
|
||||
assert_eq!(detect_format(b"zz", None), FileFormat::Other);
|
||||
assert_eq!(detect_format(b"", None), FileFormat::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_in_compressed_containers_errors() {
|
||||
let garbage: Vec<u8> = (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
|
||||
for hint in [None, Some("mp3"), Some("ogg"), Some("flac"), Some("m4a"), Some("caf"), Some("aac"), Some("mkv"), Some("wav"), Some("aiff")] {
|
||||
assert!(decode(&garbage, hint).is_err(), "{hint:?}");
|
||||
assert!(probe(&garbage, hint).is_err(), "{hint:?}");
|
||||
}
|
||||
for magic in [&b"fLaC"[..], b"OggS", b"caff", b"ID3\x03\0\0\0\0\0\0", &[0xFF, 0xFB, 0x90, 0x00], b"\0\0\0\x20ftypM4A "] {
|
||||
let mut f = magic.to_vec();
|
||||
f.extend_from_slice(&garbage);
|
||||
let _ = probe(&f, None);
|
||||
assert!(decode(&f, None).map(|(_, b)| b.frames()).unwrap_or(0) < 1_000_000);
|
||||
}
|
||||
assert!(decode(&[], None).is_err());
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
|
||||
|
||||
#[test]
|
||||
fn random_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..2048), hint in 0usize..6) {
|
||||
let hint = [None, Some("wav"), Some("aiff"), Some("flac"), Some("mp3"), Some("ogg")][hint];
|
||||
let _ = detect_format(&bytes, hint);
|
||||
let _ = probe(&bytes, hint);
|
||||
let _ = decode(&bytes, hint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bodies_behind_valid_headers_never_panic(body in proptest::collection::vec(any::<u8>(), 0..1024), which in 0usize..4) {
|
||||
let mut f = match which {
|
||||
0 => b"RIFF\xff\xff\xff\xffWAVE".to_vec(),
|
||||
1 => b"RF64\xff\xff\xff\xffWAVE".to_vec(),
|
||||
2 => b"FORM\xff\xff\xff\xffAIFC".to_vec(),
|
||||
_ => b"FORM\xff\xff\xff\xffAIFF".to_vec(),
|
||||
};
|
||||
f.extend_from_slice(&body);
|
||||
let _ = probe(&f, None);
|
||||
let _ = decode(&f, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_audio_roundtrips_through_flac(samples in proptest::collection::vec(-1.2f32..1.2, 1..3000), channels in 1usize..4) {
|
||||
let buf = AudioBuffer::from_interleaved(48_000, channels, &samples);
|
||||
prop_assume!(buf.frames() > 0);
|
||||
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int24, dither: false, bwf: None };
|
||||
let (_, flac) = decode(&encode(&buf, &opts).unwrap(), None).unwrap();
|
||||
let (_, wav) = decode(&encode(&buf, &EncodeOptions { format: FileFormat::Wav, ..opts }).unwrap(), None).unwrap();
|
||||
prop_assert_eq!(flac, wav);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
use soundcraft_audio_io::{
|
||||
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe,
|
||||
};
|
||||
|
||||
fn sine(sr: u32, channels: usize, frames: usize) -> AudioBuffer {
|
||||
let mut b = AudioBuffer::new(sr, channels, frames);
|
||||
for (c, ch) in b.channels.iter_mut().enumerate() {
|
||||
let f = 220.0 * (c as f32 + 1.0);
|
||||
for (i, s) in ch.iter_mut().enumerate() {
|
||||
*s = 0.8 * (2.0 * std::f32::consts::PI * f * i as f32 / sr as f32 + c as f32).sin();
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn tolerance(depth: BitDepth, dither: bool) -> f32 {
|
||||
let lsb = match depth {
|
||||
BitDepth::Int16 => 1.0 / 32768.0,
|
||||
BitDepth::Int24 => 1.0 / 8_388_608.0,
|
||||
BitDepth::Int32 => 1e-7,
|
||||
BitDepth::Float32 => 0.0,
|
||||
};
|
||||
lsb * if dither { 1.6 } else { 0.6 } + 1e-7
|
||||
}
|
||||
|
||||
fn max_err(a: &AudioBuffer, b: &AudioBuffer) -> f32 {
|
||||
assert_eq!(a.num_channels(), b.num_channels());
|
||||
assert_eq!(a.frames(), b.frames());
|
||||
a.channels.iter().zip(&b.channels).flat_map(|(x, y)| x.iter().zip(y).map(|(p, q)| (p - q).abs())).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
const DEPTHS: [BitDepth; 4] = [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32, BitDepth::Float32];
|
||||
|
||||
fn expected_sf(d: BitDepth) -> SampleFormat {
|
||||
match d {
|
||||
BitDepth::Int16 => SampleFormat::Int16,
|
||||
BitDepth::Int24 => SampleFormat::Int24,
|
||||
BitDepth::Int32 => SampleFormat::Int32,
|
||||
BitDepth::Float32 => SampleFormat::Float32,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_and_aiff_roundtrip_all_depths_and_layouts() {
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff] {
|
||||
for depth in DEPTHS {
|
||||
for channels in [1, 2, 6] {
|
||||
for dither in [false, true] {
|
||||
let src = sine(48_000, channels, 4801);
|
||||
let opts = EncodeOptions { format, bit_depth: depth, dither, bwf: None };
|
||||
let bytes = encode(&src, &opts).unwrap();
|
||||
assert_eq!(detect_format(&bytes, None), format);
|
||||
let info = probe(&bytes, None).unwrap();
|
||||
assert_eq!(info.format, format);
|
||||
assert_eq!(info.sample_rate, 48_000);
|
||||
assert_eq!(info.channels as usize, channels);
|
||||
assert_eq!(info.frames, 4801);
|
||||
assert_eq!(info.sample_format, expected_sf(depth));
|
||||
let (dinfo, out) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(dinfo, info);
|
||||
assert_eq!(out.sample_rate, 48_000);
|
||||
let err = max_err(&src, &out);
|
||||
assert!(err <= tolerance(depth, dither), "{format:?} {depth:?} {channels}ch dither={dither}: err {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_roundtrip_is_lossless_vs_wav() {
|
||||
for depth in [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32] {
|
||||
for channels in [1, 2, 6] {
|
||||
// Spans several blocks plus a short final block.
|
||||
let src = sine(44_100, channels, 4096 * 2 + 17);
|
||||
let wav = encode(&src, &EncodeOptions { format: FileFormat::Wav, bit_depth: depth, dither: false, bwf: None }).unwrap();
|
||||
let flac = encode(&src, &EncodeOptions { format: FileFormat::Flac, bit_depth: depth, dither: false, bwf: None }).unwrap();
|
||||
assert_eq!(detect_format(&flac, None), FileFormat::Flac);
|
||||
let info = probe(&flac, None).unwrap();
|
||||
assert_eq!(info.format, FileFormat::Flac);
|
||||
assert_eq!((info.sample_rate, info.channels as usize, info.frames), (44_100, channels, src.frames() as u64));
|
||||
assert_eq!(info.sample_format, expected_sf(depth));
|
||||
let (_, from_wav) = decode(&wav, None).unwrap();
|
||||
let (finfo, from_flac) = decode(&flac, None).unwrap();
|
||||
assert_eq!(finfo.frames, src.frames() as u64);
|
||||
assert_eq!(from_flac, from_wav, "{depth:?} {channels}ch");
|
||||
if depth != BitDepth::Int32 {
|
||||
assert!(flac.len() < wav.len(), "{depth:?} {channels}ch: flac {} >= wav {}", flac.len(), wav.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_handles_silence_noise_full_scale_and_odd_rates() {
|
||||
let frames = 10_000;
|
||||
let mut b = AudioBuffer::new(37_123, 3, frames);
|
||||
let mut seed = 1u32;
|
||||
for i in 0..frames {
|
||||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
b.channels[1][i] = (seed as f32 / u32::MAX as f32) * 2.0 - 1.0; // white noise
|
||||
b.channels[2][i] = if i % 2 == 0 { 1.5 } else { -1.5 }; // clipped full-scale square
|
||||
}
|
||||
for depth in [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32] {
|
||||
for dither in [false, true] {
|
||||
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: depth, dither, bwf: None };
|
||||
let flac = encode(&b, &opts).unwrap();
|
||||
let (info, out) = decode(&flac, None).unwrap();
|
||||
assert_eq!(info.sample_rate, 37_123);
|
||||
let wav = encode(&b, &EncodeOptions { format: FileFormat::Wav, ..opts }).unwrap();
|
||||
let (_, expect) = decode(&wav, None).unwrap();
|
||||
assert_eq!(out, expect, "{depth:?} dither={dither}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_rejects_unsupported_layouts() {
|
||||
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
assert!(encode(&sine(48_000, 2, 10), &opts).is_err());
|
||||
let opts = EncodeOptions { bit_depth: BitDepth::Int16, ..opts };
|
||||
assert!(encode(&sine(48_000, 9, 10), &opts).is_err());
|
||||
assert!(encode(&sine(700_000, 1, 10), &opts).is_err());
|
||||
// Tiny buffers still encode and decode.
|
||||
let one = sine(48_000, 2, 1);
|
||||
let (_, out) = decode(&encode(&one, &opts).unwrap(), None).unwrap();
|
||||
assert_eq!(out.frames(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bwf_roundtrip() {
|
||||
let bwf = BwfInfo {
|
||||
description: "Scene 12 take 3".into(),
|
||||
originator: "SoundCraft".into(),
|
||||
originator_reference: "SC-0001".into(),
|
||||
origination_date: "2026-10-06".into(),
|
||||
origination_time: "12:34:56".into(),
|
||||
time_reference: 0x1_2345_6789, // > 32 bits
|
||||
};
|
||||
let src = sine(96_000, 2, 1000);
|
||||
for depth in DEPTHS {
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: depth, dither: false, bwf: Some(bwf.clone()) };
|
||||
let bytes = encode(&src, &opts).unwrap();
|
||||
let info = probe(&bytes, None).unwrap();
|
||||
assert_eq!(info.bwf.as_ref(), Some(&bwf));
|
||||
let (dinfo, _) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(dinfo.bwf, Some(bwf.clone()));
|
||||
// RF64 layout reads back identically.
|
||||
let rf64 = encode_wav_rf64(&src, &opts).unwrap();
|
||||
assert_eq!(&rf64[..4], b"RF64");
|
||||
let (rinfo, rbuf) = decode(&rf64, None).unwrap();
|
||||
assert_eq!(rinfo.bwf, Some(bwf.clone()));
|
||||
assert_eq!(rinfo.frames, 1000);
|
||||
assert_eq!(rbuf, decode(&bytes, None).unwrap().1);
|
||||
}
|
||||
// Overlong strings are truncated to the field width, not an error.
|
||||
let long = BwfInfo { description: "x".repeat(400), ..BwfInfo::default() };
|
||||
let bytes = encode(&src, &EncodeOptions { bwf: Some(long), ..EncodeOptions::default() }).unwrap();
|
||||
assert_eq!(probe(&bytes, None).unwrap().bwf.unwrap().description.len(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_header_layout() {
|
||||
let b = encode(&sine(44_100, 2, 10), &EncodeOptions { bit_depth: BitDepth::Int16, ..EncodeOptions::default() }).unwrap();
|
||||
// Plain PCM for 16-bit stereo; extensible otherwise.
|
||||
let fmt = b.windows(4).position(|w| w == b"fmt ").unwrap();
|
||||
assert_eq!(u16::from_le_bytes([b[fmt + 8], b[fmt + 9]]), 1);
|
||||
let b = encode(&sine(44_100, 2, 10), &EncodeOptions::default()).unwrap();
|
||||
let fmt = b.windows(4).position(|w| w == b"fmt ").unwrap();
|
||||
assert_eq!(u16::from_le_bytes([b[fmt + 8], b[fmt + 9]]), 0xFFFE);
|
||||
let riff = u32::from_le_bytes([b[4], b[5], b[6], b[7]]) as usize;
|
||||
assert_eq!(riff + 8, b.len());
|
||||
// Odd data length (24-bit mono, odd frames) gets a pad byte.
|
||||
let b = encode(&sine(44_100, 1, 3), &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(b.len() % 2, 0);
|
||||
assert_eq!(decode(&b, None).unwrap().1.frames(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiff_float_is_aifc() {
|
||||
let b =
|
||||
encode(&sine(44_100, 2, 10), &EncodeOptions { format: FileFormat::Aiff, bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap();
|
||||
assert_eq!(&b[8..12], b"AIFC");
|
||||
assert!(b.windows(4).any(|w| w == b"fl32"));
|
||||
let b = encode(&sine(44_100, 2, 10), &EncodeOptions { format: FileFormat::Aiff, ..EncodeOptions::default() }).unwrap();
|
||||
assert_eq!(&b[8..12], b"AIFF");
|
||||
let form = u32::from_be_bytes([b[4], b[5], b[6], b[7]]) as usize;
|
||||
assert_eq!(form + 8, b.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusual_sample_rates_roundtrip() {
|
||||
for sr in [1, 8_000, 11_025, 22_050, 44_100, 88_200, 176_400, 192_000, 352_800, 384_000, 768_000] {
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff] {
|
||||
let bytes = encode(&sine(sr, 1, 8), &EncodeOptions { format, ..EncodeOptions::default() }).unwrap();
|
||||
assert_eq!(probe(&bytes, None).unwrap().sample_rate, sr, "{format:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_rejects_bad_buffers_and_formats() {
|
||||
assert!(encode(&AudioBuffer::default(), &EncodeOptions::default()).is_err());
|
||||
assert!(encode(&AudioBuffer::new(0, 2, 10), &EncodeOptions::default()).is_err());
|
||||
for f in [FileFormat::Mp3, FileFormat::Ogg, FileFormat::Aac, FileFormat::Alac, FileFormat::Caf, FileFormat::Other] {
|
||||
assert!(encode(&sine(48_000, 1, 10), &EncodeOptions { format: f, ..EncodeOptions::default() }).is_err());
|
||||
}
|
||||
// Non-finite and out-of-range samples are clamped, not errors.
|
||||
let mut b = AudioBuffer::new(48_000, 1, 4);
|
||||
b.channels[0] = vec![f32::NAN, f32::INFINITY, -5.0, 0.5];
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||
for depth in DEPTHS {
|
||||
if format == FileFormat::Flac && depth == BitDepth::Float32 {
|
||||
continue;
|
||||
}
|
||||
let bytes = encode(&b, &EncodeOptions { format, bit_depth: depth, ..EncodeOptions::default() }).unwrap();
|
||||
let (_, out) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(out.channels[0][0], 0.0);
|
||||
assert!(out.channels[0][1] > 0.99 && out.channels[0][1] <= 1.0);
|
||||
assert_eq!(out.channels[0][2], -1.0);
|
||||
}
|
||||
}
|
||||
// Ragged buffers encode the common length.
|
||||
let ragged = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.1; 10], vec![0.2; 7]] };
|
||||
let (_, out) = decode(&encode(&ragged, &EncodeOptions::default()).unwrap(), None).unwrap();
|
||||
assert_eq!(out.frames(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_buffer_helpers() {
|
||||
let b = AudioBuffer::from_interleaved(48_000, 2, &[0.1, -0.2, 0.3, -0.4, 0.5]);
|
||||
assert_eq!(b.frames(), 2);
|
||||
assert_eq!(b.num_channels(), 2);
|
||||
assert_eq!(b.interleaved(), vec![0.1, -0.2, 0.3, -0.4]);
|
||||
assert!((b.peak() - 0.4).abs() < 1e-7);
|
||||
assert!((b.duration_secs() - 2.0 / 48_000.0).abs() < 1e-12);
|
||||
assert_eq!(AudioBuffer::from_interleaved(1, 0, &[1.0]).num_channels(), 0);
|
||||
assert_eq!(AudioBuffer::new(0, 1, 5).duration_secs(), 0.0);
|
||||
assert_eq!(AudioBuffer::default().frames(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_types_roundtrip() {
|
||||
let opts = EncodeOptions { bwf: Some(BwfInfo { time_reference: 42, ..BwfInfo::default() }), ..EncodeOptions::default() };
|
||||
let s = serde_json::to_string(&opts).unwrap();
|
||||
assert_eq!(serde_json::from_str::<EncodeOptions>(&s).unwrap(), opts);
|
||||
let info = probe(&encode(&sine(48_000, 1, 4), &opts).unwrap(), None).unwrap();
|
||||
let s = serde_json::to_string(&info).unwrap();
|
||||
assert_eq!(serde_json::from_str::<soundcraft_audio_io::AudioInfo>(&s).unwrap(), info);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "soundcraft-dsp"
|
||||
description = "SoundCraft DSP: realtime plugins (EQ, dynamics, reverb, delay, modulation, instruments), meters, loudness, and offline AudioSuite-style processing"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
log.workspace = true
|
||||
rustfft.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Second-order IIR filters designed from the RBJ "Audio EQ Cookbook" formulas, plus first-order
|
||||
//! low/high-pass sections (bilinear transform) for 6 dB/oct slopes.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::util::{flush, sane_sr};
|
||||
|
||||
/// Filter response shapes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FilterType {
|
||||
LowPass,
|
||||
HighPass,
|
||||
BandPass,
|
||||
Notch,
|
||||
Peak,
|
||||
LowShelf,
|
||||
HighShelf,
|
||||
AllPass,
|
||||
/// First-order (6 dB/oct) low-pass; `q` and `gain_db` are ignored.
|
||||
LowPass1,
|
||||
/// First-order (6 dB/oct) high-pass; `q` and `gain_db` are ignored.
|
||||
HighPass1,
|
||||
}
|
||||
|
||||
/// Normalized biquad coefficients (`a0 = 1`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Coeffs {
|
||||
pub b0: f32,
|
||||
pub b1: f32,
|
||||
pub b2: f32,
|
||||
pub a1: f32,
|
||||
pub a2: f32,
|
||||
}
|
||||
|
||||
impl Default for Coeffs {
|
||||
fn default() -> Self {
|
||||
Self::IDENTITY
|
||||
}
|
||||
}
|
||||
|
||||
impl Coeffs {
|
||||
/// Passes the signal unchanged.
|
||||
pub const IDENTITY: Coeffs = Coeffs { b0: 1.0, b1: 0.0, b2: 0.0, a1: 0.0, a2: 0.0 };
|
||||
|
||||
/// Designs a filter. All inputs are sanitized: frequency is clamped to 1 Hz .. 0.49·sr,
|
||||
/// Q to 0.025 .. 100 and gain to ±48 dB.
|
||||
pub fn design(kind: FilterType, freq: f32, q: f32, gain_db: f32, sample_rate: f32) -> Coeffs {
|
||||
let sr = f64::from(sane_sr(sample_rate));
|
||||
let f = if freq.is_finite() { f64::from(freq).clamp(1.0, sr * 0.49) } else { 1000.0 };
|
||||
let q = if q.is_finite() { f64::from(q).clamp(0.025, 100.0) } else { std::f64::consts::FRAC_1_SQRT_2 };
|
||||
let g = if gain_db.is_finite() { f64::from(gain_db).clamp(-48.0, 48.0) } else { 0.0 };
|
||||
let w0 = 2.0 * PI * f / sr;
|
||||
let (sw, cw) = w0.sin_cos();
|
||||
let alpha = sw / (2.0 * q);
|
||||
let a = 10f64.powf(g / 40.0);
|
||||
let (b0, b1, b2, a0, a1, a2) = match kind {
|
||||
FilterType::LowPass => ((1.0 - cw) / 2.0, 1.0 - cw, (1.0 - cw) / 2.0, 1.0 + alpha, -2.0 * cw, 1.0 - alpha),
|
||||
FilterType::HighPass => ((1.0 + cw) / 2.0, -(1.0 + cw), (1.0 + cw) / 2.0, 1.0 + alpha, -2.0 * cw, 1.0 - alpha),
|
||||
FilterType::BandPass => (alpha, 0.0, -alpha, 1.0 + alpha, -2.0 * cw, 1.0 - alpha),
|
||||
FilterType::Notch => (1.0, -2.0 * cw, 1.0, 1.0 + alpha, -2.0 * cw, 1.0 - alpha),
|
||||
FilterType::AllPass => (1.0 - alpha, -2.0 * cw, 1.0 + alpha, 1.0 + alpha, -2.0 * cw, 1.0 - alpha),
|
||||
FilterType::Peak => (1.0 + alpha * a, -2.0 * cw, 1.0 - alpha * a, 1.0 + alpha / a, -2.0 * cw, 1.0 - alpha / a),
|
||||
FilterType::LowShelf => {
|
||||
let s = 2.0 * a.sqrt() * alpha;
|
||||
(
|
||||
a * ((a + 1.0) - (a - 1.0) * cw + s),
|
||||
2.0 * a * ((a - 1.0) - (a + 1.0) * cw),
|
||||
a * ((a + 1.0) - (a - 1.0) * cw - s),
|
||||
(a + 1.0) + (a - 1.0) * cw + s,
|
||||
-2.0 * ((a - 1.0) + (a + 1.0) * cw),
|
||||
(a + 1.0) + (a - 1.0) * cw - s,
|
||||
)
|
||||
}
|
||||
FilterType::HighShelf => {
|
||||
let s = 2.0 * a.sqrt() * alpha;
|
||||
(
|
||||
a * ((a + 1.0) + (a - 1.0) * cw + s),
|
||||
-2.0 * a * ((a - 1.0) + (a + 1.0) * cw),
|
||||
a * ((a + 1.0) + (a - 1.0) * cw - s),
|
||||
(a + 1.0) - (a - 1.0) * cw + s,
|
||||
2.0 * ((a - 1.0) - (a + 1.0) * cw),
|
||||
(a + 1.0) - (a - 1.0) * cw - s,
|
||||
)
|
||||
}
|
||||
FilterType::LowPass1 | FilterType::HighPass1 => {
|
||||
// Bilinear transform of 1/(s+1) with prewarped cutoff.
|
||||
let k = (w0 / 2.0).tan();
|
||||
let norm = 1.0 / (1.0 + k);
|
||||
let a1 = (k - 1.0) * norm;
|
||||
return if kind == FilterType::LowPass1 {
|
||||
Coeffs { b0: (k * norm) as f32, b1: (k * norm) as f32, b2: 0.0, a1: a1 as f32, a2: 0.0 }
|
||||
} else {
|
||||
Coeffs { b0: norm as f32, b1: -norm as f32, b2: 0.0, a1: a1 as f32, a2: 0.0 }
|
||||
};
|
||||
}
|
||||
};
|
||||
let c = Coeffs { b0: (b0 / a0) as f32, b1: (b1 / a0) as f32, b2: (b2 / a0) as f32, a1: (a1 / a0) as f32, a2: (a2 / a0) as f32 };
|
||||
if [c.b0, c.b1, c.b2, c.a1, c.a2].iter().all(|v| v.is_finite()) { c } else { Coeffs::IDENTITY }
|
||||
}
|
||||
|
||||
/// Magnitude response in dB at `freq` Hz.
|
||||
pub fn magnitude_db(&self, freq: f32, sample_rate: f32) -> f32 {
|
||||
let sr = f64::from(sane_sr(sample_rate));
|
||||
let f = if freq.is_finite() { f64::from(freq).clamp(0.0, sr * 0.5) } else { 0.0 };
|
||||
let w = 2.0 * PI * f / sr;
|
||||
let (s1, c1) = w.sin_cos();
|
||||
let (s2, c2) = (2.0 * w).sin_cos();
|
||||
let (b0, b1, b2, a1, a2) = (f64::from(self.b0), f64::from(self.b1), f64::from(self.b2), f64::from(self.a1), f64::from(self.a2));
|
||||
let nr = b0 + b1 * c1 + b2 * c2;
|
||||
let ni = -(b1 * s1 + b2 * s2);
|
||||
let dr = 1.0 + a1 * c1 + a2 * c2;
|
||||
let di = -(a1 * s1 + a2 * s2);
|
||||
let num = nr * nr + ni * ni;
|
||||
let den = (dr * dr + di * di).max(1e-30);
|
||||
let db = 10.0 * (num / den).max(1e-30).log10();
|
||||
db.max(-300.0) as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// A biquad section with its own state (transposed direct form II).
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct Biquad {
|
||||
pub coeffs: Coeffs,
|
||||
z1: f32,
|
||||
z2: f32,
|
||||
}
|
||||
|
||||
impl Biquad {
|
||||
pub fn new(coeffs: Coeffs) -> Self {
|
||||
Self { coeffs, z1: 0.0, z2: 0.0 }
|
||||
}
|
||||
|
||||
/// Designs a new filter (see [`Coeffs::design`]).
|
||||
pub fn design(kind: FilterType, freq: f32, q: f32, gain_db: f32, sample_rate: f32) -> Self {
|
||||
Self::new(Coeffs::design(kind, freq, q, gain_db, sample_rate))
|
||||
}
|
||||
|
||||
/// Re-designs the coefficients, keeping the state (for smooth sweeps).
|
||||
pub fn set(&mut self, kind: FilterType, freq: f32, q: f32, gain_db: f32, sample_rate: f32) {
|
||||
self.coeffs = Coeffs::design(kind, freq, q, gain_db, sample_rate);
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.z1 = 0.0;
|
||||
self.z2 = 0.0;
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn process_sample(&mut self, x: f32) -> f32 {
|
||||
let c = &self.coeffs;
|
||||
let y = c.b0 * x + self.z1;
|
||||
self.z1 = flush(c.b1 * x - c.a1 * y + self.z2);
|
||||
self.z2 = flush(c.b2 * x - c.a2 * y);
|
||||
if y.is_finite() {
|
||||
y
|
||||
} else {
|
||||
self.reset();
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process_block(&mut self, buf: &mut [f32]) {
|
||||
for s in buf {
|
||||
*s = self.process_sample(*s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Magnitude response in dB at `freq` Hz, for drawing curves.
|
||||
pub fn magnitude_db(&self, freq: f32, sample_rate: f32) -> f32 {
|
||||
self.coeffs.magnitude_db(freq, sample_rate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Butterworth Q values for cascaded sections making an `order`-th order filter (2, 4).
|
||||
pub(crate) fn butterworth_qs(order: usize) -> &'static [f32] {
|
||||
match order {
|
||||
4 => &[0.541_196_1, 1.306_563],
|
||||
_ => &[std::f32::consts::FRAC_1_SQRT_2],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn peak_gain_at_center() {
|
||||
let c = Coeffs::design(FilterType::Peak, 1000.0, 1.0, 6.0, 48_000.0);
|
||||
assert!((c.magnitude_db(1000.0, 48_000.0) - 6.0).abs() < 0.01);
|
||||
assert!(c.magnitude_db(20.0, 48_000.0).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowpass_minus3_at_cutoff() {
|
||||
let c = Coeffs::design(FilterType::LowPass, 1000.0, std::f32::consts::FRAC_1_SQRT_2, 0.0, 48_000.0);
|
||||
assert!((c.magnitude_db(1000.0, 48_000.0) + 3.01).abs() < 0.05);
|
||||
let c1 = Coeffs::design(FilterType::HighPass1, 1000.0, 0.0, 0.0, 48_000.0);
|
||||
assert!((c1.magnitude_db(1000.0, 48_000.0) + 3.01).abs() < 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_inputs() {
|
||||
let c = Coeffs::design(FilterType::Peak, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0);
|
||||
assert!(c.b0.is_finite());
|
||||
let mut b = Biquad::new(c);
|
||||
assert!(b.process_sample(f32::NAN) == 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
//! SoundCraft DSP.
|
||||
//!
|
||||
//! Realtime plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utilities and two
|
||||
//! instruments), metering (peak, RMS, ITU-R BS.1770 loudness), spectrum analysis and offline
|
||||
//! AudioSuite-style whole-buffer processing.
|
||||
//!
|
||||
//! Every algorithm here is an original implementation of textbook DSP (RBJ cookbook biquads,
|
||||
//! feed-forward log-domain dynamics, feedback-delay-network reverbs, WSOLA, windowed-sinc
|
||||
//! resampling). Plugins never panic on any parameter value: NaN falls back to the default and
|
||||
//! infinities clamp to the range. `Plugin::process` never allocates; buffers are sized in
|
||||
//! `Plugin::prepare`.
|
||||
//!
|
||||
//! Audio is planar `f32`: `io[channel][frame]`.
|
||||
|
||||
pub mod biquad;
|
||||
pub mod meter;
|
||||
pub mod offline;
|
||||
mod osc;
|
||||
pub mod pan;
|
||||
mod params;
|
||||
pub mod plugins;
|
||||
pub mod spectrum;
|
||||
mod util;
|
||||
|
||||
pub use offline::FadeShape;
|
||||
pub use osc::Waveform;
|
||||
pub use plugins::eq::{eq1_response, eq7_response};
|
||||
|
||||
/// Plugin category, used to group the insert menu.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Category {
|
||||
Eq,
|
||||
Dynamics,
|
||||
PitchShift,
|
||||
Reverb,
|
||||
Delay,
|
||||
Modulation,
|
||||
Harmonic,
|
||||
Dither,
|
||||
Instrument,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Display unit of a parameter.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
|
||||
pub enum Unit {
|
||||
None,
|
||||
Db,
|
||||
Hz,
|
||||
Ms,
|
||||
Percent,
|
||||
Ratio,
|
||||
Semitones,
|
||||
Cents,
|
||||
Seconds,
|
||||
Toggle,
|
||||
Choice,
|
||||
}
|
||||
|
||||
/// How a control maps its 0..1 travel onto the parameter range.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
|
||||
pub enum Taper {
|
||||
Linear,
|
||||
Log,
|
||||
}
|
||||
|
||||
/// Static description of one plugin parameter.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
|
||||
pub struct ParamInfo {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub min: f32,
|
||||
pub max: f32,
|
||||
pub default: f32,
|
||||
pub unit: Unit,
|
||||
pub taper: Taper,
|
||||
/// Labels for `Unit::Choice` parameters (value is the index).
|
||||
pub choices: &'static [&'static str],
|
||||
}
|
||||
|
||||
impl ParamInfo {
|
||||
/// Clamps any value (including NaN/inf) into this parameter's legal range.
|
||||
/// NaN becomes the default; toggles and choices round to whole numbers.
|
||||
pub fn clamp(&self, value: f32) -> f32 {
|
||||
let v = if value.is_nan() { self.default } else { value };
|
||||
let v = v.clamp(self.min, self.max);
|
||||
match self.unit {
|
||||
Unit::Toggle | Unit::Choice => v.round().clamp(self.min, self.max),
|
||||
_ => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a value to 0..1 control travel honoring the taper.
|
||||
pub fn to_normalized(&self, value: f32) -> f32 {
|
||||
let v = self.clamp(value);
|
||||
let span = self.max - self.min;
|
||||
if span <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
match self.taper {
|
||||
Taper::Log if self.min > 0.0 => ((v / self.min).ln() / (self.max / self.min).ln()).clamp(0.0, 1.0),
|
||||
_ => ((v - self.min) / span).clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps 0..1 control travel back to a value honoring the taper.
|
||||
pub fn from_normalized(&self, t: f32) -> f32 {
|
||||
let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };
|
||||
let v = match self.taper {
|
||||
Taper::Log if self.min > 0.0 => self.min * (self.max / self.min).powf(t),
|
||||
_ => self.min + (self.max - self.min) * t,
|
||||
};
|
||||
self.clamp(v)
|
||||
}
|
||||
|
||||
/// Human-readable label for a value (choice label, On/Off, or number with unit).
|
||||
pub fn format(&self, value: f32) -> String {
|
||||
let v = self.clamp(value);
|
||||
match self.unit {
|
||||
Unit::Choice => self.choices.get(v as usize).map(|s| (*s).to_string()).unwrap_or_else(|| format!("{v}")),
|
||||
Unit::Toggle => if v >= 0.5 { "On" } else { "Off" }.to_string(),
|
||||
Unit::Db => {
|
||||
if v <= -95.9 {
|
||||
"-inf dB".to_string()
|
||||
} else {
|
||||
format!("{v:.1} dB")
|
||||
}
|
||||
}
|
||||
Unit::Hz => {
|
||||
if v >= 1000.0 {
|
||||
format!("{:.2} kHz", v / 1000.0)
|
||||
} else {
|
||||
format!("{v:.1} Hz")
|
||||
}
|
||||
}
|
||||
Unit::Ms => format!("{v:.1} ms"),
|
||||
Unit::Percent => format!("{v:.0}%"),
|
||||
Unit::Ratio => format!("{v:.1}:1"),
|
||||
Unit::Semitones => format!("{v:.0} st"),
|
||||
Unit::Cents => format!("{v:.0} ct"),
|
||||
Unit::Seconds => format!("{v:.2} s"),
|
||||
Unit::None => format!("{v:.2}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Static description of a plugin.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
|
||||
pub struct PluginInfo {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
/// At most 8 characters, for insert slots.
|
||||
pub short_name: &'static str,
|
||||
pub category: Category,
|
||||
pub params: &'static [ParamInfo],
|
||||
pub is_instrument: bool,
|
||||
/// Usable as an offline (AudioSuite-style) process.
|
||||
pub audiosuite: bool,
|
||||
}
|
||||
|
||||
impl PluginInfo {
|
||||
/// Looks up a parameter description by id.
|
||||
pub fn param(&self, id: &str) -> Option<&'static ParamInfo> {
|
||||
self.params.iter().find(|p| p.id == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// A realtime audio processor or instrument.
|
||||
///
|
||||
/// Contract: `process` never allocates or panics. It processes `min(frames, io[c].len())` frames
|
||||
/// of at most the channel count given to `prepare`; extra channels are left untouched.
|
||||
/// Instruments replace the buffer contents with their output.
|
||||
pub trait Plugin: Send {
|
||||
fn info(&self) -> &'static PluginInfo;
|
||||
fn prepare(&mut self, sample_rate: f32, max_block: usize, channels: usize);
|
||||
fn reset(&mut self);
|
||||
/// Sets a parameter (clamped into range; NaN becomes the default). `false` if `id` is unknown.
|
||||
fn set_param(&mut self, id: &str, value: f32) -> bool;
|
||||
fn param(&self, id: &str) -> Option<f32>;
|
||||
/// In-place processing of `channels` planar buffers each `frames` long.
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize);
|
||||
/// Processing latency in samples (for delay compensation).
|
||||
fn latency(&self) -> usize {
|
||||
0
|
||||
}
|
||||
/// How long the output keeps ringing after the input stops, in samples.
|
||||
fn tail_samples(&self) -> usize {
|
||||
0
|
||||
}
|
||||
/// Gain reduction in dB for metering (a non-negative amount); 0 for non-dynamics plugins.
|
||||
fn gain_reduction_db(&self) -> f32 {
|
||||
0.0
|
||||
}
|
||||
/// Instruments: note events at sample offsets within the next block.
|
||||
fn note_on(&mut self, _offset: usize, _note: u8, _velocity: u8) {}
|
||||
fn note_off(&mut self, _offset: usize, _note: u8) {}
|
||||
fn all_notes_off(&mut self) {}
|
||||
}
|
||||
|
||||
/// The plugin registry.
|
||||
pub fn plugins() -> &'static [&'static PluginInfo] {
|
||||
plugins::REGISTRY
|
||||
}
|
||||
|
||||
/// Creates a plugin by id, prepared for 48 kHz stereo with 1024-frame blocks.
|
||||
/// Hosts should call `prepare` again with their real settings.
|
||||
pub fn create(id: &str) -> Option<Box<dyn Plugin>> {
|
||||
let mut p = plugins::instantiate(id)?;
|
||||
p.prepare(48_000.0, 1024, 2);
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Looks up a plugin description by id.
|
||||
pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> {
|
||||
plugins::REGISTRY.iter().copied().find(|i| i.id == id)
|
||||
}
|
||||
|
||||
/// Silence floor used by the dB helpers.
|
||||
pub const MIN_DB: f32 = -144.0;
|
||||
|
||||
/// Converts decibels to linear gain. Anything at or below -144 dB (or NaN) is silence.
|
||||
pub fn db_to_gain(db: f32) -> f32 {
|
||||
if db.is_nan() || db <= MIN_DB { 0.0 } else { 10f32.powf(db.min(200.0) / 20.0) }
|
||||
}
|
||||
|
||||
/// Converts linear gain (sign ignored) to decibels, floored at -144 dB.
|
||||
pub fn gain_to_db(g: f32) -> f32 {
|
||||
let a = g.abs();
|
||||
if a.is_nan() {
|
||||
return MIN_DB;
|
||||
}
|
||||
if a.is_infinite() {
|
||||
return f32::MAX;
|
||||
}
|
||||
(20.0 * a.log10()).max(MIN_DB)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
//! Level meters: DAW-style peak meter, RMS meter, and an ITU-R BS.1770 loudness meter
|
||||
//! (momentary, short-term and gated integrated LUFS plus 4× oversampled true peak).
|
||||
//!
|
||||
//! None of the `process` methods allocate.
|
||||
|
||||
use crate::biquad::{Biquad, Coeffs};
|
||||
use crate::util::sane_sr;
|
||||
use crate::{MIN_DB, gain_to_db};
|
||||
|
||||
/// Peak meter with instant attack, linear-in-dB release, peak hold and a clip latch.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PeakMeter {
|
||||
sr: f32,
|
||||
level_db: f32,
|
||||
hold_db: f32,
|
||||
hold_left: f32,
|
||||
hold_secs: f32,
|
||||
release_db_per_sec: f32,
|
||||
clipped: bool,
|
||||
}
|
||||
|
||||
impl PeakMeter {
|
||||
/// A meter with 20 dB/s release and a 2 s peak hold.
|
||||
pub fn new(sample_rate: f32) -> Self {
|
||||
Self { sr: sane_sr(sample_rate), level_db: MIN_DB, hold_db: MIN_DB, hold_left: 0.0, hold_secs: 2.0, release_db_per_sec: 20.0, clipped: false }
|
||||
}
|
||||
|
||||
pub fn set_release_db_per_sec(&mut self, rate: f32) {
|
||||
if rate.is_finite() {
|
||||
self.release_db_per_sec = rate.clamp(0.1, 1000.0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_hold_secs(&mut self, secs: f32) {
|
||||
if secs.is_finite() {
|
||||
self.hold_secs = secs.clamp(0.0, 60.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Feeds one block of samples.
|
||||
pub fn process(&mut self, samples: &[f32]) {
|
||||
let peak = samples.iter().filter(|v| v.is_finite()).fold(0.0f32, |m, v| m.max(v.abs()));
|
||||
self.feed(peak, samples.len());
|
||||
}
|
||||
|
||||
/// Feeds an already-computed block peak (linear) covering `frames` samples.
|
||||
pub fn feed(&mut self, peak: f32, frames: usize) {
|
||||
let dt = frames as f32 / self.sr;
|
||||
if peak >= 1.0 {
|
||||
self.clipped = true;
|
||||
}
|
||||
let peak_db = gain_to_db(peak);
|
||||
let decayed = (self.level_db - self.release_db_per_sec * dt).max(MIN_DB);
|
||||
self.level_db = decayed.max(peak_db);
|
||||
if peak_db >= self.hold_db {
|
||||
self.hold_db = peak_db;
|
||||
self.hold_left = self.hold_secs;
|
||||
} else if self.hold_left > 0.0 {
|
||||
self.hold_left -= dt;
|
||||
} else {
|
||||
self.hold_db = (self.hold_db - self.release_db_per_sec * dt).max(self.level_db);
|
||||
}
|
||||
}
|
||||
|
||||
/// Displayed level in dBFS.
|
||||
pub fn level_db(&self) -> f32 {
|
||||
self.level_db
|
||||
}
|
||||
|
||||
/// Held peak in dBFS.
|
||||
pub fn peak_hold_db(&self) -> f32 {
|
||||
self.hold_db
|
||||
}
|
||||
|
||||
/// True once any sample reached full scale, until [`PeakMeter::reset_clip`].
|
||||
pub fn clipped(&self) -> bool {
|
||||
self.clipped
|
||||
}
|
||||
|
||||
pub fn reset_clip(&mut self) {
|
||||
self.clipped = false;
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.level_db = MIN_DB;
|
||||
self.hold_db = MIN_DB;
|
||||
self.hold_left = 0.0;
|
||||
self.clipped = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exponentially averaged RMS meter (300 ms time constant by default).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RmsMeter {
|
||||
sr: f32,
|
||||
coef: f32,
|
||||
ms: f64,
|
||||
}
|
||||
|
||||
impl RmsMeter {
|
||||
pub fn new(sample_rate: f32) -> Self {
|
||||
let mut m = Self { sr: sane_sr(sample_rate), coef: 0.0, ms: 0.0 };
|
||||
m.set_window_ms(300.0);
|
||||
m
|
||||
}
|
||||
|
||||
pub fn set_window_ms(&mut self, ms: f32) {
|
||||
self.coef = crate::util::time_coef(if ms.is_finite() { ms.clamp(1.0, 10_000.0) } else { 300.0 }, self.sr);
|
||||
}
|
||||
|
||||
pub fn process(&mut self, samples: &[f32]) {
|
||||
let k = f64::from(1.0 - self.coef);
|
||||
for &x in samples {
|
||||
let x = if x.is_finite() { f64::from(x) } else { 0.0 };
|
||||
self.ms += (x * x - self.ms) * k;
|
||||
}
|
||||
if self.ms < 1e-30 {
|
||||
self.ms = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Current RMS level in dBFS (sine-referenced: an RMS of 0.707 reads -3 dB).
|
||||
pub fn rms_db(&self) -> f32 {
|
||||
gain_to_db(self.ms.sqrt() as f32)
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.ms = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
const SUB_BLOCKS: usize = 30; // 3 s of 100 ms sub-blocks
|
||||
const HIST_MIN: f32 = -70.0;
|
||||
const HIST_STEP: f32 = 0.05;
|
||||
const HIST_BINS: usize = 1800; // -70 .. +20 LUFS
|
||||
const TP_PHASES: usize = 4;
|
||||
const TP_TAPS: usize = 12;
|
||||
|
||||
/// ITU-R BS.1770 / EBU R128 loudness meter.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoudnessMeter {
|
||||
sr: f32,
|
||||
weights: Vec<f32>,
|
||||
filters: Vec<[Biquad; 2]>,
|
||||
block_len: usize,
|
||||
block_pos: usize,
|
||||
block_acc: Vec<f64>,
|
||||
sub: [f64; SUB_BLOCKS],
|
||||
sub_idx: usize,
|
||||
sub_filled: usize,
|
||||
hist_count: Vec<u64>,
|
||||
hist_energy: Vec<f64>,
|
||||
tp_coefs: [[f32; TP_TAPS]; TP_PHASES],
|
||||
tp_hist: Vec<[f32; TP_TAPS]>,
|
||||
true_peak: f32,
|
||||
max_momentary: f32,
|
||||
}
|
||||
|
||||
/// Loudness from mean-square energy (BS.1770 eq. 2).
|
||||
fn lufs(energy: f64) -> f32 {
|
||||
if energy <= 1e-20 { MIN_DB } else { (-0.691 + 10.0 * energy.log10()) as f32 }
|
||||
}
|
||||
|
||||
/// K-weighting pre-filter (high shelf) and RLB high-pass designed for any sample rate.
|
||||
fn k_weighting(sr: f32) -> [Coeffs; 2] {
|
||||
let sr = f64::from(sr);
|
||||
let pi = std::f64::consts::PI;
|
||||
// Stage 1: shelving.
|
||||
let (f0, g, q) = (1_681.974_450_955_533, 3.999_843_853_973_347, 0.707_175_236_955_419_6);
|
||||
let k = (pi * f0 / sr).tan();
|
||||
let vh = 10f64.powf(g / 20.0);
|
||||
let vb = vh.powf(0.499_666_774_154_541_6);
|
||||
let a0 = 1.0 + k / q + k * k;
|
||||
let s1 = Coeffs {
|
||||
b0: ((vh + vb * k / q + k * k) / a0) as f32,
|
||||
b1: (2.0 * (k * k - vh) / a0) as f32,
|
||||
b2: ((vh - vb * k / q + k * k) / a0) as f32,
|
||||
a1: (2.0 * (k * k - 1.0) / a0) as f32,
|
||||
a2: ((1.0 - k / q + k * k) / a0) as f32,
|
||||
};
|
||||
// Stage 2: high-pass.
|
||||
let (f0, q) = (38.135_470_876_024_44, 0.500_327_037_323_877_3);
|
||||
let k = (pi * f0 / sr).tan();
|
||||
let a0 = 1.0 + k / q + k * k;
|
||||
let s2 = Coeffs { b0: 1.0, b1: -2.0, b2: 1.0, a1: (2.0 * (k * k - 1.0) / a0) as f32, a2: ((1.0 - k / q + k * k) / a0) as f32 };
|
||||
[s1, s2]
|
||||
}
|
||||
|
||||
impl LoudnessMeter {
|
||||
/// Creates a meter. Channel weights default to 1.0, except a 6-channel (5.1: L R C LFE Ls Rs)
|
||||
/// layout which uses 0 for LFE and 1.41 for the surrounds.
|
||||
pub fn new(sample_rate: f32, channels: usize) -> Self {
|
||||
let sr = sane_sr(sample_rate);
|
||||
let channels = channels.clamp(1, crate::util::MAX_CHANNELS);
|
||||
let weights = if channels == 6 { vec![1.0, 1.0, 1.0, 0.0, 1.41, 1.41] } else { vec![1.0; channels] };
|
||||
let [c1, c2] = k_weighting(sr);
|
||||
// Interpolation filter: windowed sinc at the original Nyquist, split into 4 phases.
|
||||
let mut tp_coefs = [[0.0f32; TP_TAPS]; TP_PHASES];
|
||||
let total = TP_TAPS * TP_PHASES;
|
||||
let center = (total as f64 - 1.0) / 2.0;
|
||||
for i in 0..total {
|
||||
let t = (i as f64 - center) / TP_PHASES as f64;
|
||||
let sinc = if t.abs() < 1e-12 { 1.0 } else { (std::f64::consts::PI * t).sin() / (std::f64::consts::PI * t) };
|
||||
let x = i as f64 / (total as f64 - 1.0);
|
||||
let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * x).cos() + 0.08 * (4.0 * std::f64::consts::PI * x).cos();
|
||||
if let Some(slot) = tp_coefs.get_mut(i % TP_PHASES).and_then(|ph| ph.get_mut(i / TP_PHASES)) {
|
||||
*slot = (sinc * w) as f32;
|
||||
}
|
||||
}
|
||||
for ph in &mut tp_coefs {
|
||||
let s: f32 = ph.iter().sum();
|
||||
if s.abs() > 1e-6 {
|
||||
ph.iter_mut().for_each(|c| *c /= s);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
sr,
|
||||
weights,
|
||||
filters: vec![[Biquad::new(c1), Biquad::new(c2)]; channels],
|
||||
block_len: ((sr * 0.1).round() as usize).max(1),
|
||||
block_pos: 0,
|
||||
block_acc: vec![0.0; channels],
|
||||
sub: [0.0; SUB_BLOCKS],
|
||||
sub_idx: 0,
|
||||
sub_filled: 0,
|
||||
hist_count: vec![0; HIST_BINS],
|
||||
hist_energy: vec![0.0; HIST_BINS],
|
||||
tp_coefs,
|
||||
tp_hist: vec![[0.0; TP_TAPS]; channels],
|
||||
true_peak: 0.0,
|
||||
max_momentary: MIN_DB,
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides a channel's weighting (e.g. 1.41 for surrounds, 0 to exclude LFE).
|
||||
pub fn set_channel_weight(&mut self, channel: usize, weight: f32) {
|
||||
if let Some(w) = self.weights.get_mut(channel) {
|
||||
*w = if weight.is_finite() { weight.clamp(0.0, 4.0) } else { 1.0 };
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sample_rate(&self) -> f32 {
|
||||
self.sr
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
for f in &mut self.filters {
|
||||
f[0].reset();
|
||||
f[1].reset();
|
||||
}
|
||||
self.block_pos = 0;
|
||||
self.block_acc.iter_mut().for_each(|v| *v = 0.0);
|
||||
self.sub = [0.0; SUB_BLOCKS];
|
||||
self.sub_idx = 0;
|
||||
self.sub_filled = 0;
|
||||
self.hist_count.iter_mut().for_each(|v| *v = 0);
|
||||
self.hist_energy.iter_mut().for_each(|v| *v = 0.0);
|
||||
self.tp_hist.iter_mut().for_each(|h| *h = [0.0; TP_TAPS]);
|
||||
self.true_peak = 0.0;
|
||||
self.max_momentary = MIN_DB;
|
||||
}
|
||||
|
||||
/// Feeds planar audio. Channels beyond the meter's channel count are ignored.
|
||||
pub fn process(&mut self, io: &[Vec<f32>], frames: usize) {
|
||||
let ch = self.filters.len().min(io.len());
|
||||
let frames = io.iter().take(ch).map(Vec::len).fold(frames, usize::min);
|
||||
for n in 0..frames {
|
||||
for c in 0..ch {
|
||||
let x = io.get(c).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
if let Some([f1, f2]) = self.filters.get_mut(c) {
|
||||
let y = f2.process_sample(f1.process_sample(x));
|
||||
if let Some(acc) = self.block_acc.get_mut(c) {
|
||||
*acc += f64::from(y) * f64::from(y);
|
||||
}
|
||||
}
|
||||
self.true_peak_sample(c, x);
|
||||
}
|
||||
self.block_pos += 1;
|
||||
if self.block_pos >= self.block_len {
|
||||
self.finish_sub_block();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn true_peak_sample(&mut self, c: usize, x: f32) {
|
||||
let Some(h) = self.tp_hist.get_mut(c) else { return };
|
||||
h.copy_within(0..TP_TAPS - 1, 1);
|
||||
h[0] = x;
|
||||
let mut peak = x.abs();
|
||||
for ph in &self.tp_coefs {
|
||||
let y: f32 = ph.iter().zip(h.iter()).map(|(a, b)| a * b).sum();
|
||||
peak = peak.max(y.abs());
|
||||
}
|
||||
if peak > self.true_peak {
|
||||
self.true_peak = peak;
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_sub_block(&mut self) {
|
||||
let len = self.block_pos.max(1) as f64;
|
||||
let mut energy = 0.0;
|
||||
for (acc, w) in self.block_acc.iter_mut().zip(&self.weights) {
|
||||
energy += f64::from(*w) * *acc / len;
|
||||
*acc = 0.0;
|
||||
}
|
||||
self.block_pos = 0;
|
||||
if let Some(s) = self.sub.get_mut(self.sub_idx) {
|
||||
*s = energy;
|
||||
}
|
||||
self.sub_idx = (self.sub_idx + 1) % SUB_BLOCKS;
|
||||
self.sub_filled = (self.sub_filled + 1).min(SUB_BLOCKS);
|
||||
if self.sub_filled >= 4 {
|
||||
let z = self.recent_energy(4);
|
||||
let l = lufs(z);
|
||||
self.max_momentary = self.max_momentary.max(l);
|
||||
if l > HIST_MIN {
|
||||
let bin = (((l - HIST_MIN) / HIST_STEP) as usize).min(HIST_BINS - 1);
|
||||
if let (Some(c), Some(e)) = (self.hist_count.get_mut(bin), self.hist_energy.get_mut(bin)) {
|
||||
*c += 1;
|
||||
*e += z;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mean energy of the last `n` completed 100 ms sub-blocks.
|
||||
fn recent_energy(&self, n: usize) -> f64 {
|
||||
let n = n.min(self.sub_filled);
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sum = 0.0;
|
||||
for k in 1..=n {
|
||||
let idx = (self.sub_idx + SUB_BLOCKS - k) % SUB_BLOCKS;
|
||||
sum += self.sub.get(idx).copied().unwrap_or(0.0);
|
||||
}
|
||||
sum / n as f64
|
||||
}
|
||||
|
||||
/// Momentary loudness (400 ms window), LUFS. -144 until 400 ms have been measured.
|
||||
pub fn momentary_lufs(&self) -> f32 {
|
||||
if self.sub_filled < 4 { MIN_DB } else { lufs(self.recent_energy(4)) }
|
||||
}
|
||||
|
||||
/// Short-term loudness (3 s window, or what is available so far), LUFS.
|
||||
pub fn short_term_lufs(&self) -> f32 {
|
||||
if self.sub_filled < 4 { MIN_DB } else { lufs(self.recent_energy(SUB_BLOCKS)) }
|
||||
}
|
||||
|
||||
/// Highest momentary loudness seen.
|
||||
pub fn max_momentary_lufs(&self) -> f32 {
|
||||
self.max_momentary
|
||||
}
|
||||
|
||||
/// Gated integrated loudness (absolute gate -70 LUFS, relative gate -10 LU), LUFS.
|
||||
pub fn integrated_lufs(&self) -> f32 {
|
||||
let (mut n, mut e) = (0u64, 0.0f64);
|
||||
for (c, s) in self.hist_count.iter().zip(&self.hist_energy) {
|
||||
n += c;
|
||||
e += s;
|
||||
}
|
||||
if n == 0 {
|
||||
return MIN_DB;
|
||||
}
|
||||
let gate = lufs(e / n as f64) - 10.0;
|
||||
let (mut n2, mut e2) = (0u64, 0.0f64);
|
||||
for (i, (c, s)) in self.hist_count.iter().zip(&self.hist_energy).enumerate() {
|
||||
let center = HIST_MIN + (i as f32 + 0.5) * HIST_STEP;
|
||||
if center > gate {
|
||||
n2 += c;
|
||||
e2 += s;
|
||||
}
|
||||
}
|
||||
if n2 == 0 { MIN_DB } else { lufs(e2 / n2 as f64) }
|
||||
}
|
||||
|
||||
/// Maximum true peak (4× oversampled) in dBTP.
|
||||
pub fn true_peak_db(&self) -> f32 {
|
||||
gain_to_db(self.true_peak)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
//! AudioSuite-style whole-buffer processing on planar `f32` audio.
|
||||
//!
|
||||
//! These functions may allocate (they are not for the audio thread) but never panic: lengths,
|
||||
//! rates and ratios are sanitized and output sizes are capped at [`MAX_OFFLINE_SAMPLES`] per
|
||||
//! channel.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use rustfft::{FftPlanner, num_complex::Complex};
|
||||
|
||||
use crate::osc::Osc;
|
||||
pub use crate::osc::Waveform;
|
||||
use crate::util::sane_sr;
|
||||
use crate::{Plugin, db_to_gain, gain_to_db};
|
||||
|
||||
/// Largest number of samples per channel any offline function will produce.
|
||||
pub const MAX_OFFLINE_SAMPLES: usize = 1 << 28;
|
||||
|
||||
/// Fade curves. [`FadeShape::gain`] is the fade-in curve; a fade-out uses `gain(1 - t)`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FadeShape {
|
||||
Linear,
|
||||
/// Constant power (sin/cos), for crossfades between uncorrelated material.
|
||||
#[default]
|
||||
EqualPower,
|
||||
/// Raised cosine.
|
||||
SCurve,
|
||||
/// Slow start, fast finish.
|
||||
Exponential,
|
||||
/// Fast start, slow finish.
|
||||
Logarithmic,
|
||||
}
|
||||
|
||||
impl FadeShape {
|
||||
pub const ALL: [FadeShape; 5] = [FadeShape::Linear, FadeShape::EqualPower, FadeShape::SCurve, FadeShape::Exponential, FadeShape::Logarithmic];
|
||||
|
||||
/// Fade-in gain at position `t` in 0..1 (0 → silent, 1 → unity). NaN is treated as 0.
|
||||
pub fn gain(self, t: f32) -> f32 {
|
||||
let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };
|
||||
const K: f32 = 4.0;
|
||||
match self {
|
||||
FadeShape::Linear => t,
|
||||
FadeShape::EqualPower => (t * std::f32::consts::FRAC_PI_2).sin(),
|
||||
FadeShape::SCurve => 0.5 - 0.5 * (t * std::f32::consts::PI).cos(),
|
||||
FadeShape::Exponential => ((K * t).exp() - 1.0) / (K.exp() - 1.0),
|
||||
FadeShape::Logarithmic => (1.0 + (K.exp() - 1.0) * t).ln() / K,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn max_len(ch: &[Vec<f32>]) -> usize {
|
||||
ch.iter().map(Vec::len).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn peak(ch: &[Vec<f32>]) -> f32 {
|
||||
ch.iter().flatten().filter(|v| v.is_finite()).fold(0.0f32, |m, v| m.max(v.abs()))
|
||||
}
|
||||
|
||||
/// Peak-normalizes to `target_db` dBFS, either all channels together or each on its own.
|
||||
/// Silent channels are left alone.
|
||||
pub fn normalize(ch: &mut [Vec<f32>], target_db: f32, per_channel: bool) {
|
||||
let target = db_to_gain(if target_db.is_nan() { 0.0 } else { target_db.clamp(-144.0, 24.0) });
|
||||
if per_channel {
|
||||
for c in ch.iter_mut() {
|
||||
let p = peak(std::slice::from_ref(c));
|
||||
if p > 0.0 {
|
||||
let g = target / p;
|
||||
c.iter_mut().for_each(|s| *s *= g);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let p = peak(ch);
|
||||
if p > 0.0 {
|
||||
let g = target / p;
|
||||
ch.iter_mut().flatten().for_each(|s| *s *= g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a gain change in dB.
|
||||
pub fn gain(ch: &mut [Vec<f32>], db: f32) {
|
||||
let g = db_to_gain(if db.is_nan() { 0.0 } else { db.min(96.0) });
|
||||
ch.iter_mut().flatten().for_each(|s| *s *= g);
|
||||
}
|
||||
|
||||
/// Flips polarity.
|
||||
pub fn invert(ch: &mut [Vec<f32>]) {
|
||||
ch.iter_mut().flatten().for_each(|s| *s = -*s);
|
||||
}
|
||||
|
||||
/// Reverses each channel in time.
|
||||
pub fn reverse(ch: &mut [Vec<f32>]) {
|
||||
ch.iter_mut().for_each(|c| c.reverse());
|
||||
}
|
||||
|
||||
/// Removes each channel's DC offset (subtracts its mean).
|
||||
pub fn remove_dc(ch: &mut [Vec<f32>]) {
|
||||
for c in ch.iter_mut() {
|
||||
if c.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mean = c.iter().filter(|v| v.is_finite()).map(|&v| f64::from(v)).sum::<f64>() / c.len() as f64;
|
||||
let m = mean as f32;
|
||||
c.iter_mut().for_each(|s| *s -= m);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fades in over the first `fade_in` samples and out over the last `fade_out` samples.
|
||||
pub fn fade(ch: &mut [Vec<f32>], fade_in: usize, fade_out: usize, shape: FadeShape) {
|
||||
for c in ch.iter_mut() {
|
||||
let len = c.len();
|
||||
let fi = fade_in.min(len);
|
||||
for (i, s) in c.iter_mut().take(fi).enumerate() {
|
||||
*s *= shape.gain(i as f32 / fi as f32);
|
||||
}
|
||||
let fo = fade_out.min(len);
|
||||
for (j, s) in c.iter_mut().skip(len - fo).enumerate() {
|
||||
*s *= shape.gain((fo - 1 - j) as f32 / fo as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Resampling (Kaiser-windowed sinc)
|
||||
|
||||
fn bessel_i0(x: f64) -> f64 {
|
||||
let mut sum = 1.0;
|
||||
let mut term = 1.0;
|
||||
let y = x * x / 4.0;
|
||||
for k in 1..64 {
|
||||
term *= y / (k as f64 * k as f64);
|
||||
sum += term;
|
||||
if term < sum * 1e-12 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
sum
|
||||
}
|
||||
|
||||
const SINC_ZC: f64 = 32.0;
|
||||
const SINC_RES: usize = 512;
|
||||
|
||||
/// Precomputed one-sided windowed-sinc kernel for a given cutoff (fraction of input Nyquist).
|
||||
struct SincTable {
|
||||
table: Vec<f32>,
|
||||
half_width: f64,
|
||||
}
|
||||
|
||||
impl SincTable {
|
||||
fn new(cutoff: f64) -> Self {
|
||||
let cutoff = cutoff.clamp(0.01, 1.0);
|
||||
let half_width = SINC_ZC / cutoff;
|
||||
let n = (half_width * SINC_RES as f64).ceil() as usize + 2;
|
||||
let beta = 9.0;
|
||||
let i0b = bessel_i0(beta);
|
||||
let table = (0..n)
|
||||
.map(|i| {
|
||||
let t = i as f64 / SINC_RES as f64;
|
||||
let x = t / half_width;
|
||||
if x >= 1.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let arg = PI * cutoff * t;
|
||||
let sinc = if arg.abs() < 1e-12 { 1.0 } else { arg.sin() / arg };
|
||||
let w = bessel_i0(beta * (1.0 - x * x).max(0.0).sqrt()) / i0b;
|
||||
(cutoff * sinc * w) as f32
|
||||
})
|
||||
.collect();
|
||||
Self { table, half_width }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn at(&self, t: f64) -> f32 {
|
||||
let pos = t.abs() * SINC_RES as f64;
|
||||
let i = pos as usize;
|
||||
let f = (pos - i as f64) as f32;
|
||||
match (self.table.get(i), self.table.get(i + 1)) {
|
||||
(Some(a), Some(b)) => a + (b - a) * f,
|
||||
(Some(a), None) => *a,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resamples one channel to exactly `out_len` samples, where output sample `n` is read at input
|
||||
/// time `n / ratio`.
|
||||
fn resample_channel(x: &[f32], ratio: f64, out_len: usize, table: &SincTable) -> Vec<f32> {
|
||||
let len = x.len() as i64;
|
||||
let hw = table.half_width;
|
||||
(0..out_len)
|
||||
.map(|n| {
|
||||
let t = n as f64 / ratio;
|
||||
let lo = (t - hw).ceil().max(0.0) as i64;
|
||||
let hi = ((t + hw).floor() as i64).min(len - 1);
|
||||
let mut acc = 0.0f32;
|
||||
let mut j = lo;
|
||||
while j <= hi {
|
||||
if let Some(&v) = x.get(j as usize) {
|
||||
acc += v * table.at(t - j as f64);
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
acc
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resample_ratio(ch: &[Vec<f32>], ratio: f64, out_len: usize) -> Vec<Vec<f32>> {
|
||||
if !ratio.is_finite() || ratio <= 0.0 {
|
||||
return ch.iter().map(|_| vec![0.0; out_len]).collect();
|
||||
}
|
||||
// Anti-alias below the lower of the two Nyquists, with a little transition room.
|
||||
let cutoff = if ratio < 1.0 { ratio * 0.94 } else { 0.97 };
|
||||
let table = SincTable::new(cutoff);
|
||||
ch.iter().map(|c| resample_channel(c, ratio, out_len, &table)).collect()
|
||||
}
|
||||
|
||||
/// High-quality windowed-sinc sample-rate conversion from `from` Hz to `to` Hz.
|
||||
pub fn resample(ch: &[Vec<f32>], from: u32, to: u32) -> Vec<Vec<f32>> {
|
||||
if from == 0 || to == 0 || from == to {
|
||||
return ch.to_vec();
|
||||
}
|
||||
let ratio = f64::from(to) / f64::from(from);
|
||||
ch.iter()
|
||||
.map(|c| {
|
||||
let out_len = ((c.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES);
|
||||
resample_ratio(std::slice::from_ref(c), ratio, out_len).into_iter().next().unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Time stretch (WSOLA) and pitch shift
|
||||
|
||||
/// Pitch-preserving time stretch by WSOLA (waveform-similarity overlap-add).
|
||||
/// `ratio` = output length / input length, clamped to 0.1 ..= 10. All channels share the same
|
||||
/// segment choices so the stereo image stays coherent.
|
||||
pub fn time_stretch(ch: &[Vec<f32>], ratio: f64, sample_rate: f32) -> Vec<Vec<f32>> {
|
||||
let len = max_len(ch);
|
||||
let ratio = if ratio.is_finite() { ratio.clamp(0.1, 10.0) } else { 1.0 };
|
||||
let out_len = ((len as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES);
|
||||
if len == 0 || ch.is_empty() {
|
||||
return ch.iter().map(|_| vec![0.0; out_len]).collect();
|
||||
}
|
||||
if (ratio - 1.0).abs() < 1e-9 {
|
||||
return ch.iter().map(|c| c.iter().copied().chain(std::iter::repeat(0.0)).take(out_len).collect()).collect();
|
||||
}
|
||||
let sr = f64::from(sane_sr(sample_rate));
|
||||
let frame = (((sr * 0.04) as usize) & !1).clamp(64, 8192);
|
||||
let hop = frame / 2;
|
||||
let tol = ((sr * 0.010) as usize).clamp(8, 2048) as i64;
|
||||
let window: Vec<f32> = (0..frame).map(|i| (0.5 - 0.5 * (2.0 * PI * i as f64 / frame as f64).cos()) as f32).collect();
|
||||
let nch = ch.len() as f32;
|
||||
let guide: Vec<f32> = (0..len).map(|i| ch.iter().map(|c| c.get(i).copied().unwrap_or(0.0)).sum::<f32>() / nch).collect();
|
||||
let g = |i: i64| -> f32 { if i < 0 { 0.0 } else { guide.get(i as usize).copied().unwrap_or(0.0) } };
|
||||
|
||||
let buf_len = out_len + frame;
|
||||
let mut out: Vec<Vec<f32>> = ch.iter().map(|_| vec![0.0; buf_len]).collect();
|
||||
let mut wsum = vec![0.0f32; buf_len];
|
||||
let mut prev_in: i64 = 0;
|
||||
let mut k = 0usize;
|
||||
loop {
|
||||
let out_pos = k * hop;
|
||||
if out_pos >= out_len {
|
||||
break;
|
||||
}
|
||||
let in_pos = if k == 0 {
|
||||
0
|
||||
} else {
|
||||
let nominal = (out_pos as f64 / ratio).round() as i64;
|
||||
let natural = prev_in + hop as i64;
|
||||
// Similarity of candidate start `p` with the natural continuation (stride 2).
|
||||
let score = |p: i64| -> f32 {
|
||||
let (mut xy, mut yy) = (0.0f32, 1e-9f32);
|
||||
let mut i = 0i64;
|
||||
while i < hop as i64 {
|
||||
let a = g(natural + i);
|
||||
let b = g(p + i);
|
||||
xy += a * b;
|
||||
yy += b * b;
|
||||
i += 2;
|
||||
}
|
||||
xy / yy.sqrt()
|
||||
};
|
||||
let lo = (nominal - tol).max(0);
|
||||
let hi = nominal + tol;
|
||||
let mut best = nominal.max(0);
|
||||
let mut best_s = f32::NEG_INFINITY;
|
||||
let mut p = lo;
|
||||
while p <= hi {
|
||||
let s = score(p);
|
||||
if s > best_s {
|
||||
best_s = s;
|
||||
best = p;
|
||||
}
|
||||
p += 4;
|
||||
}
|
||||
let (rlo, rhi) = ((best - 3).max(lo), (best + 3).min(hi));
|
||||
for p in rlo..=rhi {
|
||||
let s = score(p);
|
||||
if s > best_s {
|
||||
best_s = s;
|
||||
best = p;
|
||||
}
|
||||
}
|
||||
best
|
||||
};
|
||||
for (c, o) in ch.iter().zip(out.iter_mut()) {
|
||||
for (i, w) in window.iter().enumerate() {
|
||||
let src = in_pos as usize + i;
|
||||
let v = c.get(src).copied().unwrap_or(0.0);
|
||||
if let Some(d) = o.get_mut(out_pos + i) {
|
||||
*d += v * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, w) in window.iter().enumerate() {
|
||||
if let Some(d) = wsum.get_mut(out_pos + i) {
|
||||
*d += w;
|
||||
}
|
||||
}
|
||||
prev_in = in_pos;
|
||||
k += 1;
|
||||
}
|
||||
for o in out.iter_mut() {
|
||||
for (s, w) in o.iter_mut().zip(&wsum) {
|
||||
if *w > 1e-3 {
|
||||
*s /= w;
|
||||
}
|
||||
}
|
||||
o.truncate(out_len);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Length-preserving pitch shift by `semitones` (clamped to ±48): WSOLA stretch followed by
|
||||
/// windowed-sinc resampling back to the original length.
|
||||
pub fn pitch_shift(ch: &[Vec<f32>], semitones: f32, sample_rate: f32) -> Vec<Vec<f32>> {
|
||||
let st = if semitones.is_finite() { semitones.clamp(-48.0, 48.0) } else { 0.0 };
|
||||
if st.abs() < 1e-4 {
|
||||
return ch.to_vec();
|
||||
}
|
||||
let factor = 2f64.powf(f64::from(st) / 12.0);
|
||||
let stretched = time_stretch(ch, factor, sample_rate);
|
||||
let len = max_len(ch);
|
||||
let slen = max_len(&stretched);
|
||||
if slen == 0 || len == 0 {
|
||||
return ch.iter().map(|c| vec![0.0; c.len()]).collect();
|
||||
}
|
||||
let ratio = len as f64 / slen as f64;
|
||||
let mut out = resample_ratio(&stretched, ratio, len);
|
||||
for (o, c) in out.iter_mut().zip(ch) {
|
||||
o.truncate(c.len());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Generators and analysis
|
||||
|
||||
/// Generates a test signal. Periodic waveforms peak at `level_db`; noise is scaled so its peak
|
||||
/// stays at or below `level_db`.
|
||||
pub fn signal_generator(kind: Waveform, freq: f32, level_db: f32, sample_rate: f32, frames: usize, channels: usize) -> Vec<Vec<f32>> {
|
||||
let sr = sane_sr(sample_rate);
|
||||
let frames = frames.min(MAX_OFFLINE_SAMPLES);
|
||||
let channels = channels.min(crate::util::MAX_CHANNELS);
|
||||
let f = if freq.is_finite() { freq.clamp(0.0, sr * 0.49) } else { 1000.0 };
|
||||
let g = db_to_gain(if level_db.is_nan() { -20.0 } else { level_db.min(0.0) });
|
||||
let mut osc = Osc::new(0x1234_5678);
|
||||
let mono: Vec<f32> = (0..frames).map(|_| osc.next(kind, f / sr) * g).collect();
|
||||
(0..channels).map(|_| mono.clone()).collect()
|
||||
}
|
||||
|
||||
/// Onset detection by half-wave-rectified spectral flux with an adaptive threshold.
|
||||
/// `sensitivity` 0..1 (higher finds more transients). Returns sample positions, ascending.
|
||||
pub fn detect_transients(ch: &[Vec<f32>], sample_rate: f32, sensitivity: f32) -> Vec<usize> {
|
||||
let len = max_len(ch);
|
||||
let sr = sane_sr(sample_rate);
|
||||
let sens = if sensitivity.is_finite() { sensitivity.clamp(0.0, 1.0) } else { 0.5 };
|
||||
let n = ((sr * 0.023) as usize).clamp(64, 8192).next_power_of_two();
|
||||
let hop = n / 4;
|
||||
if len < n || ch.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let nch = ch.len() as f32;
|
||||
let mono: Vec<f32> = (0..len).map(|i| ch.iter().map(|c| c.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0)).sum::<f32>() / nch).collect();
|
||||
let mut planner = FftPlanner::<f32>::new();
|
||||
let fft = planner.plan_fft_forward(n);
|
||||
let window: Vec<f32> = (0..n).map(|i| (0.5 - 0.5 * (2.0 * PI * i as f64 / n as f64).cos()) as f32).collect();
|
||||
let frames = (len - n) / hop + 1;
|
||||
let mut prev = vec![0.0f32; n / 2 + 1];
|
||||
let mut buf = vec![Complex::new(0.0f32, 0.0); n];
|
||||
let mut flux = Vec::with_capacity(frames);
|
||||
for m in 0..frames {
|
||||
let start = m * hop;
|
||||
for (i, b) in buf.iter_mut().enumerate() {
|
||||
*b = Complex::new(mono.get(start + i).copied().unwrap_or(0.0) * window.get(i).copied().unwrap_or(0.0), 0.0);
|
||||
}
|
||||
fft.process(&mut buf);
|
||||
let mut f = 0.0;
|
||||
for (b, p) in buf.iter().zip(prev.iter_mut()) {
|
||||
let mag = (1.0 + 100.0 * b.norm()).ln();
|
||||
f += (mag - *p).max(0.0);
|
||||
*p = mag;
|
||||
}
|
||||
flux.push(if m == 0 { 0.0 } else { f });
|
||||
}
|
||||
let max_flux = flux.iter().copied().fold(0.0f32, f32::max);
|
||||
if max_flux <= 1e-6 {
|
||||
return Vec::new();
|
||||
}
|
||||
let w = ((0.1 * sr) as usize / hop).max(2);
|
||||
let mult = 1.0 + 2.5 * (1.0 - sens);
|
||||
let delta = max_flux * (0.02 + 0.25 * (1.0 - sens));
|
||||
let min_gap = ((0.05 * sr) as usize / hop).max(1);
|
||||
let mut out = Vec::new();
|
||||
let mut last: Option<usize> = None;
|
||||
for m in 1..flux.len() {
|
||||
let cur = flux.get(m).copied().unwrap_or(0.0);
|
||||
let lo = m.saturating_sub(w);
|
||||
let hi = (m + w + 1).min(flux.len());
|
||||
let local = flux.get(lo..hi).map(|s| s.iter().sum::<f32>() / s.len().max(1) as f32).unwrap_or(0.0);
|
||||
let prev_v = flux.get(m - 1).copied().unwrap_or(0.0);
|
||||
let next_v = flux.get(m + 1).copied().unwrap_or(0.0);
|
||||
if cur > local * mult + delta && cur >= prev_v && cur > next_v && last.is_none_or(|l| m - l >= min_gap) {
|
||||
last = Some(m);
|
||||
// Refine: first sample in the analysis frame reaching a quarter of the frame's peak.
|
||||
let start = m * hop;
|
||||
let seg = mono.get(start..(start + n).min(len)).unwrap_or(&[]);
|
||||
let pk = seg.iter().fold(0.0f32, |a, v| a.max(v.abs()));
|
||||
let off = seg.iter().position(|v| v.abs() >= 0.25 * pk).unwrap_or(n / 2);
|
||||
let pos = start + off;
|
||||
if out.last().is_none_or(|&p: &usize| pos > p) {
|
||||
out.push(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Strip Silence: ranges `(start, end)` (end exclusive) of material whose peak across channels
|
||||
/// exceeds `threshold_db`. Silent gaps shorter than `min_len` samples do not split a region.
|
||||
/// Each region is padded by `pad_before`/`pad_after` (clamped to the buffer) and overlapping
|
||||
/// regions are merged.
|
||||
pub fn non_silent_ranges(ch: &[Vec<f32>], threshold_db: f32, min_len: usize, pad_before: usize, pad_after: usize) -> Vec<(usize, usize)> {
|
||||
let len = max_len(ch);
|
||||
let thr = db_to_gain(if threshold_db.is_nan() { -48.0 } else { threshold_db });
|
||||
let mut raw: Vec<(usize, usize)> = Vec::new();
|
||||
let mut start: Option<usize> = None;
|
||||
let mut last_loud = 0usize;
|
||||
for i in 0..len {
|
||||
let loud = ch.iter().any(|c| c.get(i).is_some_and(|v| v.is_finite() && v.abs() > thr));
|
||||
if !loud {
|
||||
continue;
|
||||
}
|
||||
match start {
|
||||
Some(s) if i - last_loud > min_len.max(1) => {
|
||||
raw.push((s, last_loud + 1));
|
||||
start = Some(i);
|
||||
}
|
||||
None => start = Some(i),
|
||||
_ => {}
|
||||
}
|
||||
last_loud = i;
|
||||
}
|
||||
if let Some(s) = start {
|
||||
raw.push((s, last_loud + 1));
|
||||
}
|
||||
let mut out: Vec<(usize, usize)> = Vec::with_capacity(raw.len());
|
||||
for (s, e) in raw {
|
||||
let s = s.saturating_sub(pad_before);
|
||||
let e = e.saturating_add(pad_after).min(len);
|
||||
match out.last_mut() {
|
||||
Some(last) if s <= last.1 => last.1 = last.1.max(e),
|
||||
_ => out.push((s, e)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Runs a plugin over whole buffers in blocks, compensating its latency so the output lines up
|
||||
/// with the input. The buffer length is unchanged (any tail past the end is dropped; see
|
||||
/// [`apply_plugin_with_tail`]).
|
||||
pub fn apply_plugin(ch: &mut [Vec<f32>], plugin: &mut dyn Plugin, sample_rate: f32) {
|
||||
let len = max_len(ch);
|
||||
let out = render(ch, plugin, sample_rate, len);
|
||||
for (c, o) in ch.iter_mut().zip(out) {
|
||||
for (d, s) in c.iter_mut().zip(o) {
|
||||
*d = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`apply_plugin`] but returns new buffers extended by the plugin's tail (capped at 60 s).
|
||||
pub fn apply_plugin_with_tail(ch: &[Vec<f32>], plugin: &mut dyn Plugin, sample_rate: f32) -> Vec<Vec<f32>> {
|
||||
let len = max_len(ch);
|
||||
plugin.prepare(sane_sr(sample_rate), 1024, ch.len());
|
||||
let tail = plugin.tail_samples().min((sane_sr(sample_rate) * 60.0) as usize);
|
||||
render(ch, plugin, sample_rate, (len + tail).min(MAX_OFFLINE_SAMPLES))
|
||||
}
|
||||
|
||||
fn render(ch: &[Vec<f32>], plugin: &mut dyn Plugin, sample_rate: f32, out_len: usize) -> Vec<Vec<f32>> {
|
||||
const BLOCK: usize = 1024;
|
||||
let sr = sane_sr(sample_rate);
|
||||
let channels = ch.len();
|
||||
plugin.prepare(sr, BLOCK, channels);
|
||||
plugin.reset();
|
||||
let lat = plugin.latency().min(MAX_OFFLINE_SAMPLES);
|
||||
let mut out: Vec<Vec<f32>> = (0..channels).map(|_| vec![0.0; out_len]).collect();
|
||||
let mut scratch: Vec<Vec<f32>> = (0..channels).map(|_| vec![0.0; BLOCK]).collect();
|
||||
let total = out_len + lat;
|
||||
let mut done = 0usize;
|
||||
while done < total {
|
||||
let n = BLOCK.min(total - done);
|
||||
for (s, c) in scratch.iter_mut().zip(ch) {
|
||||
for (i, v) in s.iter_mut().enumerate() {
|
||||
*v = if i < n { c.get(done + i).copied().unwrap_or(0.0) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
plugin.process(&mut scratch, n);
|
||||
for (s, o) in scratch.iter().zip(out.iter_mut()) {
|
||||
for (i, v) in s.iter().take(n).enumerate() {
|
||||
let idx = done + i;
|
||||
if idx >= lat
|
||||
&& let Some(d) = o.get_mut(idx - lat)
|
||||
{
|
||||
*d = if v.is_finite() { *v } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
done += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Peak level of the buffers in dBFS (-144 for silence).
|
||||
pub fn peak_db(ch: &[Vec<f32>]) -> f32 {
|
||||
gain_to_db(peak(ch))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fade_shapes_endpoints() {
|
||||
for s in FadeShape::ALL {
|
||||
assert!(s.gain(0.0).abs() < 1e-6, "{s:?}");
|
||||
assert!((s.gain(1.0) - 1.0).abs() < 1e-5, "{s:?}");
|
||||
assert!(s.gain(f32::NAN) == 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fade_hits_zero_at_edges() {
|
||||
let mut ch = vec![vec![1.0f32; 100]];
|
||||
fade(&mut ch, 10, 10, FadeShape::Linear);
|
||||
assert_eq!(ch[0][0], 0.0);
|
||||
assert_eq!(ch[0][99], 0.0);
|
||||
assert_eq!(ch[0][50], 1.0);
|
||||
fade(&mut ch, 1000, 1000, FadeShape::SCurve);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Band-limited (PolyBLEP) oscillators shared by the generator and the synth.
|
||||
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
use crate::util::{PinkNoise, Rng};
|
||||
|
||||
/// Test-signal / oscillator waveforms.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Waveform {
|
||||
Sine,
|
||||
Square,
|
||||
Saw,
|
||||
Triangle,
|
||||
WhiteNoise,
|
||||
PinkNoise,
|
||||
}
|
||||
|
||||
impl Waveform {
|
||||
pub const ALL: [Waveform; 6] = [Waveform::Sine, Waveform::Square, Waveform::Saw, Waveform::Triangle, Waveform::WhiteNoise, Waveform::PinkNoise];
|
||||
|
||||
/// Waveform for a choice index (out of range → Sine).
|
||||
pub fn from_index(i: usize) -> Waveform {
|
||||
Self::ALL.get(i).copied().unwrap_or(Waveform::Sine)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn poly_blep(t: f32, dt: f32) -> f32 {
|
||||
if dt <= 0.0 {
|
||||
0.0
|
||||
} else if t < dt {
|
||||
let t = t / dt;
|
||||
2.0 * t - t * t - 1.0
|
||||
} else if t > 1.0 - dt {
|
||||
let t = (t - 1.0) / dt;
|
||||
t * t + 2.0 * t + 1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase-accumulator oscillator. Output peaks at ±1 (noise is uniform ±1 / pink ≈ ±1).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct Osc {
|
||||
pub(crate) phase: f32,
|
||||
rng: Rng,
|
||||
pink: PinkNoise,
|
||||
}
|
||||
|
||||
impl Osc {
|
||||
pub(crate) fn new(seed: u32) -> Self {
|
||||
Self { phase: 0.0, rng: Rng::new(seed), pink: PinkNoise::new() }
|
||||
}
|
||||
|
||||
pub(crate) fn reset(&mut self) {
|
||||
self.phase = 0.0;
|
||||
self.pink = PinkNoise::new();
|
||||
}
|
||||
|
||||
/// Produces one sample; `inc` is frequency / sample rate.
|
||||
#[inline]
|
||||
pub(crate) fn next(&mut self, wave: Waveform, inc: f32) -> f32 {
|
||||
let dt = if inc.is_finite() { inc.clamp(0.0, 0.49) } else { 0.0 };
|
||||
let t = self.phase;
|
||||
let out = match wave {
|
||||
Waveform::Sine => (t * TAU).sin(),
|
||||
Waveform::Saw => 2.0 * t - 1.0 - poly_blep(t, dt),
|
||||
Waveform::Square => {
|
||||
let naive = if t < 0.5 { 1.0 } else { -1.0 };
|
||||
let t2 = if t + 0.5 >= 1.0 { t - 0.5 } else { t + 0.5 };
|
||||
naive + poly_blep(t, dt) - poly_blep(t2, dt)
|
||||
}
|
||||
Waveform::Triangle => {
|
||||
// Starts at 0 rising, like a sine.
|
||||
let u = t + 0.25;
|
||||
let u = if u >= 1.0 { u - 1.0 } else { u };
|
||||
1.0 - 4.0 * (u - 0.5).abs()
|
||||
}
|
||||
Waveform::WhiteNoise => self.rng.bipolar(),
|
||||
Waveform::PinkNoise => {
|
||||
let w = self.rng.bipolar();
|
||||
self.pink.next(w).clamp(-1.0, 1.0)
|
||||
}
|
||||
};
|
||||
self.phase += dt;
|
||||
if self.phase >= 1.0 {
|
||||
self.phase -= 1.0;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Mono-to-stereo pan laws.
|
||||
|
||||
use std::f32::consts::FRAC_PI_4;
|
||||
|
||||
/// Attenuation of a centered signal.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum PanLaw {
|
||||
/// Constant power: -3 dB at center.
|
||||
#[default]
|
||||
Minus3,
|
||||
/// Compromise: -4.5 dB at center.
|
||||
Minus4_5,
|
||||
/// Constant voltage: -6 dB at center.
|
||||
Minus6,
|
||||
/// Balance: 0 dB at center, the far side fades out linearly.
|
||||
Zero,
|
||||
}
|
||||
|
||||
impl PanLaw {
|
||||
/// Center attenuation in dB (negative).
|
||||
pub fn center_db(self) -> f32 {
|
||||
match self {
|
||||
PanLaw::Minus3 => -3.0,
|
||||
PanLaw::Minus4_5 => -4.5,
|
||||
PanLaw::Minus6 => -6.0,
|
||||
PanLaw::Zero => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Left/right gains for `pan` in -1 (hard left) ..= 1 (hard right). NaN pans center.
|
||||
pub fn gains(pan: f32, law: PanLaw) -> (f32, f32) {
|
||||
let pan = if pan.is_nan() { 0.0 } else { pan.clamp(-1.0, 1.0) };
|
||||
if law == PanLaw::Zero {
|
||||
return ((1.0 - pan).min(1.0), (1.0 + pan).min(1.0));
|
||||
}
|
||||
let theta = (pan + 1.0) * FRAC_PI_4;
|
||||
let (l, r) = (theta.cos().max(0.0), theta.sin().max(0.0));
|
||||
// cos(pi/4) = -3.0103 dB; raise to a power to reach the requested center level.
|
||||
let exp = law.center_db() / -3.010_3;
|
||||
let l = if pan <= -1.0 { 1.0 } else { l.powf(exp) };
|
||||
let r = if pan >= 1.0 { 1.0 } else { r.powf(exp) };
|
||||
(l, r)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gain_to_db;
|
||||
|
||||
#[test]
|
||||
fn center_levels() {
|
||||
for law in [PanLaw::Minus3, PanLaw::Minus4_5, PanLaw::Minus6, PanLaw::Zero] {
|
||||
let (l, r) = gains(0.0, law);
|
||||
assert!((gain_to_db(l) - law.center_db()).abs() < 0.02, "{law:?}");
|
||||
assert!((l - r).abs() < 1e-6);
|
||||
let (l, r) = gains(-1.0, law);
|
||||
assert!((l - 1.0).abs() < 1e-6 && r.abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Parameter storage and const constructors for static parameter tables.
|
||||
|
||||
use crate::{ParamInfo, Taper, Unit};
|
||||
|
||||
/// A continuous parameter.
|
||||
pub(crate) const fn p(id: &'static str, name: &'static str, min: f32, max: f32, default: f32, unit: Unit, taper: Taper) -> ParamInfo {
|
||||
ParamInfo { id, name, min, max, default, unit, taper, choices: &[] }
|
||||
}
|
||||
|
||||
/// A linear-taper parameter.
|
||||
pub(crate) const fn lin(id: &'static str, name: &'static str, min: f32, max: f32, default: f32, unit: Unit) -> ParamInfo {
|
||||
p(id, name, min, max, default, unit, Taper::Linear)
|
||||
}
|
||||
|
||||
/// A log-taper parameter (frequencies, times, ratios).
|
||||
pub(crate) const fn log(id: &'static str, name: &'static str, min: f32, max: f32, default: f32, unit: Unit) -> ParamInfo {
|
||||
p(id, name, min, max, default, unit, Taper::Log)
|
||||
}
|
||||
|
||||
/// An on/off switch.
|
||||
pub(crate) const fn toggle(id: &'static str, name: &'static str, default_on: bool) -> ParamInfo {
|
||||
ParamInfo { id, name, min: 0.0, max: 1.0, default: if default_on { 1.0 } else { 0.0 }, unit: Unit::Toggle, taper: Taper::Linear, choices: &[] }
|
||||
}
|
||||
|
||||
/// A list choice; the value is the index.
|
||||
pub(crate) const fn choice(id: &'static str, name: &'static str, choices: &'static [&'static str], default: usize) -> ParamInfo {
|
||||
let max = if choices.is_empty() { 0.0 } else { (choices.len() - 1) as f32 };
|
||||
ParamInfo { id, name, min: 0.0, max, default: default as f32, unit: Unit::Choice, taper: Taper::Linear, choices }
|
||||
}
|
||||
|
||||
/// Current values for a plugin's parameter table (allocated once at construction).
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Params {
|
||||
info: &'static [ParamInfo],
|
||||
values: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Params {
|
||||
pub(crate) fn new(info: &'static [ParamInfo]) -> Self {
|
||||
Self { info, values: info.iter().map(|p| p.default).collect() }
|
||||
}
|
||||
|
||||
/// Clamps and stores; returns the parameter index, or `None` for an unknown id.
|
||||
pub(crate) fn set(&mut self, id: &str, value: f32) -> Option<usize> {
|
||||
let idx = self.info.iter().position(|p| p.id == id)?;
|
||||
let info = self.info.get(idx)?;
|
||||
let slot = self.values.get_mut(idx)?;
|
||||
*slot = info.clamp(value);
|
||||
Some(idx)
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, id: &str) -> Option<f32> {
|
||||
let idx = self.info.iter().position(|p| p.id == id)?;
|
||||
self.values.get(idx).copied()
|
||||
}
|
||||
|
||||
/// Value by index (0.0 if out of range, which only a table/index mismatch could cause).
|
||||
#[inline]
|
||||
pub(crate) fn v(&self, idx: usize) -> f32 {
|
||||
self.values.get(idx).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn on(&self, idx: usize) -> bool {
|
||||
self.v(idx) >= 0.5
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn choice(&self, idx: usize) -> usize {
|
||||
let v = self.v(idx);
|
||||
if v.is_finite() && v > 0.0 { v.round() as usize } else { 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements `info`, `set_param` and `param` for a plugin struct with a `p: Params` field and an
|
||||
/// `update(&mut self)` method that pushes new values to smoothers/coefficients.
|
||||
macro_rules! param_plumbing {
|
||||
($info:expr) => {
|
||||
fn info(&self) -> &'static $crate::PluginInfo {
|
||||
&$info
|
||||
}
|
||||
fn set_param(&mut self, id: &str, value: f32) -> bool {
|
||||
if self.p.set(id, value).is_some() {
|
||||
self.update();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
fn param(&self, id: &str) -> Option<f32> {
|
||||
self.p.get(id)
|
||||
}
|
||||
};
|
||||
}
|
||||
pub(crate) use param_plumbing;
|
||||
@@ -0,0 +1,400 @@
|
||||
//! Drum Synth: fully synthesized kit on a General-MIDI-style note map.
|
||||
//!
|
||||
//! 35/36 kick, 37/39 clap, 38/40 snare, 42/44 closed hat (chokes open hat), 46 open hat,
|
||||
//! 41/43 floor tom, 45 low tom, 47/48 mid tom, 50 high tom, 49/57 crash, 51/59 ride.
|
||||
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
use super::synth::{EventKind, EventQueue, MAX_EVENTS};
|
||||
use crate::biquad::{Biquad, FilterType};
|
||||
use crate::params::{Params, lin, log, param_plumbing};
|
||||
use crate::util::{Rng, Smoother, frames_in, ms_to_samples, sane_channels, sane_sr};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain};
|
||||
|
||||
static DRUM_PARAMS: [ParamInfo; 11] = [
|
||||
lin("level", "Level", -48.0, 6.0, -6.0, Unit::Db),
|
||||
lin("kick_tune", "Kick Tune", -12.0, 12.0, 0.0, Unit::Semitones),
|
||||
log("kick_decay", "Kick Decay", 50.0, 2000.0, 450.0, Unit::Ms),
|
||||
lin("snare_tune", "Snare Tune", -12.0, 12.0, 0.0, Unit::Semitones),
|
||||
lin("snare_snappy", "Snare Snappy", 0.0, 100.0, 60.0, Unit::Percent),
|
||||
log("snare_decay", "Snare Decay", 50.0, 1000.0, 220.0, Unit::Ms),
|
||||
log("hat_decay", "Closed Hat Decay", 20.0, 500.0, 80.0, Unit::Ms),
|
||||
log("open_hat_decay", "Open Hat Decay", 100.0, 3000.0, 600.0, Unit::Ms),
|
||||
lin("tom_tune", "Tom Tune", -12.0, 12.0, 0.0, Unit::Semitones),
|
||||
log("tom_decay", "Tom Decay", 50.0, 2000.0, 500.0, Unit::Ms),
|
||||
log("cymbal_decay", "Cymbal Decay", 200.0, 6000.0, 2000.0, Unit::Ms),
|
||||
];
|
||||
|
||||
pub static DRUM_INFO: PluginInfo = PluginInfo {
|
||||
id: "drum_synth",
|
||||
name: "Drum Synth",
|
||||
short_name: "Drums",
|
||||
category: Category::Instrument,
|
||||
params: &DRUM_PARAMS,
|
||||
is_instrument: true,
|
||||
audiosuite: false,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum Kind {
|
||||
Kick,
|
||||
Snare,
|
||||
Clap,
|
||||
ClosedHat,
|
||||
OpenHat,
|
||||
Tom(f32),
|
||||
Crash,
|
||||
Ride,
|
||||
}
|
||||
|
||||
fn kind_for(note: u8) -> Option<Kind> {
|
||||
Some(match note {
|
||||
35 | 36 => Kind::Kick,
|
||||
38 | 40 => Kind::Snare,
|
||||
37 | 39 => Kind::Clap,
|
||||
42 | 44 => Kind::ClosedHat,
|
||||
46 => Kind::OpenHat,
|
||||
41 | 43 => Kind::Tom(82.0),
|
||||
45 => Kind::Tom(110.0),
|
||||
47 | 48 => Kind::Tom(147.0),
|
||||
50 => Kind::Tom(196.0),
|
||||
49 | 57 => Kind::Crash,
|
||||
51 | 59 => Kind::Ride,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Inharmonic partials (Hz) for the metallic cymbal/hat oscillator bank.
|
||||
const METAL: [f32; 6] = [263.0, 400.0, 421.0, 474.0, 587.0, 845.0];
|
||||
const VOICES: usize = 24;
|
||||
const OFF: f32 = 1.0e-4;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct DrumVoice {
|
||||
kind: Option<Kind>,
|
||||
vel: f32,
|
||||
t: usize,
|
||||
amp: f32,
|
||||
amp_coef: f32,
|
||||
noise_amp: f32,
|
||||
noise_coef: f32,
|
||||
pitch_env: f32,
|
||||
pitch_coef: f32,
|
||||
f_start: f32,
|
||||
f_end: f32,
|
||||
phase: f32,
|
||||
metal: [f32; 6],
|
||||
filt: Biquad,
|
||||
filt2: Biquad,
|
||||
rng: Rng,
|
||||
age: u64,
|
||||
}
|
||||
|
||||
impl DrumVoice {
|
||||
fn new(seed: u32) -> Self {
|
||||
Self {
|
||||
kind: None,
|
||||
vel: 0.0,
|
||||
t: 0,
|
||||
amp: 0.0,
|
||||
amp_coef: 0.0,
|
||||
noise_amp: 0.0,
|
||||
noise_coef: 0.0,
|
||||
pitch_env: 0.0,
|
||||
pitch_coef: 0.0,
|
||||
f_start: 0.0,
|
||||
f_end: 0.0,
|
||||
phase: 0.0,
|
||||
metal: [0.0; 6],
|
||||
filt: Biquad::default(),
|
||||
filt2: Biquad::default(),
|
||||
rng: Rng::new(seed),
|
||||
age: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Coefficient that decays by 60 dB over `ms`.
|
||||
fn decay60(ms: f32, sr: f32) -> f32 {
|
||||
(0.001f32.ln() / ms_to_samples(ms, sr).max(1.0)).exp()
|
||||
}
|
||||
|
||||
pub struct DrumSynth {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
ch: usize,
|
||||
voices: [DrumVoice; VOICES],
|
||||
events: EventQueue,
|
||||
counter: u64,
|
||||
level: Smoother,
|
||||
}
|
||||
|
||||
impl Default for DrumSynth {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DrumSynth {
|
||||
pub fn new() -> Self {
|
||||
let mut voices = [DrumVoice::new(1); VOICES];
|
||||
for (i, v) in voices.iter_mut().enumerate() {
|
||||
*v = DrumVoice::new(0xBEEF + i as u32 * 977);
|
||||
}
|
||||
let mut d =
|
||||
Self { p: Params::new(&DRUM_PARAMS), sr: 48_000.0, ch: 0, voices, events: EventQueue::new(), counter: 0, level: Smoother::new(0.5) };
|
||||
d.update();
|
||||
d
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.level.set(db_to_gain(self.p.v(0)));
|
||||
}
|
||||
|
||||
fn trigger(&mut self, note: u8, vel: u8) {
|
||||
let Some(kind) = kind_for(note) else { return };
|
||||
if vel == 0 {
|
||||
return;
|
||||
}
|
||||
let sr = self.sr;
|
||||
if kind == Kind::ClosedHat {
|
||||
let choke = decay60(15.0, sr);
|
||||
for v in self.voices.iter_mut().filter(|v| v.kind == Some(Kind::OpenHat)) {
|
||||
v.amp_coef = v.amp_coef.min(choke);
|
||||
v.noise_coef = v.noise_coef.min(choke);
|
||||
}
|
||||
}
|
||||
self.counter += 1;
|
||||
let idx = self
|
||||
.voices
|
||||
.iter()
|
||||
.position(|v| v.kind.is_none())
|
||||
.or_else(|| self.voices.iter().enumerate().min_by_key(|(_, v)| v.age).map(|(i, _)| i))
|
||||
.unwrap_or(0);
|
||||
let v = |i| self.p.v(i);
|
||||
let semis = |st: f32| 2f32.powf(st / 12.0);
|
||||
let (kick_tune, kick_decay, snare_tune, snappy, snare_decay) = (semis(v(1)), v(2), semis(v(3)), v(4) / 100.0, v(5));
|
||||
let (hat, open, tom_tune, tom_decay, cym) = (v(6), v(7), semis(v(8)), v(9), v(10));
|
||||
let counter = self.counter;
|
||||
let Some(dv) = self.voices.get_mut(idx) else { return };
|
||||
let seed = dv.rng.next_u32();
|
||||
*dv = DrumVoice::new(seed);
|
||||
dv.kind = Some(kind);
|
||||
dv.vel = f32::from(vel.min(127)) / 127.0;
|
||||
dv.age = counter;
|
||||
dv.amp = 1.0;
|
||||
dv.pitch_env = 1.0;
|
||||
match kind {
|
||||
Kind::Kick => {
|
||||
dv.f_start = 170.0 * kick_tune;
|
||||
dv.f_end = 48.0 * kick_tune;
|
||||
dv.pitch_coef = decay60(120.0, sr);
|
||||
dv.amp_coef = decay60(kick_decay, sr);
|
||||
dv.noise_amp = 0.5;
|
||||
dv.noise_coef = decay60(6.0, sr);
|
||||
dv.filt = Biquad::design(FilterType::LowPass, 3000.0, 0.7, 0.0, sr);
|
||||
}
|
||||
Kind::Snare => {
|
||||
dv.f_start = 260.0 * snare_tune;
|
||||
dv.f_end = 185.0 * snare_tune;
|
||||
dv.pitch_coef = decay60(40.0, sr);
|
||||
dv.amp_coef = decay60(snare_decay * 0.6, sr);
|
||||
dv.noise_amp = 0.3 + snappy;
|
||||
dv.noise_coef = decay60(snare_decay, sr);
|
||||
dv.filt = Biquad::design(FilterType::HighPass, 1200.0, 0.7, 0.0, sr);
|
||||
dv.filt2 = Biquad::design(FilterType::LowPass, 9000.0, 0.7, 0.0, sr);
|
||||
}
|
||||
Kind::Clap => {
|
||||
dv.amp = 0.0;
|
||||
dv.noise_amp = 1.0;
|
||||
dv.noise_coef = decay60(250.0, sr);
|
||||
dv.amp_coef = decay60(25.0, sr);
|
||||
dv.filt = Biquad::design(FilterType::BandPass, 1150.0, 1.6, 0.0, sr);
|
||||
}
|
||||
Kind::ClosedHat | Kind::OpenHat | Kind::Crash | Kind::Ride => {
|
||||
let (ms, hp, noise) = match kind {
|
||||
Kind::ClosedHat => (hat, 7000.0, 0.6),
|
||||
Kind::OpenHat => (open, 6500.0, 0.6),
|
||||
Kind::Crash => (cym, 4500.0, 0.9),
|
||||
_ => (cym * 0.6, 5500.0, 0.25),
|
||||
};
|
||||
dv.amp_coef = decay60(ms, sr);
|
||||
dv.noise_amp = noise;
|
||||
dv.noise_coef = dv.amp_coef;
|
||||
dv.filt = Biquad::design(FilterType::HighPass, hp, 0.7, 0.0, sr);
|
||||
dv.filt2 = Biquad::design(FilterType::HighPass, hp, 0.7, 0.0, sr);
|
||||
for (i, m) in dv.metal.iter_mut().enumerate() {
|
||||
*m = i as f32 * 0.13;
|
||||
}
|
||||
}
|
||||
Kind::Tom(base) => {
|
||||
dv.f_start = base * 1.6 * tom_tune;
|
||||
dv.f_end = base * tom_tune;
|
||||
dv.pitch_coef = decay60(180.0, sr);
|
||||
dv.amp_coef = decay60(tom_decay, sr);
|
||||
dv.noise_amp = 0.15;
|
||||
dv.noise_coef = decay60(10.0, sr);
|
||||
dv.filt = Biquad::design(FilterType::LowPass, 4000.0, 0.7, 0.0, sr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn render(&mut self) -> f32 {
|
||||
let sr = self.sr;
|
||||
let clap_gap = ms_to_samples(11.0, sr).max(1.0) as usize;
|
||||
let mut out = 0.0;
|
||||
for v in &mut self.voices {
|
||||
let Some(kind) = v.kind else { continue };
|
||||
let y = match kind {
|
||||
Kind::Kick | Kind::Tom(_) => {
|
||||
v.pitch_env *= v.pitch_coef;
|
||||
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
|
||||
v.phase += f / sr;
|
||||
v.phase -= v.phase.floor();
|
||||
let tone = (v.phase * TAU).sin() * v.amp;
|
||||
let click = v.filt.process_sample(v.rng.bipolar()) * v.noise_amp;
|
||||
v.amp *= v.amp_coef;
|
||||
v.noise_amp *= v.noise_coef;
|
||||
tone + click
|
||||
}
|
||||
Kind::Snare => {
|
||||
v.pitch_env *= v.pitch_coef;
|
||||
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
|
||||
v.phase += f / sr;
|
||||
v.phase -= v.phase.floor();
|
||||
let tone = (v.phase * TAU).sin() * v.amp * 0.6;
|
||||
let n = v.filt2.process_sample(v.filt.process_sample(v.rng.bipolar())) * v.noise_amp * 0.7;
|
||||
v.amp *= v.amp_coef;
|
||||
v.noise_amp *= v.noise_coef;
|
||||
tone + n
|
||||
}
|
||||
Kind::Clap => {
|
||||
// Three quick bursts then a diffuse tail.
|
||||
let burst = if v.t < clap_gap * 3 {
|
||||
if v.t % clap_gap == 0 {
|
||||
v.amp = 1.0;
|
||||
}
|
||||
v.amp
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let env = burst.max(if v.t >= clap_gap * 2 { v.noise_amp * 0.6 } else { 0.0 });
|
||||
let y = v.filt.process_sample(v.rng.bipolar()) * env * 2.0;
|
||||
v.amp *= v.amp_coef;
|
||||
if v.t >= clap_gap * 2 {
|
||||
v.noise_amp *= v.noise_coef;
|
||||
}
|
||||
y
|
||||
}
|
||||
Kind::ClosedHat | Kind::OpenHat | Kind::Crash | Kind::Ride => {
|
||||
let scale = if matches!(kind, Kind::Crash | Kind::Ride) { 1.0 } else { 1.9 };
|
||||
let mut metal = 0.0;
|
||||
for (m, f) in v.metal.iter_mut().zip(METAL) {
|
||||
*m += f * scale / sr;
|
||||
*m -= m.floor();
|
||||
metal += if *m < 0.5 { 1.0 } else { -1.0 };
|
||||
}
|
||||
let raw = metal / 6.0 * (1.0 - v.noise_amp * 0.5) + v.rng.bipolar() * v.noise_amp;
|
||||
let y = v.filt2.process_sample(v.filt.process_sample(raw)) * v.amp;
|
||||
v.amp *= v.amp_coef;
|
||||
y
|
||||
}
|
||||
};
|
||||
v.t += 1;
|
||||
let alive = match kind {
|
||||
Kind::Clap => v.t < clap_gap * 3 || v.noise_amp > OFF,
|
||||
_ => v.amp > OFF || v.noise_amp * v.noise_coef > OFF && v.t < (sr * 0.05) as usize,
|
||||
};
|
||||
if !alive {
|
||||
v.kind = None;
|
||||
}
|
||||
out += y * v.vel;
|
||||
}
|
||||
out * 0.5
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for DrumSynth {
|
||||
param_plumbing!(DRUM_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.ch = sane_channels(channels);
|
||||
self.level.set_time(20.0, self.sr);
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
for v in &mut self.voices {
|
||||
v.kind = None;
|
||||
}
|
||||
self.events.clear();
|
||||
self.level.snap();
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.ch.min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let mut evs = [(0usize, EventKind::AllOff); MAX_EVENTS];
|
||||
let count = {
|
||||
let sorted = self.events.sorted();
|
||||
for (d, s) in evs.iter_mut().zip(sorted) {
|
||||
*d = (s.offset, s.kind);
|
||||
}
|
||||
sorted.len().min(MAX_EVENTS)
|
||||
};
|
||||
self.events.clear();
|
||||
let mut next = 0;
|
||||
for n in 0..frames {
|
||||
while next < count {
|
||||
let Some(&(off, kind)) = evs.get(next) else { break };
|
||||
if off > n && n + 1 < frames {
|
||||
break;
|
||||
}
|
||||
self.apply(kind);
|
||||
next += 1;
|
||||
}
|
||||
let y = self.render() * self.level.next();
|
||||
for b in io.iter_mut().take(ch) {
|
||||
if let Some(x) = b.get_mut(n) {
|
||||
*x = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
for &(_, kind) in evs.iter().take(count).skip(next) {
|
||||
self.apply(kind);
|
||||
}
|
||||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
ms_to_samples(self.p.v(10).max(self.p.v(2)), self.sr) as usize
|
||||
}
|
||||
|
||||
fn note_on(&mut self, offset: usize, note: u8, velocity: u8) {
|
||||
self.events.push(offset, EventKind::On(note, velocity));
|
||||
}
|
||||
|
||||
fn note_off(&mut self, _offset: usize, _note: u8) {}
|
||||
|
||||
fn all_notes_off(&mut self) {
|
||||
self.events.push(0, EventKind::AllOff);
|
||||
}
|
||||
}
|
||||
|
||||
impl DrumSynth {
|
||||
fn apply(&mut self, kind: EventKind) {
|
||||
match kind {
|
||||
EventKind::On(n, v) => self.trigger(n, v),
|
||||
EventKind::Off(_) => {}
|
||||
EventKind::AllOff => {
|
||||
let fast = decay60(30.0, self.sr);
|
||||
for v in &mut self.voices {
|
||||
v.amp_coef = v.amp_coef.min(fast);
|
||||
v.noise_coef = v.noise_coef.min(fast);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
//! Dynamics: Compressor/Limiter, Expander/Gate, De-Esser and Maximizer.
|
||||
//!
|
||||
//! All detectors are stereo-linked (the loudest channel drives a shared gain) so the image does
|
||||
//! not wander. Gain computers work in the log domain with smoothed gain reduction.
|
||||
|
||||
use crate::biquad::{Biquad, Coeffs, FilterType};
|
||||
use crate::params::{Params, lin, log, param_plumbing, toggle};
|
||||
use crate::util::{DelayLine, Smoother, frames_in, ms_to_samples, sane_channels, sane_sr, time_coef};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain, gain_to_db};
|
||||
|
||||
/// Static soft-knee gain computer: returns the gain reduction amount in dB (≥ 0).
|
||||
#[inline]
|
||||
pub(crate) fn compress_gr(level_db: f32, threshold: f32, ratio: f32, knee: f32) -> f32 {
|
||||
let slope = 1.0 - 1.0 / ratio.max(1.0);
|
||||
let over = level_db - threshold;
|
||||
if knee > 1e-3 && 2.0 * over.abs() <= knee {
|
||||
let t = over + knee / 2.0;
|
||||
slope * t * t / (2.0 * knee)
|
||||
} else if over > 0.0 {
|
||||
slope * over
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn ballistic(state: f32, target: f32, att: f32, rel: f32) -> f32 {
|
||||
let c = if target > state { att } else { rel };
|
||||
target + (state - target) * c
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
static COMP_PARAMS: [ParamInfo; 11] = [
|
||||
lin("threshold", "Threshold", -60.0, 0.0, -20.0, Unit::Db),
|
||||
log("ratio", "Ratio", 1.0, 100.0, 4.0, Unit::Ratio),
|
||||
log("attack", "Attack", 0.01, 300.0, 10.0, Unit::Ms),
|
||||
log("release", "Release", 5.0, 3000.0, 100.0, Unit::Ms),
|
||||
lin("knee", "Knee", 0.0, 24.0, 6.0, Unit::Db),
|
||||
lin("makeup", "Makeup", 0.0, 40.0, 0.0, Unit::Db),
|
||||
toggle("sc_hpf_on", "SC HPF", false),
|
||||
log("sc_hpf_freq", "SC HPF Freq", 20.0, 500.0, 100.0, Unit::Hz),
|
||||
toggle("limiter", "Limit", false),
|
||||
lin("lookahead", "Lookahead", 0.0, 10.0, 0.0, Unit::Ms),
|
||||
lin("mix", "Mix", 0.0, 100.0, 100.0, Unit::Percent),
|
||||
];
|
||||
|
||||
pub static COMP_INFO: PluginInfo = PluginInfo {
|
||||
id: "compressor",
|
||||
name: "Compressor/Limiter",
|
||||
short_name: "Comp",
|
||||
category: Category::Dynamics,
|
||||
params: &COMP_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// Feed-forward compressor / limiter with optional lookahead and sidechain high-pass.
|
||||
pub struct Compressor {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
sc: Vec<Biquad>,
|
||||
delay: Vec<DelayLine>,
|
||||
gr: f32,
|
||||
att: f32,
|
||||
rel: f32,
|
||||
makeup: Smoother,
|
||||
mix: Smoother,
|
||||
lookahead: usize,
|
||||
gr_meter: f32,
|
||||
}
|
||||
|
||||
impl Default for Compressor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Compressor {
|
||||
pub fn new() -> Self {
|
||||
let mut c = Self {
|
||||
p: Params::new(&COMP_PARAMS),
|
||||
sr: 48_000.0,
|
||||
sc: Vec::new(),
|
||||
delay: Vec::new(),
|
||||
gr: 0.0,
|
||||
att: 0.0,
|
||||
rel: 0.0,
|
||||
makeup: Smoother::new(1.0),
|
||||
mix: Smoother::new(1.0),
|
||||
lookahead: 0,
|
||||
gr_meter: 0.0,
|
||||
};
|
||||
c.update();
|
||||
c
|
||||
}
|
||||
|
||||
fn limiting(&self) -> bool {
|
||||
self.p.on(8)
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
let sr = self.sr;
|
||||
self.att = time_coef(self.p.v(2), sr);
|
||||
self.rel = time_coef(self.p.v(3), sr);
|
||||
self.makeup.set(db_to_gain(self.p.v(5)));
|
||||
self.mix.set(self.p.v(10) / 100.0);
|
||||
let f = self.p.v(7);
|
||||
for b in &mut self.sc {
|
||||
b.set(FilterType::HighPass, f, std::f32::consts::FRAC_1_SQRT_2, 0.0, sr);
|
||||
}
|
||||
let max = self.delay.first().map(|d| d.max_delay() as usize).unwrap_or(0);
|
||||
self.lookahead = (ms_to_samples(self.p.v(9), sr).round() as usize).min(max);
|
||||
}
|
||||
|
||||
/// Processes one frame of gain computation from a sidechain level (linear).
|
||||
#[inline]
|
||||
fn gain_for(&mut self, level: f32) -> f32 {
|
||||
let ratio = if self.limiting() { 1000.0 } else { self.p.v(1) };
|
||||
let target = compress_gr(gain_to_db(level), self.p.v(0), ratio, self.p.v(4));
|
||||
self.gr = ballistic(self.gr, target, self.att, self.rel);
|
||||
if !self.gr.is_finite() {
|
||||
self.gr = 0.0;
|
||||
}
|
||||
self.gr
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for Compressor {
|
||||
param_plumbing!(COMP_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
let ch = sane_channels(channels);
|
||||
self.sc = vec![Biquad::default(); ch];
|
||||
let max = ms_to_samples(10.0, self.sr).ceil() as usize + 2;
|
||||
self.delay = (0..ch)
|
||||
.map(|_| {
|
||||
let mut d = DelayLine::default();
|
||||
d.allocate(max);
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
self.makeup.set_time(20.0, self.sr);
|
||||
self.mix.set_time(20.0, self.sr);
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.sc.iter_mut().for_each(Biquad::reset);
|
||||
self.delay.iter_mut().for_each(DelayLine::clear);
|
||||
self.gr = 0.0;
|
||||
self.gr_meter = 0.0;
|
||||
self.makeup.snap();
|
||||
self.mix.snap();
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.sc.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let hpf = self.p.on(6);
|
||||
let la = self.lookahead;
|
||||
let mut max_gr = 0.0f32;
|
||||
for n in 0..frames {
|
||||
let mut level = 0.0f32;
|
||||
for c in 0..ch {
|
||||
let x = io.get(c).and_then(|b| b.get(n)).copied().unwrap_or(0.0);
|
||||
let s = match self.sc.get_mut(c) {
|
||||
Some(f) if hpf => f.process_sample(x),
|
||||
_ => x,
|
||||
};
|
||||
if s.is_finite() {
|
||||
level = level.max(s.abs());
|
||||
}
|
||||
}
|
||||
let gr = self.gain_for(level);
|
||||
max_gr = max_gr.max(gr);
|
||||
let g = db_to_gain(-gr) * self.makeup.next();
|
||||
let m = self.mix.next();
|
||||
for c in 0..ch {
|
||||
let (Some(buf), Some(d)) = (io.get_mut(c), self.delay.get_mut(c)) else { continue };
|
||||
let Some(x) = buf.get_mut(n) else { continue };
|
||||
d.push(*x);
|
||||
let dry = if la == 0 { *x } else { d.tap(la + 1) };
|
||||
*x = dry * (1.0 - m) + dry * g * m;
|
||||
}
|
||||
}
|
||||
self.gr_meter = max_gr;
|
||||
}
|
||||
|
||||
fn latency(&self) -> usize {
|
||||
self.lookahead
|
||||
}
|
||||
|
||||
fn gain_reduction_db(&self) -> f32 {
|
||||
self.gr_meter
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
static GATE_PARAMS: [ParamInfo; 6] = [
|
||||
lin("threshold", "Threshold", -80.0, 0.0, -40.0, Unit::Db),
|
||||
log("ratio", "Ratio", 1.0, 100.0, 100.0, Unit::Ratio),
|
||||
lin("range", "Range", 0.0, 80.0, 80.0, Unit::Db),
|
||||
log("attack", "Attack", 0.01, 100.0, 0.5, Unit::Ms),
|
||||
lin("hold", "Hold", 0.0, 2000.0, 50.0, Unit::Ms),
|
||||
log("release", "Release", 1.0, 4000.0, 100.0, Unit::Ms),
|
||||
];
|
||||
|
||||
pub static GATE_INFO: PluginInfo = PluginInfo {
|
||||
id: "expander_gate",
|
||||
name: "Expander/Gate",
|
||||
short_name: "Gate",
|
||||
category: Category::Dynamics,
|
||||
params: &GATE_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// Downward expander / gate with hold.
|
||||
pub struct ExpanderGate {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
ch: usize,
|
||||
env: f32,
|
||||
env_rel: f32,
|
||||
gr: f32,
|
||||
att: f32,
|
||||
rel: f32,
|
||||
hold: usize,
|
||||
hold_left: usize,
|
||||
gr_meter: f32,
|
||||
}
|
||||
|
||||
impl Default for ExpanderGate {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExpanderGate {
|
||||
pub fn new() -> Self {
|
||||
let mut g = Self {
|
||||
p: Params::new(&GATE_PARAMS),
|
||||
sr: 48_000.0,
|
||||
ch: 0,
|
||||
env: 0.0,
|
||||
env_rel: 0.0,
|
||||
gr: 0.0,
|
||||
att: 0.0,
|
||||
rel: 0.0,
|
||||
hold: 0,
|
||||
hold_left: 0,
|
||||
gr_meter: 0.0,
|
||||
};
|
||||
g.update();
|
||||
g
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.att = time_coef(self.p.v(3), self.sr);
|
||||
self.rel = time_coef(self.p.v(5), self.sr);
|
||||
self.hold = ms_to_samples(self.p.v(4), self.sr) as usize;
|
||||
self.env_rel = time_coef(5.0, self.sr);
|
||||
}
|
||||
|
||||
/// Returns the gain reduction (dB ≥ 0) for one frame given the linked peak level.
|
||||
#[inline]
|
||||
pub(crate) fn tick(&mut self, level: f32) -> f32 {
|
||||
let level = if level.is_finite() { level } else { 0.0 };
|
||||
self.env = level.max(self.env * self.env_rel);
|
||||
let ldb = gain_to_db(self.env);
|
||||
let thr = self.p.v(0);
|
||||
let mut target = if ldb < thr { ((thr - ldb) * (self.p.v(1) - 1.0)).min(self.p.v(2)) } else { 0.0 };
|
||||
if target <= 0.0 {
|
||||
self.hold_left = self.hold;
|
||||
} else if target > self.gr && self.hold_left > 0 {
|
||||
self.hold_left -= 1;
|
||||
target = self.gr;
|
||||
}
|
||||
// Opening (less reduction) uses attack, closing uses release.
|
||||
let c = if target < self.gr { self.att } else { self.rel };
|
||||
self.gr = target + (self.gr - target) * c;
|
||||
if !self.gr.is_finite() {
|
||||
self.gr = 0.0;
|
||||
}
|
||||
self.gr
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for ExpanderGate {
|
||||
param_plumbing!(GATE_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.ch = sane_channels(channels);
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.env = 0.0;
|
||||
self.gr = 0.0;
|
||||
self.hold_left = 0;
|
||||
self.gr_meter = 0.0;
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.ch.min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let mut max_gr = 0.0f32;
|
||||
for n in 0..frames {
|
||||
let level = io.iter().take(ch).filter_map(|b| b.get(n)).fold(0.0f32, |m, v| m.max(v.abs()));
|
||||
let gr = self.tick(level);
|
||||
max_gr = max_gr.max(gr);
|
||||
let g = db_to_gain(-gr);
|
||||
for b in io.iter_mut().take(ch) {
|
||||
if let Some(x) = b.get_mut(n) {
|
||||
*x *= g;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.gr_meter = max_gr;
|
||||
}
|
||||
|
||||
fn gain_reduction_db(&self) -> f32 {
|
||||
self.gr_meter
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
static DEESS_PARAMS: [ParamInfo; 4] = [
|
||||
log("freq", "Freq", 2000.0, 16000.0, 6000.0, Unit::Hz),
|
||||
lin("range", "Range", 0.0, 40.0, 10.0, Unit::Db),
|
||||
lin("threshold", "Threshold", -60.0, 0.0, -30.0, Unit::Db),
|
||||
toggle("listen", "Listen", false),
|
||||
];
|
||||
|
||||
pub static DEESS_INFO: PluginInfo = PluginInfo {
|
||||
id: "de_esser",
|
||||
name: "De-Esser",
|
||||
short_name: "DeEss",
|
||||
category: Category::Dynamics,
|
||||
params: &DEESS_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// Dynamic-EQ de-esser: a high-pass sidechain detects sibilance and a high shelf at `freq`
|
||||
/// dips by up to `range` dB while it exceeds the threshold. At rest the shelf is flat, so the
|
||||
/// signal passes untouched.
|
||||
pub struct DeEsser {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
hp: Vec<Biquad>,
|
||||
shelf: Vec<Biquad>,
|
||||
env: f32,
|
||||
att: f32,
|
||||
rel: f32,
|
||||
shelf_gr: f32,
|
||||
gr_meter: f32,
|
||||
}
|
||||
|
||||
impl Default for DeEsser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DeEsser {
|
||||
pub fn new() -> Self {
|
||||
let mut d = Self {
|
||||
p: Params::new(&DEESS_PARAMS),
|
||||
sr: 48_000.0,
|
||||
hp: Vec::new(),
|
||||
shelf: Vec::new(),
|
||||
env: 0.0,
|
||||
att: 0.0,
|
||||
rel: 0.0,
|
||||
shelf_gr: 0.0,
|
||||
gr_meter: 0.0,
|
||||
};
|
||||
d.update();
|
||||
d
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.att = time_coef(0.5, self.sr);
|
||||
self.rel = time_coef(60.0, self.sr);
|
||||
let f = self.p.v(0);
|
||||
let sr = self.sr;
|
||||
for b in &mut self.hp {
|
||||
b.set(FilterType::HighPass, f, std::f32::consts::FRAC_1_SQRT_2, 0.0, sr);
|
||||
}
|
||||
self.set_shelf(self.shelf_gr);
|
||||
}
|
||||
|
||||
fn set_shelf(&mut self, gr: f32) {
|
||||
self.shelf_gr = gr;
|
||||
let c = if gr < 0.01 { Coeffs::IDENTITY } else { Coeffs::design(FilterType::HighShelf, self.p.v(0) * 0.8, 0.8, -gr, self.sr) };
|
||||
for b in &mut self.shelf {
|
||||
b.coeffs = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for DeEsser {
|
||||
param_plumbing!(DEESS_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
let ch = sane_channels(channels);
|
||||
self.hp = vec![Biquad::default(); ch];
|
||||
self.shelf = vec![Biquad::default(); ch];
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.hp.iter_mut().for_each(Biquad::reset);
|
||||
self.shelf.iter_mut().for_each(Biquad::reset);
|
||||
self.env = 0.0;
|
||||
self.gr_meter = 0.0;
|
||||
self.set_shelf(0.0);
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
const CHUNK: usize = 16;
|
||||
let ch = self.hp.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let (thr, range, listen) = (self.p.v(2), self.p.v(1), self.p.on(3));
|
||||
let mut max_gr = 0.0f32;
|
||||
let mut n = 0;
|
||||
while n < frames {
|
||||
let len = CHUNK.min(frames - n);
|
||||
for i in n..n + len {
|
||||
let mut level = 0.0f32;
|
||||
for c in 0..ch {
|
||||
let (Some(b), Some(f)) = (io.get_mut(c), self.hp.get_mut(c)) else { continue };
|
||||
let Some(x) = b.get_mut(i) else { continue };
|
||||
let h = f.process_sample(*x);
|
||||
level = level.max(h.abs());
|
||||
if listen {
|
||||
*x = h;
|
||||
}
|
||||
}
|
||||
self.env = ballistic(self.env, level, self.att, self.rel);
|
||||
if !self.env.is_finite() {
|
||||
self.env = 0.0;
|
||||
}
|
||||
}
|
||||
let gr = (gain_to_db(self.env) - thr).clamp(0.0, range);
|
||||
max_gr = max_gr.max(gr);
|
||||
if (gr - self.shelf_gr).abs() > 0.05 || (gr == 0.0 && self.shelf_gr != 0.0) {
|
||||
self.set_shelf(gr);
|
||||
}
|
||||
if !listen {
|
||||
for (b, f) in io.iter_mut().zip(self.shelf.iter_mut()).take(ch) {
|
||||
if let Some(seg) = b.get_mut(n..n + len) {
|
||||
f.process_block(seg);
|
||||
}
|
||||
}
|
||||
}
|
||||
n += len;
|
||||
}
|
||||
self.gr_meter = max_gr;
|
||||
}
|
||||
|
||||
fn gain_reduction_db(&self) -> f32 {
|
||||
self.gr_meter
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
static MAX_PARAMS: [ParamInfo; 3] = [
|
||||
lin("threshold", "Threshold", -30.0, 0.0, 0.0, Unit::Db),
|
||||
lin("ceiling", "Ceiling", -30.0, 0.0, -0.3, Unit::Db),
|
||||
log("release", "Release", 1.0, 1000.0, 50.0, Unit::Ms),
|
||||
];
|
||||
|
||||
pub static MAX_INFO: PluginInfo = PluginInfo {
|
||||
id: "maximizer",
|
||||
name: "Maximizer",
|
||||
short_name: "Max",
|
||||
category: Category::Dynamics,
|
||||
params: &MAX_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
const MAX_LOOKAHEAD_MS: f32 = 1.5;
|
||||
|
||||
/// Lookahead brickwall limiter: the input is driven by `-threshold` dB and never exceeds the
|
||||
/// ceiling. Gain is a min-hold over the lookahead window followed by an equal-length moving
|
||||
/// average, which ramps the gain down before each peak reaches the output.
|
||||
pub struct Maximizer {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
la: usize,
|
||||
delay: Vec<DelayLine>,
|
||||
req: Vec<f32>,
|
||||
smooth: Vec<f32>,
|
||||
idx: usize,
|
||||
sum: f64,
|
||||
held: f32,
|
||||
rel: f32,
|
||||
drive: Smoother,
|
||||
ceiling: Smoother,
|
||||
gr_meter: f32,
|
||||
}
|
||||
|
||||
impl Default for Maximizer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Maximizer {
|
||||
pub fn new() -> Self {
|
||||
let mut m = Self {
|
||||
p: Params::new(&MAX_PARAMS),
|
||||
sr: 48_000.0,
|
||||
la: 1,
|
||||
delay: Vec::new(),
|
||||
req: vec![1.0],
|
||||
smooth: vec![1.0],
|
||||
idx: 0,
|
||||
sum: 1.0,
|
||||
held: 1.0,
|
||||
rel: 0.0,
|
||||
drive: Smoother::new(1.0),
|
||||
ceiling: Smoother::new(1.0),
|
||||
gr_meter: 0.0,
|
||||
};
|
||||
m.update();
|
||||
m
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.drive.set(db_to_gain(-self.p.v(0)));
|
||||
// The ceiling is a hard guarantee, so it changes immediately.
|
||||
self.ceiling.set(db_to_gain(self.p.v(1)));
|
||||
self.ceiling.snap();
|
||||
self.rel = time_coef(self.p.v(2), self.sr);
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for Maximizer {
|
||||
param_plumbing!(MAX_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.la = (ms_to_samples(MAX_LOOKAHEAD_MS, self.sr).round() as usize).max(1);
|
||||
self.delay = (0..sane_channels(channels))
|
||||
.map(|_| {
|
||||
let mut d = DelayLine::default();
|
||||
d.allocate(self.la + 2);
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
self.req = vec![1.0; self.la];
|
||||
self.smooth = vec![1.0; self.la];
|
||||
self.drive.set_time(20.0, self.sr);
|
||||
self.ceiling.set_time(20.0, self.sr);
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.delay.iter_mut().for_each(DelayLine::clear);
|
||||
self.req.iter_mut().for_each(|v| *v = 1.0);
|
||||
self.smooth.iter_mut().for_each(|v| *v = 1.0);
|
||||
self.sum = self.la as f64;
|
||||
self.held = 1.0;
|
||||
self.idx = 0;
|
||||
self.drive.snap();
|
||||
self.ceiling.snap();
|
||||
self.gr_meter = 0.0;
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.delay.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let la = self.la.max(1);
|
||||
let mut min_gain = 1.0f32;
|
||||
for n in 0..frames {
|
||||
let drive = self.drive.next();
|
||||
let ceil = self.ceiling.next();
|
||||
let mut peak = 0.0f32;
|
||||
for c in 0..ch {
|
||||
let (Some(buf), Some(d)) = (io.get(c), self.delay.get_mut(c)) else { continue };
|
||||
let x = buf.get(n).copied().filter(|v| v.is_finite()).unwrap_or(0.0) * drive;
|
||||
d.push(x);
|
||||
peak = peak.max(x.abs());
|
||||
}
|
||||
let req = if peak > ceil { ceil / peak } else { 1.0 };
|
||||
if let Some(r) = self.req.get_mut(self.idx) {
|
||||
*r = req;
|
||||
}
|
||||
let hold = self.req.iter().copied().fold(1.0f32, f32::min);
|
||||
self.held = if hold < self.held { hold } else { hold + (self.held - hold) * self.rel };
|
||||
if let Some(s) = self.smooth.get_mut(self.idx) {
|
||||
self.sum += f64::from(self.held) - f64::from(*s);
|
||||
*s = self.held;
|
||||
}
|
||||
self.idx = (self.idx + 1) % la;
|
||||
if self.idx == 0 {
|
||||
// Re-sum once per window to cancel floating-point drift.
|
||||
self.sum = self.smooth.iter().map(|&v| f64::from(v)).sum();
|
||||
}
|
||||
let g = ((self.sum / la as f64) as f32).clamp(0.0, 1.0);
|
||||
min_gain = min_gain.min(g);
|
||||
for c in 0..ch {
|
||||
let (Some(buf), Some(d)) = (io.get_mut(c), self.delay.get(c)) else { continue };
|
||||
if let Some(x) = buf.get_mut(n) {
|
||||
*x = (d.tap(la) * g).clamp(-ceil, ceil);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.gr_meter = -gain_to_db(min_gain);
|
||||
}
|
||||
|
||||
fn latency(&self) -> usize {
|
||||
self.la - 1
|
||||
}
|
||||
|
||||
fn gain_reduction_db(&self) -> f32 {
|
||||
self.gr_meter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Parametric equalizers: EQ 1-Band and EQ 7-Band (HPF, low shelf, three peaks, high shelf, LPF).
|
||||
|
||||
use crate::biquad::{Biquad, Coeffs, FilterType, butterworth_qs};
|
||||
use crate::params::{Params, choice, lin, log, toggle};
|
||||
use crate::util::{Smoother, frames_in, sane_channels, sane_sr};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain};
|
||||
|
||||
const SLOPES: &[&str] = &["6 dB/oct", "12 dB/oct", "18 dB/oct", "24 dB/oct"];
|
||||
|
||||
static EQ7_PARAMS: [ParamInfo; 23] = [
|
||||
lin("input_gain", "Input", -24.0, 24.0, 0.0, Unit::Db),
|
||||
toggle("hpf_on", "HPF", false),
|
||||
log("hpf_freq", "HPF Freq", 10.0, 2000.0, 80.0, Unit::Hz),
|
||||
choice("hpf_slope", "HPF Slope", SLOPES, 1),
|
||||
log("low_shelf_freq", "LF Freq", 20.0, 1000.0, 100.0, Unit::Hz),
|
||||
lin("low_shelf_gain", "LF Gain", -24.0, 24.0, 0.0, Unit::Db),
|
||||
log("low_shelf_q", "LF Q", 0.3, 2.0, 0.707, Unit::None),
|
||||
log("low_mid_freq", "LMF Freq", 20.0, 2000.0, 250.0, Unit::Hz),
|
||||
lin("low_mid_gain", "LMF Gain", -24.0, 24.0, 0.0, Unit::Db),
|
||||
log("low_mid_q", "LMF Q", 0.1, 10.0, 1.0, Unit::None),
|
||||
log("mid_freq", "MF Freq", 100.0, 8000.0, 1000.0, Unit::Hz),
|
||||
lin("mid_gain", "MF Gain", -24.0, 24.0, 0.0, Unit::Db),
|
||||
log("mid_q", "MF Q", 0.1, 10.0, 1.0, Unit::None),
|
||||
log("high_mid_freq", "HMF Freq", 500.0, 20000.0, 4000.0, Unit::Hz),
|
||||
lin("high_mid_gain", "HMF Gain", -24.0, 24.0, 0.0, Unit::Db),
|
||||
log("high_mid_q", "HMF Q", 0.1, 10.0, 1.0, Unit::None),
|
||||
log("high_shelf_freq", "HF Freq", 1000.0, 20000.0, 8000.0, Unit::Hz),
|
||||
lin("high_shelf_gain", "HF Gain", -24.0, 24.0, 0.0, Unit::Db),
|
||||
log("high_shelf_q", "HF Q", 0.3, 2.0, 0.707, Unit::None),
|
||||
toggle("lpf_on", "LPF", false),
|
||||
log("lpf_freq", "LPF Freq", 1000.0, 20000.0, 18000.0, Unit::Hz),
|
||||
choice("lpf_slope", "LPF Slope", SLOPES, 1),
|
||||
lin("output_gain", "Output", -24.0, 24.0, 0.0, Unit::Db),
|
||||
];
|
||||
|
||||
pub static EQ7_INFO: PluginInfo = PluginInfo {
|
||||
id: "eq_7band",
|
||||
name: "EQ 7-Band",
|
||||
short_name: "EQ7",
|
||||
category: Category::Eq,
|
||||
params: &EQ7_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
const EQ1_TYPES: &[&str] = &["Peak", "Low Shelf", "High Shelf", "High Pass", "Low Pass", "Notch", "Band Pass"];
|
||||
|
||||
static EQ1_PARAMS: [ParamInfo; 5] = [
|
||||
choice("type", "Type", EQ1_TYPES, 0),
|
||||
log("freq", "Freq", 20.0, 20000.0, 1000.0, Unit::Hz),
|
||||
lin("gain", "Gain", -24.0, 24.0, 0.0, Unit::Db),
|
||||
log("q", "Q", 0.1, 10.0, 1.0, Unit::None),
|
||||
lin("output_gain", "Output", -24.0, 24.0, 0.0, Unit::Db),
|
||||
];
|
||||
|
||||
pub static EQ1_INFO: PluginInfo = PluginInfo {
|
||||
id: "eq_1band",
|
||||
name: "EQ 1-Band",
|
||||
short_name: "EQ1",
|
||||
category: Category::Eq,
|
||||
params: &EQ1_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
#[inline]
|
||||
fn val(v: &[f32], i: usize) -> f32 {
|
||||
v.get(i).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Writes 1–2 cascaded sections for an HPF/LPF of the given slope index (6/12/18/24 dB/oct).
|
||||
fn pass_filter(slope: usize, high: bool, freq: f32, sr: f32) -> [Coeffs; 2] {
|
||||
let (k1, k2) = if high { (FilterType::HighPass1, FilterType::HighPass) } else { (FilterType::LowPass1, FilterType::LowPass) };
|
||||
match slope {
|
||||
0 => [Coeffs::design(k1, freq, 0.707, 0.0, sr), Coeffs::IDENTITY],
|
||||
2 => [Coeffs::design(k1, freq, 0.707, 0.0, sr), Coeffs::design(k2, freq, 1.0, 0.0, sr)],
|
||||
3 => {
|
||||
let q = butterworth_qs(4);
|
||||
let q0 = q.first().copied().unwrap_or(0.54);
|
||||
let q1 = q.get(1).copied().unwrap_or(1.31);
|
||||
[Coeffs::design(k2, freq, q0, 0.0, sr), Coeffs::design(k2, freq, q1, 0.0, sr)]
|
||||
}
|
||||
_ => [Coeffs::design(k2, freq, std::f32::consts::FRAC_1_SQRT_2, 0.0, sr), Coeffs::IDENTITY],
|
||||
}
|
||||
}
|
||||
|
||||
fn design_eq7(v: &[f32], sr: f32) -> [Coeffs; 9] {
|
||||
let mut c = [Coeffs::IDENTITY; 9];
|
||||
if val(v, 1) >= 0.5 {
|
||||
let [a, b] = pass_filter(val(v, 3).round() as usize, true, val(v, 2), sr);
|
||||
c[0] = a;
|
||||
c[1] = b;
|
||||
}
|
||||
let bands = [(4, FilterType::LowShelf), (7, FilterType::Peak), (10, FilterType::Peak), (13, FilterType::Peak), (16, FilterType::HighShelf)];
|
||||
for (k, (i, kind)) in bands.into_iter().enumerate() {
|
||||
let g = val(v, i + 1);
|
||||
if g.abs() > 1e-4
|
||||
&& let Some(slot) = c.get_mut(2 + k)
|
||||
{
|
||||
*slot = Coeffs::design(kind, val(v, i), val(v, i + 2), g, sr);
|
||||
}
|
||||
}
|
||||
if val(v, 19) >= 0.5 {
|
||||
let [a, b] = pass_filter(val(v, 21).round() as usize, false, val(v, 20), sr);
|
||||
c[7] = a;
|
||||
c[8] = b;
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn design_eq1(v: &[f32], sr: f32) -> [Coeffs; 1] {
|
||||
let kind = match val(v, 0).round() as usize {
|
||||
1 => FilterType::LowShelf,
|
||||
2 => FilterType::HighShelf,
|
||||
3 => FilterType::HighPass,
|
||||
4 => FilterType::LowPass,
|
||||
5 => FilterType::Notch,
|
||||
6 => FilterType::BandPass,
|
||||
_ => FilterType::Peak,
|
||||
};
|
||||
let g = val(v, 2);
|
||||
if kind == FilterType::Peak && g.abs() < 1e-4 {
|
||||
return [Coeffs::IDENTITY];
|
||||
}
|
||||
[Coeffs::design(kind, val(v, 1), val(v, 3), g, sr)]
|
||||
}
|
||||
|
||||
/// Generic smoothed EQ: `S` cascaded sections designed from the parameter values.
|
||||
pub struct Eq<const S: usize> {
|
||||
info: &'static PluginInfo,
|
||||
design: fn(&[f32], f32) -> [Coeffs; S],
|
||||
gain_ids: &'static [usize],
|
||||
p: Params,
|
||||
sm: Vec<Smoother>,
|
||||
vals: Vec<f32>,
|
||||
coeffs: [Coeffs; S],
|
||||
filt: Vec<[Biquad; S]>,
|
||||
gain: Smoother,
|
||||
sr: f32,
|
||||
}
|
||||
|
||||
/// EQ 7-Band.
|
||||
pub type Eq7 = Eq<9>;
|
||||
/// EQ 1-Band.
|
||||
pub type Eq1 = Eq<1>;
|
||||
|
||||
impl Eq7 {
|
||||
pub fn new() -> Self {
|
||||
Eq::build(&EQ7_INFO, design_eq7, &[0, 22])
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Eq7 {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq1 {
|
||||
pub fn new() -> Self {
|
||||
Eq::build(&EQ1_INFO, design_eq1, &[4])
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Eq1 {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<const S: usize> Eq<S> {
|
||||
fn build(info: &'static PluginInfo, design: fn(&[f32], f32) -> [Coeffs; S], gain_ids: &'static [usize]) -> Self {
|
||||
let p = Params::new(info.params);
|
||||
let sm = info.params.iter().map(|i| Smoother::new(i.default)).collect();
|
||||
let vals = info.params.iter().map(|i| i.default).collect();
|
||||
let mut eq =
|
||||
Self { info, design, gain_ids, p, sm, vals, coeffs: [Coeffs::IDENTITY; S], filt: Vec::new(), gain: Smoother::new(1.0), sr: 48_000.0 };
|
||||
eq.redesign();
|
||||
eq
|
||||
}
|
||||
|
||||
fn total_gain_db(&self, v: impl Fn(usize) -> f32) -> f32 {
|
||||
self.gain_ids.iter().map(|&i| v(i)).sum()
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
for (i, (s, info)) in self.sm.iter_mut().zip(self.info.params).enumerate() {
|
||||
s.set(self.p.v(i));
|
||||
if matches!(info.unit, Unit::Toggle | Unit::Choice) {
|
||||
s.snap();
|
||||
}
|
||||
}
|
||||
let g = db_to_gain(self.total_gain_db(|i| self.p.v(i)));
|
||||
self.gain.set(g);
|
||||
}
|
||||
|
||||
fn redesign(&mut self) {
|
||||
for (v, s) in self.vals.iter_mut().zip(&self.sm) {
|
||||
*v = s.value();
|
||||
}
|
||||
self.coeffs = (self.design)(&self.vals, self.sr);
|
||||
for f in &mut self.filt {
|
||||
for (b, c) in f.iter_mut().zip(&self.coeffs) {
|
||||
b.coeffs = *c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Magnitude response (dB, including input/output gain) of the current settings.
|
||||
pub fn response_db(&self, freqs: &[f32], sample_rate: f32) -> Vec<f32> {
|
||||
let coeffs = (self.design)(&(0..self.info.params.len()).map(|i| self.p.v(i)).collect::<Vec<_>>(), sample_rate);
|
||||
let g = self.total_gain_db(|i| self.p.v(i));
|
||||
freqs.iter().map(|&f| g + coeffs.iter().map(|c| c.magnitude_db(f, sample_rate)).sum::<f32>()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<const S: usize> Plugin for Eq<S> {
|
||||
fn info(&self) -> &'static PluginInfo {
|
||||
self.info
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: &str, value: f32) -> bool {
|
||||
if self.p.set(id, value).is_some() {
|
||||
self.update();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: &str) -> Option<f32> {
|
||||
self.p.get(id)
|
||||
}
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
for s in &mut self.sm {
|
||||
s.set_time(20.0, self.sr);
|
||||
}
|
||||
self.gain.set_time(20.0, self.sr);
|
||||
self.filt = vec![[Biquad::default(); S]; sane_channels(channels)];
|
||||
self.update();
|
||||
self.sm.iter_mut().for_each(Smoother::snap);
|
||||
self.gain.snap();
|
||||
self.redesign();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.filt.iter_mut().flatten().for_each(Biquad::reset);
|
||||
self.sm.iter_mut().for_each(Smoother::snap);
|
||||
self.gain.snap();
|
||||
self.redesign();
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
const CHUNK: usize = 32;
|
||||
let frames = frames_in(io, frames, self.filt.len());
|
||||
let mut pos = 0;
|
||||
while pos < frames {
|
||||
let n = CHUNK.min(frames - pos);
|
||||
if self.sm.iter().any(Smoother::settling) {
|
||||
self.sm.iter_mut().for_each(|s| {
|
||||
s.advance(n);
|
||||
});
|
||||
self.redesign();
|
||||
}
|
||||
let mut gains = [0.0f32; CHUNK];
|
||||
for g in gains.iter_mut().take(n) {
|
||||
*g = self.gain.next();
|
||||
}
|
||||
for (buf, f) in io.iter_mut().zip(self.filt.iter_mut()) {
|
||||
let Some(seg) = buf.get_mut(pos..pos + n) else { continue };
|
||||
for (x, g) in seg.iter_mut().zip(&gains) {
|
||||
let mut y = *x;
|
||||
for b in f.iter_mut() {
|
||||
y = b.process_sample(y);
|
||||
}
|
||||
*x = y * g;
|
||||
}
|
||||
}
|
||||
pos += n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn response_from(
|
||||
info: &'static PluginInfo,
|
||||
design: &dyn Fn(&[f32], f32) -> Vec<Coeffs>,
|
||||
gain_ids: &[usize],
|
||||
params: &[(&str, f32)],
|
||||
freqs: &[f32],
|
||||
sr: f32,
|
||||
) -> Vec<f32> {
|
||||
let mut p = Params::new(info.params);
|
||||
for (id, v) in params {
|
||||
let _ = p.set(id, *v);
|
||||
}
|
||||
let vals: Vec<f32> = (0..info.params.len()).map(|i| p.v(i)).collect();
|
||||
let coeffs = design(&vals, sr);
|
||||
let g: f32 = gain_ids.iter().map(|&i| p.v(i)).sum();
|
||||
freqs.iter().map(|&f| g + coeffs.iter().map(|c| c.magnitude_db(f, sr)).sum::<f32>()).collect()
|
||||
}
|
||||
|
||||
/// EQ 7-Band magnitude response in dB at `freqs`, for curve drawing. `params` overrides the
|
||||
/// defaults by id; unknown ids are ignored.
|
||||
pub fn eq7_response(params: &[(&str, f32)], freqs: &[f32], sr: f32) -> Vec<f32> {
|
||||
response_from(&EQ7_INFO, &|v, sr| design_eq7(v, sr).to_vec(), &[0, 22], params, freqs, sr)
|
||||
}
|
||||
|
||||
/// EQ 1-Band magnitude response in dB at `freqs`.
|
||||
pub fn eq1_response(params: &[(&str, f32)], freqs: &[f32], sr: f32) -> Vec<f32> {
|
||||
response_from(&EQ1_INFO, &|v, sr| design_eq1(v, sr).to_vec(), &[4], params, freqs, sr)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction) and Rectifier.
|
||||
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing};
|
||||
use crate::util::{DcBlocker, Rng, Smoother, flush, frames_in, sane_channels, sane_sr};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain};
|
||||
|
||||
static SAT_PARAMS: [ParamInfo; 5] = [
|
||||
lin("drive", "Drive", 0.0, 36.0, 6.0, Unit::Db),
|
||||
lin("bias", "Asymmetry", 0.0, 100.0, 0.0, Unit::Percent),
|
||||
log("tone", "Tone", 1000.0, 20000.0, 20000.0, Unit::Hz),
|
||||
lin("mix", "Mix", 0.0, 100.0, 100.0, Unit::Percent),
|
||||
lin("output", "Output", -24.0, 12.0, 0.0, Unit::Db),
|
||||
];
|
||||
|
||||
pub static SAT_INFO: PluginInfo = PluginInfo {
|
||||
id: "saturator",
|
||||
name: "Saturator",
|
||||
short_name: "Satur",
|
||||
category: Category::Harmonic,
|
||||
params: &SAT_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// Smooth tanh waveshaper, normalized so a full-scale input still peaks near full scale (more
|
||||
/// drive = more harmonics and density). Asymmetry adds even harmonics (the DC is removed).
|
||||
pub struct Saturator {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
dc: Vec<DcBlocker>,
|
||||
lp: Vec<f32>,
|
||||
drive: Smoother,
|
||||
bias: Smoother,
|
||||
mix: Smoother,
|
||||
out: Smoother,
|
||||
tone: f32,
|
||||
}
|
||||
|
||||
impl Default for Saturator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Saturator {
|
||||
pub fn new() -> Self {
|
||||
let mut s = Self {
|
||||
p: Params::new(&SAT_PARAMS),
|
||||
sr: 48_000.0,
|
||||
dc: Vec::new(),
|
||||
lp: Vec::new(),
|
||||
drive: Smoother::new(1.0),
|
||||
bias: Smoother::new(0.0),
|
||||
mix: Smoother::new(1.0),
|
||||
out: Smoother::new(1.0),
|
||||
tone: 0.0,
|
||||
};
|
||||
s.update();
|
||||
s
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.drive.set(db_to_gain(self.p.v(0)));
|
||||
self.bias.set(self.p.v(1) / 100.0 * 0.5);
|
||||
self.mix.set(self.p.v(3) / 100.0);
|
||||
self.out.set(db_to_gain(self.p.v(4)));
|
||||
let f = self.p.v(2);
|
||||
self.tone = if f >= 19_999.0 { 0.0 } else { (-std::f32::consts::TAU * f / self.sr).exp() };
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for Saturator {
|
||||
param_plumbing!(SAT_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
let ch = sane_channels(channels);
|
||||
self.dc = vec![DcBlocker::new(10.0, self.sr); ch];
|
||||
self.lp = vec![0.0; ch];
|
||||
for s in [&mut self.drive, &mut self.bias, &mut self.mix, &mut self.out] {
|
||||
s.set_time(20.0, self.sr);
|
||||
}
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.dc.iter_mut().for_each(DcBlocker::reset);
|
||||
self.lp.iter_mut().for_each(|v| *v = 0.0);
|
||||
for s in [&mut self.drive, &mut self.bias, &mut self.mix, &mut self.out] {
|
||||
s.snap();
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.dc.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let a = self.tone;
|
||||
for n in 0..frames {
|
||||
let g = self.drive.next();
|
||||
let b = self.bias.next();
|
||||
let m = self.mix.next();
|
||||
let o = self.out.next();
|
||||
let tb = b.tanh();
|
||||
let norm = 1.0 / ((g + b).tanh() - tb).max(1e-3);
|
||||
for c in 0..ch {
|
||||
let (Some(buf), Some(dc), Some(lp)) = (io.get_mut(c), self.dc.get_mut(c), self.lp.get_mut(c)) else { continue };
|
||||
let Some(x) = buf.get_mut(n) else { continue };
|
||||
let dry = if x.is_finite() { *x } else { 0.0 };
|
||||
let mut y = ((g * dry + b).tanh() - tb) * norm;
|
||||
if b > 0.0 {
|
||||
y = dc.process(y);
|
||||
}
|
||||
if a > 0.0 {
|
||||
*lp = flush(y + (*lp - y) * a);
|
||||
y = *lp;
|
||||
}
|
||||
*x = (dry * (1.0 - m) + y * m) * o;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
static LOFI_PARAMS: [ParamInfo; 4] = [
|
||||
lin("bits", "Bit Depth", 1.0, 24.0, 8.0, Unit::None),
|
||||
log("sample_rate", "Sample Rate", 500.0, 48000.0, 11025.0, Unit::Hz),
|
||||
lin("noise", "Noise", 0.0, 100.0, 0.0, Unit::Percent),
|
||||
lin("mix", "Mix", 0.0, 100.0, 100.0, Unit::Percent),
|
||||
];
|
||||
|
||||
pub static LOFI_INFO: PluginInfo = PluginInfo {
|
||||
id: "lofi",
|
||||
name: "Lo-Fi",
|
||||
short_name: "LoFi",
|
||||
category: Category::Harmonic,
|
||||
params: &LOFI_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// Bit crusher and sample-and-hold decimator with optional hiss.
|
||||
pub struct LoFi {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
held: Vec<f32>,
|
||||
phase: f32,
|
||||
rng: Rng,
|
||||
mix: Smoother,
|
||||
noise: Smoother,
|
||||
}
|
||||
|
||||
impl Default for LoFi {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl LoFi {
|
||||
pub fn new() -> Self {
|
||||
let mut l = Self {
|
||||
p: Params::new(&LOFI_PARAMS),
|
||||
sr: 48_000.0,
|
||||
held: Vec::new(),
|
||||
phase: 1.0,
|
||||
rng: Rng::new(77),
|
||||
mix: Smoother::new(1.0),
|
||||
noise: Smoother::new(0.0),
|
||||
};
|
||||
l.update();
|
||||
l
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.mix.set(self.p.v(3) / 100.0);
|
||||
let n = self.p.v(2) / 100.0;
|
||||
self.noise.set(n * n * 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for LoFi {
|
||||
param_plumbing!(LOFI_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.held = vec![0.0; sane_channels(channels)];
|
||||
self.mix.set_time(20.0, self.sr);
|
||||
self.noise.set_time(20.0, self.sr);
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.held.iter_mut().for_each(|v| *v = 0.0);
|
||||
self.phase = 1.0;
|
||||
self.mix.snap();
|
||||
self.noise.snap();
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.held.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let step = 2f32.powf(1.0 - self.p.v(0));
|
||||
let inc = (self.p.v(1) / self.sr).min(1.0);
|
||||
for n in 0..frames {
|
||||
let m = self.mix.next();
|
||||
let nz = self.noise.next();
|
||||
self.phase += inc;
|
||||
let grab = self.phase >= 1.0;
|
||||
if grab {
|
||||
self.phase -= 1.0;
|
||||
}
|
||||
for c in 0..ch {
|
||||
let (Some(buf), Some(h)) = (io.get_mut(c), self.held.get_mut(c)) else { continue };
|
||||
let Some(x) = buf.get_mut(n) else { continue };
|
||||
let dry = if x.is_finite() { *x } else { 0.0 };
|
||||
if grab {
|
||||
let noisy = dry + if nz > 0.0 { self.rng.bipolar() * nz } else { 0.0 };
|
||||
*h = (noisy / step).round() * step;
|
||||
}
|
||||
*x = dry * (1.0 - m) + *h * m;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
static RECT_PARAMS: [ParamInfo; 3] = [
|
||||
choice("mode", "Mode", &["Half Wave", "Full Wave"], 1),
|
||||
lin("mix", "Mix", 0.0, 100.0, 100.0, Unit::Percent),
|
||||
lin("output", "Output", -24.0, 12.0, 0.0, Unit::Db),
|
||||
];
|
||||
|
||||
pub static RECT_INFO: PluginInfo = PluginInfo {
|
||||
id: "rectifier",
|
||||
name: "Rectifier",
|
||||
short_name: "Rectify",
|
||||
category: Category::Harmonic,
|
||||
params: &RECT_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// Half/full-wave rectifier (octave-up harmonics) with DC removal.
|
||||
pub struct Rectifier {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
dc: Vec<DcBlocker>,
|
||||
mix: Smoother,
|
||||
out: Smoother,
|
||||
}
|
||||
|
||||
impl Default for Rectifier {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Rectifier {
|
||||
pub fn new() -> Self {
|
||||
let mut r = Self { p: Params::new(&RECT_PARAMS), sr: 48_000.0, dc: Vec::new(), mix: Smoother::new(1.0), out: Smoother::new(1.0) };
|
||||
r.update();
|
||||
r
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.mix.set(self.p.v(1) / 100.0);
|
||||
self.out.set(db_to_gain(self.p.v(2)));
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for Rectifier {
|
||||
param_plumbing!(RECT_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.dc = vec![DcBlocker::new(10.0, self.sr); sane_channels(channels)];
|
||||
self.mix.set_time(20.0, self.sr);
|
||||
self.out.set_time(20.0, self.sr);
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.dc.iter_mut().for_each(DcBlocker::reset);
|
||||
self.mix.snap();
|
||||
self.out.snap();
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.dc.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let full = self.p.choice(0) == 1;
|
||||
for n in 0..frames {
|
||||
let m = self.mix.next();
|
||||
let o = self.out.next();
|
||||
for c in 0..ch {
|
||||
let (Some(buf), Some(dc)) = (io.get_mut(c), self.dc.get_mut(c)) else { continue };
|
||||
let Some(x) = buf.get_mut(n) else { continue };
|
||||
let dry = if x.is_finite() { *x } else { 0.0 };
|
||||
let r = if full { dry.abs() } else { dry.max(0.0) };
|
||||
let wet = dc.process(r);
|
||||
*x = (dry * (1.0 - m) + wet * m) * o;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! The built-in plugins and their registry.
|
||||
|
||||
pub mod drums;
|
||||
pub mod dynamics;
|
||||
pub mod eq;
|
||||
pub mod harmonic;
|
||||
pub mod modulation;
|
||||
pub mod pitch;
|
||||
pub mod reverb;
|
||||
pub mod strip;
|
||||
pub mod synth;
|
||||
pub mod utility;
|
||||
|
||||
use crate::{Plugin, PluginInfo};
|
||||
|
||||
/// Every built-in plugin, in menu order.
|
||||
pub(crate) static REGISTRY: &[&PluginInfo] = &[
|
||||
&eq::EQ1_INFO,
|
||||
&eq::EQ7_INFO,
|
||||
&dynamics::COMP_INFO,
|
||||
&dynamics::GATE_INFO,
|
||||
&dynamics::DEESS_INFO,
|
||||
&dynamics::MAX_INFO,
|
||||
&strip::STRIP_INFO,
|
||||
&reverb::ROOM_INFO,
|
||||
&reverb::PLATE_INFO,
|
||||
&modulation::DELAY_INFO,
|
||||
&modulation::CHORUS_INFO,
|
||||
&modulation::FLANGER_INFO,
|
||||
&modulation::PHASER_INFO,
|
||||
&harmonic::SAT_INFO,
|
||||
&harmonic::LOFI_INFO,
|
||||
&harmonic::RECT_INFO,
|
||||
&pitch::PITCH_INFO,
|
||||
&utility::SHIFT_INFO,
|
||||
&utility::GAIN_INFO,
|
||||
&utility::TRIM_INFO,
|
||||
&utility::INVERT_INFO,
|
||||
&utility::DC_INFO,
|
||||
&utility::GEN_INFO,
|
||||
&utility::DITHER_INFO,
|
||||
&synth::SYNTH_INFO,
|
||||
&drums::DRUM_INFO,
|
||||
];
|
||||
|
||||
/// Constructs an (unprepared) plugin by id.
|
||||
pub(crate) fn instantiate(id: &str) -> Option<Box<dyn Plugin>> {
|
||||
Some(match id {
|
||||
"eq_1band" => Box::new(eq::Eq1::new()),
|
||||
"eq_7band" => Box::new(eq::Eq7::new()),
|
||||
"compressor" => Box::new(dynamics::Compressor::new()),
|
||||
"expander_gate" => Box::new(dynamics::ExpanderGate::new()),
|
||||
"de_esser" => Box::new(dynamics::DeEsser::new()),
|
||||
"maximizer" => Box::new(dynamics::Maximizer::new()),
|
||||
"channel_strip" => Box::new(strip::ChannelStrip::new()),
|
||||
"room_reverb" => Box::new(reverb::Reverb::room()),
|
||||
"plate_reverb" => Box::new(reverb::Reverb::plate()),
|
||||
"mod_delay" => Box::new(modulation::ModDelay::new()),
|
||||
"chorus" => Box::new(modulation::ModFx::chorus()),
|
||||
"flanger" => Box::new(modulation::ModFx::flanger()),
|
||||
"phaser" => Box::new(modulation::Phaser::new()),
|
||||
"saturator" => Box::new(harmonic::Saturator::new()),
|
||||
"lofi" => Box::new(harmonic::LoFi::new()),
|
||||
"rectifier" => Box::new(harmonic::Rectifier::new()),
|
||||
"pitch_shifter" => Box::new(pitch::PitchShifter::new()),
|
||||
"time_shift" => Box::new(utility::TimeShift::new()),
|
||||
"gain" => Box::new(utility::Gain::new()),
|
||||
"trim" => Box::new(utility::Trim::new()),
|
||||
"invert" => Box::new(utility::Invert::default()),
|
||||
"dc_offset_removal" => Box::new(utility::DcRemoval::new()),
|
||||
"signal_generator" => Box::new(utility::SignalGenerator::new()),
|
||||
"dither" => Box::new(utility::Dither::new()),
|
||||
"subtractive_synth" => Box::new(synth::SubtractiveSynth::new()),
|
||||
"drum_synth" => Box::new(drums::DrumSynth::new()),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Delay and modulation effects: Mod Delay, Chorus, Flanger, Phaser.
|
||||
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing};
|
||||
use crate::util::{DelayLine, Lfo, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit};
|
||||
|
||||
/// Per-channel LFO phase offset for stereo movement (channel 1 is offset by `spread` cycles).
|
||||
#[inline]
|
||||
fn channel_phase(phase: f32, c: usize, spread: f32) -> f32 {
|
||||
let p = phase + if c % 2 == 1 { spread } else { 0.0 };
|
||||
if p >= 1.0 { p - 1.0 } else { p }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
const MAX_DELAY_MS: f32 = 2000.0;
|
||||
const MOD_DELAY_DEPTH_MS: f32 = 5.0;
|
||||
|
||||
static DELAY_PARAMS: [ParamInfo; 6] = [
|
||||
log("time", "Time", 1.0, MAX_DELAY_MS, 250.0, Unit::Ms),
|
||||
lin("feedback", "Feedback", -99.0, 99.0, 30.0, Unit::Percent),
|
||||
lin("mix", "Mix", 0.0, 100.0, 30.0, Unit::Percent),
|
||||
log("mod_rate", "Mod Rate", 0.01, 10.0, 0.5, Unit::Hz),
|
||||
lin("mod_depth", "Mod Depth", 0.0, 100.0, 0.0, Unit::Percent),
|
||||
log("lpf", "LPF", 500.0, 20000.0, 12000.0, Unit::Hz),
|
||||
];
|
||||
|
||||
pub static DELAY_INFO: PluginInfo = PluginInfo {
|
||||
id: "mod_delay",
|
||||
name: "Mod Delay",
|
||||
short_name: "ModDly",
|
||||
category: Category::Delay,
|
||||
params: &DELAY_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// Feedback delay with modulation and a low-pass in the feedback path.
|
||||
pub struct ModDelay {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
lines: Vec<DelayLine>,
|
||||
lp: Vec<f32>,
|
||||
lfo: Lfo,
|
||||
time: Smoother,
|
||||
fb: Smoother,
|
||||
mix: Smoother,
|
||||
depth: Smoother,
|
||||
lp_coef: f32,
|
||||
}
|
||||
|
||||
impl Default for ModDelay {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ModDelay {
|
||||
pub fn new() -> Self {
|
||||
let mut d = Self {
|
||||
p: Params::new(&DELAY_PARAMS),
|
||||
sr: 48_000.0,
|
||||
lines: Vec::new(),
|
||||
lp: Vec::new(),
|
||||
lfo: Lfo::new(),
|
||||
time: Smoother::new(250.0),
|
||||
fb: Smoother::new(0.0),
|
||||
mix: Smoother::new(0.0),
|
||||
depth: Smoother::new(0.0),
|
||||
lp_coef: 0.0,
|
||||
};
|
||||
d.update();
|
||||
d
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.time.set(self.p.v(0));
|
||||
self.fb.set(self.p.v(1) / 100.0);
|
||||
self.mix.set(self.p.v(2) / 100.0);
|
||||
self.depth.set(self.p.v(4) / 100.0 * MOD_DELAY_DEPTH_MS);
|
||||
self.lp_coef = (-std::f32::consts::TAU * self.p.v(5) / self.sr).exp();
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for ModDelay {
|
||||
param_plumbing!(DELAY_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
let max = ms_to_samples(MAX_DELAY_MS + 2.0 * MOD_DELAY_DEPTH_MS, self.sr) as usize + 8;
|
||||
self.lines = (0..sane_channels(channels))
|
||||
.map(|_| {
|
||||
let mut d = DelayLine::default();
|
||||
d.allocate(max);
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
self.lp = vec![0.0; self.lines.len()];
|
||||
self.time.set_time(120.0, self.sr);
|
||||
for s in [&mut self.fb, &mut self.mix, &mut self.depth] {
|
||||
s.set_time(20.0, self.sr);
|
||||
}
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.lines.iter_mut().for_each(DelayLine::clear);
|
||||
self.lp.iter_mut().for_each(|v| *v = 0.0);
|
||||
self.lfo.reset();
|
||||
for s in [&mut self.time, &mut self.fb, &mut self.mix, &mut self.depth] {
|
||||
s.snap();
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.lines.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let inc = self.p.v(3) / self.sr;
|
||||
let a = self.lp_coef;
|
||||
for n in 0..frames {
|
||||
let phase = self.lfo.tick(inc);
|
||||
let t = self.time.next();
|
||||
let fb = self.fb.next();
|
||||
let m = self.mix.next();
|
||||
let depth = self.depth.next();
|
||||
for c in 0..ch {
|
||||
let (Some(buf), Some(line), Some(lp)) = (io.get_mut(c), self.lines.get_mut(c), self.lp.get_mut(c)) else { continue };
|
||||
let Some(x) = buf.get_mut(n) else { continue };
|
||||
let dry = if x.is_finite() { *x } else { 0.0 };
|
||||
let modv = depth * 0.5 * (1.0 + Lfo::sin_at(channel_phase(phase, c, 0.25)));
|
||||
let d = ms_to_samples(t + modv, self.sr);
|
||||
let y = line.read_cubic(d);
|
||||
*lp = flush(y + (*lp - y) * a);
|
||||
line.push(flush(dry + fb * *lp));
|
||||
*x = dry * (1.0 - m) + *lp * m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
// Time for feedback to fall by 60 dB.
|
||||
let fb = (self.p.v(1).abs() / 100.0).clamp(0.0, 0.99);
|
||||
let repeats = if fb < 1e-3 { 1.0 } else { (-60.0 / (20.0 * fb.log10())).min(500.0) + 1.0 };
|
||||
ms_to_samples(self.p.v(0) * repeats, self.sr) as usize
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
static CHORUS_PARAMS: [ParamInfo; 6] = [
|
||||
log("rate", "Rate", 0.05, 5.0, 0.8, Unit::Hz),
|
||||
lin("depth", "Depth", 0.0, 100.0, 50.0, Unit::Percent),
|
||||
lin("delay", "Delay", 5.0, 30.0, 12.0, Unit::Ms),
|
||||
lin("feedback", "Feedback", 0.0, 90.0, 0.0, Unit::Percent),
|
||||
lin("mix", "Mix", 0.0, 100.0, 50.0, Unit::Percent),
|
||||
lin("spread", "Spread", 0.0, 100.0, 100.0, Unit::Percent),
|
||||
];
|
||||
|
||||
pub static CHORUS_INFO: PluginInfo = PluginInfo {
|
||||
id: "chorus",
|
||||
name: "Chorus",
|
||||
short_name: "Chorus",
|
||||
category: Category::Modulation,
|
||||
params: &CHORUS_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
static FLANGER_PARAMS: [ParamInfo; 5] = [
|
||||
log("rate", "Rate", 0.02, 5.0, 0.25, Unit::Hz),
|
||||
lin("depth", "Depth", 0.0, 100.0, 70.0, Unit::Percent),
|
||||
lin("delay", "Delay", 0.1, 10.0, 2.0, Unit::Ms),
|
||||
lin("feedback", "Feedback", -95.0, 95.0, 50.0, Unit::Percent),
|
||||
lin("mix", "Mix", 0.0, 100.0, 50.0, Unit::Percent),
|
||||
];
|
||||
|
||||
pub static FLANGER_INFO: PluginInfo = PluginInfo {
|
||||
id: "flanger",
|
||||
name: "Flanger",
|
||||
short_name: "Flanger",
|
||||
category: Category::Modulation,
|
||||
params: &FLANGER_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum ModKind {
|
||||
Chorus,
|
||||
Flanger,
|
||||
}
|
||||
|
||||
/// Modulated short delay used by both Chorus and Flanger.
|
||||
pub struct ModFx {
|
||||
kind: ModKind,
|
||||
p: Params,
|
||||
sr: f32,
|
||||
lines: Vec<DelayLine>,
|
||||
fbs: Vec<f32>,
|
||||
lfo: Lfo,
|
||||
delay: Smoother,
|
||||
depth: Smoother,
|
||||
fb: Smoother,
|
||||
mix: Smoother,
|
||||
}
|
||||
|
||||
impl ModFx {
|
||||
pub fn chorus() -> Self {
|
||||
Self::build(ModKind::Chorus)
|
||||
}
|
||||
|
||||
pub fn flanger() -> Self {
|
||||
Self::build(ModKind::Flanger)
|
||||
}
|
||||
|
||||
fn build(kind: ModKind) -> Self {
|
||||
let info = if kind == ModKind::Chorus { &CHORUS_INFO } else { &FLANGER_INFO };
|
||||
let mut m = Self {
|
||||
kind,
|
||||
p: Params::new(info.params),
|
||||
sr: 48_000.0,
|
||||
lines: Vec::new(),
|
||||
fbs: Vec::new(),
|
||||
lfo: Lfo::new(),
|
||||
delay: Smoother::new(0.0),
|
||||
depth: Smoother::new(0.0),
|
||||
fb: Smoother::new(0.0),
|
||||
mix: Smoother::new(0.0),
|
||||
};
|
||||
m.update();
|
||||
m
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.delay.set(self.p.v(2));
|
||||
self.depth.set(self.p.v(1) / 100.0);
|
||||
self.fb.set(self.p.v(3) / 100.0);
|
||||
self.mix.set(self.p.v(4) / 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for ModFx {
|
||||
fn info(&self) -> &'static PluginInfo {
|
||||
if self.kind == ModKind::Chorus { &CHORUS_INFO } else { &FLANGER_INFO }
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: &str, value: f32) -> bool {
|
||||
if self.p.set(id, value).is_some() {
|
||||
self.update();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: &str) -> Option<f32> {
|
||||
self.p.get(id)
|
||||
}
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
let max = ms_to_samples(45.0, self.sr) as usize + 8;
|
||||
self.lines = (0..sane_channels(channels))
|
||||
.map(|_| {
|
||||
let mut d = DelayLine::default();
|
||||
d.allocate(max);
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
self.fbs = vec![0.0; self.lines.len()];
|
||||
for s in [&mut self.delay, &mut self.depth, &mut self.fb, &mut self.mix] {
|
||||
s.set_time(30.0, self.sr);
|
||||
}
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.lines.iter_mut().for_each(DelayLine::clear);
|
||||
self.fbs.iter_mut().for_each(|v| *v = 0.0);
|
||||
self.lfo.reset();
|
||||
for s in [&mut self.delay, &mut self.depth, &mut self.fb, &mut self.mix] {
|
||||
s.snap();
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.lines.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let inc = self.p.v(0) / self.sr;
|
||||
let spread = if self.kind == ModKind::Chorus { 0.25 * self.p.v(5) / 100.0 } else { 0.25 };
|
||||
for n in 0..frames {
|
||||
let phase = self.lfo.tick(inc);
|
||||
let base = self.delay.next();
|
||||
let depth = self.depth.next();
|
||||
let fb = self.fb.next();
|
||||
let m = self.mix.next();
|
||||
for c in 0..ch {
|
||||
let (Some(buf), Some(line), Some(fbs)) = (io.get_mut(c), self.lines.get_mut(c), self.fbs.get_mut(c)) else { continue };
|
||||
let Some(x) = buf.get_mut(n) else { continue };
|
||||
let dry = if x.is_finite() { *x } else { 0.0 };
|
||||
let s = Lfo::sin_at(channel_phase(phase, c, spread));
|
||||
let ms = match self.kind {
|
||||
// Up to ±6 ms of sweep around the base delay.
|
||||
ModKind::Chorus => base + depth * 6.0 * 0.5 * (1.0 + s),
|
||||
// Sweep from (1 - depth)·delay up to delay.
|
||||
ModKind::Flanger => (base * (1.0 - depth * 0.5 * (1.0 - s))).max(0.05),
|
||||
};
|
||||
line.push(flush(dry + fb * *fbs));
|
||||
let y = line.read_cubic(ms_to_samples(ms, self.sr).max(2.0));
|
||||
*fbs = flush(y);
|
||||
*x = dry * (1.0 - m) + y * m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
ms_to_samples(200.0, self.sr) as usize
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
const STAGE_CHOICES: &[&str] = &["2", "4", "6", "8", "12"];
|
||||
const STAGE_COUNTS: [usize; 5] = [2, 4, 6, 8, 12];
|
||||
const MAX_STAGES: usize = 12;
|
||||
|
||||
static PHASER_PARAMS: [ParamInfo; 6] = [
|
||||
log("rate", "Rate", 0.02, 5.0, 0.5, Unit::Hz),
|
||||
lin("depth", "Depth", 0.0, 100.0, 70.0, Unit::Percent),
|
||||
choice("stages", "Stages", STAGE_CHOICES, 2),
|
||||
lin("feedback", "Feedback", -95.0, 95.0, 40.0, Unit::Percent),
|
||||
log("center", "Center", 100.0, 4000.0, 800.0, Unit::Hz),
|
||||
lin("mix", "Mix", 0.0, 100.0, 50.0, Unit::Percent),
|
||||
];
|
||||
|
||||
pub static PHASER_INFO: PluginInfo = PluginInfo {
|
||||
id: "phaser",
|
||||
name: "Phaser",
|
||||
short_name: "Phaser",
|
||||
category: Category::Modulation,
|
||||
params: &PHASER_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// Cascade of modulated first-order allpasses with feedback.
|
||||
pub struct Phaser {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
ch: usize,
|
||||
z: Vec<[f32; MAX_STAGES]>,
|
||||
fbs: Vec<f32>,
|
||||
lfo: Lfo,
|
||||
depth: Smoother,
|
||||
fb: Smoother,
|
||||
center: Smoother,
|
||||
mix: Smoother,
|
||||
}
|
||||
|
||||
impl Default for Phaser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Phaser {
|
||||
pub fn new() -> Self {
|
||||
let mut p = Self {
|
||||
p: Params::new(&PHASER_PARAMS),
|
||||
sr: 48_000.0,
|
||||
ch: 0,
|
||||
z: Vec::new(),
|
||||
fbs: Vec::new(),
|
||||
lfo: Lfo::new(),
|
||||
depth: Smoother::new(0.0),
|
||||
fb: Smoother::new(0.0),
|
||||
center: Smoother::new(800.0),
|
||||
mix: Smoother::new(0.0),
|
||||
};
|
||||
p.update();
|
||||
p
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.depth.set(self.p.v(1) / 100.0);
|
||||
self.fb.set(self.p.v(3) / 100.0);
|
||||
self.center.set(self.p.v(4));
|
||||
self.mix.set(self.p.v(5) / 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for Phaser {
|
||||
param_plumbing!(PHASER_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.ch = sane_channels(channels);
|
||||
self.z = vec![[0.0; MAX_STAGES]; self.ch];
|
||||
self.fbs = vec![0.0; self.ch];
|
||||
for s in [&mut self.depth, &mut self.fb, &mut self.center, &mut self.mix] {
|
||||
s.set_time(30.0, self.sr);
|
||||
}
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.z.iter_mut().for_each(|z| *z = [0.0; MAX_STAGES]);
|
||||
self.fbs.iter_mut().for_each(|v| *v = 0.0);
|
||||
self.lfo.reset();
|
||||
for s in [&mut self.depth, &mut self.fb, &mut self.center, &mut self.mix] {
|
||||
s.snap();
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.ch.min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let inc = self.p.v(0) / self.sr;
|
||||
let stages = STAGE_COUNTS.get(self.p.choice(2)).copied().unwrap_or(6);
|
||||
let nyq = self.sr * 0.45;
|
||||
for n in 0..frames {
|
||||
let phase = self.lfo.tick(inc);
|
||||
let depth = self.depth.next();
|
||||
let fb = self.fb.next();
|
||||
let center = self.center.next();
|
||||
let m = self.mix.next();
|
||||
for c in 0..ch {
|
||||
let (Some(buf), Some(z), Some(fbs)) = (io.get_mut(c), self.z.get_mut(c), self.fbs.get_mut(c)) else { continue };
|
||||
let Some(x) = buf.get_mut(n) else { continue };
|
||||
let dry = if x.is_finite() { *x } else { 0.0 };
|
||||
let s = Lfo::sin_at(channel_phase(phase, c, 0.25));
|
||||
let f = (center * 2f32.powf(2.0 * depth * s)).clamp(20.0, nyq);
|
||||
let t = (std::f32::consts::PI * f / self.sr).tan();
|
||||
let a = (t - 1.0) / (t + 1.0);
|
||||
let mut y = dry + fb * *fbs;
|
||||
for zi in z.iter_mut().take(stages) {
|
||||
let out = a * y + *zi;
|
||||
*zi = flush(y - a * out);
|
||||
y = out;
|
||||
}
|
||||
*fbs = flush(y);
|
||||
*x = dry * (1.0 - m) + y * m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
ms_to_samples(100.0, self.sr) as usize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Realtime Pitch Shifter: two crossfaded read taps sweeping through a short delay (granular
|
||||
//! "rotating tape head" design). Latency is half the grain window.
|
||||
|
||||
use crate::params::{Params, lin, param_plumbing};
|
||||
use crate::util::{DelayLine, Smoother, frames_in, ms_to_samples, sane_channels, sane_sr};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit};
|
||||
|
||||
const WINDOW_MS: f32 = 50.0;
|
||||
|
||||
static PITCH_PARAMS: [ParamInfo; 3] = [
|
||||
lin("semitones", "Semitones", -24.0, 24.0, 0.0, Unit::Semitones),
|
||||
lin("cents", "Fine", -100.0, 100.0, 0.0, Unit::Cents),
|
||||
lin("mix", "Mix", 0.0, 100.0, 100.0, Unit::Percent),
|
||||
];
|
||||
|
||||
pub static PITCH_INFO: PluginInfo = PluginInfo {
|
||||
id: "pitch_shifter",
|
||||
name: "Pitch Shifter",
|
||||
short_name: "Pitch",
|
||||
category: Category::PitchShift,
|
||||
params: &PITCH_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
pub struct PitchShifter {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
lines: Vec<DelayLine>,
|
||||
window: f32,
|
||||
phase: f32,
|
||||
shift: Smoother,
|
||||
mix: Smoother,
|
||||
}
|
||||
|
||||
impl Default for PitchShifter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PitchShifter {
|
||||
pub fn new() -> Self {
|
||||
let mut p = Self {
|
||||
p: Params::new(&PITCH_PARAMS),
|
||||
sr: 48_000.0,
|
||||
lines: Vec::new(),
|
||||
window: 2400.0,
|
||||
phase: 0.0,
|
||||
shift: Smoother::new(0.0),
|
||||
mix: Smoother::new(1.0),
|
||||
};
|
||||
p.update();
|
||||
p
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.shift.set(self.p.v(0) + self.p.v(1) / 100.0);
|
||||
self.mix.set(self.p.v(2) / 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for PitchShifter {
|
||||
param_plumbing!(PITCH_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.window = ms_to_samples(WINDOW_MS, self.sr).max(16.0);
|
||||
self.lines = (0..sane_channels(channels))
|
||||
.map(|_| {
|
||||
let mut d = DelayLine::default();
|
||||
d.allocate(self.window as usize + 8);
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
self.shift.set_time(30.0, self.sr);
|
||||
self.mix.set_time(20.0, self.sr);
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.lines.iter_mut().for_each(DelayLine::clear);
|
||||
self.phase = 0.0;
|
||||
self.shift.snap();
|
||||
self.mix.snap();
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.lines.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let w = self.window;
|
||||
let half = w * 0.5 + 2.0;
|
||||
for n in 0..frames {
|
||||
let ratio = 2f32.powf(self.shift.next() / 12.0);
|
||||
let m = self.mix.next();
|
||||
self.phase += (1.0 - ratio) / w;
|
||||
self.phase -= self.phase.floor();
|
||||
if !self.phase.is_finite() {
|
||||
self.phase = 0.0;
|
||||
}
|
||||
let p2 = if self.phase >= 0.5 { self.phase - 0.5 } else { self.phase + 0.5 };
|
||||
let g1 = (std::f32::consts::PI * self.phase).sin().powi(2);
|
||||
let g2 = 1.0 - g1;
|
||||
let (d1, d2) = (self.phase * w + 2.0, p2 * w + 2.0);
|
||||
for c in 0..ch {
|
||||
let (Some(buf), Some(line)) = (io.get_mut(c), self.lines.get_mut(c)) else { continue };
|
||||
let Some(x) = buf.get_mut(n) else { continue };
|
||||
line.push(if x.is_finite() { *x } else { 0.0 });
|
||||
let wet = g1 * line.read(d1) + g2 * line.read(d2);
|
||||
let dry = line.read(half);
|
||||
*x = dry * (1.0 - m) + wet * m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn latency(&self) -> usize {
|
||||
(self.window * 0.5 + 1.0).round() as usize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//! Room Reverb and Plate Reverb: pre-delay → allpass input diffusion → 8-line feedback delay
|
||||
//! network (Hadamard mixing, per-line RT60 gains, in-loop HF damping, gentle delay modulation).
|
||||
|
||||
use crate::biquad::{Biquad, FilterType};
|
||||
use crate::params::{Params, lin, log};
|
||||
use crate::util::{DelayLine, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit};
|
||||
|
||||
const LINES: usize = 8;
|
||||
const DIFFUSERS: usize = 4;
|
||||
const MAX_PREDELAY_MS: f32 = 250.0;
|
||||
const SIZE_MIN: f32 = 0.35;
|
||||
const SIZE_MAX: f32 = 1.6;
|
||||
|
||||
struct Config {
|
||||
delays_ms: [f32; LINES],
|
||||
diffusers_ms: [f32; DIFFUSERS],
|
||||
mod_ms: f32,
|
||||
mod_hz: f32,
|
||||
}
|
||||
|
||||
const ROOM: Config = Config {
|
||||
delays_ms: [23.13, 27.71, 31.87, 36.29, 41.33, 45.67, 51.09, 56.93],
|
||||
diffusers_ms: [4.71, 3.59, 12.73, 9.31],
|
||||
mod_ms: 0.25,
|
||||
mod_hz: 0.7,
|
||||
};
|
||||
|
||||
const PLATE: Config =
|
||||
Config { delays_ms: [13.71, 17.93, 21.07, 24.73, 28.31, 32.89, 36.13, 40.31], diffusers_ms: [3.13, 4.27, 7.93, 11.29], mod_ms: 0.4, mod_hz: 1.1 };
|
||||
|
||||
macro_rules! reverb_params {
|
||||
($mix:expr, $pre:expr, $decay:expr, $damp:expr, $diff:expr) => {
|
||||
[
|
||||
lin("mix", "Mix", 0.0, 100.0, $mix, Unit::Percent),
|
||||
lin("pre_delay", "Pre-Delay", 0.0, MAX_PREDELAY_MS, $pre, Unit::Ms),
|
||||
log("decay", "Decay", 0.1, 20.0, $decay, Unit::Seconds),
|
||||
lin("size", "Size", 0.0, 100.0, 50.0, Unit::Percent),
|
||||
log("damping", "HF Damping", 1000.0, 20000.0, $damp, Unit::Hz),
|
||||
lin("diffusion", "Diffusion", 0.0, 100.0, $diff, Unit::Percent),
|
||||
lin("width", "Width", 0.0, 100.0, 100.0, Unit::Percent),
|
||||
log("low_cut", "Low Cut", 20.0, 1000.0, 20.0, Unit::Hz),
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
static ROOM_PARAMS: [ParamInfo; 8] = reverb_params!(25.0, 10.0, 1.2, 6000.0, 70.0);
|
||||
static PLATE_PARAMS: [ParamInfo; 8] = reverb_params!(30.0, 20.0, 2.5, 10000.0, 85.0);
|
||||
|
||||
pub static ROOM_INFO: PluginInfo = PluginInfo {
|
||||
id: "room_reverb",
|
||||
name: "Room Reverb",
|
||||
short_name: "Room",
|
||||
category: Category::Reverb,
|
||||
params: &ROOM_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
pub static PLATE_INFO: PluginInfo = PluginInfo {
|
||||
id: "plate_reverb",
|
||||
name: "Plate Reverb",
|
||||
short_name: "Plate",
|
||||
category: Category::Reverb,
|
||||
params: &PLATE_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// Schroeder allpass section with a fixed delay.
|
||||
#[derive(Default)]
|
||||
struct Allpass {
|
||||
buf: Vec<f32>,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl Allpass {
|
||||
fn allocate(&mut self, len: usize) {
|
||||
self.buf = vec![0.0; len.max(1)];
|
||||
self.pos = 0;
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn process(&mut self, x: f32, g: f32) -> f32 {
|
||||
let Some(slot) = self.buf.get_mut(self.pos) else { return x };
|
||||
let d = *slot;
|
||||
let v = x + g * d;
|
||||
*slot = flush(v);
|
||||
self.pos += 1;
|
||||
if self.pos >= self.buf.len() {
|
||||
self.pos = 0;
|
||||
}
|
||||
d - g * v
|
||||
}
|
||||
}
|
||||
|
||||
/// In-place normalized 8-point fast Walsh–Hadamard transform (an orthogonal mixing matrix).
|
||||
#[inline]
|
||||
fn hadamard8(v: &mut [f32; LINES]) {
|
||||
let mut h = 1;
|
||||
while h < LINES {
|
||||
let mut i = 0;
|
||||
while i < LINES {
|
||||
for j in i..i + h {
|
||||
let (a, b) = (v[j], v[j + h]);
|
||||
v[j] = a + b;
|
||||
v[j + h] = a - b;
|
||||
}
|
||||
i += h * 2;
|
||||
}
|
||||
h *= 2;
|
||||
}
|
||||
let s = 1.0 / (LINES as f32).sqrt();
|
||||
v.iter_mut().for_each(|x| *x *= s);
|
||||
}
|
||||
|
||||
/// FDN reverb shared by the room and plate models.
|
||||
pub struct Reverb {
|
||||
info: &'static PluginInfo,
|
||||
cfg: &'static Config,
|
||||
p: Params,
|
||||
sr: f32,
|
||||
ch: usize,
|
||||
pre: [DelayLine; 2],
|
||||
diff: [[Allpass; DIFFUSERS]; 2],
|
||||
lines: [DelayLine; LINES],
|
||||
lp: [f32; LINES],
|
||||
gains: [f32; LINES],
|
||||
damp: f32,
|
||||
lowcut: [Biquad; 2],
|
||||
mix: Smoother,
|
||||
size: Smoother,
|
||||
pre_ms: Smoother,
|
||||
width: Smoother,
|
||||
lfo: f32,
|
||||
}
|
||||
|
||||
impl Reverb {
|
||||
pub fn room() -> Self {
|
||||
Self::build(&ROOM_INFO, &ROOM)
|
||||
}
|
||||
|
||||
pub fn plate() -> Self {
|
||||
Self::build(&PLATE_INFO, &PLATE)
|
||||
}
|
||||
|
||||
fn build(info: &'static PluginInfo, cfg: &'static Config) -> Self {
|
||||
let mut r = Self {
|
||||
info,
|
||||
cfg,
|
||||
p: Params::new(info.params),
|
||||
sr: 48_000.0,
|
||||
ch: 0,
|
||||
pre: Default::default(),
|
||||
diff: Default::default(),
|
||||
lines: Default::default(),
|
||||
lp: [0.0; LINES],
|
||||
gains: [0.0; LINES],
|
||||
damp: 0.0,
|
||||
lowcut: [Biquad::default(); 2],
|
||||
mix: Smoother::new(0.0),
|
||||
size: Smoother::new(1.0),
|
||||
pre_ms: Smoother::new(0.0),
|
||||
width: Smoother::new(1.0),
|
||||
lfo: 0.0,
|
||||
};
|
||||
r.update();
|
||||
r.mix.snap();
|
||||
r.size.snap();
|
||||
r.pre_ms.snap();
|
||||
r.width.snap();
|
||||
r
|
||||
}
|
||||
|
||||
fn size_factor(&self) -> f32 {
|
||||
SIZE_MIN + (SIZE_MAX - SIZE_MIN) * self.p.v(3) / 100.0
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.mix.set(self.p.v(0) / 100.0);
|
||||
self.pre_ms.set(self.p.v(1));
|
||||
self.size.set(self.size_factor());
|
||||
self.width.set(self.p.v(6) / 100.0);
|
||||
let lc = self.p.v(7);
|
||||
let sr = self.sr;
|
||||
for b in &mut self.lowcut {
|
||||
b.set(FilterType::HighPass, lc, std::f32::consts::FRAC_1_SQRT_2, 0.0, sr);
|
||||
}
|
||||
self.damp = (-std::f32::consts::TAU * self.p.v(4) / sr).exp();
|
||||
self.update_gains();
|
||||
}
|
||||
|
||||
/// Per-line feedback gain for the target RT60 at the current (smoothed) size.
|
||||
fn update_gains(&mut self) {
|
||||
let rt60 = self.p.v(2).max(0.05);
|
||||
let size = self.size.value();
|
||||
for (g, ms) in self.gains.iter_mut().zip(self.cfg.delays_ms) {
|
||||
let secs = ms * size * 0.001;
|
||||
*g = 10f32.powf(-3.0 * secs / rt60).min(0.9999);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for Reverb {
|
||||
fn info(&self) -> &'static PluginInfo {
|
||||
self.info
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: &str, value: f32) -> bool {
|
||||
if self.p.set(id, value).is_some() {
|
||||
self.update();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: &str) -> Option<f32> {
|
||||
self.p.get(id)
|
||||
}
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.ch = sane_channels(channels);
|
||||
let sr = self.sr;
|
||||
let pre_max = ms_to_samples(MAX_PREDELAY_MS, sr) as usize + 4;
|
||||
self.pre.iter_mut().for_each(|d| d.allocate(pre_max));
|
||||
for (side, aps) in self.diff.iter_mut().enumerate() {
|
||||
let spread = if side == 0 { 1.0 } else { 1.071 };
|
||||
for (ap, ms) in aps.iter_mut().zip(self.cfg.diffusers_ms) {
|
||||
ap.allocate(ms_to_samples(ms * spread, sr) as usize);
|
||||
}
|
||||
}
|
||||
let mod_max = ms_to_samples(self.cfg.mod_ms, sr) + 4.0;
|
||||
for (l, ms) in self.lines.iter_mut().zip(self.cfg.delays_ms) {
|
||||
l.allocate((ms_to_samples(ms * SIZE_MAX, sr) + mod_max) as usize);
|
||||
}
|
||||
self.mix.set_time(30.0, sr);
|
||||
self.size.set_time(200.0, sr);
|
||||
self.pre_ms.set_time(100.0, sr);
|
||||
self.width.set_time(30.0, sr);
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.pre.iter_mut().for_each(DelayLine::clear);
|
||||
for aps in &mut self.diff {
|
||||
for ap in aps.iter_mut() {
|
||||
ap.buf.iter_mut().for_each(|v| *v = 0.0);
|
||||
}
|
||||
}
|
||||
self.lines.iter_mut().for_each(DelayLine::clear);
|
||||
self.lp = [0.0; LINES];
|
||||
self.lowcut.iter_mut().for_each(Biquad::reset);
|
||||
self.mix.snap();
|
||||
self.size.snap();
|
||||
self.pre_ms.snap();
|
||||
self.width.snap();
|
||||
self.update_gains();
|
||||
self.lfo = 0.0;
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.ch.min(io.len());
|
||||
if ch == 0 {
|
||||
return;
|
||||
}
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let sr = self.sr;
|
||||
let g_diff = 0.7 * self.p.v(5) / 100.0;
|
||||
let mod_depth = ms_to_samples(self.cfg.mod_ms, sr);
|
||||
let lfo_inc = self.cfg.mod_hz / sr;
|
||||
let damp = self.damp;
|
||||
if self.size.settling() {
|
||||
self.size.advance(frames);
|
||||
self.update_gains();
|
||||
}
|
||||
let size = self.size.value();
|
||||
let base: [f32; LINES] = self.cfg.delays_ms.map(|ms| ms_to_samples(ms * size, sr));
|
||||
for n in 0..frames {
|
||||
let xl = io.first().and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let xr = if ch > 1 { io.get(1).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0) } else { xl };
|
||||
let pre = ms_to_samples(self.pre_ms.next(), sr);
|
||||
let mut ins = [xl, xr];
|
||||
for (side, x) in ins.iter_mut().enumerate() {
|
||||
let d = &mut self.pre[side];
|
||||
d.push(*x);
|
||||
let mut v = if pre < 1.0 { *x } else { d.read(pre) };
|
||||
for ap in &mut self.diff[side] {
|
||||
v = ap.process(v, g_diff);
|
||||
}
|
||||
*x = v;
|
||||
}
|
||||
self.lfo += lfo_inc;
|
||||
if self.lfo >= 1.0 {
|
||||
self.lfo -= 1.0;
|
||||
}
|
||||
let (s, c) = (self.lfo * std::f32::consts::TAU).sin_cos();
|
||||
let mods = [0.0, s, 0.0, c, 0.0, -s, 0.0, -c];
|
||||
let mut o = [0.0f32; LINES];
|
||||
for i in 0..LINES {
|
||||
let raw = self.lines[i].read(base[i] + mod_depth * (1.0 + mods[i]));
|
||||
self.lp[i] = flush(raw + (self.lp[i] - raw) * damp);
|
||||
o[i] = raw;
|
||||
}
|
||||
let mut fb: [f32; LINES] = std::array::from_fn(|i| self.lp[i] * self.gains[i]);
|
||||
hadamard8(&mut fb);
|
||||
for (i, (line, f)) in self.lines.iter_mut().zip(fb).enumerate() {
|
||||
let inj = if i % 2 == 0 { ins[0] } else { ins[1] };
|
||||
line.push(flush(f + inj * 0.5));
|
||||
}
|
||||
let wl = 0.4 * (o[0] - o[2] + o[4] - o[6]);
|
||||
let wr = 0.4 * (o[1] - o[3] + o[5] - o[7]);
|
||||
let wl = self.lowcut[0].process_sample(wl);
|
||||
let wr = self.lowcut[1].process_sample(wr);
|
||||
let w = self.width.next();
|
||||
let mid = 0.5 * (wl + wr);
|
||||
let side = 0.5 * (wl - wr) * w;
|
||||
let (wl, wr) = (mid + side, mid - side);
|
||||
let m = self.mix.next();
|
||||
for (c, b) in io.iter_mut().take(ch).enumerate() {
|
||||
let wet = if ch == 1 {
|
||||
mid
|
||||
} else if c % 2 == 0 {
|
||||
wl
|
||||
} else {
|
||||
wr
|
||||
};
|
||||
if let Some(x) = b.get_mut(n) {
|
||||
let dry = if x.is_finite() { *x } else { 0.0 };
|
||||
*x = dry * (1.0 - m) + wet * m;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
let secs = self.p.v(2) * 1.3 + self.p.v(1) * 0.001 + 0.1;
|
||||
(secs * self.sr) as usize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Channel Strip: input trim → gate → compressor → 3-band EQ with HPF → output.
|
||||
|
||||
use super::dynamics::{Compressor, ExpanderGate};
|
||||
use super::eq::Eq7;
|
||||
use crate::params::{Params, lin, log, toggle};
|
||||
use crate::util::{Smoother, frames_in, sane_channels, sane_sr};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain};
|
||||
|
||||
static STRIP_PARAMS: [ParamInfo; 22] = [
|
||||
lin("input_gain", "Input", -24.0, 24.0, 0.0, Unit::Db),
|
||||
toggle("gate_on", "Gate", false),
|
||||
lin("gate_threshold", "Gate Thresh", -80.0, 0.0, -50.0, Unit::Db),
|
||||
lin("gate_range", "Gate Range", 0.0, 80.0, 40.0, Unit::Db),
|
||||
log("gate_release", "Gate Release", 1.0, 4000.0, 150.0, Unit::Ms),
|
||||
toggle("comp_on", "Comp", false),
|
||||
lin("comp_threshold", "Comp Thresh", -60.0, 0.0, -18.0, Unit::Db),
|
||||
log("comp_ratio", "Comp Ratio", 1.0, 100.0, 3.0, Unit::Ratio),
|
||||
log("comp_attack", "Comp Attack", 0.01, 300.0, 10.0, Unit::Ms),
|
||||
log("comp_release", "Comp Release", 5.0, 3000.0, 120.0, Unit::Ms),
|
||||
lin("comp_makeup", "Comp Makeup", 0.0, 40.0, 0.0, Unit::Db),
|
||||
toggle("eq_on", "EQ", true),
|
||||
toggle("hpf_on", "HPF", false),
|
||||
log("hpf_freq", "HPF Freq", 10.0, 2000.0, 80.0, Unit::Hz),
|
||||
lin("low_gain", "Low Gain", -24.0, 24.0, 0.0, Unit::Db),
|
||||
log("low_freq", "Low Freq", 20.0, 1000.0, 100.0, Unit::Hz),
|
||||
lin("mid_gain", "Mid Gain", -24.0, 24.0, 0.0, Unit::Db),
|
||||
log("mid_freq", "Mid Freq", 100.0, 8000.0, 1000.0, Unit::Hz),
|
||||
log("mid_q", "Mid Q", 0.1, 10.0, 1.0, Unit::None),
|
||||
lin("high_gain", "High Gain", -24.0, 24.0, 0.0, Unit::Db),
|
||||
log("high_freq", "High Freq", 1000.0, 20000.0, 8000.0, Unit::Hz),
|
||||
lin("output_gain", "Output", -24.0, 24.0, 0.0, Unit::Db),
|
||||
];
|
||||
|
||||
pub static STRIP_INFO: PluginInfo = PluginInfo {
|
||||
id: "channel_strip",
|
||||
name: "Channel Strip",
|
||||
short_name: "Strip",
|
||||
category: Category::Dynamics,
|
||||
params: &STRIP_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Target {
|
||||
Gate,
|
||||
Comp,
|
||||
Eq,
|
||||
}
|
||||
|
||||
/// Parameter forwarding: strip index → (module, module parameter id).
|
||||
const ROUTES: &[(usize, Target, &str)] = &[
|
||||
(2, Target::Gate, "threshold"),
|
||||
(3, Target::Gate, "range"),
|
||||
(4, Target::Gate, "release"),
|
||||
(6, Target::Comp, "threshold"),
|
||||
(7, Target::Comp, "ratio"),
|
||||
(8, Target::Comp, "attack"),
|
||||
(9, Target::Comp, "release"),
|
||||
(10, Target::Comp, "makeup"),
|
||||
(12, Target::Eq, "hpf_on"),
|
||||
(13, Target::Eq, "hpf_freq"),
|
||||
(14, Target::Eq, "low_shelf_gain"),
|
||||
(15, Target::Eq, "low_shelf_freq"),
|
||||
(16, Target::Eq, "mid_gain"),
|
||||
(17, Target::Eq, "mid_freq"),
|
||||
(18, Target::Eq, "mid_q"),
|
||||
(19, Target::Eq, "high_shelf_gain"),
|
||||
(20, Target::Eq, "high_shelf_freq"),
|
||||
];
|
||||
|
||||
/// Gate + compressor + EQ in one insert.
|
||||
pub struct ChannelStrip {
|
||||
p: Params,
|
||||
gate: ExpanderGate,
|
||||
comp: Compressor,
|
||||
eq: Eq7,
|
||||
input: Smoother,
|
||||
output: Smoother,
|
||||
ch: usize,
|
||||
}
|
||||
|
||||
impl Default for ChannelStrip {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ChannelStrip {
|
||||
pub fn new() -> Self {
|
||||
let mut s = Self {
|
||||
p: Params::new(&STRIP_PARAMS),
|
||||
gate: ExpanderGate::new(),
|
||||
comp: Compressor::new(),
|
||||
eq: Eq7::new(),
|
||||
input: Smoother::new(1.0),
|
||||
output: Smoother::new(1.0),
|
||||
ch: 0,
|
||||
};
|
||||
s.comp.set_param("knee", 6.0);
|
||||
for &(idx, _, _) in ROUTES {
|
||||
s.forward(idx);
|
||||
}
|
||||
s.update();
|
||||
s
|
||||
}
|
||||
|
||||
fn forward(&mut self, idx: usize) {
|
||||
let v = self.p.v(idx);
|
||||
for &(i, t, id) in ROUTES {
|
||||
if i == idx {
|
||||
match t {
|
||||
Target::Gate => self.gate.set_param(id, v),
|
||||
Target::Comp => self.comp.set_param(id, v),
|
||||
Target::Eq => self.eq.set_param(id, v),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.input.set(db_to_gain(self.p.v(0)));
|
||||
self.output.set(db_to_gain(self.p.v(21)));
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for ChannelStrip {
|
||||
fn info(&self) -> &'static PluginInfo {
|
||||
&STRIP_INFO
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: &str, value: f32) -> bool {
|
||||
let Some(idx) = self.p.set(id, value) else { return false };
|
||||
self.forward(idx);
|
||||
self.update();
|
||||
true
|
||||
}
|
||||
|
||||
fn param(&self, id: &str) -> Option<f32> {
|
||||
self.p.get(id)
|
||||
}
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, max_block: usize, channels: usize) {
|
||||
let sr = sane_sr(sample_rate);
|
||||
self.ch = sane_channels(channels);
|
||||
self.gate.prepare(sr, max_block, channels);
|
||||
self.comp.prepare(sr, max_block, channels);
|
||||
self.eq.prepare(sr, max_block, channels);
|
||||
self.input.set_time(20.0, sr);
|
||||
self.output.set_time(20.0, sr);
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.gate.reset();
|
||||
self.comp.reset();
|
||||
self.eq.reset();
|
||||
self.input.snap();
|
||||
self.output.snap();
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.ch.min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
for n in 0..frames {
|
||||
let g = self.input.next();
|
||||
for b in io.iter_mut().take(ch) {
|
||||
if let Some(x) = b.get_mut(n) {
|
||||
*x *= g;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.p.on(1) {
|
||||
self.gate.process(io, frames);
|
||||
}
|
||||
if self.p.on(5) {
|
||||
self.comp.process(io, frames);
|
||||
}
|
||||
if self.p.on(11) {
|
||||
self.eq.process(io, frames);
|
||||
}
|
||||
for n in 0..frames {
|
||||
let g = self.output.next();
|
||||
for b in io.iter_mut().take(ch) {
|
||||
if let Some(x) = b.get_mut(n) {
|
||||
*x *= g;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gain_reduction_db(&self) -> f32 {
|
||||
let mut gr = 0.0;
|
||||
if self.p.on(1) {
|
||||
gr += self.gate.gain_reduction_db();
|
||||
}
|
||||
if self.p.on(5) {
|
||||
gr += self.comp.gain_reduction_db();
|
||||
}
|
||||
gr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
//! Subtractive Synth: 16-voice polyphonic, two PolyBLEP oscillators, resonant state-variable
|
||||
//! low-pass (TPT form) with its own ADSR, amp ADSR, optional glide.
|
||||
|
||||
use crate::osc::{Osc, Waveform};
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing};
|
||||
use crate::util::{Smoother, frames_in, ms_to_samples, note_hz, sane_channels, sane_sr, sort_events};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain};
|
||||
|
||||
pub(crate) const MAX_EVENTS: usize = 512;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum EventKind {
|
||||
On(u8, u8),
|
||||
Off(u8),
|
||||
AllOff,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct Event {
|
||||
pub(crate) offset: usize,
|
||||
pub(crate) kind: EventKind,
|
||||
}
|
||||
|
||||
/// Fixed-capacity event queue (never reallocates on the audio thread).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct EventQueue {
|
||||
ev: Vec<Event>,
|
||||
}
|
||||
|
||||
impl EventQueue {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self { ev: Vec::with_capacity(MAX_EVENTS) }
|
||||
}
|
||||
|
||||
pub(crate) fn push(&mut self, offset: usize, kind: EventKind) {
|
||||
if self.ev.len() < MAX_EVENTS {
|
||||
self.ev.push(Event { offset, kind });
|
||||
} else {
|
||||
log::warn!("instrument event queue full; dropping event");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sorted(&mut self) -> &[Event] {
|
||||
sort_events(&mut self.ev, |e| e.offset);
|
||||
&self.ev
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.ev.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const WAVES: &[&str] = &["Saw", "Square", "Sine", "Triangle"];
|
||||
|
||||
fn wave(i: usize) -> Waveform {
|
||||
match i {
|
||||
1 => Waveform::Square,
|
||||
2 => Waveform::Sine,
|
||||
3 => Waveform::Triangle,
|
||||
_ => Waveform::Saw,
|
||||
}
|
||||
}
|
||||
|
||||
static SYNTH_PARAMS: [ParamInfo; 19] = [
|
||||
choice("osc1_wave", "Osc 1", WAVES, 0),
|
||||
choice("osc2_wave", "Osc 2", WAVES, 0),
|
||||
lin("osc2_semitones", "Osc 2 Pitch", -24.0, 24.0, 0.0, Unit::Semitones),
|
||||
lin("osc2_detune", "Osc 2 Detune", -100.0, 100.0, 7.0, Unit::Cents),
|
||||
lin("osc_mix", "Osc Mix", 0.0, 100.0, 50.0, Unit::Percent),
|
||||
log("cutoff", "Cutoff", 20.0, 20000.0, 2000.0, Unit::Hz),
|
||||
lin("resonance", "Resonance", 0.0, 100.0, 20.0, Unit::Percent),
|
||||
lin("filter_env_amount", "Filter Env", -100.0, 100.0, 30.0, Unit::Percent),
|
||||
log("amp_attack", "Amp Attack", 0.5, 5000.0, 5.0, Unit::Ms),
|
||||
log("amp_decay", "Amp Decay", 1.0, 5000.0, 200.0, Unit::Ms),
|
||||
lin("amp_sustain", "Amp Sustain", 0.0, 100.0, 70.0, Unit::Percent),
|
||||
log("amp_release", "Amp Release", 1.0, 10000.0, 300.0, Unit::Ms),
|
||||
log("filter_attack", "Filter Attack", 0.5, 5000.0, 5.0, Unit::Ms),
|
||||
log("filter_decay", "Filter Decay", 1.0, 5000.0, 300.0, Unit::Ms),
|
||||
lin("filter_sustain", "Filter Sustain", 0.0, 100.0, 30.0, Unit::Percent),
|
||||
log("filter_release", "Filter Release", 1.0, 10000.0, 300.0, Unit::Ms),
|
||||
lin("glide", "Glide", 0.0, 1000.0, 0.0, Unit::Ms),
|
||||
lin("velocity", "Velocity Sens", 0.0, 100.0, 70.0, Unit::Percent),
|
||||
lin("level", "Level", -48.0, 6.0, -6.0, Unit::Db),
|
||||
];
|
||||
|
||||
pub static SYNTH_INFO: PluginInfo = PluginInfo {
|
||||
id: "subtractive_synth",
|
||||
name: "Subtractive Synth",
|
||||
short_name: "SubSynth",
|
||||
category: Category::Instrument,
|
||||
params: &SYNTH_PARAMS,
|
||||
is_instrument: true,
|
||||
audiosuite: false,
|
||||
};
|
||||
|
||||
const VOICES: usize = 16;
|
||||
const SILENT: f32 = 1.0e-5;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum Stage {
|
||||
Idle,
|
||||
Attack,
|
||||
Decay,
|
||||
Sustain,
|
||||
Release,
|
||||
}
|
||||
|
||||
/// Linear-attack, exponential decay/release ADSR. Times are "to -60 dB" for decay/release.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Adsr {
|
||||
stage: Stage,
|
||||
level: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct AdsrRates {
|
||||
attack: f32,
|
||||
decay: f32,
|
||||
sustain: f32,
|
||||
release: f32,
|
||||
}
|
||||
|
||||
impl AdsrRates {
|
||||
fn new(a_ms: f32, d_ms: f32, s: f32, r_ms: f32, sr: f32) -> Self {
|
||||
let exp60 = |ms: f32| (0.001f32.ln() / ms_to_samples(ms, sr).max(1.0)).exp();
|
||||
Self { attack: 1.0 / ms_to_samples(a_ms, sr).max(1.0), decay: exp60(d_ms), sustain: s.clamp(0.0, 1.0), release: exp60(r_ms) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Adsr {
|
||||
const fn new() -> Self {
|
||||
Self { stage: Stage::Idle, level: 0.0 }
|
||||
}
|
||||
|
||||
fn gate_on(&mut self) {
|
||||
self.stage = Stage::Attack;
|
||||
}
|
||||
|
||||
fn gate_off(&mut self) {
|
||||
if self.stage != Stage::Idle {
|
||||
self.stage = Stage::Release;
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self, r: &AdsrRates) -> f32 {
|
||||
match self.stage {
|
||||
Stage::Idle => self.level = 0.0,
|
||||
Stage::Attack => {
|
||||
self.level += r.attack;
|
||||
if self.level >= 1.0 {
|
||||
self.level = 1.0;
|
||||
self.stage = Stage::Decay;
|
||||
}
|
||||
}
|
||||
Stage::Decay => {
|
||||
self.level = r.sustain + (self.level - r.sustain) * r.decay;
|
||||
if (self.level - r.sustain).abs() < 1e-4 {
|
||||
self.stage = Stage::Sustain;
|
||||
}
|
||||
}
|
||||
Stage::Sustain => self.level = r.sustain,
|
||||
Stage::Release => {
|
||||
self.level *= r.release;
|
||||
if self.level < SILENT {
|
||||
self.level = 0.0;
|
||||
self.stage = Stage::Idle;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.level
|
||||
}
|
||||
}
|
||||
|
||||
/// Topology-preserving-transform state-variable low-pass.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct Svf {
|
||||
ic1: f32,
|
||||
ic2: f32,
|
||||
}
|
||||
|
||||
impl Svf {
|
||||
#[inline]
|
||||
fn lowpass(&mut self, x: f32, g: f32, k: f32) -> f32 {
|
||||
let a1 = 1.0 / (1.0 + g * (g + k));
|
||||
let a2 = g * a1;
|
||||
let a3 = g * a2;
|
||||
let v3 = x - self.ic2;
|
||||
let v1 = a1 * self.ic1 + a2 * v3;
|
||||
let v2 = self.ic2 + a2 * self.ic1 + a3 * v3;
|
||||
self.ic1 = crate::util::flush(2.0 * v1 - self.ic1);
|
||||
self.ic2 = crate::util::flush(2.0 * v2 - self.ic2);
|
||||
if v2.is_finite() {
|
||||
v2
|
||||
} else {
|
||||
*self = Svf::default();
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Voice {
|
||||
note: u8,
|
||||
vel: f32,
|
||||
freq: f32,
|
||||
target: f32,
|
||||
osc1: Osc,
|
||||
osc2: Osc,
|
||||
amp: Adsr,
|
||||
filt: Adsr,
|
||||
svf: Svf,
|
||||
age: u64,
|
||||
held: bool,
|
||||
}
|
||||
|
||||
impl Voice {
|
||||
fn new(seed: u32) -> Self {
|
||||
Self {
|
||||
note: 0,
|
||||
vel: 0.0,
|
||||
freq: 440.0,
|
||||
target: 440.0,
|
||||
osc1: Osc::new(seed),
|
||||
osc2: Osc::new(seed.wrapping_mul(7919)),
|
||||
amp: Adsr::new(),
|
||||
filt: Adsr::new(),
|
||||
svf: Svf::default(),
|
||||
age: 0,
|
||||
held: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn active(&self) -> bool {
|
||||
self.amp.stage != Stage::Idle
|
||||
}
|
||||
}
|
||||
|
||||
/// Polyphonic subtractive synthesizer.
|
||||
pub struct SubtractiveSynth {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
ch: usize,
|
||||
voices: [Voice; VOICES],
|
||||
events: EventQueue,
|
||||
amp_rates: AdsrRates,
|
||||
filt_rates: AdsrRates,
|
||||
glide_coef: f32,
|
||||
last_freq: Option<f32>,
|
||||
counter: u64,
|
||||
level: Smoother,
|
||||
cutoff: Smoother,
|
||||
}
|
||||
|
||||
impl Default for SubtractiveSynth {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SubtractiveSynth {
|
||||
pub fn new() -> Self {
|
||||
let mut voices = [Voice::new(1); VOICES];
|
||||
for (i, v) in voices.iter_mut().enumerate() {
|
||||
*v = Voice::new(0x1000 + i as u32 * 31);
|
||||
}
|
||||
let mut s = Self {
|
||||
p: Params::new(&SYNTH_PARAMS),
|
||||
sr: 48_000.0,
|
||||
ch: 0,
|
||||
voices,
|
||||
events: EventQueue::new(),
|
||||
amp_rates: AdsrRates::default(),
|
||||
filt_rates: AdsrRates::default(),
|
||||
glide_coef: 0.0,
|
||||
last_freq: None,
|
||||
counter: 0,
|
||||
level: Smoother::new(0.5),
|
||||
cutoff: Smoother::new(2000.0),
|
||||
};
|
||||
s.update();
|
||||
s
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
let sr = self.sr;
|
||||
let v = |i| self.p.v(i);
|
||||
self.amp_rates = AdsrRates::new(v(8), v(9), v(10) / 100.0, v(11), sr);
|
||||
self.filt_rates = AdsrRates::new(v(12), v(13), v(14) / 100.0, v(15), sr);
|
||||
let glide = v(16);
|
||||
self.glide_coef = if glide < 0.5 { 0.0 } else { crate::util::time_coef(glide / 3.0, sr) };
|
||||
self.level.set(db_to_gain(v(18)));
|
||||
self.cutoff.set(v(5));
|
||||
}
|
||||
|
||||
fn start_note(&mut self, note: u8, vel: u8) {
|
||||
if vel == 0 {
|
||||
self.stop_note(note);
|
||||
return;
|
||||
}
|
||||
self.counter += 1;
|
||||
let freq = note_hz(f32::from(note.min(127)));
|
||||
// Reuse a voice already playing this note, else a free one, else steal.
|
||||
let idx = self
|
||||
.voices
|
||||
.iter()
|
||||
.position(|v| v.active() && v.note == note)
|
||||
.or_else(|| self.voices.iter().position(|v| !v.active()))
|
||||
.or_else(|| {
|
||||
self.voices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by(|(_, a), (_, b)| {
|
||||
let ka = (a.held, a.amp.level);
|
||||
let kb = (b.held, b.amp.level);
|
||||
ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let sens = self.p.v(17) / 100.0;
|
||||
let glide = self.glide_coef > 0.0;
|
||||
let start = if glide { self.last_freq.unwrap_or(freq) } else { freq };
|
||||
let counter = self.counter;
|
||||
if let Some(v) = self.voices.get_mut(idx) {
|
||||
let was_active = v.active();
|
||||
v.note = note;
|
||||
v.vel = (1.0 - sens) + sens * f32::from(vel.min(127)) / 127.0;
|
||||
v.target = freq;
|
||||
v.freq = if was_active && glide { v.freq } else { start };
|
||||
if !was_active {
|
||||
v.osc1.reset();
|
||||
v.osc2.phase = 0.37;
|
||||
v.svf = Svf::default();
|
||||
}
|
||||
v.amp.gate_on();
|
||||
v.filt.gate_on();
|
||||
v.age = counter;
|
||||
v.held = true;
|
||||
}
|
||||
self.last_freq = Some(freq);
|
||||
}
|
||||
|
||||
fn stop_note(&mut self, note: u8) {
|
||||
for v in self.voices.iter_mut().filter(|v| v.held && v.note == note) {
|
||||
v.held = false;
|
||||
v.amp.gate_off();
|
||||
v.filt.gate_off();
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&mut self, kind: EventKind) {
|
||||
match kind {
|
||||
EventKind::On(n, v) => self.start_note(n, v),
|
||||
EventKind::Off(n) => self.stop_note(n),
|
||||
EventKind::AllOff => {
|
||||
for v in &mut self.voices {
|
||||
v.held = false;
|
||||
v.amp.gate_off();
|
||||
v.filt.gate_off();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn render_sample(&mut self) -> f32 {
|
||||
let sr = self.sr;
|
||||
let w1 = wave(self.p.choice(0));
|
||||
let w2 = wave(self.p.choice(1));
|
||||
let ratio2 = 2f32.powf((self.p.v(2) + self.p.v(3) / 100.0) / 12.0);
|
||||
let mix = self.p.v(4) / 100.0;
|
||||
let k = 2.0 - 1.95 * (self.p.v(6) / 100.0);
|
||||
let env_oct = self.p.v(7) / 100.0 * 6.0;
|
||||
let cutoff = self.cutoff.next();
|
||||
let nyq = sr * 0.45;
|
||||
let mut out = 0.0;
|
||||
for v in &mut self.voices {
|
||||
if !v.active() {
|
||||
continue;
|
||||
}
|
||||
if self.glide_coef > 0.0 {
|
||||
v.freq = v.target + (v.freq - v.target) * self.glide_coef;
|
||||
} else {
|
||||
v.freq = v.target;
|
||||
}
|
||||
let a = v.amp.next(&self.amp_rates);
|
||||
let fe = v.filt.next(&self.filt_rates);
|
||||
let s1 = v.osc1.next(w1, v.freq / sr);
|
||||
let s2 = v.osc2.next(w2, v.freq * ratio2 / sr);
|
||||
let raw = s1 * (1.0 - mix) + s2 * mix;
|
||||
let fc = (cutoff * 2f32.powf(env_oct * fe)).clamp(20.0, nyq);
|
||||
let g = (std::f32::consts::PI * fc / sr).tan();
|
||||
let y = v.svf.lowpass(raw, g, k);
|
||||
out += y * a * v.vel;
|
||||
}
|
||||
out * 0.3
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for SubtractiveSynth {
|
||||
param_plumbing!(SYNTH_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.ch = sane_channels(channels);
|
||||
self.level.set_time(20.0, self.sr);
|
||||
self.cutoff.set_time(20.0, self.sr);
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
for (i, v) in self.voices.iter_mut().enumerate() {
|
||||
*v = Voice::new(0x1000 + i as u32 * 31);
|
||||
}
|
||||
self.events.clear();
|
||||
self.last_freq = None;
|
||||
self.level.snap();
|
||||
self.cutoff.snap();
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.ch.min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let mut evs = [Event { offset: 0, kind: EventKind::AllOff }; MAX_EVENTS];
|
||||
let count = {
|
||||
let sorted = self.events.sorted();
|
||||
for (d, s) in evs.iter_mut().zip(sorted) {
|
||||
*d = *s;
|
||||
}
|
||||
sorted.len().min(MAX_EVENTS)
|
||||
};
|
||||
self.events.clear();
|
||||
let mut next = 0;
|
||||
for n in 0..frames {
|
||||
while let Some(e) = evs.get(next).filter(|_| next < count) {
|
||||
if e.offset > n && n + 1 < frames {
|
||||
break;
|
||||
}
|
||||
let kind = e.kind;
|
||||
self.apply(kind);
|
||||
next += 1;
|
||||
}
|
||||
let y = self.render_sample() * self.level.next();
|
||||
for b in io.iter_mut().take(ch) {
|
||||
if let Some(x) = b.get_mut(n) {
|
||||
*x = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Events for an empty block take effect immediately.
|
||||
for e in evs.iter().take(count).skip(next) {
|
||||
self.apply(e.kind);
|
||||
}
|
||||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
ms_to_samples(self.p.v(11), self.sr) as usize
|
||||
}
|
||||
|
||||
fn note_on(&mut self, offset: usize, note: u8, velocity: u8) {
|
||||
self.events.push(offset, EventKind::On(note, velocity));
|
||||
}
|
||||
|
||||
fn note_off(&mut self, offset: usize, note: u8) {
|
||||
self.events.push(offset, EventKind::Off(note));
|
||||
}
|
||||
|
||||
fn all_notes_off(&mut self) {
|
||||
self.events.push(0, EventKind::AllOff);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
//! Utilities: Time Shift, Gain, Trim, Invert, DC Offset Removal, Signal Generator, Dither.
|
||||
|
||||
use crate::osc::{Osc, Waveform};
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
|
||||
use crate::util::{DcBlocker, DelayLine, Rng, Smoother, frames_in, ms_to_samples, sane_channels, sane_sr};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain};
|
||||
|
||||
/// Gain parameter floor that means "-inf".
|
||||
const OFF_DB: f32 = -96.0;
|
||||
|
||||
fn fader_gain(db: f32) -> f32 {
|
||||
if db <= OFF_DB { 0.0 } else { db_to_gain(db) }
|
||||
}
|
||||
|
||||
/// Applies a per-sample smoothed gain to all channels.
|
||||
fn apply_gain(io: &mut [Vec<f32>], frames: usize, ch: usize, g: &mut Smoother) {
|
||||
let ch = ch.min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
if !g.settling() {
|
||||
let v = g.value();
|
||||
if v == 1.0 {
|
||||
return;
|
||||
}
|
||||
io.iter_mut().take(ch).for_each(|b| b.iter_mut().take(frames).for_each(|x| *x *= v));
|
||||
return;
|
||||
}
|
||||
for n in 0..frames {
|
||||
let v = g.next();
|
||||
for b in io.iter_mut().take(ch) {
|
||||
if let Some(x) = b.get_mut(n) {
|
||||
*x *= v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
const MAX_SHIFT_MS: f32 = 1000.0;
|
||||
const MAX_SHIFT_SAMPLES: f32 = 10_000.0;
|
||||
const XFADE: usize = 256;
|
||||
|
||||
static SHIFT_PARAMS: [ParamInfo; 2] =
|
||||
[lin("delay_ms", "Delay", 0.0, MAX_SHIFT_MS, 0.0, Unit::Ms), lin("delay_samples", "Samples", 0.0, MAX_SHIFT_SAMPLES, 0.0, Unit::None)];
|
||||
|
||||
pub static SHIFT_INFO: PluginInfo = PluginInfo {
|
||||
id: "time_shift",
|
||||
name: "Time Shift",
|
||||
short_name: "TShift",
|
||||
category: Category::Other,
|
||||
params: &SHIFT_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: false,
|
||||
};
|
||||
|
||||
/// Delays the signal by `delay_ms + delay_samples`; changes crossfade between taps (no clicks).
|
||||
pub struct TimeShift {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
lines: Vec<DelayLine>,
|
||||
cur: usize,
|
||||
target: usize,
|
||||
fade: usize,
|
||||
}
|
||||
|
||||
impl Default for TimeShift {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TimeShift {
|
||||
pub fn new() -> Self {
|
||||
Self { p: Params::new(&SHIFT_PARAMS), sr: 48_000.0, lines: Vec::new(), cur: 0, target: 0, fade: 0 }
|
||||
}
|
||||
|
||||
fn delay_samples(&self) -> usize {
|
||||
let max = self.lines.first().map(|l| l.max_delay() as usize).unwrap_or(1).saturating_sub(2);
|
||||
((ms_to_samples(self.p.v(0), self.sr) + self.p.v(1).max(0.0)).round() as usize).min(max)
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
let d = self.delay_samples();
|
||||
if d != self.target {
|
||||
if self.fade == 0 {
|
||||
self.fade = XFADE;
|
||||
}
|
||||
self.target = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for TimeShift {
|
||||
param_plumbing!(SHIFT_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
let max = (ms_to_samples(MAX_SHIFT_MS, self.sr) + MAX_SHIFT_SAMPLES) as usize + 8;
|
||||
self.lines = (0..sane_channels(channels))
|
||||
.map(|_| {
|
||||
let mut d = DelayLine::default();
|
||||
d.allocate(max);
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.lines.iter_mut().for_each(DelayLine::clear);
|
||||
self.target = self.delay_samples();
|
||||
self.cur = self.target;
|
||||
self.fade = 0;
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.lines.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
for n in 0..frames {
|
||||
let t = if self.fade > 0 { 1.0 - self.fade as f32 / XFADE as f32 } else { 1.0 };
|
||||
for c in 0..ch {
|
||||
let (Some(buf), Some(line)) = (io.get_mut(c), self.lines.get_mut(c)) else { continue };
|
||||
let Some(x) = buf.get_mut(n) else { continue };
|
||||
line.push(if x.is_finite() { *x } else { 0.0 });
|
||||
let new = line.tap(self.target + 1);
|
||||
*x = if self.fade > 0 { line.tap(self.cur + 1) * (1.0 - t) + new * t } else { new };
|
||||
}
|
||||
if self.fade > 0 {
|
||||
self.fade -= 1;
|
||||
if self.fade == 0 {
|
||||
self.cur = self.target;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
self.target
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
static GAIN_PARAMS: [ParamInfo; 1] = [lin("gain", "Gain", OFF_DB, 24.0, 0.0, Unit::Db)];
|
||||
|
||||
pub static GAIN_INFO: PluginInfo = PluginInfo {
|
||||
id: "gain",
|
||||
name: "Gain",
|
||||
short_name: "Gain",
|
||||
category: Category::Other,
|
||||
params: &GAIN_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// Smoothed gain change (-inf .. +24 dB).
|
||||
pub struct Gain {
|
||||
p: Params,
|
||||
ch: usize,
|
||||
g: Smoother,
|
||||
}
|
||||
|
||||
impl Default for Gain {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Gain {
|
||||
pub fn new() -> Self {
|
||||
Self { p: Params::new(&GAIN_PARAMS), ch: 0, g: Smoother::new(1.0) }
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.g.set(fader_gain(self.p.v(0)));
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for Gain {
|
||||
param_plumbing!(GAIN_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.ch = sane_channels(channels);
|
||||
self.g.set_time(20.0, sane_sr(sample_rate));
|
||||
self.update();
|
||||
self.g.snap();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.g.snap();
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
apply_gain(io, frames, self.ch, &mut self.g);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
static TRIM_PARAMS: [ParamInfo; 2] = [lin("gain", "Trim", OFF_DB, 12.0, 0.0, Unit::Db), toggle("invert", "Phase Invert", false)];
|
||||
|
||||
pub static TRIM_INFO: PluginInfo = PluginInfo {
|
||||
id: "trim",
|
||||
name: "Trim",
|
||||
short_name: "Trim",
|
||||
category: Category::Other,
|
||||
params: &TRIM_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// Trim (-inf .. +12 dB) with a click-free polarity switch.
|
||||
pub struct Trim {
|
||||
p: Params,
|
||||
ch: usize,
|
||||
g: Smoother,
|
||||
}
|
||||
|
||||
impl Default for Trim {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Trim {
|
||||
pub fn new() -> Self {
|
||||
Self { p: Params::new(&TRIM_PARAMS), ch: 0, g: Smoother::new(1.0) }
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
let sign = if self.p.on(1) { -1.0 } else { 1.0 };
|
||||
self.g.set(sign * fader_gain(self.p.v(0)));
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for Trim {
|
||||
param_plumbing!(TRIM_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.ch = sane_channels(channels);
|
||||
self.g.set_time(10.0, sane_sr(sample_rate));
|
||||
self.update();
|
||||
self.g.snap();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.g.snap();
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
apply_gain(io, frames, self.ch, &mut self.g);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
pub static INVERT_INFO: PluginInfo =
|
||||
PluginInfo { id: "invert", name: "Invert", short_name: "Invert", category: Category::Other, params: &[], is_instrument: false, audiosuite: true };
|
||||
|
||||
/// Polarity inversion.
|
||||
#[derive(Default)]
|
||||
pub struct Invert {
|
||||
ch: usize,
|
||||
}
|
||||
|
||||
impl Plugin for Invert {
|
||||
fn info(&self) -> &'static PluginInfo {
|
||||
&INVERT_INFO
|
||||
}
|
||||
|
||||
fn prepare(&mut self, _sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.ch = sane_channels(channels);
|
||||
}
|
||||
|
||||
fn reset(&mut self) {}
|
||||
|
||||
fn set_param(&mut self, _id: &str, _value: f32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn param(&self, _id: &str) -> Option<f32> {
|
||||
None
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.ch.min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
io.iter_mut().take(ch).for_each(|b| b.iter_mut().take(frames).for_each(|x| *x = -*x));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
static DC_PARAMS: [ParamInfo; 1] = [log("cutoff", "Cutoff", 1.0, 40.0, 5.0, Unit::Hz)];
|
||||
|
||||
pub static DC_INFO: PluginInfo = PluginInfo {
|
||||
id: "dc_offset_removal",
|
||||
name: "DC Offset Removal",
|
||||
short_name: "DC Rmv",
|
||||
category: Category::Other,
|
||||
params: &DC_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// One-pole DC-blocking high-pass.
|
||||
pub struct DcRemoval {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
f: Vec<DcBlocker>,
|
||||
}
|
||||
|
||||
impl Default for DcRemoval {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DcRemoval {
|
||||
pub fn new() -> Self {
|
||||
Self { p: Params::new(&DC_PARAMS), sr: 48_000.0, f: Vec::new() }
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
let (fc, sr) = (self.p.v(0), self.sr);
|
||||
self.f.iter_mut().for_each(|f| f.set_cutoff(fc, sr));
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for DcRemoval {
|
||||
param_plumbing!(DC_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.f = vec![DcBlocker::new(self.p.v(0), self.sr); sane_channels(channels)];
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.f.iter_mut().for_each(DcBlocker::reset);
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.f.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
for (b, f) in io.iter_mut().zip(self.f.iter_mut()) {
|
||||
for x in b.iter_mut().take(frames) {
|
||||
*x = f.process(if x.is_finite() { *x } else { 0.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
const WAVE_NAMES: &[&str] = &["Sine", "Square", "Saw", "Triangle", "White Noise", "Pink Noise"];
|
||||
|
||||
static GEN_PARAMS: [ParamInfo; 3] = [
|
||||
choice("waveform", "Waveform", WAVE_NAMES, 0),
|
||||
log("freq", "Frequency", 20.0, 20000.0, 1000.0, Unit::Hz),
|
||||
lin("level", "Level", OFF_DB, 0.0, -20.0, Unit::Db),
|
||||
];
|
||||
|
||||
pub static GEN_INFO: PluginInfo = PluginInfo {
|
||||
id: "signal_generator",
|
||||
name: "Signal Generator",
|
||||
short_name: "SigGen",
|
||||
category: Category::Other,
|
||||
params: &GEN_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// Test-tone and noise generator; replaces its input.
|
||||
pub struct SignalGenerator {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
ch: usize,
|
||||
osc: Osc,
|
||||
level: Smoother,
|
||||
freq: Smoother,
|
||||
}
|
||||
|
||||
impl Default for SignalGenerator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SignalGenerator {
|
||||
pub fn new() -> Self {
|
||||
let mut g = Self {
|
||||
p: Params::new(&GEN_PARAMS),
|
||||
sr: 48_000.0,
|
||||
ch: 0,
|
||||
osc: Osc::new(0xC0FFEE),
|
||||
level: Smoother::new(0.1),
|
||||
freq: Smoother::new(1000.0),
|
||||
};
|
||||
g.update();
|
||||
g
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.level.set(fader_gain(self.p.v(2)));
|
||||
self.freq.set(self.p.v(1));
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for SignalGenerator {
|
||||
param_plumbing!(GEN_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.ch = sane_channels(channels);
|
||||
self.level.set_time(20.0, self.sr);
|
||||
self.freq.set_time(20.0, self.sr);
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.osc.reset();
|
||||
self.level.snap();
|
||||
self.freq.snap();
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.ch.min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let wave = Waveform::from_index(self.p.choice(0));
|
||||
for n in 0..frames {
|
||||
let g = self.level.next();
|
||||
let f = self.freq.next();
|
||||
let y = self.osc.next(wave, f / self.sr) * g;
|
||||
for b in io.iter_mut().take(ch) {
|
||||
if let Some(x) = b.get_mut(n) {
|
||||
*x = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
static DITHER_PARAMS: [ParamInfo; 2] = [choice("bits", "Bit Depth", &["16", "20", "24"], 0), toggle("noise_shaping", "Noise Shaping", false)];
|
||||
|
||||
pub static DITHER_INFO: PluginInfo = PluginInfo {
|
||||
id: "dither",
|
||||
name: "Dither",
|
||||
short_name: "Dither",
|
||||
category: Category::Dither,
|
||||
params: &DITHER_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// TPDF dither and requantization with optional first-order error-feedback noise shaping.
|
||||
/// Digital silence passes through as silence.
|
||||
pub struct Dither {
|
||||
p: Params,
|
||||
rng: Rng,
|
||||
err: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Default for Dither {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Dither {
|
||||
pub fn new() -> Self {
|
||||
Self { p: Params::new(&DITHER_PARAMS), rng: Rng::new(0xD17E), err: Vec::new() }
|
||||
}
|
||||
|
||||
fn update(&mut self) {}
|
||||
}
|
||||
|
||||
impl Plugin for Dither {
|
||||
param_plumbing!(DITHER_INFO);
|
||||
|
||||
fn prepare(&mut self, _sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.err = vec![0.0; sane_channels(channels)];
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.err.iter_mut().for_each(|e| *e = 0.0);
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.err.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let bits = [16.0f32, 20.0, 24.0].get(self.p.choice(0)).copied().unwrap_or(16.0);
|
||||
let lsb = 2f32.powf(1.0 - bits);
|
||||
let shape = self.p.on(1);
|
||||
for (b, e) in io.iter_mut().zip(self.err.iter_mut()) {
|
||||
for x in b.iter_mut().take(frames) {
|
||||
if *x == 0.0 || !x.is_finite() {
|
||||
*x = 0.0;
|
||||
*e = 0.0;
|
||||
continue;
|
||||
}
|
||||
let v = if shape { *x - *e } else { *x };
|
||||
let d = (self.rng.bipolar() + self.rng.bipolar()) * 0.5 * lsb;
|
||||
let q = ((v + d) / lsb).round() * lsb;
|
||||
*e = q - v;
|
||||
*x = q.clamp(-1.0, 1.0 - lsb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! FFT magnitude analysis for analyzers and EQ displays.
|
||||
|
||||
use rustfft::{FftPlanner, num_complex::Complex};
|
||||
|
||||
/// Largest FFT size accepted (larger requests are clamped).
|
||||
pub const MAX_FFT: usize = 1 << 18;
|
||||
|
||||
/// Hann-windowed magnitude spectrum in dBFS, `fft_size / 2 + 1` bins (bin `k` is at
|
||||
/// `k · sr / fft_size` Hz). A full-scale sine reads ≈ 0 dB. Input shorter than `fft_size` is
|
||||
/// zero-padded; longer input uses the first `fft_size` samples. Floored at -144 dB.
|
||||
pub fn magnitude_db(samples: &[f32], fft_size: usize) -> Vec<f32> {
|
||||
let n = fft_size.min(MAX_FFT);
|
||||
if n < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut planner = FftPlanner::<f32>::new();
|
||||
let fft = planner.plan_fft_forward(n);
|
||||
let mut buf: Vec<Complex<f32>> = (0..n)
|
||||
.map(|i| {
|
||||
let w = 0.5 - 0.5 * (std::f32::consts::TAU * i as f32 / n as f32).cos();
|
||||
let x = samples.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
Complex::new(x * w, 0.0)
|
||||
})
|
||||
.collect();
|
||||
fft.process(&mut buf);
|
||||
// Hann coherent gain is 0.5; a sine's energy splits across ±f.
|
||||
let scale = 2.0 / (n as f32 * 0.5);
|
||||
buf.iter().take(n / 2 + 1).map(|c| crate::gain_to_db(c.norm() * scale)).collect()
|
||||
}
|
||||
|
||||
/// Frequency in Hz of bin `k` for a given FFT size.
|
||||
pub fn bin_hz(k: usize, fft_size: usize, sample_rate: f32) -> f32 {
|
||||
if fft_size == 0 { 0.0 } else { k as f32 * sample_rate / fft_size as f32 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sine_peak_near_zero_db() {
|
||||
let sr = 48_000.0;
|
||||
let n = 4096;
|
||||
let k = 100;
|
||||
let f = bin_hz(k, n, sr);
|
||||
let x: Vec<f32> = (0..n).map(|i| (std::f32::consts::TAU * f * i as f32 / sr).sin()).collect();
|
||||
let m = magnitude_db(&x, n);
|
||||
assert_eq!(m.len(), n / 2 + 1);
|
||||
assert!(m[k].abs() < 0.1, "{}", m[k]);
|
||||
assert!(magnitude_db(&x, 0).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Small realtime building blocks shared by the plugins.
|
||||
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
/// Default sample rate used when a host passes something unusable.
|
||||
pub(crate) const FALLBACK_SR: f32 = 48_000.0;
|
||||
|
||||
/// Sanitizes a host sample rate into 1 kHz ..= 1.536 MHz.
|
||||
pub(crate) fn sane_sr(sr: f32) -> f32 {
|
||||
if sr.is_finite() && sr >= 1000.0 { sr.min(1_536_000.0) } else { FALLBACK_SR }
|
||||
}
|
||||
|
||||
/// Upper bound on channels any plugin will allocate state for.
|
||||
pub(crate) const MAX_CHANNELS: usize = 64;
|
||||
|
||||
pub(crate) fn sane_channels(ch: usize) -> usize {
|
||||
ch.min(MAX_CHANNELS)
|
||||
}
|
||||
|
||||
/// Converts milliseconds to (fractional) samples, never negative or non-finite.
|
||||
#[inline]
|
||||
pub(crate) fn ms_to_samples(ms: f32, sr: f32) -> f32 {
|
||||
let s = ms * 0.001 * sr;
|
||||
if s.is_finite() { s.max(0.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// One-pole coefficient for a time constant in milliseconds.
|
||||
#[inline]
|
||||
pub(crate) fn time_coef(ms: f32, sr: f32) -> f32 {
|
||||
let n = ms_to_samples(ms, sr);
|
||||
if n < 1.0e-3 { 0.0 } else { (-1.0 / n).exp() }
|
||||
}
|
||||
|
||||
/// Flushes denormals and non-finite values in feedback paths to zero.
|
||||
#[inline]
|
||||
pub(crate) fn flush(x: f32) -> f32 {
|
||||
if x.is_finite() && x.abs() > 1.0e-25 { x } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Number of frames a plugin may safely touch.
|
||||
pub(crate) fn frames_in(io: &[Vec<f32>], frames: usize, channels: usize) -> usize {
|
||||
io.iter().take(channels).map(Vec::len).fold(frames, usize::min)
|
||||
}
|
||||
|
||||
/// One-pole exponential parameter smoother (no zipper noise).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct Smoother {
|
||||
cur: f32,
|
||||
target: f32,
|
||||
coef: f32,
|
||||
}
|
||||
|
||||
impl Smoother {
|
||||
pub(crate) fn new(v: f32) -> Self {
|
||||
Self { cur: v, target: v, coef: 0.0 }
|
||||
}
|
||||
|
||||
/// Sets the time constant (default ~20 ms is typical).
|
||||
pub(crate) fn set_time(&mut self, ms: f32, sr: f32) {
|
||||
self.coef = time_coef(ms, sr);
|
||||
}
|
||||
|
||||
pub(crate) fn set(&mut self, v: f32) {
|
||||
if v.is_finite() {
|
||||
self.target = v;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn snap(&mut self) {
|
||||
self.cur = self.target;
|
||||
}
|
||||
|
||||
pub(crate) fn value(&self) -> f32 {
|
||||
self.cur
|
||||
}
|
||||
|
||||
pub(crate) fn settling(&self) -> bool {
|
||||
self.cur != self.target
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn next(&mut self) -> f32 {
|
||||
if self.cur != self.target {
|
||||
self.cur = self.target + (self.cur - self.target) * self.coef;
|
||||
if (self.cur - self.target).abs() <= 1.0e-6 * (1.0 + self.target.abs()) {
|
||||
self.cur = self.target;
|
||||
}
|
||||
}
|
||||
self.cur
|
||||
}
|
||||
|
||||
/// Advances `n` samples at once (used for block-rate coefficient updates).
|
||||
pub(crate) fn advance(&mut self, n: usize) -> f32 {
|
||||
if self.cur != self.target {
|
||||
let k = self.coef.powi(n.min(i32::MAX as usize) as i32);
|
||||
self.cur = self.target + (self.cur - self.target) * k;
|
||||
if (self.cur - self.target).abs() <= 1.0e-6 * (1.0 + self.target.abs()) {
|
||||
self.cur = self.target;
|
||||
}
|
||||
}
|
||||
self.cur
|
||||
}
|
||||
}
|
||||
|
||||
/// Power-of-two circular delay line with fractional reads.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct DelayLine {
|
||||
buf: Vec<f32>,
|
||||
mask: usize,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl DelayLine {
|
||||
/// Allocates room for at least `max_delay` samples of delay.
|
||||
pub(crate) fn allocate(&mut self, max_delay: usize) {
|
||||
let len = max_delay.saturating_add(4).clamp(4, 1 << 26).next_power_of_two();
|
||||
self.buf = vec![0.0; len];
|
||||
self.mask = len - 1;
|
||||
self.pos = 0;
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.buf.iter_mut().for_each(|x| *x = 0.0);
|
||||
}
|
||||
|
||||
/// Largest delay (in samples) that `read` supports.
|
||||
pub(crate) fn max_delay(&self) -> f32 {
|
||||
self.buf.len().saturating_sub(3) as f32
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn push(&mut self, x: f32) {
|
||||
if let Some(s) = self.buf.get_mut(self.pos) {
|
||||
*s = x;
|
||||
}
|
||||
self.pos = (self.pos + 1) & self.mask;
|
||||
}
|
||||
|
||||
/// Reads the sample written `d` pushes ago (`d = 1` is the last written sample).
|
||||
#[inline]
|
||||
pub(crate) fn tap(&self, d: usize) -> f32 {
|
||||
let idx = self.pos.wrapping_sub(d) & self.mask;
|
||||
self.buf.get(idx).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Linear-interpolated read `d` samples back (clamped to 1..=max_delay).
|
||||
#[inline]
|
||||
pub(crate) fn read(&self, d: f32) -> f32 {
|
||||
let d = if d.is_finite() { d.clamp(1.0, self.max_delay().max(1.0)) } else { 1.0 };
|
||||
let i = d as usize;
|
||||
let f = d - i as f32;
|
||||
let a = self.tap(i);
|
||||
let b = self.tap(i + 1);
|
||||
a + (b - a) * f
|
||||
}
|
||||
|
||||
/// Cubic (Hermite) interpolated read, for modulated delays.
|
||||
#[inline]
|
||||
pub(crate) fn read_cubic(&self, d: f32) -> f32 {
|
||||
let d = if d.is_finite() { d.clamp(2.0, (self.max_delay() - 1.0).max(2.0)) } else { 2.0 };
|
||||
let i = d as usize;
|
||||
let f = d - i as f32;
|
||||
let xm1 = self.tap(i - 1);
|
||||
let x0 = self.tap(i);
|
||||
let x1 = self.tap(i + 1);
|
||||
let x2 = self.tap(i + 2);
|
||||
let c1 = 0.5 * (x1 - xm1);
|
||||
let c2 = xm1 - 2.5 * x0 + 2.0 * x1 - 0.5 * x2;
|
||||
let c3 = 0.5 * (x2 - xm1) + 1.5 * (x0 - x1);
|
||||
((c3 * f + c2) * f + c1) * f + x0
|
||||
}
|
||||
}
|
||||
|
||||
/// Xorshift32 noise source (deterministic, allocation free).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct Rng(u32);
|
||||
|
||||
impl Rng {
|
||||
pub(crate) fn new(seed: u32) -> Self {
|
||||
Self(if seed == 0 { 0x9E37_79B9 } else { seed })
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn next_u32(&mut self) -> u32 {
|
||||
let mut x = self.0;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
self.0 = x;
|
||||
x
|
||||
}
|
||||
|
||||
/// Uniform white noise in -1..1.
|
||||
#[inline]
|
||||
pub(crate) fn bipolar(&mut self) -> f32 {
|
||||
(self.next_u32() >> 8) as f32 * (2.0 / 16_777_216.0) - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Pink noise via a sum of first-order filtered white noise (Kellet-style economy filter).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct PinkNoise {
|
||||
b: [f32; 7],
|
||||
}
|
||||
|
||||
impl PinkNoise {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self { b: [0.0; 7] }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn next(&mut self, white: f32) -> f32 {
|
||||
let b = &mut self.b;
|
||||
b[0] = 0.99886 * b[0] + white * 0.0555179;
|
||||
b[1] = 0.99332 * b[1] + white * 0.0750759;
|
||||
b[2] = 0.96900 * b[2] + white * 0.153_852;
|
||||
b[3] = 0.86650 * b[3] + white * 0.3104856;
|
||||
b[4] = 0.55000 * b[4] + white * 0.5329522;
|
||||
b[5] = -0.7616 * b[5] - white * 0.016_898;
|
||||
let out = b[0] + b[1] + b[2] + b[3] + b[4] + b[5] + b[6] + white * 0.5362;
|
||||
b[6] = white * 0.115926;
|
||||
out * 0.11
|
||||
}
|
||||
}
|
||||
|
||||
/// Sine LFO with phase in 0..1.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct Lfo {
|
||||
phase: f32,
|
||||
}
|
||||
|
||||
impl Lfo {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self { phase: 0.0 }
|
||||
}
|
||||
|
||||
pub(crate) fn reset(&mut self) {
|
||||
self.phase = 0.0;
|
||||
}
|
||||
|
||||
/// Advances by `inc` cycles and returns sin at `offset` cycles ahead (for stereo spread).
|
||||
#[inline]
|
||||
pub(crate) fn tick(&mut self, inc: f32) -> f32 {
|
||||
let inc = if inc.is_finite() { inc.clamp(0.0, 0.5) } else { 0.0 };
|
||||
self.phase += inc;
|
||||
if self.phase >= 1.0 {
|
||||
self.phase -= 1.0;
|
||||
}
|
||||
self.phase
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn sin_at(phase: f32) -> f32 {
|
||||
(phase * TAU).sin()
|
||||
}
|
||||
}
|
||||
|
||||
/// DC blocking one-pole high-pass.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct DcBlocker {
|
||||
r: f32,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
}
|
||||
|
||||
impl DcBlocker {
|
||||
pub(crate) fn new(cutoff_hz: f32, sr: f32) -> Self {
|
||||
let mut d = Self { r: 0.995, x1: 0.0, y1: 0.0 };
|
||||
d.set_cutoff(cutoff_hz, sr);
|
||||
d
|
||||
}
|
||||
|
||||
pub(crate) fn set_cutoff(&mut self, cutoff_hz: f32, sr: f32) {
|
||||
let fc = if cutoff_hz.is_finite() { cutoff_hz.clamp(0.1, 1000.0) } else { 5.0 };
|
||||
self.r = (-TAU * fc / sane_sr(sr)).exp();
|
||||
}
|
||||
|
||||
pub(crate) fn reset(&mut self) {
|
||||
self.x1 = 0.0;
|
||||
self.y1 = 0.0;
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn process(&mut self, x: f32) -> f32 {
|
||||
let y = x - self.x1 + self.r * self.y1;
|
||||
self.x1 = x;
|
||||
self.y1 = flush(y);
|
||||
self.y1
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorts pending note events by offset in place (insertion sort; tiny lists, no allocation).
|
||||
pub(crate) fn sort_events<T: Copy>(ev: &mut [T], key: impl Fn(&T) -> usize) {
|
||||
for i in 1..ev.len() {
|
||||
let mut j = i;
|
||||
while j > 0 {
|
||||
let (Some(a), Some(b)) = (ev.get(j - 1), ev.get(j)) else { break };
|
||||
if key(a) <= key(b) {
|
||||
break;
|
||||
}
|
||||
ev.swap(j - 1, j);
|
||||
j -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a MIDI note number to Hz (A4 = 440).
|
||||
#[inline]
|
||||
pub(crate) fn note_hz(note: f32) -> f32 {
|
||||
440.0 * 2f32.powf((note - 69.0) / 12.0)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use proptest::prelude::*;
|
||||
use soundcraft_dsp::{create, offline, plugins};
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(48))]
|
||||
|
||||
/// Any parameter values and any finite input: no panic, finite output.
|
||||
#[test]
|
||||
fn plugins_survive_random_params(
|
||||
which in 0usize..64,
|
||||
values in proptest::collection::vec(any::<f32>(), 32),
|
||||
input in proptest::collection::vec(-4.0f32..4.0, 1..700),
|
||||
block in 1usize..300,
|
||||
) {
|
||||
let info = plugins()[which % plugins().len()];
|
||||
let mut p = create(info.id).unwrap();
|
||||
p.prepare(44_100.0, block, 2);
|
||||
for (param, v) in info.params.iter().zip(values.iter().cycle()) {
|
||||
p.set_param(param.id, *v);
|
||||
}
|
||||
p.note_on(0, 60, 100);
|
||||
let mut io = [input.clone(), input.clone()];
|
||||
let len = input.len();
|
||||
let mut pos = 0;
|
||||
while pos < len {
|
||||
let n = block.min(len - pos);
|
||||
let mut buf = vec![io[0][pos..pos + n].to_vec(), io[1][pos..pos + n].to_vec()];
|
||||
p.process(&mut buf, n);
|
||||
io[0][pos..pos + n].copy_from_slice(&buf[0]);
|
||||
io[1][pos..pos + n].copy_from_slice(&buf[1]);
|
||||
pos += n;
|
||||
}
|
||||
prop_assert!(io.iter().flatten().all(|v| v.is_finite()), "{} produced non-finite output", info.id);
|
||||
}
|
||||
|
||||
/// Offline helpers accept any arguments without panicking.
|
||||
#[test]
|
||||
fn offline_survives_random_args(
|
||||
data in proptest::collection::vec(any::<f32>(), 0..400),
|
||||
a in any::<f32>(),
|
||||
n in 0usize..1000,
|
||||
r in any::<f64>(),
|
||||
) {
|
||||
let ch = vec![data.clone(), data];
|
||||
let _ = offline::non_silent_ranges(&ch, a, n, n, n);
|
||||
let _ = offline::detect_transients(&ch, a, a);
|
||||
let _ = offline::time_stretch(&ch, r, a);
|
||||
let _ = offline::pitch_shift(&ch, a, a);
|
||||
let _ = offline::resample(&ch, n as u32, (n as u32).wrapping_mul(3) % 200_000);
|
||||
let mut m = ch.clone();
|
||||
offline::normalize(&mut m, a, n % 2 == 0);
|
||||
offline::fade(&mut m, n, n / 2, offline::FadeShape::SCurve);
|
||||
offline::gain(&mut m, a);
|
||||
let _ = soundcraft_dsp::spectrum::magnitude_db(&ch[0], n);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
use soundcraft_dsp::meter::{LoudnessMeter, PeakMeter, RmsMeter};
|
||||
use soundcraft_dsp::offline::{self, FadeShape, Waveform};
|
||||
use soundcraft_dsp::spectrum::{bin_hz, magnitude_db};
|
||||
use soundcraft_dsp::{create, db_to_gain, gain_to_db, pan};
|
||||
|
||||
fn sine(freq: f32, amp: f32, sr: f32, len: usize) -> Vec<f32> {
|
||||
(0..len).map(|i| amp * (TAU * freq * i as f32 / sr).sin()).collect()
|
||||
}
|
||||
|
||||
fn peak(x: &[f32]) -> f32 {
|
||||
x.iter().fold(0.0f32, |m, v| m.max(v.abs()))
|
||||
}
|
||||
|
||||
/// Dominant frequency by FFT with parabolic interpolation.
|
||||
fn dominant_hz(x: &[f32], sr: f32) -> f32 {
|
||||
let n = 32_768.min(x.len().next_power_of_two() / 2);
|
||||
let spec = magnitude_db(&x[x.len() / 2 - n / 2..], n);
|
||||
let k = spec.iter().enumerate().skip(1).max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0;
|
||||
let (a, b, c) = (spec[k - 1], spec[k], spec[k + 1]);
|
||||
let d = 0.5 * (a - c) / (a - 2.0 * b + c);
|
||||
bin_hz(k, n, sr) + d * sr / n as f32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_helpers() {
|
||||
assert_eq!(db_to_gain(-200.0), 0.0);
|
||||
assert_eq!(db_to_gain(f32::NAN), 0.0);
|
||||
assert!((db_to_gain(-6.0206) - 0.5).abs() < 1e-4);
|
||||
assert_eq!(gain_to_db(0.0), -144.0);
|
||||
assert_eq!(gain_to_db(f32::NAN), -144.0);
|
||||
assert!((gain_to_db(0.5) + 6.0206).abs() < 1e-3);
|
||||
assert!((gain_to_db(-1.0)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_law_hard_right() {
|
||||
let (l, r) = pan::gains(1.0, pan::PanLaw::Minus3);
|
||||
assert!(l.abs() < 1e-6 && (r - 1.0).abs() < 1e-6);
|
||||
let (l, r) = pan::gains(f32::NAN, pan::PanLaw::Minus6);
|
||||
assert!((l - r).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loudness_997hz_minus20_stereo_reads_minus20() {
|
||||
let sr = 48_000.0;
|
||||
let x = sine(997.0, db_to_gain(-20.0), sr, 48_000 * 10);
|
||||
let mut m = LoudnessMeter::new(sr, 2);
|
||||
m.process(&[x.clone(), x], 48_000 * 10);
|
||||
assert!((m.integrated_lufs() + 20.0).abs() < 0.1, "integrated {}", m.integrated_lufs());
|
||||
assert!((m.momentary_lufs() + 20.0).abs() < 0.1, "momentary {}", m.momentary_lufs());
|
||||
assert!((m.short_term_lufs() + 20.0).abs() < 0.1, "short {}", m.short_term_lufs());
|
||||
assert!((m.true_peak_db() + 20.0).abs() < 0.2, "tp {}", m.true_peak_db());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loudness_full_scale_mono_and_44k() {
|
||||
for sr in [44_100.0f32, 48_000.0, 96_000.0] {
|
||||
let len = (sr * 5.0) as usize;
|
||||
let x = sine(997.0, 1.0, sr, len);
|
||||
let mut m = LoudnessMeter::new(sr, 1);
|
||||
m.process(&[x], len);
|
||||
assert!((m.integrated_lufs() + 3.01).abs() < 0.1, "{sr}: {}", m.integrated_lufs());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loudness_gating_ignores_silence() {
|
||||
let sr = 48_000.0;
|
||||
let mut x = sine(997.0, db_to_gain(-20.0), sr, 48_000 * 5);
|
||||
x.extend(std::iter::repeat_n(0.0, 48_000 * 20));
|
||||
let mut m = LoudnessMeter::new(sr, 2);
|
||||
m.process(&[x.clone(), x], 48_000 * 25);
|
||||
assert!((m.integrated_lufs() + 20.0).abs() < 0.2, "{}", m.integrated_lufs());
|
||||
assert!(m.momentary_lufs() < -100.0);
|
||||
m.reset();
|
||||
assert_eq!(m.integrated_lufs(), -144.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn true_peak_catches_intersample_overs() {
|
||||
// A sine at fs/4 phased 45° has samples at ±0.707·A but true peak A.
|
||||
let sr = 48_000.0;
|
||||
let x: Vec<f32> = (0..48_000).map(|i| (TAU * 0.25 * (i % 4) as f32 + TAU / 8.0).sin()).collect();
|
||||
assert!(peak(&x) < 0.71);
|
||||
let mut m = LoudnessMeter::new(sr, 1);
|
||||
m.process(&[x], 48_000);
|
||||
assert!(m.true_peak_db() > -0.5, "{}", m.true_peak_db());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peak_meter_ballistics() {
|
||||
let mut m = PeakMeter::new(48_000.0);
|
||||
m.process(&[0.5; 480]);
|
||||
assert!((m.level_db() + 6.02).abs() < 0.01);
|
||||
m.process(&vec![0.0; 48_000]);
|
||||
assert!((m.level_db() - (-6.02 - 20.0)).abs() < 0.1, "{}", m.level_db());
|
||||
assert!((m.peak_hold_db() + 6.02).abs() < 0.01);
|
||||
assert!(!m.clipped());
|
||||
m.process(&[1.0]);
|
||||
assert!(m.clipped());
|
||||
m.reset_clip();
|
||||
assert!(!m.clipped());
|
||||
let mut r = RmsMeter::new(48_000.0);
|
||||
r.process(&sine(1000.0, 1.0, 48_000.0, 48_000));
|
||||
assert!((r.rms_db() + 3.01).abs() < 0.2, "{}", r.rms_db());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resample_preserves_frequency_and_length() {
|
||||
let x = sine(1000.0, 0.5, 48_000.0, 48_000);
|
||||
let out = offline::resample(std::slice::from_ref(&x), 48_000, 44_100);
|
||||
assert_eq!(out[0].len(), 44_100);
|
||||
let f = dominant_hz(&out[0], 44_100.0);
|
||||
assert!((f - 1000.0).abs() < 1.0, "{f}");
|
||||
assert!((peak(&out[0][1000..43_000]) - 0.5).abs() < 0.01);
|
||||
let up = offline::resample(&out, 44_100, 96_000);
|
||||
assert_eq!(up[0].len(), 96_000);
|
||||
assert!((dominant_hz(&up[0], 96_000.0) - 1000.0).abs() < 1.0);
|
||||
// Content above the new Nyquist is removed.
|
||||
let hi = sine(23_000.0, 0.5, 48_000.0, 48_000);
|
||||
let down = offline::resample(&[hi], 48_000, 22_050);
|
||||
assert!(peak(&down[0][1000..20_000]) < 0.01, "alias {}", peak(&down[0][1000..20_000]));
|
||||
assert_eq!(offline::resample(&[vec![1.0; 10]], 0, 48_000)[0].len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_stretch_length_and_pitch() {
|
||||
let sr = 48_000.0;
|
||||
let x = sine(440.0, 0.5, sr, 48_000);
|
||||
for ratio in [0.5, 0.8, 1.25, 2.0] {
|
||||
let out = offline::time_stretch(&[x.clone(), x.clone()], ratio, sr);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(out[0].len(), (48_000.0 * ratio).round() as usize);
|
||||
let f = dominant_hz(&out[0], sr);
|
||||
assert!((f - 440.0).abs() < 3.0, "ratio {ratio}: {f}");
|
||||
}
|
||||
assert!(offline::time_stretch(&[vec![]], 2.0, sr)[0].is_empty());
|
||||
let _ = offline::time_stretch(std::slice::from_ref(&x), f64::NAN, f32::NAN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pitch_shift_preserves_length_and_shifts() {
|
||||
let sr = 48_000.0;
|
||||
let x = sine(440.0, 0.5, sr, 48_000);
|
||||
let out = offline::pitch_shift(std::slice::from_ref(&x), 12.0, sr);
|
||||
assert_eq!(out[0].len(), x.len());
|
||||
let f = dominant_hz(&out[0], sr);
|
||||
assert!((f - 880.0).abs() < 6.0, "{f}");
|
||||
let down = offline::pitch_shift(std::slice::from_ref(&x), -7.0, sr);
|
||||
assert_eq!(down[0].len(), x.len());
|
||||
let f = dominant_hz(&down[0], sr);
|
||||
let want = 440.0 * 2f32.powf(-7.0 / 12.0);
|
||||
assert!((f - want).abs() < 4.0, "{f} vs {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_hits_target_peak() {
|
||||
let mut ch = vec![sine(100.0, 0.2, 48_000.0, 4800), sine(100.0, 0.05, 48_000.0, 4800)];
|
||||
offline::normalize(&mut ch, -1.0, false);
|
||||
assert!((gain_to_db(peak(&ch[0])) + 1.0).abs() < 0.01);
|
||||
assert!((gain_to_db(peak(&ch[1])) + 13.04).abs() < 0.05);
|
||||
offline::normalize(&mut ch, -3.0, true);
|
||||
assert!((gain_to_db(peak(&ch[1])) + 3.0).abs() < 0.01);
|
||||
let mut silent = vec![vec![0.0; 10]];
|
||||
offline::normalize(&mut silent, 0.0, true);
|
||||
assert!(silent[0].iter().all(|&v| v == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_offline_ops() {
|
||||
let mut ch = vec![vec![1.0, 2.0, 3.0]];
|
||||
offline::reverse(&mut ch);
|
||||
assert_eq!(ch[0], vec![3.0, 2.0, 1.0]);
|
||||
offline::invert(&mut ch);
|
||||
assert_eq!(ch[0], vec![-3.0, -2.0, -1.0]);
|
||||
offline::remove_dc(&mut ch);
|
||||
assert!(ch[0].iter().sum::<f32>().abs() < 1e-5);
|
||||
offline::gain(&mut ch, f32::NAN);
|
||||
offline::fade(&mut ch, 2, 2, FadeShape::EqualPower);
|
||||
assert_eq!(ch[0][0], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_silent_ranges_finds_bursts() {
|
||||
let sr = 48_000.0;
|
||||
let mut x = vec![0.0f32; 48_000];
|
||||
for (i, v) in sine(500.0, 0.5, sr, 4800).into_iter().enumerate() {
|
||||
x[10_000 + i] = v;
|
||||
x[30_000 + i] = v;
|
||||
}
|
||||
let r = offline::non_silent_ranges(std::slice::from_ref(&x), -40.0, 480, 100, 200);
|
||||
assert_eq!(r.len(), 2, "{r:?}");
|
||||
assert!((r[0].0 as i64 - 9_900).abs() <= 5 && (r[0].1 as i64 - 15_000).abs() <= 5, "{r:?}");
|
||||
assert!((r[1].0 as i64 - 29_900).abs() <= 5, "{r:?}");
|
||||
// A huge minimum gap merges them.
|
||||
let r = offline::non_silent_ranges(&[x], -40.0, 30_000, 0, 0);
|
||||
assert_eq!(r.len(), 1);
|
||||
assert!(offline::non_silent_ranges(&[vec![0.0; 100]], -40.0, 10, 5, 5).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transients_detected_near_onsets() {
|
||||
let sr = 48_000.0;
|
||||
let mut x = vec![0.0f32; 48_000 * 2];
|
||||
let onsets = [9_600usize, 33_600, 60_000, 81_000];
|
||||
for &o in &onsets {
|
||||
for i in 0..6000 {
|
||||
x[o + i] += 0.8 * (-(i as f32) / 1500.0).exp() * (TAU * 180.0 * i as f32 / sr).sin();
|
||||
}
|
||||
}
|
||||
let t = offline::detect_transients(&[x], sr, 0.5);
|
||||
assert_eq!(t.len(), onsets.len(), "{t:?}");
|
||||
for (a, b) in t.iter().zip(onsets) {
|
||||
assert!((*a as i64 - b as i64).abs() < 480, "{a} vs {b}");
|
||||
}
|
||||
assert!(offline::detect_transients(&[vec![0.0; 48_000]], sr, 1.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_generator_offline() {
|
||||
for w in Waveform::ALL {
|
||||
let out = offline::signal_generator(w, 1000.0, -6.0, 48_000.0, 4800, 2);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(out[0].len(), 4800);
|
||||
assert!(peak(&out[0]) <= db_to_gain(-6.0) * 1.1 + 1e-3, "{w:?} {}", peak(&out[0]));
|
||||
assert!(peak(&out[0]) > 0.05, "{w:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_plugin_compensates_latency() {
|
||||
let sr = 48_000.0;
|
||||
let mut p = create("pitch_shifter").unwrap();
|
||||
let mut x = vec![vec![0.0f32; 10_000]];
|
||||
x[0][2000] = 1.0;
|
||||
offline::apply_plugin(&mut x, p.as_mut(), sr);
|
||||
let pos = x[0].iter().enumerate().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).unwrap().0;
|
||||
assert_eq!(pos, 2000);
|
||||
|
||||
let mut r = create("room_reverb").unwrap();
|
||||
let out = offline::apply_plugin_with_tail(&[vec![1.0; 100]], r.as_mut(), sr);
|
||||
assert!(out[0].len() > 100 + 48_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spectrum_edge_cases() {
|
||||
assert!(magnitude_db(&[], 1).is_empty());
|
||||
let m = magnitude_db(&[f32::NAN; 64], 64);
|
||||
assert!(m.iter().all(|v| v.is_finite()));
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
use std::collections::HashSet;
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
use soundcraft_dsp::{Plugin, Unit, create, db_to_gain, gain_to_db, plugin_info, plugins};
|
||||
|
||||
const SR: f32 = 48_000.0;
|
||||
const BLOCK: usize = 512;
|
||||
|
||||
fn run(p: &mut dyn Plugin, input: &[Vec<f32>]) -> Vec<Vec<f32>> {
|
||||
let len = input[0].len();
|
||||
let ch = input.len();
|
||||
let mut out = vec![Vec::with_capacity(len); ch];
|
||||
let mut pos = 0;
|
||||
let mut buf = vec![vec![0.0f32; BLOCK]; ch];
|
||||
while pos < len {
|
||||
let n = BLOCK.min(len - pos);
|
||||
for c in 0..ch {
|
||||
buf[c][..n].copy_from_slice(&input[c][pos..pos + n]);
|
||||
}
|
||||
p.process(&mut buf, n);
|
||||
for c in 0..ch {
|
||||
out[c].extend_from_slice(&buf[c][..n]);
|
||||
}
|
||||
pos += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn sine(freq: f32, amp: f32, len: usize) -> Vec<f32> {
|
||||
(0..len).map(|i| amp * (TAU * freq * i as f32 / SR).sin()).collect()
|
||||
}
|
||||
|
||||
fn peak(x: &[f32]) -> f32 {
|
||||
x.iter().fold(0.0f32, |m, v| m.max(v.abs()))
|
||||
}
|
||||
|
||||
fn rms(x: &[f32]) -> f32 {
|
||||
(x.iter().map(|v| v * v).sum::<f32>() / x.len().max(1) as f32).sqrt()
|
||||
}
|
||||
|
||||
fn prepared(id: &str) -> Box<dyn Plugin> {
|
||||
let mut p = create(id).unwrap();
|
||||
p.prepare(SR, BLOCK, 2);
|
||||
p
|
||||
}
|
||||
|
||||
fn is_source(id: &str) -> bool {
|
||||
plugin_info(id).unwrap().is_instrument || id == "signal_generator"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_ids_unique_and_creatable() {
|
||||
let mut ids = HashSet::new();
|
||||
for info in plugins() {
|
||||
assert!(ids.insert(info.id), "duplicate id {}", info.id);
|
||||
assert!(info.short_name.chars().count() <= 8, "{} short name too long", info.id);
|
||||
let p = create(info.id).unwrap_or_else(|| panic!("cannot create {}", info.id));
|
||||
assert_eq!(p.info().id, info.id);
|
||||
assert_eq!(plugin_info(info.id).unwrap().id, info.id);
|
||||
}
|
||||
assert!(create("nope").is_none());
|
||||
assert!(plugins().len() >= 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_well_formed() {
|
||||
for info in plugins() {
|
||||
let mut ids = HashSet::new();
|
||||
for p in info.params {
|
||||
assert!(ids.insert(p.id), "{}: duplicate param {}", info.id, p.id);
|
||||
assert!(p.min <= p.default && p.default <= p.max, "{}.{} default out of range", info.id, p.id);
|
||||
assert!(p.min.is_finite() && p.max.is_finite());
|
||||
if p.unit == Unit::Choice {
|
||||
assert_eq!(p.choices.len() as f32, p.max + 1.0, "{}.{}", info.id, p.id);
|
||||
}
|
||||
let n = p.to_normalized(p.default);
|
||||
assert!((p.from_normalized(n) - p.default).abs() <= 1e-3 * (1.0 + p.default.abs()), "{}.{}", info.id, p.id);
|
||||
}
|
||||
let mut plug = create(info.id).unwrap();
|
||||
for p in info.params {
|
||||
assert_eq!(plug.param(p.id), Some(p.default), "{}.{}", info.id, p.id);
|
||||
assert!(plug.set_param(p.id, p.max + 1000.0));
|
||||
assert_eq!(plug.param(p.id), Some(p.max));
|
||||
}
|
||||
assert!(!plug.set_param("definitely_not_a_param", 1.0));
|
||||
assert_eq!(plug.param("definitely_not_a_param"), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_in_silence_out() {
|
||||
for info in plugins() {
|
||||
if is_source(info.id) {
|
||||
continue;
|
||||
}
|
||||
let mut p = prepared(info.id);
|
||||
let out = run(p.as_mut(), &vec![vec![0.0; 48_000]; 2]);
|
||||
for c in &out {
|
||||
assert!(peak(c) < 1e-6, "{} made noise from silence: {}", info.id, peak(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impulse_response_finite_and_bounded() {
|
||||
for info in plugins() {
|
||||
let mut p = prepared(info.id);
|
||||
let mut x = vec![vec![0.0; 96_000]; 2];
|
||||
x[0][100] = 1.0;
|
||||
x[1][100] = 1.0;
|
||||
p.note_on(0, 36, 100);
|
||||
let out = run(p.as_mut(), &x);
|
||||
for c in &out {
|
||||
assert!(c.iter().all(|v| v.is_finite()), "{} produced non-finite output", info.id);
|
||||
assert!(peak(c) < 8.0, "{} impulse peak {}", info.id, peak(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_never_break_processing() {
|
||||
let hostile = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1e30, 1e30, 0.0, -0.0];
|
||||
for info in plugins() {
|
||||
for &v in &hostile {
|
||||
let mut p = prepared(info.id);
|
||||
for param in info.params {
|
||||
assert!(p.set_param(param.id, v));
|
||||
let got = p.param(param.id).unwrap();
|
||||
assert!(got.is_finite() && got >= param.min && got <= param.max, "{}.{} = {got}", info.id, param.id);
|
||||
}
|
||||
p.note_on(3, 60, 127);
|
||||
let x = vec![sine(440.0, 0.5, 9600), sine(660.0, 0.5, 9600)];
|
||||
let out = run(p.as_mut(), &x);
|
||||
assert!(out.iter().flatten().all(|s| s.is_finite()), "{} non-finite with param value {v}", info.id);
|
||||
let _ = p.latency();
|
||||
let _ = p.tail_samples();
|
||||
assert!(p.gain_reduction_db().is_finite());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_host_calls_do_not_panic() {
|
||||
for info in plugins() {
|
||||
let mut p = create(info.id).unwrap();
|
||||
// Unprepared-ish, weird rates, block bigger than prepared, mismatched channels.
|
||||
for (sr, block, ch) in [(0.0, 0, 0), (f32::NAN, 16, 1), (-44_100.0, 64, 2), (1e9, 64, 8), (44_100.0, 32, 2)] {
|
||||
p.prepare(sr, block, ch);
|
||||
let mut io = vec![vec![0.25f32; 300]; 3];
|
||||
p.process(&mut io, 4096);
|
||||
p.process(&mut io, 0);
|
||||
p.process(&mut [], 128);
|
||||
let mut short = vec![vec![0.1f32; 10], vec![0.1f32; 3]];
|
||||
p.process(&mut short, 100);
|
||||
p.note_on(10_000, 200, 255);
|
||||
p.note_off(0, 200);
|
||||
p.all_notes_off();
|
||||
p.process(&mut io, 300);
|
||||
p.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_changes_are_smooth() {
|
||||
// Jumping gain by 24 dB mid-stream must not produce a step discontinuity.
|
||||
let mut p = prepared("gain");
|
||||
let x = vec![vec![0.5f32; 4096]; 1];
|
||||
let _ = run(p.as_mut(), &x);
|
||||
p.set_param("gain", 24.0);
|
||||
let out = run(p.as_mut(), &x);
|
||||
let max_step = out[0].windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max);
|
||||
assert!(max_step < 0.05, "zipper step {max_step}");
|
||||
let out = run(p.as_mut(), &x);
|
||||
assert!((out[0][4095] - 0.5 * db_to_gain(24.0)).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eq_magnitude_at_center_matches_gain() {
|
||||
let sr = SR;
|
||||
let resp = soundcraft_dsp::eq7_response(&[("mid_freq", 1000.0), ("mid_gain", 9.0), ("mid_q", 2.0)], &[1000.0, 30.0, 15_000.0], sr);
|
||||
assert!((resp[0] - 9.0).abs() < 0.05, "{resp:?}");
|
||||
assert!(resp[1].abs() < 0.2 && resp[2].abs() < 0.2, "{resp:?}");
|
||||
let r1 = soundcraft_dsp::eq1_response(&[("freq", 2500.0), ("gain", -12.0)], &[2500.0], sr);
|
||||
assert!((r1[0] + 12.0).abs() < 0.05);
|
||||
|
||||
// And the real-time EQ agrees with the curve.
|
||||
let mut p = prepared("eq_7band");
|
||||
p.set_param("mid_freq", 1000.0);
|
||||
p.set_param("mid_gain", 9.0);
|
||||
p.set_param("mid_q", 2.0);
|
||||
p.reset();
|
||||
let out = run(p.as_mut(), &[sine(1000.0, 0.1, 48_000)]);
|
||||
let g = gain_to_db(rms(&out[0][24_000..]) / rms(&sine(1000.0, 0.1, 48_000)[24_000..]));
|
||||
assert!((g - 9.0).abs() < 0.2, "measured {g}");
|
||||
|
||||
// HPF attenuates lows.
|
||||
let r = soundcraft_dsp::eq7_response(&[("hpf_on", 1.0), ("hpf_freq", 200.0), ("hpf_slope", 3.0)], &[50.0, 200.0, 2000.0], sr);
|
||||
assert!(r[0] < -40.0 && (r[1] + 3.0).abs() < 0.2 && r[2].abs() < 0.1, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressor_steady_state_gain_reduction() {
|
||||
let mut p = prepared("compressor");
|
||||
p.set_param("threshold", -20.0);
|
||||
p.set_param("ratio", 4.0);
|
||||
p.set_param("knee", 0.0);
|
||||
p.set_param("attack", 1.0);
|
||||
p.set_param("release", 50.0);
|
||||
// Constant level at -10 dBFS: 10 dB over → 7.5 dB of reduction.
|
||||
let level = db_to_gain(-10.0);
|
||||
let out = run(p.as_mut(), &vec![vec![level; 48_000]; 2]);
|
||||
let got = gain_to_db(out[0][47_999]) - -10.0;
|
||||
assert!((got + 7.5).abs() < 0.1, "gain change {got}");
|
||||
assert!((p.gain_reduction_db() - 7.5).abs() < 0.1);
|
||||
|
||||
// A sine settles near the same reduction (peak detection).
|
||||
p.reset();
|
||||
let x = sine(1000.0, level, 48_000);
|
||||
let out = run(p.as_mut(), &[x.clone(), x]);
|
||||
let got = gain_to_db(peak(&out[0][40_000..])) + 10.0;
|
||||
assert!((got + 7.5).abs() < 1.0, "sine gain change {got}");
|
||||
|
||||
// Limiter mode with lookahead reports latency.
|
||||
p.set_param("limiter", 1.0);
|
||||
p.set_param("lookahead", 5.0);
|
||||
assert_eq!(p.latency(), 240);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_closes_below_threshold() {
|
||||
let mut p = prepared("expander_gate");
|
||||
p.set_param("threshold", -40.0);
|
||||
p.set_param("range", 80.0);
|
||||
p.set_param("hold", 10.0);
|
||||
p.set_param("release", 20.0);
|
||||
let quiet = sine(300.0, db_to_gain(-60.0), 48_000);
|
||||
let out = run(p.as_mut(), std::slice::from_ref(&quiet));
|
||||
let atten = gain_to_db(peak(&out[0][24_000..]) / peak(&quiet[24_000..]));
|
||||
assert!(atten < -70.0, "gate attenuation only {atten}");
|
||||
let loud = sine(300.0, db_to_gain(-10.0), 48_000);
|
||||
let out = run(p.as_mut(), std::slice::from_ref(&loud));
|
||||
let g = gain_to_db(peak(&out[0][24_000..]) / peak(&loud[24_000..]));
|
||||
assert!(g.abs() < 0.5, "open gate changed level {g}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximizer_respects_ceiling_and_reports_latency() {
|
||||
let mut p = prepared("maximizer");
|
||||
p.set_param("threshold", -12.0);
|
||||
p.set_param("ceiling", -1.0);
|
||||
let x = sine(220.0, 0.9, 48_000);
|
||||
let out = run(p.as_mut(), &[x.clone(), x]);
|
||||
assert!(peak(&out[0]) <= db_to_gain(-1.0) + 1e-5, "{}", peak(&out[0]));
|
||||
assert!(p.latency() > 0 && p.latency() < 200);
|
||||
assert!(p.gain_reduction_db() > 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn de_esser_reduces_sibilance_only() {
|
||||
let mut p = prepared("de_esser");
|
||||
p.set_param("threshold", -40.0);
|
||||
p.set_param("range", 20.0);
|
||||
let hi = sine(8000.0, 0.5, 48_000);
|
||||
let out = run(p.as_mut(), std::slice::from_ref(&hi));
|
||||
assert!(gain_to_db(rms(&out[0][24_000..]) / rms(&hi[24_000..])) < -6.0);
|
||||
p.reset();
|
||||
let lo = sine(200.0, 0.005, 48_000);
|
||||
let out = run(p.as_mut(), std::slice::from_ref(&lo));
|
||||
assert!(gain_to_db(rms(&out[0][24_000..]) / rms(&lo[24_000..])).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latency_reporting_aligns_pitch_shifter() {
|
||||
let mut p = prepared("pitch_shifter");
|
||||
let lat = p.latency();
|
||||
assert!(lat > 0);
|
||||
let mut x = vec![0.0f32; 8000];
|
||||
x[1000] = 1.0;
|
||||
let out = run(p.as_mut(), &[x]);
|
||||
let pos = out[0].iter().enumerate().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).unwrap().0;
|
||||
assert_eq!(pos, 1000 + lat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pitch_shifter_moves_pitch() {
|
||||
let mut p = prepared("pitch_shifter");
|
||||
p.set_param("semitones", 12.0);
|
||||
p.reset();
|
||||
let out = run(p.as_mut(), &[sine(440.0, 0.5, 48_000)]);
|
||||
let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][16_384..], 16_384);
|
||||
let bin = spec.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0;
|
||||
let hz = soundcraft_dsp::spectrum::bin_hz(bin, 16_384, SR);
|
||||
assert!((hz - 880.0).abs() < 15.0, "peak at {hz}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverb_has_tail_and_decays() {
|
||||
for id in ["room_reverb", "plate_reverb"] {
|
||||
let mut p = prepared(id);
|
||||
p.set_param("mix", 100.0);
|
||||
p.set_param("decay", 1.0);
|
||||
let mut x = vec![vec![0.0; 48_000 * 4]; 2];
|
||||
x[0][0] = 1.0;
|
||||
x[1][0] = 1.0;
|
||||
let out = run(p.as_mut(), &x);
|
||||
let early = rms(&out[0][2400..24_000]);
|
||||
let late = rms(&out[0][48_000 * 3..]);
|
||||
assert!(early > 1e-3, "{id} no reverb");
|
||||
assert!(late < early * 1e-2, "{id} not decaying {early} {late}");
|
||||
assert!(p.tail_samples() > 48_000);
|
||||
// Stereo decorrelation.
|
||||
assert!(out[0][2400..4800] != out[1][2400..4800]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delay_repeats_at_time() {
|
||||
let mut p = prepared("mod_delay");
|
||||
p.set_param("time", 100.0);
|
||||
p.set_param("mix", 100.0);
|
||||
p.set_param("feedback", 0.0);
|
||||
p.set_param("lpf", 20_000.0);
|
||||
p.reset();
|
||||
let mut x = vec![0.0f32; 20_000];
|
||||
x[0] = 1.0;
|
||||
let out = run(p.as_mut(), &[x]);
|
||||
let pos = out[0].iter().enumerate().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).unwrap().0;
|
||||
assert!((pos as i64 - 4800).abs() <= 2, "echo at {pos}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_and_invert() {
|
||||
let mut p = prepared("invert");
|
||||
let out = run(p.as_mut(), &[vec![0.5; 16]]);
|
||||
assert!(out[0].iter().all(|&v| v == -0.5));
|
||||
let mut t = prepared("trim");
|
||||
t.set_param("gain", -96.0);
|
||||
t.reset();
|
||||
let out = run(t.as_mut(), &[vec![0.5; 16]]);
|
||||
assert!(out[0].iter().all(|&v| v == 0.0));
|
||||
t.set_param("gain", 0.0);
|
||||
t.set_param("invert", 1.0);
|
||||
t.reset();
|
||||
let out = run(t.as_mut(), &[vec![0.5; 16]]);
|
||||
assert!(out[0].iter().all(|&v| (v + 0.5).abs() < 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_shift_delays() {
|
||||
let mut p = prepared("time_shift");
|
||||
p.set_param("delay_samples", 123.0);
|
||||
p.reset();
|
||||
let mut x = vec![0.0f32; 1000];
|
||||
x[10] = 1.0;
|
||||
let out = run(p.as_mut(), &[x]);
|
||||
assert_eq!(out[0][133], 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dc_removal_removes_dc() {
|
||||
let mut p = prepared("dc_offset_removal");
|
||||
let out = run(p.as_mut(), &[vec![0.3; 96_000]]);
|
||||
assert!(out[0][95_999].abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_generator_level() {
|
||||
let mut p = prepared("signal_generator");
|
||||
p.set_param("level", -6.0);
|
||||
p.reset();
|
||||
let out = run(p.as_mut(), &[vec![0.0; 48_000]]);
|
||||
assert!((gain_to_db(peak(&out[0])) + 6.0).abs() < 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synth_note_on_sounds_and_note_off_decays() {
|
||||
let mut p = prepared("subtractive_synth");
|
||||
p.note_on(0, 60, 100);
|
||||
let out = run(p.as_mut(), &vec![vec![0.0; 24_000]; 2]);
|
||||
assert!(rms(&out[0][4800..]) > 0.01, "synth silent");
|
||||
assert_eq!(out[0], out[1]);
|
||||
p.note_off(0, 60);
|
||||
let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]);
|
||||
assert!(peak(&out[0][72_000..]) < 1e-6, "synth did not decay");
|
||||
// Polyphony: a chord is louder than one note and stays finite.
|
||||
for n in [60, 64, 67, 71, 74, 77, 81, 84, 60, 62, 65, 69, 72, 76, 79, 83, 86, 88] {
|
||||
p.note_on(0, n, 127);
|
||||
}
|
||||
let out = run(p.as_mut(), &vec![vec![0.0; 9600]; 2]);
|
||||
assert!(out[0].iter().all(|v| v.is_finite()) && peak(&out[0]) > 0.05);
|
||||
p.all_notes_off();
|
||||
let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]);
|
||||
assert!(peak(&out[0][72_000..]) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synth_event_offsets_are_sample_accurate() {
|
||||
let mut p = prepared("subtractive_synth");
|
||||
p.set_param("amp_attack", 0.5);
|
||||
p.note_on(300, 69, 127);
|
||||
let out = run(p.as_mut(), &[vec![0.0; 512]]);
|
||||
assert!(out[0][..300].iter().all(|&v| v == 0.0));
|
||||
assert!(out[0][300..].iter().any(|&v| v != 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drum_kit_notes_sound_and_end() {
|
||||
for note in [36, 38, 39, 42, 46, 45, 47, 50, 49, 51] {
|
||||
let mut p = prepared("drum_synth");
|
||||
p.note_on(0, note, 120);
|
||||
let out = run(p.as_mut(), &[vec![0.0; 48_000 * 7]]);
|
||||
assert!(peak(&out[0][..4800]) > 0.01, "drum note {note} silent");
|
||||
assert!(peak(&out[0][48_000 * 6..]) < 1e-6, "drum note {note} rings forever");
|
||||
}
|
||||
let mut p = prepared("drum_synth");
|
||||
p.note_on(0, 99, 120);
|
||||
let out = run(p.as_mut(), &[vec![0.0; 4800]]);
|
||||
assert!(peak(&out[0]) == 0.0, "unmapped note should be silent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saturator_adds_harmonics_and_stays_bounded() {
|
||||
let mut p = prepared("saturator");
|
||||
p.set_param("drive", 30.0);
|
||||
let out = run(p.as_mut(), &[sine(1000.0, 1.0, 48_000)]);
|
||||
assert!(peak(&out[0]) <= 1.01);
|
||||
let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][8192..], 8192);
|
||||
let third = (3000.0 / (SR / 8192.0)) as usize;
|
||||
assert!(spec[third - 1..=third + 1].iter().cloned().fold(f32::MIN, f32::max) > -30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lofi_quantizes() {
|
||||
let mut p = prepared("lofi");
|
||||
p.set_param("bits", 2.0);
|
||||
p.set_param("sample_rate", 48_000.0);
|
||||
let out = run(p.as_mut(), &[sine(100.0, 0.9, 4800)]);
|
||||
let mut levels: Vec<i32> = out[0].iter().map(|v| (v * 1000.0).round() as i32).collect();
|
||||
levels.sort();
|
||||
levels.dedup();
|
||||
assert!(levels.len() <= 5, "{levels:?}");
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "soundcraft-engine"
|
||||
description = "SoundCraft command engine: commands, undo, editing operations, import/export, bounce, parity catalog"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
soundcraft-time.workspace = true
|
||||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-midi.workspace = true
|
||||
soundcraft-dsp.workspace = true
|
||||
soundcraft-model.workspace = true
|
||||
soundcraft-mix.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,512 @@
|
||||
File > New...
|
||||
File > Open > Session...
|
||||
File > Open > Project...
|
||||
File > Open > Sketch...
|
||||
File > Dashboard...
|
||||
File > Close Session
|
||||
File > Save Session
|
||||
File > Save Session As...
|
||||
File > Save Session Copy In...
|
||||
File > Save As Template...
|
||||
File > Revert Session to Saved...
|
||||
File > Start Collaboration...
|
||||
File > Send To... > Sibelius
|
||||
File > Bounce Mix...
|
||||
File > Import > Session Data...
|
||||
File > Import > Audio...
|
||||
File > Import > MIDI...
|
||||
File > Import > Video...
|
||||
File > Import > Clip Groups...
|
||||
File > Import > VENUE Channel Names as Track Names
|
||||
File > Export > Selected Tracks as New AAF/OMF...
|
||||
File > Export > Selected Tracks as New Session...
|
||||
File > Export > Selected Tracks as New Media Composer Compatible Session...
|
||||
File > Export > MIDI...
|
||||
File > Export > Sibelius...
|
||||
File > Export > Session Info as Text...
|
||||
File > Get Info...
|
||||
File > Score Setup...
|
||||
File > Print Score...
|
||||
File > Sign In
|
||||
Edit > Can't Undo
|
||||
Edit > Can't Redo
|
||||
Edit > Restore Last Selection
|
||||
Edit > Cut
|
||||
Edit > Copy
|
||||
Edit > Paste
|
||||
Edit > Clear
|
||||
Edit > Cut Special > Clip Gain
|
||||
Edit > Cut Special > Clip Effects
|
||||
Edit > Cut Special > All Automation
|
||||
Edit > Cut Special > Pan Automation
|
||||
Edit > Cut Special > Plugin Automation
|
||||
Edit > Copy Special > Clip Gain
|
||||
Edit > Copy Special > Clip Effects
|
||||
Edit > Copy Special > All Automation
|
||||
Edit > Copy Special > Pan Automation
|
||||
Edit > Copy Special > Plugin Automation
|
||||
Edit > Copy Special > Audio as MIDI
|
||||
Edit > Paste Special > Merge MIDI
|
||||
Edit > Paste Special > Merge Markers
|
||||
Edit > Paste Special > Repeat to Fill Selection
|
||||
Edit > Paste Special > To Current Automation Type
|
||||
Edit > Clear Special > Clip Gain
|
||||
Edit > Clear Special > Clip Effects
|
||||
Edit > Clear Special > All Automation
|
||||
Edit > Clear Special > Pan Automation
|
||||
Edit > Clear Special > Plugin Automation
|
||||
Edit > Select All
|
||||
Edit > Selection > Change Timeline to Match Edit
|
||||
Edit > Selection > Change Edit to Match Timeline
|
||||
Edit > Selection > Play Edit
|
||||
Edit > Selection > Play Timeline
|
||||
Edit > Selection > Move Edit Left
|
||||
Edit > Selection > Move Edit Right
|
||||
Edit > Selection > Halve Edit
|
||||
Edit > Selection > Double Edit
|
||||
Edit > Selection > Duplicate and Extend Edit
|
||||
Edit > Selection > Extend Edit Up
|
||||
Edit > Selection > Extend Edit Down
|
||||
Edit > Selection > Extend Edit Up to Members
|
||||
Edit > Selection > Extend Edit Down to Members
|
||||
Edit > Selection > Remove Edit from Top
|
||||
Edit > Selection > Remove Edit from Bottom
|
||||
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 > Duplicate
|
||||
Edit > Repeat...
|
||||
Edit > Shift...
|
||||
Edit > Space Clips…
|
||||
Edit > Insert Silence
|
||||
Edit > Snap to > Next
|
||||
Edit > Snap to > Previous
|
||||
Edit > Trim > To Selection
|
||||
Edit > Trim > Start to Insertion
|
||||
Edit > Trim > End to Insertion
|
||||
Edit > Trim > Start to Fill Selection
|
||||
Edit > Trim > End to Fill Selection
|
||||
Edit > Trim > To Fill Selection
|
||||
Edit > Trim > To File Start
|
||||
Edit > Trim > To File End
|
||||
Edit > Trim > To File Boundaries
|
||||
Edit > Separate > At Selection
|
||||
Edit > Separate > On Grid
|
||||
Edit > Separate > At Transients
|
||||
Edit > Heal Separation
|
||||
Edit > Consolidate
|
||||
Edit > Mute
|
||||
Edit > Copy Selection to... > Target Playlist
|
||||
Edit > Copy Selection to... > New 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 > Strip Silence
|
||||
Edit > TCE Edit to Timeline Selection
|
||||
Edit > Automation > Copy to Send...
|
||||
Edit > Automation > Thin
|
||||
Edit > Automation > Thin All
|
||||
Edit > Automation > Convert Volume Automation to Clip Gain
|
||||
Edit > Automation > Convert Clip Gain to Volume Automation
|
||||
Edit > Automation > Coalesce Volume Automation to Clip Gain
|
||||
Edit > Automation > Coalesce Clip Gain to Volume Automation
|
||||
Edit > Automation > Write to Current
|
||||
Edit > Automation > Write to All Enabled
|
||||
Edit > Automation > Trim to Current
|
||||
Edit > Automation > Trim to All Enabled
|
||||
Edit > Automation > Glide to Current
|
||||
Edit > Automation > Glide to All Enabled
|
||||
Edit > Automation > Glide Pan Only
|
||||
Edit > Automation > Duplicate Dolby Atmos Plugin Automation to Pan Automation
|
||||
Edit > Automation > Duplicate Pan Automation to Dolby Atmos Plugin Automation
|
||||
Edit > Automation > Coalesce Pan Guide Automation
|
||||
Edit > Fades > Create...
|
||||
Edit > Fades > Delete
|
||||
Edit > Fades > Fade to Start
|
||||
Edit > Fades > Fade to End
|
||||
View > Mix Window Views > Mic Preamps
|
||||
View > Mix Window Views > Instruments
|
||||
View > Mix Window Views > HEAT
|
||||
View > Mix Window Views > Inserts A-E
|
||||
View > Mix Window Views > Inserts F-J
|
||||
View > Mix Window Views > Sends A-E
|
||||
View > Mix Window Views > Sends F-J
|
||||
View > Mix Window Views > EQ Curve
|
||||
View > Mix Window Views > I/O
|
||||
View > Mix Window Views > Object
|
||||
View > Mix Window Views > Meters and Faders
|
||||
View > Mix Window Views > Delay Compensation
|
||||
View > Mix Window Views > Track Color
|
||||
View > Mix Window Views > Comments
|
||||
View > Mix Window Views > All
|
||||
View > Mix Window Views > Minimal
|
||||
View > Narrow Mix
|
||||
View > Edit Window Views > Comments
|
||||
View > Edit Window Views > Mic Preamps
|
||||
View > Edit Window Views > Instruments
|
||||
View > Edit Window Views > Inserts A-E
|
||||
View > Edit Window Views > Inserts F-J
|
||||
View > Edit Window Views > Sends A-E
|
||||
View > Edit Window Views > Sends F-J
|
||||
View > Edit Window Views > I/O
|
||||
View > Edit Window Views > Object
|
||||
View > Edit Window Views > Real-Time Properties
|
||||
View > Edit Window Views > Track Color
|
||||
View > Edit Window Views > Marker Controls
|
||||
View > Edit Window Views > All
|
||||
View > Edit Window Views > Minimal
|
||||
View > Edit Window Views > Pinned Tracks
|
||||
View > Rulers > Bars|Beats
|
||||
View > Rulers > Min:Secs
|
||||
View > Rulers > Timecode
|
||||
View > Rulers > Timecode 2
|
||||
View > Rulers > Feet+Frames
|
||||
View > Rulers > Samples
|
||||
View > Rulers > Tempo > Tempo Editor
|
||||
View > Rulers > Meter
|
||||
View > Rulers > Key Signature > Key Signature Staff
|
||||
View > Rulers > Chord Symbols
|
||||
View > Rulers > Markers
|
||||
View > Rulers > Markers 2
|
||||
View > Rulers > Markers 3
|
||||
View > Rulers > Markers 4
|
||||
View > Rulers > Markers 5
|
||||
View > Rulers > All Marker Rulers
|
||||
View > Rulers > All
|
||||
View > Rulers > Minimal
|
||||
View > Marker Displays > Track Lane
|
||||
View > Marker Displays > Ruler Lines
|
||||
View > Marker Displays > Folder Lines
|
||||
View > Marker Displays > Ruler Lane Color
|
||||
View > Marker Displays > Track Lane Color
|
||||
View > Other Displays > Universe
|
||||
View > Other Displays > Track List
|
||||
View > Other Displays > Clip List
|
||||
View > Other Displays > MIDI Input Display
|
||||
View > Other Displays > Lower Dock > MIDI Editor > Clip Effects
|
||||
View > Clip > Sync Point
|
||||
View > Clip > Processing State
|
||||
View > Clip > Name
|
||||
View > Clip > Channel Name
|
||||
View > Clip > Scene And Take
|
||||
View > Clip > Rating
|
||||
View > Clip > Overlap Shadows
|
||||
View > Clip > Transparency
|
||||
View > Clip > Overwrite Indicator
|
||||
View > Clip > Copy/Move Indicators
|
||||
View > Clip > Clip Gain Line
|
||||
View > Clip > Clip Gain Info
|
||||
View > Clip > Clip Effects Status
|
||||
View > Clip > ARA Note Overlay
|
||||
View > Clip > Current Time
|
||||
View > Clip > Original Time Stamp
|
||||
View > Clip > User Time Stamp
|
||||
View > Clip > No Time
|
||||
View > Clip > Display on All Channels
|
||||
View > Waveforms > Peak
|
||||
View > Waveforms > Power
|
||||
View > Waveforms > Rectified
|
||||
View > Waveforms > Outlines
|
||||
View > Waveforms > Overlapped Crossfades
|
||||
View > Automation > Trim Playlist
|
||||
View > Automation > Composite Playlist
|
||||
View > Expanded Sends > All
|
||||
View > Expanded Sends > None
|
||||
View > Expanded Sends > Send A
|
||||
View > Expanded Sends > Send B
|
||||
View > Expanded Sends > Send C
|
||||
View > Expanded Sends > Send D
|
||||
View > Expanded Sends > Send E
|
||||
View > Expanded Sends > Send F
|
||||
View > Expanded Sends > Send G
|
||||
View > Expanded Sends > Send H
|
||||
View > Expanded Sends > Send I
|
||||
View > Expanded Sends > Send J
|
||||
View > Track Number
|
||||
View > Track Transcription Lane
|
||||
View > Dropped Video Frame Indicator
|
||||
View > Transport > Counters
|
||||
View > Transport > MIDI Controls
|
||||
View > Transport > Synchronization
|
||||
View > Transport > Ableton Link
|
||||
View > Transport > Output Meters
|
||||
View > Transport > Expanded
|
||||
View > Main Counter > Bars|Beats
|
||||
View > Main Counter > Min:Secs
|
||||
View > Main Counter > Timecode
|
||||
View > Main Counter > Feet+Frames
|
||||
View > Main Counter > Samples
|
||||
Track > New...
|
||||
Track > Group...
|
||||
Track > Duplicate...
|
||||
Track > Split into Mono
|
||||
Track > Make Inactive
|
||||
Track > Move to New Folder...
|
||||
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 > Delete
|
||||
Track > Freeze
|
||||
Track > Commit...
|
||||
Track > Bounce...
|
||||
Track > Extract MIDI to New Track
|
||||
Track > ARA Plugins > Melodyne
|
||||
Track > Save Track Preset...
|
||||
Track > Bypass Inserts > All
|
||||
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 > All
|
||||
Track > Mute Sends > Sends A-E
|
||||
Track > Mute Sends > Sends F-J
|
||||
Track > Write MIDI Real-Time Properties
|
||||
Track > Set Record Tracks to Input Only
|
||||
Track > Scroll to Track...
|
||||
Track > Clear All Clip Indicators
|
||||
Track > Coalesce VCA Controller Automation
|
||||
Track > Coalesce Trim Automation
|
||||
Track > Clear Trim Automation
|
||||
Track > Designate as Target Playlist
|
||||
Track > Show Target Playlist
|
||||
Track > Toggle Recent Playlist
|
||||
Track > Create Click Track
|
||||
Clip > Edit Lock/Unlock
|
||||
Clip > Time Lock/Unlock
|
||||
Clip > Send to Back
|
||||
Clip > Bring to Front
|
||||
Clip > Rating > none
|
||||
Clip > Rating > 1
|
||||
Clip > Rating > 2
|
||||
Clip > Rating > 3
|
||||
Clip > Rating > 4
|
||||
Clip > Rating > 5
|
||||
Clip > Group
|
||||
Clip > Ungroup
|
||||
Clip > Ungroup All
|
||||
Clip > Regroup
|
||||
Clip > Loop...
|
||||
Clip > Unloop...
|
||||
Clip > Capture...
|
||||
Clip > Rename...
|
||||
Clip > Identify Sync Point
|
||||
Clip > Quantize to Grid
|
||||
Clip > Clip Gain > Bypass
|
||||
Clip > Clip Gain > Render
|
||||
Clip > Clip Effects > Bypass
|
||||
Clip > Clip Effects > Render
|
||||
Clip > ARA Plugins > Melodyne
|
||||
Clip > Elastic Properties
|
||||
Clip > Conform to Tempo
|
||||
Clip > Remove Warp
|
||||
Clip > Remove Pitch Shift
|
||||
Event > Time Operations > Time Operations Window
|
||||
Event > Time Operations > Change Meter...
|
||||
Event > Time Operations > Insert Time...
|
||||
Event > Time Operations > Cut Time...
|
||||
Event > Time Operations > Move Song Start...
|
||||
Event > Tempo Operations > Tempo Operations Window
|
||||
Event > Tempo Operations > Constant...
|
||||
Event > Tempo Operations > Linear...
|
||||
Event > Tempo Operations > Parabolic...
|
||||
Event > Tempo Operations > S-Curve...
|
||||
Event > Tempo Operations > Scale...
|
||||
Event > Tempo Operations > Stretch...
|
||||
Event > MIDI Operations > MIDI Operations Window
|
||||
Event > MIDI Operations > Quantize...
|
||||
Event > MIDI Operations > Change Velocity...
|
||||
Event > MIDI Operations > Change Duration...
|
||||
Event > MIDI Operations > Transpose...
|
||||
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 > Remove Duplicate Notes
|
||||
Event > Add Key Change...
|
||||
Event > Extract Chords from Selection
|
||||
Event > Beat Detective
|
||||
Event > Identify Beat...
|
||||
Event > Renumber Bars...
|
||||
Event > All MIDI Notes Off
|
||||
Event > Retrospective Record
|
||||
AudioSuite > EQ > Channel Strip
|
||||
AudioSuite > EQ > EQ3 1-Band
|
||||
AudioSuite > EQ > EQ3 7-Band
|
||||
AudioSuite > Dynamics > BF-76
|
||||
AudioSuite > Dynamics > Channel Strip
|
||||
AudioSuite > Dynamics > Dyn3 Compressor/Limiter
|
||||
AudioSuite > Dynamics > Dyn3 De-Esser
|
||||
AudioSuite > Dynamics > Dyn3 Expander/Gate
|
||||
AudioSuite > Dynamics > Maxim
|
||||
AudioSuite > Pitch Shift > Pitch II
|
||||
AudioSuite > Pitch Shift > Pitch Shift Legacy
|
||||
AudioSuite > Pitch Shift > Time Shift
|
||||
AudioSuite > Pitch Shift > Vari-Fi
|
||||
AudioSuite > Reverb > D-Verb
|
||||
AudioSuite > Delay > Mod Delay III
|
||||
AudioSuite > Modulation > Sci-Fi
|
||||
AudioSuite > Harmonic > Eleven Lite
|
||||
AudioSuite > Harmonic > Lo-Fi
|
||||
AudioSuite > Harmonic > Recti-Fi
|
||||
AudioSuite > Harmonic > SansAmp PSA-1
|
||||
AudioSuite > Other > DC Offset Removal
|
||||
AudioSuite > Other > Duplicate
|
||||
AudioSuite > Other > Gain
|
||||
AudioSuite > Other > Invert
|
||||
AudioSuite > Other > Normalize
|
||||
AudioSuite > Other > Reverse
|
||||
AudioSuite > Other > Signal Generator
|
||||
AudioSuite > Other > Time Compression Expansion
|
||||
Options > Destructive Record
|
||||
Options > Loop Record
|
||||
Options > QuickPunch
|
||||
Options > TrackPunch
|
||||
Options > DestructivePunch
|
||||
Options > Prepare DPE Tracks
|
||||
Options > Transport Online
|
||||
Options > Video Track Online
|
||||
Options > Pre/Post-Roll
|
||||
Options > Loop Playback
|
||||
Options > Dynamic Transport
|
||||
Options > Edit Window Scrolling > No Scrolling
|
||||
Options > Edit Window Scrolling > After Playback
|
||||
Options > Edit Window Scrolling > Page
|
||||
Options > Edit Window Scrolling > Continuous
|
||||
Options > Edit Window Scrolling > Center Playhead
|
||||
Options > Link Timeline and Edit Selection
|
||||
Options > Link Track and Edit Selection
|
||||
Options > Insertion Follows Playback
|
||||
Options > Tab to Transient
|
||||
Options > Mirrored MIDI Editing
|
||||
Options > Automation Follows Edit
|
||||
Options > Markers Follow Edit
|
||||
Options > Marker Target Follows First Selected Track
|
||||
Options > Layered Editing
|
||||
Options > Click
|
||||
Options > MIDI Thru
|
||||
Options > Auto-Spot Clips
|
||||
Options > Pre-Fader Metering
|
||||
Options > Solo Mode > SIP (Solo In Place)
|
||||
Options > Solo Mode > AFL (After Fader Listen)
|
||||
Options > Solo Mode > PFL (Pre Fader Listen)
|
||||
Options > Solo Mode > Latch
|
||||
Options > Solo Mode > X-OR (Cancels Previous Solo)
|
||||
Options > Solo Mode > Momentary
|
||||
Options > Edit/Tool Mode Keyboard Lock
|
||||
Options > Calibration Mode
|
||||
Options > Delay Compensation
|
||||
Options > MIDI Live Mode
|
||||
Options > Activate HEAT
|
||||
Options > Low Latency Monitoring
|
||||
Setup > Hardware...
|
||||
Setup > Playback Engine...
|
||||
Setup > Disk Allocation...
|
||||
Setup > Peripherals...
|
||||
Setup > I/O...
|
||||
Setup > Machine Track Arming Profiles...
|
||||
Setup > Video Sync Offset...
|
||||
Setup > Session
|
||||
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...
|
||||
Setup > Preferences...
|
||||
Window > Configurations > Window Configuration List
|
||||
Window > Configurations > New Configuration...
|
||||
Window > Configurations > Update Active Configuration
|
||||
Window > Configurations > Auto-Update Active Configuration
|
||||
Window > Arrange > Tile
|
||||
Window > Arrange > Tile Horizontal
|
||||
Window > Arrange > Tile Vertical
|
||||
Window > Arrange > Cascade
|
||||
Window > Hide All Floating Windows
|
||||
Window > Close Window
|
||||
Window > Mix
|
||||
Window > Edit
|
||||
Window > Sketch to Front
|
||||
Window > MIDI Editor
|
||||
Window > Score Editor
|
||||
Window > MIDI Event List
|
||||
Window > MIDI Editors > Bring to Front
|
||||
Window > MIDI Editors > Send to Back
|
||||
Window > MIDI Keyboard
|
||||
Window > Clip List
|
||||
Window > Clip Effects
|
||||
Window > ARA Plugins
|
||||
Window > Splice
|
||||
Window > Learn
|
||||
Window > SoundFlow
|
||||
Window > Task Manager
|
||||
Window > New Workspace > Default
|
||||
Window > New Workspace > Soundbase
|
||||
Window > New Workspace > Track Presets
|
||||
Window > Workspaces > Bring to Front
|
||||
Window > Workspaces > Send to Back
|
||||
Window > Workspaces > Close All Workspaces
|
||||
Window > Transport
|
||||
Window > Big Counter
|
||||
Window > Automation
|
||||
Window > Memory Locations
|
||||
Window > Machine Track Arming
|
||||
Window > Video Universe
|
||||
Window > Video
|
||||
Window > Color Palette
|
||||
Window > UI Customization
|
||||
Window > Metadata Inspector
|
||||
Window > Undo History
|
||||
Window > Transcript
|
||||
Window > Pro Tools Search
|
||||
Window > Renderer
|
||||
Window > Trim and Downmix
|
||||
Window > Collaboration > Pro Tools Projects
|
||||
Window > Collaboration > Find Talent
|
||||
Window > Collaboration > Messages
|
||||
Window > Disk Usage
|
||||
Window > System Usage
|
||||
@@ -0,0 +1,133 @@
|
||||
//! The incumbent's menu tree (feature names only, observed black-box) and the parity measure.
|
||||
//!
|
||||
//! A catalog entry counts as implemented when a registered command has the same menu path and
|
||||
//! label, or when [`ALIASES`] maps the entry to a command id (for UI-layer commands, the UI crate
|
||||
//! registers extra ids through [`parity_with`]).
|
||||
|
||||
use crate::cmd::command_specs;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// One leaf per line: `Menu > Submenu > Item`.
|
||||
pub const MENUS: &str = include_str!("../catalog/menus.txt");
|
||||
|
||||
/// Catalog path → command id, for entries whose label or menu differs from ours.
|
||||
pub const ALIASES: &[(&str, &str)] = &[
|
||||
("File > Open > Session...", "session.open"),
|
||||
("Edit > Can't Undo", "edit.undo"),
|
||||
("Edit > Can't Redo", "edit.redo"),
|
||||
("Edit > Selection > Play Edit", "transport.play_selection"),
|
||||
("Edit > Selection > Change Timeline to Match Edit", "edit.selection_to_timeline"),
|
||||
("Edit > Selection > Change Edit to Match Timeline", "edit.timeline_to_selection"),
|
||||
("Edit > Copy Selection to... > New Playlist", "edit.copy_to_new_playlist"),
|
||||
("Edit > Paste Special > Repeat to Fill Selection", "edit.paste_repeat_to_fill"),
|
||||
("Edit > Trim > Start to Fill Selection", "edit.trim_to_fill_selection"),
|
||||
("Edit > Trim > End to Fill Selection", "edit.trim_to_fill_selection"),
|
||||
("Edit > Trim > To File Start", "edit.trim_to_file_boundaries"),
|
||||
("Edit > Trim > To File End", "edit.trim_to_file_boundaries"),
|
||||
("Edit > Automation > Glide to Current", "automation.write_range"),
|
||||
("Edit > Automation > Trim to Current", "automation.write_to_current"),
|
||||
("Track > Bypass Inserts > All", "track.bypass_inserts"),
|
||||
("Track > Mute Sends > All", "track.mute_sends"),
|
||||
("Clip > Rating > none", "clip.rating"),
|
||||
("Clip > Rating > 1", "clip.rating"),
|
||||
("Clip > Rating > 2", "clip.rating"),
|
||||
("Clip > Rating > 3", "clip.rating"),
|
||||
("Clip > Rating > 4", "clip.rating"),
|
||||
("Clip > Rating > 5", "clip.rating"),
|
||||
("Clip > Clip Gain > Bypass", "clip.gain_bypass"),
|
||||
("Clip > Clip Gain > Render", "clip.gain_render"),
|
||||
("Event > Time Operations > Change Meter...", "event.meter"),
|
||||
("Event > Tempo Operations > Constant...", "event.tempo_constant"),
|
||||
("Event > Tempo Operations > Scale...", "event.tempo_scale"),
|
||||
("Options > Solo Mode > SIP (Solo In Place)", "options.solo_mode"),
|
||||
("Options > Solo Mode > AFL (After Fader Listen)", "options.solo_mode"),
|
||||
("Options > Solo Mode > PFL (Pre Fader Listen)", "options.solo_mode"),
|
||||
("Options > Solo Mode > X-OR (Cancels Previous Solo)", "options.solo_mode"),
|
||||
("Options > Solo Mode > Latch", "options.solo_mode"),
|
||||
("Options > Solo Mode > Momentary", "options.solo_mode"),
|
||||
("Options > Edit Window Scrolling > No Scrolling", "options.scrolling"),
|
||||
("Options > Edit Window Scrolling > After Playback", "options.scrolling"),
|
||||
("Options > Edit Window Scrolling > Page", "options.scrolling"),
|
||||
("Options > Edit Window Scrolling > Continuous", "options.scrolling"),
|
||||
("Options > Edit Window Scrolling > Center Playhead", "options.scrolling"),
|
||||
("View > Main Counter > Bars|Beats", "view.main_counter"),
|
||||
("View > Main Counter > Min:Secs", "view.main_counter"),
|
||||
("View > Main Counter > Timecode", "view.main_counter"),
|
||||
("View > Main Counter > Feet+Frames", "view.main_counter"),
|
||||
("View > Main Counter > Samples", "view.main_counter"),
|
||||
("View > Rulers > Bars|Beats", "view.ruler"),
|
||||
("View > Rulers > Min:Secs", "view.ruler"),
|
||||
("View > Rulers > Timecode", "view.ruler"),
|
||||
("View > Rulers > Feet+Frames", "view.ruler"),
|
||||
("View > Rulers > Samples", "view.ruler"),
|
||||
("View > Rulers > Meter", "view.ruler"),
|
||||
("View > Rulers > Markers", "view.ruler"),
|
||||
("View > Rulers > Chord Symbols", "view.ruler"),
|
||||
("Window > Undo History", "engine.history"),
|
||||
];
|
||||
|
||||
pub fn catalog() -> Vec<&'static str> {
|
||||
MENUS.lines().map(str::trim).filter(|l| !l.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Command id implementing a catalog entry, if any.
|
||||
pub fn implemented_by(entry: &str, extra: &[(&str, &str)]) -> Option<String> {
|
||||
if let Some((_, id)) = ALIASES.iter().chain(extra.iter()).find(|(p, _)| *p == entry) {
|
||||
return Some((*id).to_string());
|
||||
}
|
||||
let parts: Vec<&str> = entry.split(" > ").collect();
|
||||
let (label, menu) = parts.split_last()?;
|
||||
command_specs().iter().find(|c| c.label == *label && c.menu == menu).map(|c| c.id.to_string())
|
||||
}
|
||||
|
||||
/// Parity over the catalog, with extra UI-layer mappings.
|
||||
pub fn parity_with(extra: &[(&str, &str)]) -> Value {
|
||||
let cat = catalog();
|
||||
let mut done = 0usize;
|
||||
let mut missing = Vec::new();
|
||||
let mut per_menu: std::collections::BTreeMap<String, (usize, usize)> = Default::default();
|
||||
for e in &cat {
|
||||
let top = e.split(" > ").next().unwrap_or("").to_string();
|
||||
let slot = per_menu.entry(top).or_default();
|
||||
slot.1 += 1;
|
||||
if implemented_by(e, extra).is_some() {
|
||||
done += 1;
|
||||
slot.0 += 1;
|
||||
} else {
|
||||
missing.push(*e);
|
||||
}
|
||||
}
|
||||
let pct = if cat.is_empty() { 0.0 } else { done as f64 * 100.0 / cat.len() as f64 };
|
||||
json!({
|
||||
"total": cat.len(),
|
||||
"implemented": done,
|
||||
"percent": (pct * 10.0).round() / 10.0,
|
||||
"per_menu": per_menu.iter().map(|(k, (d, t))| json!({"menu": k, "implemented": d, "total": t})).collect::<Vec<_>>(),
|
||||
"missing": missing,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parity_json() -> Value {
|
||||
parity_with(&[])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aliases_point_at_real_commands() {
|
||||
for (path, id) in ALIASES {
|
||||
assert!(crate::find_command(id).is_some(), "{path} → unknown {id}");
|
||||
assert!(catalog().contains(path), "alias for unknown catalog entry {path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_floor() {
|
||||
let p = parity_json();
|
||||
let pct = p["percent"].as_f64().unwrap_or(0.0);
|
||||
// The floor only ever rises.
|
||||
assert!(pct >= 25.0, "engine parity regressed: {pct}%");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Clip menu commands.
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("clip.edit_lock", "Edit Lock/Unlock", ["Clip"], Some("Cmd+L"), "{clips?}", has_selection, |e, p| flag(
|
||||
e,
|
||||
p,
|
||||
|c| c.edit_locked,
|
||||
|c, v| c.edit_locked = v
|
||||
)),
|
||||
cmd!("clip.time_lock", "Time Lock/Unlock", ["Clip"], Some("Ctrl+Alt+L"), "{clips?}", has_selection, |e, p| flag(
|
||||
e,
|
||||
p,
|
||||
|c| c.time_locked,
|
||||
|c, v| c.time_locked = v
|
||||
)),
|
||||
cmd!("clip.send_to_back", "Send to Back", ["Clip"], Some("Alt+Shift+B"), "{clips?}", has_selection, |e, p| layer(e, p, false)),
|
||||
cmd!("clip.bring_to_front", "Bring to Front", ["Clip"], Some("Alt+Shift+F"), "{clips?}", has_selection, |e, p| layer(e, p, true)),
|
||||
cmd!("clip.rating", "Rating", ["Clip", "Rating"], Some("Cmd+Alt+0-5"), "{clips?, rating: 0..5}", has_selection, |e, p| {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let r = i64_or(p, "rating", 0).clamp(0, 5) as u8;
|
||||
let s = e.session_mut();
|
||||
for id in &ids {
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
c.rating = r
|
||||
}
|
||||
}
|
||||
Ok(json!({"rating": r}))
|
||||
}),
|
||||
cmd!("clip.group", "Group", ["Clip"], Some("Cmd+Alt+G"), "{clips?}", has_selection, |e, p| {
|
||||
let ids = clip_ids_param(e, p);
|
||||
if ids.len() < 2 {
|
||||
return Err(bad("clip.group", "select two or more clips"));
|
||||
}
|
||||
let s = e.session_mut();
|
||||
let g = s.alloc();
|
||||
for id in &ids {
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
c.group = Some(g)
|
||||
}
|
||||
}
|
||||
Ok(json!({"group": g}))
|
||||
}),
|
||||
cmd!("clip.ungroup", "Ungroup", ["Clip"], Some("Cmd+Alt+U"), "{clips?}", has_selection, |e, p| {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
for id in &ids {
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
c.group = None
|
||||
}
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("clip.ungroup_all", "Ungroup All", ["Clip"], None, "{}", always, |e, _| {
|
||||
for t in &mut e.session_mut().tracks {
|
||||
for pl in &mut t.playlists {
|
||||
for c in &mut pl.clips {
|
||||
c.group = None
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("clip.loop", "Loop...", ["Clip"], Some("Cmd+Alt+L"), "{clips?, count?: n, length?: samples}", has_selection, loop_clips),
|
||||
cmd!("clip.rename", "Rename...", ["Clip"], Some("Cmd+Alt+Shift+R"), "{clip?, name}", has_selection, rename),
|
||||
cmd!("clip.gain", "Clip Gain", [], None, "{clips?, db | delta_db}", has_selection, gain),
|
||||
cmd!("clip.gain_render", "Render", ["Clip", "Clip Gain"], None, "{clips?}", has_selection, |e, p| {
|
||||
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
|
||||
}
|
||||
}
|
||||
Ok(json!({"rendered": n}))
|
||||
}),
|
||||
cmd!("clip.gain_bypass", "Bypass", ["Clip", "Clip Gain"], None, "{clips?}", has_selection, |e, p| {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
for id in &ids {
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
c.gain_db = 0.0;
|
||||
c.gain_env.clear()
|
||||
}
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("clip.identify_sync_point", "Identify Sync Point", ["Clip"], Some("Cmd+,"), "{clip?, at?}", has_selection, |e, p| {
|
||||
let at = position_param(e, "clip.identify_sync_point", p, "at")?.unwrap_or(e.session().edit.selection.start);
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
for id in &ids {
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
c.sync_point = (at - c.start).clamp(0, c.length)
|
||||
}
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("clip.quantize_to_grid", "Quantize to Grid", ["Clip"], Some("Cmd+0"), "{clips?}", has_selection, quantize_to_grid),
|
||||
cmd!("clip.color", "Clip Color", [], None, "{clips?, color: [r,g,b] | null}", has_selection, |e, p| {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let col = p.get("color").and_then(Value::as_array).map(|a| {
|
||||
let g = |i: usize| a.get(i).and_then(Value::as_u64).unwrap_or(0).min(255) as u8;
|
||||
[g(0), g(1), g(2)]
|
||||
});
|
||||
let s = e.session_mut();
|
||||
for id in &ids {
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
c.color = col
|
||||
}
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("clip.elastic_properties", "Elastic Properties", ["Clip"], Some("Alt+5"), "{clips?, ratio?: 1.0}", has_selection, |e, p| {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let r = f64_or(p, "ratio", 1.0).clamp(0.05, 20.0);
|
||||
let s = e.session_mut();
|
||||
for id in &ids {
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
c.stretch = r
|
||||
}
|
||||
}
|
||||
Ok(json!({"ratio": r}))
|
||||
}),
|
||||
cmd!("clip.remove_warp", "Remove Warp", ["Clip"], None, "{clips?}", has_selection, |e, p| {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
for id in &ids {
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
c.stretch = 1.0
|
||||
}
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("clip.capture", "Capture...", ["Clip"], Some("Cmd+R"), "{name?}", has_range, |e, p| {
|
||||
// Captures the selection as a new whole-file-referencing clip in the clip list (we keep it on the track).
|
||||
let name = str_param(p, "name").unwrap_or("Captured").to_string();
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
for id in &ids {
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
c.name = name.clone()
|
||||
}
|
||||
}
|
||||
Ok(json!({"clips": ids.len()}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn flag(e: &mut Engine, p: &Value, get: fn(&soundcraft_model::Clip) -> bool, set: fn(&mut soundcraft_model::Clip, bool)) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
let v = p.get("value").and_then(Value::as_bool).unwrap_or_else(|| !ids.iter().all(|id| s.find_clip(*id).is_some_and(|(_, c)| get(c))));
|
||||
for id in &ids {
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
set(c, v);
|
||||
}
|
||||
}
|
||||
Ok(json!({"value": v, "clips": ids.len()}))
|
||||
}
|
||||
|
||||
fn layer(e: &mut Engine, p: &Value, front: bool) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
for t in &mut s.tracks {
|
||||
if let Some(pl) = t.playlist_mut() {
|
||||
let (mut sel, mut rest): (Vec<_>, Vec<_>) = pl.clips.drain(..).partition(|c| ids.contains(&c.id));
|
||||
if front {
|
||||
rest.append(&mut sel);
|
||||
pl.clips = rest;
|
||||
} else {
|
||||
sel.append(&mut rest);
|
||||
pl.clips = sel;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let count = i64_or(p, "count", 4).clamp(1, 512);
|
||||
let total = position_param(e, "clip.loop", p, "length")?;
|
||||
let s = e.session_mut();
|
||||
let mut made = 0;
|
||||
for id in ids {
|
||||
let Some((tid, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
|
||||
let reps = total.map_or(count - 1, |l| (l / c.length.max(1)).max(1) - 1);
|
||||
let mut copies = Vec::new();
|
||||
for k in 1..=reps {
|
||||
let mut nc = c.clone();
|
||||
nc.start = 0;
|
||||
copies.push((c.start + c.length * k, nc));
|
||||
}
|
||||
for (at, nc) in copies {
|
||||
crate::edit::paste(s, tid, at, &[nc], c.length, false);
|
||||
made += 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({"loops": made}))
|
||||
}
|
||||
|
||||
fn rename(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("clip.rename", "`name` required"))?.to_string();
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
for id in &ids {
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
c.name = name.clone();
|
||||
}
|
||||
}
|
||||
Ok(json!({"clips": ids.len()}))
|
||||
}
|
||||
|
||||
fn gain(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let abs = p.get("db").and_then(Value::as_f64);
|
||||
let delta = p.get("delta_db").and_then(Value::as_f64);
|
||||
if abs.is_none() && delta.is_none() {
|
||||
return Err(bad("clip.gain", "`db` or `delta_db` required"));
|
||||
}
|
||||
let s = e.session_mut();
|
||||
for id in &ids {
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
let v = abs.unwrap_or(f64::from(c.gain_db) + delta.unwrap_or(0.0));
|
||||
c.gain_db = if v.is_finite() { (v as f32).clamp(-144.0, 36.0) } else { c.gain_db };
|
||||
}
|
||||
}
|
||||
Ok(json!({"clips": ids.len()}))
|
||||
}
|
||||
|
||||
fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session();
|
||||
let moves: Vec<(soundcraft_model::ClipId, i64)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, c.start + c.sync_point)))
|
||||
.map(|(id, anchor)| (id, s.edit.grid.snap(anchor, s.sample_rate, &s.tempo, s.frame_rate) - anchor))
|
||||
.collect();
|
||||
let s = e.session_mut();
|
||||
for (id, d) in &moves {
|
||||
crate::edit::move_clips(s, &[*id], *d, None);
|
||||
}
|
||||
Ok(json!({"clips": moves.len()}))
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
//! Edit menu commands.
|
||||
|
||||
use super::*;
|
||||
use crate::edit as ops;
|
||||
use crate::{Clipboard, cmd};
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{EditMode, FadeShape, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |e, _| Ok(json!({"undone": e.undo()}))),
|
||||
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |e, _| Ok(json!({"redone": e.redo()}))),
|
||||
cmd!("edit.restore_last_selection", "Restore Last Selection", ["Edit"], Some("Cmd+Alt+Z"), "{}", always, restore_selection),
|
||||
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, true, "edit.cut")),
|
||||
cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, false, "edit.copy")),
|
||||
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{tracks?, at?}", has_clipboard, paste),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Cmd+B"), "{tracks?, start?, end?}", has_range, clear),
|
||||
cmd!("edit.cut_clip_gain", "Clip Gain", ["Edit", "Cut Special"], Some("Cmd+Ctrl+X"), "{}", has_range, |e, p| reset_clip_gain(
|
||||
e,
|
||||
p,
|
||||
"edit.cut_clip_gain"
|
||||
)),
|
||||
cmd!("edit.clear_clip_gain", "Clip Gain", ["Edit", "Clear Special"], Some("Cmd+Ctrl+B"), "{}", has_range, |e, p| reset_clip_gain(
|
||||
e,
|
||||
p,
|
||||
"edit.clear_clip_gain"
|
||||
)),
|
||||
cmd!("edit.cut_all_automation", "All Automation", ["Edit", "Cut Special"], None, "{}", has_range, |e, p| clear_automation(e, p, None)),
|
||||
cmd!("edit.clear_all_automation", "All Automation", ["Edit", "Clear Special"], None, "{}", has_range, |e, p| clear_automation(e, p, None)),
|
||||
cmd!("edit.clear_pan_automation", "Pan Automation", ["Edit", "Clear Special"], None, "{}", has_range, |e, p| clear_automation(
|
||||
e,
|
||||
p,
|
||||
Some("pan")
|
||||
)),
|
||||
cmd!("edit.clear_plugin_automation", "Plugin Automation", ["Edit", "Clear Special"], None, "{}", has_range, |e, p| clear_automation(
|
||||
e,
|
||||
p,
|
||||
Some("plugin")
|
||||
)),
|
||||
cmd!(
|
||||
"edit.paste_repeat_to_fill",
|
||||
"Repeat to Fill Selection",
|
||||
["Edit", "Paste Special"],
|
||||
Some("Cmd+Alt+V"),
|
||||
"{}",
|
||||
has_clipboard,
|
||||
repeat_to_fill
|
||||
),
|
||||
cmd!(noundo "edit.select_all", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, select_all),
|
||||
cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id]}", always, select),
|
||||
cmd!(noundo "edit.select_none", "Deselect All", [], None, "{}", always, |e, _| {
|
||||
let s = e.session_mut();
|
||||
s.edit.selected_tracks.clear();
|
||||
s.edit.selected_clips.clear();
|
||||
s.edit.selection.end = s.edit.selection.start;
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!(noundo "edit.selection_to_timeline", "Change Timeline to Match Edit", ["Edit", "Selection"], Some("Alt+Shift+5"), "{}", always, |e, _| {
|
||||
let s = e.session_mut();
|
||||
s.edit.timeline_selection = s.edit.selection;
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!(noundo "edit.timeline_to_selection", "Change Edit to Match Timeline", ["Edit", "Selection"], Some("Alt+Shift+6"), "{}", always, |e, _| {
|
||||
let s = e.session_mut();
|
||||
s.edit.selection = s.edit.timeline_selection;
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!(noundo "edit.move_selection_left", "Move Edit Left", ["Edit", "Selection"], Some("Cmd+Alt+L"), "{}", always, |e, _| shift_selection(e, -1)),
|
||||
cmd!(noundo "edit.move_selection_right", "Move Edit Right", ["Edit", "Selection"], Some("Cmd+Alt+'"), "{}", always, |e, _| shift_selection(e, 1)),
|
||||
cmd!(noundo "edit.halve_selection", "Halve Edit", ["Edit", "Selection"], None, "{}", always, |e, _| scale_selection(e, 0.5)),
|
||||
cmd!(noundo "edit.double_selection", "Double Edit", ["Edit", "Selection"], None, "{}", always, |e, _| scale_selection(e, 2.0)),
|
||||
cmd!(noundo "edit.extend_selection_up", "Extend Edit Up", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| extend_tracks(e, -1)),
|
||||
cmd!(noundo "edit.extend_selection_down", "Extend Edit Down", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| extend_tracks(e, 1)),
|
||||
cmd!(noundo "edit.remove_selection_top", "Remove Edit from Top", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| {
|
||||
let s = e.session_mut();
|
||||
if !s.edit.selected_tracks.is_empty() {
|
||||
s.edit.selected_tracks.remove(0);
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!(noundo "edit.remove_selection_bottom", "Remove Edit from Bottom", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| {
|
||||
e.session_mut().edit.selected_tracks.pop();
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{tracks?, start?, end?}", has_range, duplicate),
|
||||
cmd!("edit.repeat", "Repeat...", ["Edit"], Some("Alt+R"), "{count: n}", has_range, repeat),
|
||||
cmd!("edit.shift", "Shift...", ["Edit"], Some("Alt+H"), "{by: samples|{seconds}, later?: bool}", has_range, shift),
|
||||
cmd!("edit.insert_silence", "Insert Silence", ["Edit"], Some("Cmd+Shift+E"), "{tracks?, start?, end?}", has_range, insert_silence),
|
||||
cmd!("edit.separate", "At Selection", ["Edit", "Separate"], Some("Cmd+E"), "{tracks?, at?|start/end}", has_selection, separate),
|
||||
cmd!("edit.separate_on_grid", "On Grid", ["Edit", "Separate"], None, "{tracks?, start?, end?}", has_range, separate_on_grid),
|
||||
cmd!("edit.separate_at_transients", "At Transients", ["Edit", "Separate"], None, "{sensitivity?: 0..1}", has_range, separate_at_transients),
|
||||
cmd!("edit.heal", "Heal Separation", ["Edit"], Some("Cmd+H"), "{tracks?, start?, end?}", has_selection, heal),
|
||||
cmd!("edit.trim_to_selection", "To Selection", ["Edit", "Trim"], Some("Cmd+T"), "{tracks?, start?, end?}", has_range, trim_to_selection),
|
||||
cmd!("edit.trim_start_to_insertion", "Start to Insertion", ["Edit", "Trim"], Some("Alt+Shift+7"), "{tracks?, at?}", has_selection, |e, p| {
|
||||
trim_edge(e, p, true)
|
||||
}),
|
||||
cmd!("edit.trim_end_to_insertion", "End to Insertion", ["Edit", "Trim"], Some("Alt+Shift+8"), "{tracks?, at?}", has_selection, |e, p| {
|
||||
trim_edge(e, p, false)
|
||||
}),
|
||||
cmd!("edit.trim_to_file_boundaries", "To File Boundaries", ["Edit", "Trim"], None, "{clips?}", has_selection, trim_to_file),
|
||||
cmd!("edit.trim_to_fill_selection", "To Fill Selection", ["Edit", "Trim"], None, "{}", has_range, trim_to_fill),
|
||||
cmd!("edit.mute_clips", "Mute", ["Edit"], Some("Cmd+M"), "{clips?}", has_selection, mute_clips),
|
||||
cmd!("edit.copy_to_new_playlist", "New Playlist", ["Edit", "Copy Selection to..."], None, "{}", has_range, copy_to_new_playlist),
|
||||
cmd!(
|
||||
"edit.strip_silence",
|
||||
"Strip Silence",
|
||||
["Edit"],
|
||||
Some("Cmd+U"),
|
||||
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20}",
|
||||
has_range,
|
||||
strip_silence
|
||||
),
|
||||
cmd!(
|
||||
"edit.fades_create",
|
||||
"Create...",
|
||||
["Edit", "Fades"],
|
||||
Some("Cmd+F"),
|
||||
"{shape?: linear|equal power|s-curve|exponential|logarithmic, tracks?, start?, end?}",
|
||||
has_range,
|
||||
fades_create
|
||||
),
|
||||
cmd!("edit.fades_delete", "Delete", ["Edit", "Fades"], None, "{tracks?, start?, end?}", has_selection, fades_delete),
|
||||
cmd!("edit.fade_to_start", "Fade to Start", ["Edit", "Fades"], Some("Cmd+Ctrl+D"), "{}", has_selection, |e, p| fade_to(e, p, true)),
|
||||
cmd!("edit.fade_to_end", "Fade to End", ["Edit", "Fades"], Some("Cmd+Ctrl+G"), "{}", has_selection, |e, p| fade_to(e, p, false)),
|
||||
cmd!("edit.nudge", "Nudge", [], Some("Plus/Minus"), "{direction: 1|-1, count?: n}", has_selection, nudge),
|
||||
cmd!(noundo "edit.mode", "Edit Mode", [], Some("F1-F4"), "{mode: shuffle|slip|spot|grid|relative_grid}", always, |e, p| {
|
||||
let m = str_param(p, "mode").and_then(EditMode::from_id).ok_or_else(|| bad("edit.mode", "mode must be shuffle|slip|spot|grid|relative_grid"))?;
|
||||
e.session_mut().edit.edit_mode = m;
|
||||
Ok(json!({"mode": m}))
|
||||
}),
|
||||
cmd!(noundo "edit.tool", "Edit Tool", [], Some("F5-F10"), "{tool: zoom|trim|selector|grabber|scrubber|pencil|smart}", always, |e, p| {
|
||||
let t = str_param(p, "tool").and_then(soundcraft_model::Tool::from_id).ok_or_else(|| bad("edit.tool", "unknown tool"))?;
|
||||
e.session_mut().edit.tool = t;
|
||||
Ok(json!({"tool": t}))
|
||||
}),
|
||||
cmd!(
|
||||
"edit.move_clips",
|
||||
"Move Clips",
|
||||
[],
|
||||
None,
|
||||
"{clips?: [id], by?: samples, to?: position (first clip start), track?: target}",
|
||||
has_selection,
|
||||
move_clips
|
||||
),
|
||||
cmd!("edit.consolidate", "Consolidate", ["Edit"], Some("Alt+Shift+3"), "{tracks?, start?, end?}", has_range, consolidate),
|
||||
]
|
||||
}
|
||||
|
||||
fn shuffle(s: &Session) -> bool {
|
||||
s.edit.edit_mode == EditMode::Shuffle
|
||||
}
|
||||
|
||||
fn restore_selection(e: &mut Engine, _: &Value) -> Result<Value> {
|
||||
Ok(json!({"selection": e.session().edit.selection}))
|
||||
}
|
||||
|
||||
fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
|
||||
let tracks = tracks_required(e, id, p)?;
|
||||
let range = range_param(e, id, p)?;
|
||||
if range.is_empty() {
|
||||
return Err(bad(id, "selection is empty"));
|
||||
}
|
||||
let mut cb = Clipboard { length: range.len(), ..Clipboard::default() };
|
||||
let follow = e.session().edit.automation_follows_edit;
|
||||
for t in &tracks {
|
||||
cb.tracks.push(ops::copy_range(e.session(), *t, range));
|
||||
let lanes = e
|
||||
.session()
|
||||
.track(*t)
|
||||
.map(|tr| {
|
||||
tr.automation
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let mut l2 = l.clone();
|
||||
l2.points = l
|
||||
.points
|
||||
.iter()
|
||||
.filter(|p| range.contains(p.at))
|
||||
.map(|p| soundcraft_model::AutomationPoint { at: p.at - range.start, value: p.value })
|
||||
.collect();
|
||||
l2
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
cb.automation.push(lanes);
|
||||
}
|
||||
if cut {
|
||||
let sh = shuffle(e.session());
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
ops::clear_range(s, *t, range, sh);
|
||||
if follow && let Some(tr) = s.track_mut(*t) {
|
||||
for l in &mut tr.automation {
|
||||
l.clear_range(range.start, range.end);
|
||||
if sh {
|
||||
l.shift_from(range.end, -range.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.edit.selection = soundcraft_time::Range::point(range.start);
|
||||
}
|
||||
let n: usize = cb.tracks.iter().map(Vec::len).sum();
|
||||
e.clipboard = cb;
|
||||
Ok(json!({"clips": n, "length": range.len()}))
|
||||
}
|
||||
|
||||
fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.paste", p)?;
|
||||
let at = position_param(e, "edit.paste", p, "at")?.unwrap_or(e.session().edit.selection.start);
|
||||
let cb = e.clipboard.clone();
|
||||
let sh = shuffle(e.session());
|
||||
let follow = e.session().edit.automation_follows_edit;
|
||||
let s = e.session_mut();
|
||||
let mut pasted = 0;
|
||||
for (i, t) in tracks.iter().enumerate() {
|
||||
let Some(clips) = cb.tracks.get(i % cb.tracks.len().max(1)) else { continue };
|
||||
pasted += ops::paste(s, *t, at, clips, cb.length, sh).len();
|
||||
if follow && let (Some(lanes), Some(tr)) = (cb.automation.get(i % cb.automation.len().max(1)), s.track_mut(*t)) {
|
||||
for l in lanes {
|
||||
let dst = tr.lane_mut(&l.param);
|
||||
if !l.points.is_empty() {
|
||||
dst.clear_range(at, at + cb.length);
|
||||
}
|
||||
for pt in &l.points {
|
||||
dst.set_point(pt.at + at, pt.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.edit.selection = soundcraft_time::Range::new(at, at + cb.length);
|
||||
Ok(json!({"pasted": pasted}))
|
||||
}
|
||||
|
||||
fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.clear", p)?;
|
||||
let range = range_param(e, "edit.clear", p)?;
|
||||
let ids = clip_ids_param(e, p);
|
||||
let sh = shuffle(e.session());
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
if range.is_empty() && !ids.is_empty() {
|
||||
for t in &mut s.tracks {
|
||||
if let Some(pl) = t.playlist_mut() {
|
||||
let before = pl.clips.len();
|
||||
pl.clips.retain(|c| !ids.contains(&c.id));
|
||||
n += before - pl.clips.len();
|
||||
}
|
||||
}
|
||||
s.edit.selected_clips.clear();
|
||||
} else {
|
||||
for t in &tracks {
|
||||
n += ops::clear_range(s, *t, range, sh).len();
|
||||
}
|
||||
}
|
||||
Ok(json!({"removed": n}))
|
||||
}
|
||||
|
||||
fn reset_clip_gain(e: &mut Engine, p: &Value, _id: &str) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
for id in &ids {
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
c.gain_db = 0.0;
|
||||
c.gain_env.clear();
|
||||
}
|
||||
}
|
||||
Ok(json!({"clips": ids.len()}))
|
||||
}
|
||||
|
||||
fn clear_automation(e: &mut Engine, p: &Value, which: Option<&str>) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.clear_automation", p)?;
|
||||
let r = range_param(e, "edit.clear_automation", p)?;
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
for l in &mut tr.automation {
|
||||
let hit = match which {
|
||||
None => true,
|
||||
Some("pan") => matches!(l.param, soundcraft_model::AutoParam::Pan(_)),
|
||||
Some(_) => matches!(l.param, soundcraft_model::AutoParam::Plugin { .. }),
|
||||
};
|
||||
if hit {
|
||||
n += l.clear_range(r.start, r.end);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"points_removed": n}))
|
||||
}
|
||||
|
||||
fn repeat_to_fill(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let range = range_param(e, "edit.paste_repeat_to_fill", p)?;
|
||||
let len = e.clipboard.length.max(1);
|
||||
let tracks = tracks_required(e, "edit.paste_repeat_to_fill", p)?;
|
||||
let cb = e.clipboard.clone();
|
||||
let s = e.session_mut();
|
||||
let mut at = range.start;
|
||||
let mut n = 0;
|
||||
while at < range.end && n < 10_000 {
|
||||
for (i, t) in tracks.iter().enumerate() {
|
||||
if let Some(clips) = cb.tracks.get(i % cb.tracks.len().max(1)) {
|
||||
let trimmed: Vec<_> = clips
|
||||
.iter()
|
||||
.filter(|c| at + c.start < range.end)
|
||||
.map(|c| {
|
||||
let mut c = c.clone();
|
||||
if at + c.end() > range.end {
|
||||
c.trim_end_to(range.end - at);
|
||||
}
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
ops::paste(s, *t, at, &trimmed, (range.end - at).min(len), false);
|
||||
}
|
||||
}
|
||||
at += len;
|
||||
n += 1;
|
||||
}
|
||||
Ok(json!({"repeats": n}))
|
||||
}
|
||||
|
||||
fn select_all(e: &mut Engine, _: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
s.edit.selected_tracks = s.tracks.iter().filter(|t| !t.hidden).map(|t| t.id).collect();
|
||||
let end = s.content_end();
|
||||
s.edit.selection = soundcraft_time::Range::new(0, end);
|
||||
Ok(json!({"tracks": s.edit.selected_tracks.len(), "end": end}))
|
||||
}
|
||||
|
||||
fn select(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = if p.get("tracks").is_some() || p.get("track").is_some() { Some(tracks_param(e, "edit.select", p)?) } else { None };
|
||||
let st = position_param(e, "edit.select", p, "start")?;
|
||||
let en = position_param(e, "edit.select", p, "end")?;
|
||||
let clips =
|
||||
p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::ClipId).collect::<Vec<_>>());
|
||||
let s = e.session_mut();
|
||||
if let Some(t) = tracks {
|
||||
s.edit.selected_tracks = t;
|
||||
}
|
||||
match (st, en) {
|
||||
(Some(a), Some(b)) => s.edit.selection = soundcraft_time::Range::new(a.max(0), b.max(0)),
|
||||
(Some(a), None) => s.edit.selection = soundcraft_time::Range::point(a.max(0)),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(c) = clips {
|
||||
// Selecting clips also selects their range and tracks (Grabber click).
|
||||
let mut range: Option<soundcraft_time::Range> = None;
|
||||
let mut trs = Vec::new();
|
||||
for id in &c {
|
||||
if let Some((tid, clip)) = s.find_clip(*id) {
|
||||
range = Some(range.map_or(clip.range(), |r| soundcraft_time::Range::new(r.start.min(clip.start), r.end.max(clip.end()))));
|
||||
if !trs.contains(&tid) {
|
||||
trs.push(tid);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(r) = range {
|
||||
s.edit.selection = r;
|
||||
s.edit.selected_tracks = trs;
|
||||
}
|
||||
s.edit.selected_clips = c;
|
||||
} else if st.is_some() {
|
||||
s.edit.selected_clips.clear();
|
||||
}
|
||||
if s.edit.link_timeline_edit {
|
||||
s.edit.timeline_selection = s.edit.selection;
|
||||
}
|
||||
Ok(json!({"selection": s.edit.selection, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips}))
|
||||
}
|
||||
|
||||
fn shift_selection(e: &mut Engine, dir: i64) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let len = s.edit.selection.len().max(1);
|
||||
s.edit.selection = s.edit.selection.shifted(dir * len);
|
||||
if s.edit.selection.start < 0 {
|
||||
s.edit.selection = s.edit.selection.shifted(-s.edit.selection.start);
|
||||
}
|
||||
Ok(json!({"selection": s.edit.selection}))
|
||||
}
|
||||
|
||||
fn scale_selection(e: &mut Engine, f: f64) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let len = soundcraft_time::to_samples(s.edit.selection.len() as f64 * f);
|
||||
s.edit.selection.end = s.edit.selection.start.saturating_add(len);
|
||||
Ok(json!({"selection": s.edit.selection}))
|
||||
}
|
||||
|
||||
fn extend_tracks(e: &mut Engine, dir: i64) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let idxs: Vec<usize> = s.edit.selected_tracks.iter().filter_map(|t| s.track_index(*t)).collect();
|
||||
let next = if dir < 0 { idxs.iter().min().and_then(|i| i.checked_sub(1)) } else { idxs.iter().max().map(|i| i + 1) };
|
||||
if let Some(t) = next.and_then(|i| s.tracks.get(i)).map(|t| t.id) {
|
||||
if dir < 0 {
|
||||
s.edit.selected_tracks.insert(0, t);
|
||||
} else {
|
||||
s.edit.selected_tracks.push(t);
|
||||
}
|
||||
} else if idxs.is_empty()
|
||||
&& let Some(t) = s.tracks.first()
|
||||
{
|
||||
s.edit.selected_tracks.push(t.id);
|
||||
}
|
||||
Ok(json!({"tracks": s.edit.selected_tracks}))
|
||||
}
|
||||
|
||||
fn duplicate(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.duplicate", p)?;
|
||||
let range = range_param(e, "edit.duplicate", p)?;
|
||||
if range.is_empty() {
|
||||
return Err(bad("edit.duplicate", "selection is empty"));
|
||||
}
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for t in &tracks {
|
||||
let clips = ops::copy_range(s, *t, range);
|
||||
n += ops::paste(s, *t, range.end, &clips, range.len(), false).len();
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
for l in &mut tr.automation {
|
||||
let pts: Vec<_> = l.points.iter().filter(|p| range.contains(p.at)).copied().collect();
|
||||
for pt in pts {
|
||||
l.set_point(pt.at + range.len(), pt.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.edit.selection = range.shifted(range.len());
|
||||
Ok(json!({"clips": n}))
|
||||
}
|
||||
|
||||
fn repeat(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let count = i64_or(p, "count", 1).clamp(1, 999);
|
||||
let tracks = tracks_required(e, "edit.repeat", p)?;
|
||||
let range = range_param(e, "edit.repeat", p)?;
|
||||
if range.is_empty() {
|
||||
return Err(bad("edit.repeat", "selection is empty"));
|
||||
}
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
let clips = ops::copy_range(s, *t, range);
|
||||
for k in 1..=count {
|
||||
ops::paste(s, *t, range.start + range.len() * k, &clips, range.len(), false);
|
||||
}
|
||||
}
|
||||
Ok(json!({"repeats": count}))
|
||||
}
|
||||
|
||||
fn shift(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let by = position_param(e, "edit.shift", p, "by")?.ok_or_else(|| bad("edit.shift", "`by` is required"))?;
|
||||
let later = bool_or(p, "later", true);
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
let n = ops::move_clips(s, &ids, if later { by } else { -by }, None);
|
||||
Ok(json!({"moved": n}))
|
||||
}
|
||||
|
||||
fn insert_silence(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.insert_silence", p)?;
|
||||
let range = range_param(e, "edit.insert_silence", p)?;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
ops::insert_silence(s, *t, range);
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
for l in &mut tr.automation {
|
||||
l.shift_from(range.start, range.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"inserted": range.len()}))
|
||||
}
|
||||
|
||||
fn separate(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.separate", p)?;
|
||||
let points: Vec<i64> = if let Some(at) = position_param(e, "edit.separate", p, "at")? {
|
||||
vec![at]
|
||||
} else {
|
||||
let r = range_param(e, "edit.separate", p)?;
|
||||
if r.is_empty() { vec![r.start] } else { vec![r.start, r.end] }
|
||||
};
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for t in &tracks {
|
||||
for at in &points {
|
||||
n += ops::separate_at(s, *t, *at);
|
||||
}
|
||||
}
|
||||
Ok(json!({"separated": n}))
|
||||
}
|
||||
|
||||
fn separate_on_grid(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.separate_on_grid", p)?;
|
||||
let r = range_param(e, "edit.separate_on_grid", p)?;
|
||||
let s = e.session_mut();
|
||||
let lines = s.edit.grid.lines(r.start, r.end, s.sample_rate, &s.tempo, s.frame_rate, 10_000);
|
||||
let mut n = 0;
|
||||
for t in &tracks {
|
||||
for at in &lines {
|
||||
n += ops::separate_at(s, *t, *at);
|
||||
}
|
||||
}
|
||||
Ok(json!({"separated": n}))
|
||||
}
|
||||
|
||||
fn separate_at_transients(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.separate_at_transients", p)?;
|
||||
let r = range_param(e, "edit.separate_at_transients", p)?;
|
||||
let sens = f32_or(p, "sensitivity", 0.5).clamp(0.0, 1.0);
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for t in &tracks {
|
||||
let hits = crate::io::transients_in(s, *t, r, sens);
|
||||
for at in hits {
|
||||
n += ops::separate_at(s, *t, at);
|
||||
}
|
||||
}
|
||||
Ok(json!({"separated": n}))
|
||||
}
|
||||
|
||||
fn heal(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.heal", p)?;
|
||||
let mut r = range_param(e, "edit.heal", p)?;
|
||||
if r.is_empty() {
|
||||
r = soundcraft_time::Range::new(r.start - 1, r.start + 1);
|
||||
}
|
||||
let s = e.session_mut();
|
||||
let n: usize = tracks.iter().map(|t| ops::heal(s, *t, r)).sum();
|
||||
Ok(json!({"healed": n}))
|
||||
}
|
||||
|
||||
fn trim_to_selection(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.trim_to_selection", p)?;
|
||||
let r = range_param(e, "edit.trim_to_selection", p)?;
|
||||
let s = e.session_mut();
|
||||
let n: usize = tracks.iter().map(|t| ops::trim_to(s, *t, r)).sum();
|
||||
Ok(json!({"trimmed": n}))
|
||||
}
|
||||
|
||||
fn trim_edge(e: &mut Engine, p: &Value, start: bool) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.trim", p)?;
|
||||
let at = position_param(e, "edit.trim", p, "at")?.unwrap_or(e.session().edit.selection.start);
|
||||
let s = e.session_mut();
|
||||
let n: usize = tracks.iter().map(|t| if start { ops::trim_start_to(s, *t, at) } else { ops::trim_end_to(s, *t, at) }).sum();
|
||||
Ok(json!({"trimmed": n}))
|
||||
}
|
||||
|
||||
fn trim_to_file(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for id in ids {
|
||||
let frames =
|
||||
s.find_clip(id).and_then(|(_, c)| c.source()).and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX));
|
||||
if let (Some(frames), Some(c)) = (frames, s.find_clip_mut(id)) {
|
||||
let off = c.source_offset();
|
||||
let new_start = c.start - off;
|
||||
c.trim_start_to(new_start);
|
||||
c.trim_end_to(new_start + frames);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({"trimmed": n}))
|
||||
}
|
||||
|
||||
fn trim_to_fill(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let r = range_param(e, "edit.trim_to_fill_selection", p)?;
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for id in ids {
|
||||
let frames =
|
||||
s.find_clip(id).and_then(|(_, c)| c.source()).and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX));
|
||||
if let (Some(frames), Some(c)) = (frames, s.find_clip_mut(id)) {
|
||||
let file_start = c.start - c.source_offset();
|
||||
c.trim_start_to(r.start.max(file_start));
|
||||
c.trim_end_to(r.end.min(file_start + frames));
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({"trimmed": n}))
|
||||
}
|
||||
|
||||
fn mute_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
let any_unmuted = ids.iter().any(|id| s.find_clip(*id).is_some_and(|(_, c)| !c.muted));
|
||||
for id in &ids {
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
c.muted = any_unmuted;
|
||||
}
|
||||
}
|
||||
Ok(json!({"muted": any_unmuted, "clips": ids.len()}))
|
||||
}
|
||||
|
||||
fn copy_to_new_playlist(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.copy_to_new_playlist", p)?;
|
||||
let r = range_param(e, "edit.copy_to_new_playlist", p)?;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
let clips = ops::copy_range(s, *t, r);
|
||||
let mut fresh = Vec::new();
|
||||
for mut c in clips {
|
||||
c.id = s.new_clip_id();
|
||||
c.start += r.start;
|
||||
fresh.push(c);
|
||||
}
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
let name = format!("{}.{:02}", tr.name, tr.playlists.len() + 1);
|
||||
tr.playlists.push(soundcraft_model::Playlist { name, clips: fresh });
|
||||
tr.active_playlist = tr.playlists.len() - 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({"tracks": tracks.len()}))
|
||||
}
|
||||
|
||||
fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.strip_silence", p)?;
|
||||
let r = range_param(e, "edit.strip_silence", p)?;
|
||||
let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0);
|
||||
let sr = e.session().sample_rate;
|
||||
let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0);
|
||||
let (min_len, pre, post) = (ms("min_length_ms", 50.0), ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0));
|
||||
let s = e.session_mut();
|
||||
let mut kept = 0;
|
||||
for t in &tracks {
|
||||
kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post);
|
||||
}
|
||||
Ok(json!({"clips": kept}))
|
||||
}
|
||||
|
||||
fn fades_create(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.fades_create", p)?;
|
||||
let r = range_param(e, "edit.fades_create", p)?;
|
||||
let shape = str_param(p, "shape")
|
||||
.map_or(Some(FadeShape::EqualPower), FadeShape::from_id)
|
||||
.ok_or_else(|| bad("edit.fades_create", "unknown fade shape"))?;
|
||||
let s = e.session_mut();
|
||||
let n: usize = tracks.iter().map(|t| ops::create_fades(s, *t, r, shape)).sum();
|
||||
if n == 0 {
|
||||
return Err(bad("edit.fades_create", "the selection must cover a clip start, end or the boundary between two clips"));
|
||||
}
|
||||
Ok(json!({"fades": n}))
|
||||
}
|
||||
|
||||
fn fades_delete(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.fades_delete", p)?;
|
||||
let r = range_param(e, "edit.fades_delete", p)?;
|
||||
let s = e.session_mut();
|
||||
let n: usize = tracks.iter().map(|t| ops::delete_fades(s, *t, r)).sum();
|
||||
Ok(json!({"deleted": n}))
|
||||
}
|
||||
|
||||
fn fade_to(e: &mut Engine, p: &Value, to_start: bool) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.fade_to", p)?;
|
||||
let at = e.session().edit.selection.start;
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for t in &tracks {
|
||||
let range = s.track(*t).and_then(|tr| {
|
||||
tr.clips()
|
||||
.iter()
|
||||
.find(|c| c.range().contains(at))
|
||||
.map(|c| if to_start { soundcraft_time::Range::new(c.start, at) } else { soundcraft_time::Range::new(at, c.end()) })
|
||||
});
|
||||
if let Some(r) = range {
|
||||
n += ops::create_fades(s, *t, r, FadeShape::EqualPower);
|
||||
}
|
||||
}
|
||||
Ok(json!({"fades": n}))
|
||||
}
|
||||
|
||||
fn nudge(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let dir = i64_or(p, "direction", 1).signum();
|
||||
let count = i64_or(p, "count", 1).clamp(1, 1000);
|
||||
let s = e.session();
|
||||
let step = s.edit.nudge.step_samples(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate) * count * if dir == 0 { 1 } else { dir };
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
let n = if ids.is_empty() { 0 } else { ops::move_clips(s, &ids, step, None) };
|
||||
s.edit.selection = s.edit.selection.shifted(step);
|
||||
if s.edit.selection.start < 0 {
|
||||
s.edit.selection = s.edit.selection.shifted(-s.edit.selection.start);
|
||||
}
|
||||
Ok(json!({"moved": n, "by": step}))
|
||||
}
|
||||
|
||||
fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
if ids.is_empty() {
|
||||
return Err(bad("edit.move_clips", "no clips"));
|
||||
}
|
||||
let to_track = track_param(e, "edit.move_clips", p, "track")?;
|
||||
let first = ids.iter().filter_map(|id| e.session().find_clip(*id).map(|(_, c)| c.start)).min().unwrap_or(0);
|
||||
let delta = match (position_param(e, "edit.move_clips", p, "by")?, position_param(e, "edit.move_clips", p, "to")?) {
|
||||
(Some(by), _) => by,
|
||||
(None, Some(to)) => to - first,
|
||||
_ => 0,
|
||||
};
|
||||
// Grid mode snaps the destination.
|
||||
let s = e.session();
|
||||
let delta = if s.edit.edit_mode.is_grid() && p.get("to").is_some() {
|
||||
s.edit.grid.snap(first + delta, s.sample_rate, &s.tempo, s.frame_rate) - first
|
||||
} else {
|
||||
delta
|
||||
};
|
||||
let s = e.session_mut();
|
||||
let n = ops::move_clips(s, &ids, delta, to_track);
|
||||
s.edit.selected_clips = ids;
|
||||
Ok(json!({"moved": n, "by": delta}))
|
||||
}
|
||||
|
||||
fn consolidate(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.consolidate", p)?;
|
||||
let r = range_param(e, "edit.consolidate", p)?;
|
||||
if r.is_empty() {
|
||||
return Err(bad("edit.consolidate", "selection is empty"));
|
||||
}
|
||||
let mut n = 0;
|
||||
for t in tracks {
|
||||
if crate::io::consolidate(e, t, r)? {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({"consolidated": n}))
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//! Event menu: time/tempo operations, MIDI operations, markers.
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_midi::ops::{DurationOp, QuantizeOptions, VelocityOp};
|
||||
use soundcraft_model::{ClipContent, MarkerKind};
|
||||
use soundcraft_time::TICKS_PER_QUARTER;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("event.tempo", "Tempo Change", [], None, "{bpm, at?: position (default session start)}", always, tempo),
|
||||
cmd!("event.tempo_remove", "Remove Tempo Change", [], None, "{at}", always, |e, p| {
|
||||
let at = position_param(e, "event.tempo_remove", p, "at")?.unwrap_or(0);
|
||||
let s = e.session_mut();
|
||||
let tick = s.tempo.samples_to_ticks(at, s.sample_rate);
|
||||
Ok(json!({"removed": s.tempo.remove_tempo(tick)}))
|
||||
}),
|
||||
cmd!("event.meter", "Change Meter...", ["Event", "Time Operations"], None, "{numerator, denominator, at?: position}", always, meter),
|
||||
cmd!("event.tempo_scale", "Scale...", ["Event", "Tempo Operations"], None, "{factor}", always, |e, p| {
|
||||
let f = f64_or(p, "factor", 1.0);
|
||||
e.session_mut().tempo.scale_tempos(f).map_err(|err| bad("event.tempo_scale", err.to_string()))?;
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("event.tempo_constant", "Constant...", ["Event", "Tempo Operations"], None, "{bpm, start?, end?}", always, tempo),
|
||||
cmd!("event.insert_time", "Insert Time...", ["Event", "Time Operations"], None, "{start?, length | end}", has_tracks, insert_time),
|
||||
cmd!("event.cut_time", "Cut Time...", ["Event", "Time Operations"], None, "{start?, end?}", has_tracks, cut_time),
|
||||
cmd!(
|
||||
"event.quantize",
|
||||
"Quantize...",
|
||||
["Event", "MIDI Operations"],
|
||||
Some("Alt+0"),
|
||||
"{grid?: 1/16 | ticks, strength?: 100, swing?: 0, clips?}",
|
||||
has_selection,
|
||||
quantize
|
||||
),
|
||||
cmd!("event.transpose", "Transpose...", ["Event", "MIDI Operations"], Some("Alt+T"), "{semitones, clips?}", has_selection, |e, p| {
|
||||
let st = i64_or(p, "semitones", 0).clamp(-127, 127) as i32;
|
||||
midi_op(e, p, move |n| soundcraft_midi::ops::transpose(n, st))
|
||||
}),
|
||||
cmd!(
|
||||
"event.change_velocity",
|
||||
"Change Velocity...",
|
||||
["Event", "MIDI Operations"],
|
||||
None,
|
||||
"{set?|add?|scale?|min?,max?, clips?}",
|
||||
has_selection,
|
||||
velocity
|
||||
),
|
||||
cmd!(
|
||||
"event.change_duration",
|
||||
"Change Duration...",
|
||||
["Event", "MIDI Operations"],
|
||||
Some("Alt+P"),
|
||||
"{set?|add?|scale?|legato?: gap, clips?}",
|
||||
has_selection,
|
||||
duration
|
||||
),
|
||||
cmd!("event.remove_duplicate_notes", "Remove Duplicate Notes", ["Event"], None, "{clips?}", has_selection, |e, p| {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for id in ids {
|
||||
if let Some(c) = s.find_clip_mut(id)
|
||||
&& let ClipContent::Midi { sequence } = &mut c.content
|
||||
{
|
||||
n += sequence.remove_duplicates()
|
||||
}
|
||||
}
|
||||
Ok(json!({"removed": n}))
|
||||
}),
|
||||
cmd!("event.add_key_change", "Add Key Change...", ["Event"], None, "{key: 'C major', at?}", always, |e, p| {
|
||||
let key = str_param(p, "key").unwrap_or("C major").to_string();
|
||||
let at = position_param(e, "event.add_key_change", p, "at")?.unwrap_or(0);
|
||||
let s = e.session_mut();
|
||||
s.key_signatures.retain(|(t, _)| *t != at);
|
||||
s.key_signatures.push((at, key));
|
||||
s.key_signatures.sort_by_key(|k| k.0);
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("event.renumber_bars", "Renumber Bars...", ["Event"], None, "{}", always, |_, _| Ok(json!({"note": "bars start at 1"}))),
|
||||
cmd!("event.all_notes_off", "All MIDI Notes Off", ["Event"], Some("Cmd+Shift+."), "{}", always, |e, _| {
|
||||
e.transport_requests.push(crate::TransportRequest::AllNotesOff);
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("event.midi_note", "Add MIDI Note", [], None, "{clip, pitch, start_ticks, length_ticks, velocity?: 100}", has_selection, add_note),
|
||||
cmd!(
|
||||
"markers.add",
|
||||
"New Memory Location",
|
||||
[],
|
||||
Some("Enter"),
|
||||
"{name?, at?, start?, end?, kind?: marker|selection|none, ruler?: 1..5}",
|
||||
always,
|
||||
add_marker
|
||||
),
|
||||
cmd!("markers.delete", "Delete Memory Location", [], None, "{number | id}", always, delete_marker),
|
||||
cmd!("markers.edit", "Edit Memory Location", [], None, "{number | id, name?, comments?, at?, color?}", always, edit_marker),
|
||||
cmd!(noundo "markers.recall", "Recall Memory Location", [], Some("Period + n + Period"), "{number}", always, recall_marker),
|
||||
cmd!(noundo "markers.next", "Go to Next Marker", [], None, "{}", always, |e, _| step_marker(e, 1)),
|
||||
cmd!(noundo "markers.previous", "Go to Previous Marker", [], None, "{}", always, |e, _| step_marker(e, -1)),
|
||||
]
|
||||
}
|
||||
|
||||
fn tempo(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let bpm = f64_or(p, "bpm", f64::NAN);
|
||||
let at = position_param(e, "event.tempo", p, "at")?.or(position_param(e, "event.tempo", p, "start")?).unwrap_or(0);
|
||||
let s = e.session_mut();
|
||||
let tick = s.tempo.samples_to_ticks(at, s.sample_rate);
|
||||
s.tempo.set_tempo(tick, bpm).map_err(|err| bad("event.tempo", err.to_string()))?;
|
||||
Ok(json!({"tick": tick, "bpm": bpm}))
|
||||
}
|
||||
|
||||
fn meter(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let num = i64_or(p, "numerator", 4).clamp(1, 64) as u32;
|
||||
let den = i64_or(p, "denominator", 4).clamp(1, 64) as u32;
|
||||
let at = position_param(e, "event.meter", p, "at")?.unwrap_or(0);
|
||||
let s = e.session_mut();
|
||||
let tick = s.tempo.samples_to_ticks(at, s.sample_rate);
|
||||
s.tempo.set_meter(tick, num, den).map_err(|err| bad("event.meter", err.to_string()))?;
|
||||
Ok(json!({"meter": format!("{num}/{den}")}))
|
||||
}
|
||||
|
||||
fn insert_time(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let r = range_param(e, "event.insert_time", p)?;
|
||||
if r.is_empty() {
|
||||
return Err(bad("event.insert_time", "give `start` and `length` or `end`"));
|
||||
}
|
||||
let s = e.session_mut();
|
||||
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
|
||||
for t in ids {
|
||||
crate::edit::insert_silence(s, t, r);
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
for l in &mut tr.automation {
|
||||
l.shift_from(r.start, r.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
for m in &mut s.markers {
|
||||
if m.start >= r.start {
|
||||
m.start += r.len();
|
||||
m.end += r.len();
|
||||
}
|
||||
}
|
||||
Ok(json!({"inserted": r.len()}))
|
||||
}
|
||||
|
||||
fn cut_time(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let r = range_param(e, "event.cut_time", p)?;
|
||||
if r.is_empty() {
|
||||
return Err(bad("event.cut_time", "selection is empty"));
|
||||
}
|
||||
let s = e.session_mut();
|
||||
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
|
||||
for t in ids {
|
||||
crate::edit::clear_range(s, t, r, true);
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
for l in &mut tr.automation {
|
||||
l.clear_range(r.start, r.end);
|
||||
l.shift_from(r.end, -r.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
s.markers.retain(|m| !r.contains(m.start));
|
||||
for m in &mut s.markers {
|
||||
if m.start >= r.end {
|
||||
m.start -= r.len();
|
||||
m.end -= r.len();
|
||||
}
|
||||
}
|
||||
Ok(json!({"removed": r.len()}))
|
||||
}
|
||||
|
||||
fn midi_op(e: &mut Engine, p: &Value, f: impl Fn(&mut [soundcraft_midi::Note])) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for id in ids {
|
||||
if let Some(c) = s.find_clip_mut(id)
|
||||
&& let ClipContent::Midi { sequence } = &mut c.content
|
||||
{
|
||||
f(&mut sequence.notes);
|
||||
sequence.sort();
|
||||
n += sequence.notes.len();
|
||||
}
|
||||
}
|
||||
Ok(json!({"notes": n}))
|
||||
}
|
||||
|
||||
fn grid_ticks(p: &Value) -> i64 {
|
||||
match p.get("grid") {
|
||||
Some(Value::Number(n)) => n.as_i64().unwrap_or(240).clamp(1, TICKS_PER_QUARTER * 16),
|
||||
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/8" => TICKS_PER_QUARTER / 2,
|
||||
"1/8t" => TICKS_PER_QUARTER / 3,
|
||||
"1/16t" => TICKS_PER_QUARTER / 6,
|
||||
"1/32" => TICKS_PER_QUARTER / 8,
|
||||
"1/64" => TICKS_PER_QUARTER / 16,
|
||||
_ => TICKS_PER_QUARTER / 4,
|
||||
},
|
||||
_ => TICKS_PER_QUARTER / 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn quantize(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let opts = QuantizeOptions {
|
||||
grid_ticks: grid_ticks(p),
|
||||
strength: (f32_or(p, "strength", 100.0) / 100.0).clamp(0.0, 1.0),
|
||||
swing: (f32_or(p, "swing", 0.0) / 100.0).clamp(0.0, 1.0),
|
||||
..QuantizeOptions::default()
|
||||
};
|
||||
midi_op(e, p, move |n| soundcraft_midi::ops::quantize(n, &opts))
|
||||
}
|
||||
|
||||
fn velocity(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let op = if let Some(v) = p.get("set").and_then(Value::as_u64) {
|
||||
VelocityOp::Set(v.min(127) as u8)
|
||||
} else if let Some(v) = p.get("add").and_then(Value::as_i64) {
|
||||
VelocityOp::Add(v.clamp(-127, 127) as i32)
|
||||
} else if let Some(v) = p.get("scale").and_then(Value::as_f64) {
|
||||
VelocityOp::Scale((v as f32).clamp(0.0, 10.0))
|
||||
} else {
|
||||
VelocityOp::Limit { min: i64_or(p, "min", 1).clamp(1, 127) as u8, max: i64_or(p, "max", 127).clamp(1, 127) as u8 }
|
||||
};
|
||||
midi_op(e, p, move |n| soundcraft_midi::ops::change_velocity(n, op))
|
||||
}
|
||||
|
||||
fn duration(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let op = if let Some(v) = p.get("set").and_then(Value::as_i64) {
|
||||
DurationOp::Set(v.max(1))
|
||||
} else if let Some(v) = p.get("add").and_then(Value::as_i64) {
|
||||
DurationOp::Add(v)
|
||||
} else if let Some(v) = p.get("scale").and_then(Value::as_f64) {
|
||||
DurationOp::Scale((v as f32).clamp(0.01, 100.0))
|
||||
} else {
|
||||
DurationOp::Legato { gap: i64_or(p, "legato", 0).max(0) }
|
||||
};
|
||||
midi_op(e, p, move |n| soundcraft_midi::ops::change_duration(n, op))
|
||||
}
|
||||
|
||||
fn add_note(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let id = *ids.first().ok_or_else(|| bad("event.midi_note", "`clip` required"))?;
|
||||
let note = soundcraft_midi::Note {
|
||||
pitch: i64_or(p, "pitch", 60).clamp(0, 127) as u8,
|
||||
velocity: i64_or(p, "velocity", 100).clamp(1, 127) as u8,
|
||||
release_velocity: 64,
|
||||
channel: i64_or(p, "channel", 0).clamp(0, 15) as u8,
|
||||
start: i64_or(p, "start_ticks", 0).max(0),
|
||||
length: i64_or(p, "length_ticks", TICKS_PER_QUARTER).max(1),
|
||||
};
|
||||
let s = e.session_mut();
|
||||
match s.find_clip_mut(id).map(|c| &mut c.content) {
|
||||
Some(ClipContent::Midi { sequence }) => {
|
||||
sequence.notes.push(note);
|
||||
sequence.sort();
|
||||
Ok(json!({"notes": sequence.notes.len()}))
|
||||
}
|
||||
_ => Err(bad("event.midi_note", "not a MIDI clip")),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_marker(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let sel = e.session().edit.selection;
|
||||
let at = position_param(e, "markers.add", p, "at")?;
|
||||
let st = position_param(e, "markers.add", p, "start")?;
|
||||
let en = position_param(e, "markers.add", p, "end")?;
|
||||
let kind = match str_param(p, "kind") {
|
||||
Some("selection") => MarkerKind::Selection,
|
||||
Some("none") => MarkerKind::None,
|
||||
_ => MarkerKind::Marker,
|
||||
};
|
||||
let (a, b) = match (at, st, en) {
|
||||
(Some(a), _, _) => (a, a),
|
||||
(None, Some(a), Some(b)) => (a, b),
|
||||
_ if kind == MarkerKind::Selection => (sel.start, sel.end),
|
||||
_ => (if e.transport.playing { e.transport.position } else { sel.start }, sel.start),
|
||||
};
|
||||
let s = e.session_mut();
|
||||
let n = s.markers.len() + 1;
|
||||
let name = str_param(p, "name").map_or_else(|| format!("Marker {n}"), str::to_string);
|
||||
let id = s.add_marker(&name, kind, a.max(0), b.max(a).max(0));
|
||||
if let Some(m) = s.markers.iter_mut().find(|m| m.id == id) {
|
||||
m.ruler = i64_or(p, "ruler", 1).clamp(1, 5) as u8;
|
||||
}
|
||||
let number = s.marker(id).map_or(0, |m| m.number);
|
||||
Ok(json!({"id": id, "number": number}))
|
||||
}
|
||||
|
||||
fn marker_index(e: &Engine, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let s = e.session();
|
||||
let idx = if let Some(n) = p.get("number").and_then(Value::as_u64) {
|
||||
s.markers.iter().position(|m| u64::from(m.number) == n)
|
||||
} else if let Some(n) = p.get("id").and_then(Value::as_u64) {
|
||||
s.markers.iter().position(|m| m.id.0 == n)
|
||||
} else if let Some(name) = str_param(p, "name") {
|
||||
s.markers.iter().position(|m| m.name == name)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
idx.ok_or_else(|| bad(cmd, "unknown memory location (give `number`, `id` or `name`)"))
|
||||
}
|
||||
|
||||
fn delete_marker(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let i = marker_index(e, p, "markers.delete")?;
|
||||
e.session_mut().markers.remove(i);
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn edit_marker(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let i = marker_index(e, p, "markers.edit")?;
|
||||
let at = position_param(e, "markers.edit", p, "at")?;
|
||||
let s = e.session_mut();
|
||||
let Some(m) = s.markers.get_mut(i) else { return Err(bad("markers.edit", "gone")) };
|
||||
if let Some(n) = p.get("rename").and_then(Value::as_str) {
|
||||
m.name = n.to_string();
|
||||
}
|
||||
if let Some(c) = str_param(p, "comments") {
|
||||
m.comments = c.to_string();
|
||||
}
|
||||
if let Some(a) = at {
|
||||
let len = m.end - m.start;
|
||||
m.start = a.max(0);
|
||||
m.end = m.start + len;
|
||||
}
|
||||
s.markers.sort_by_key(|m| m.start);
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn recall_marker(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let i = marker_index(e, p, "markers.recall")?;
|
||||
let s = e.session();
|
||||
let Some(m) = s.markers.get(i).cloned() else { return Err(bad("markers.recall", "gone")) };
|
||||
let s = e.session_mut();
|
||||
match m.kind {
|
||||
MarkerKind::Marker => s.edit.selection = soundcraft_time::Range::point(m.start),
|
||||
MarkerKind::Selection => s.edit.selection = soundcraft_time::Range::new(m.start, m.end),
|
||||
MarkerKind::None => {}
|
||||
}
|
||||
s.edit.playhead = m.start;
|
||||
e.transport_requests.push(crate::TransportRequest::Locate(m.start));
|
||||
Ok(json!({"at": m.start}))
|
||||
}
|
||||
|
||||
fn step_marker(e: &mut Engine, dir: i64) -> Result<Value> {
|
||||
let cur = e.session().edit.selection.start;
|
||||
let s = e.session();
|
||||
let target = if dir > 0 {
|
||||
s.markers.iter().map(|m| m.start).filter(|t| *t > cur).min()
|
||||
} else {
|
||||
s.markers.iter().map(|m| m.start).filter(|t| *t < cur).max()
|
||||
};
|
||||
let Some(t) = target else { return Ok(json!({"moved": false})) };
|
||||
let s = e.session_mut();
|
||||
s.edit.selection = soundcraft_time::Range::point(t);
|
||||
s.edit.playhead = t;
|
||||
e.transport_requests.push(crate::TransportRequest::Locate(t));
|
||||
Ok(json!({"at": t}))
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! File menu commands.
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::Session;
|
||||
use soundcraft_time::SampleRate;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "session.new", "New...", ["File"], Some("Cmd+N"), "{name?: 'Untitled', sample_rate?: 48000, bit_depth?: 24, template?: blank|demo}", always, new_session),
|
||||
cmd!(noundo "session.open", "Open Session...", ["File"], Some("Cmd+O"), "{path}", always, |e, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("session.open", "`path` required"))?.to_string();
|
||||
crate::io::open_session(e, &path)?;
|
||||
Ok(json!({"name": e.session().name, "tracks": e.session().tracks.len()}))
|
||||
}),
|
||||
cmd!(noundo "session.close", "Close Session", ["File"], Some("Cmd+Shift+W"), "{}", always, |e, _| { e.replace_session(Session::default()); e.path = None; Ok(json!({})) }),
|
||||
cmd!(noundo "session.save", "Save Session", ["File"], Some("Cmd+S"), "{path?}", always, |e, p| {
|
||||
let path = str_param(p, "path").map(str::to_string).or_else(|| e.path.clone()).ok_or_else(|| bad("session.save", "no path yet: pass `path` (Save As)"))?;
|
||||
let written = crate::io::save_session(e, &path)?;
|
||||
Ok(json!({"path": path, "audio_files_written": written}))
|
||||
}),
|
||||
cmd!(noundo "session.save_as", "Save Session As...", ["File"], Some("Cmd+Shift+S"), "{path}", always, |e, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("session.save_as", "`path` required"))?.to_string();
|
||||
let written = crate::io::save_session(e, &path)?;
|
||||
Ok(json!({"path": path, "audio_files_written": written}))
|
||||
}),
|
||||
cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path}", always, |e, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("session.save_copy", "`path` required"))?.to_string();
|
||||
let keep = e.path.clone();
|
||||
let written = crate::io::save_session(e, &path)?;
|
||||
e.path = keep;
|
||||
Ok(json!({"path": path, "audio_files_written": written}))
|
||||
}),
|
||||
cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path}", always, |e, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("session.save_template", "`path` required"))?.to_string();
|
||||
let mut s = e.session().clone();
|
||||
for t in &mut s.tracks { for pl in &mut t.playlists { pl.clips.clear() } }
|
||||
s.sources.clear();
|
||||
let text = s.to_json().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}))
|
||||
}),
|
||||
cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", always, |e, _| {
|
||||
let path = e.path.clone().ok_or_else(|| bad("session.revert", "the session has not been saved"))?;
|
||||
crate::io::open_session(e, &path)?;
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!(
|
||||
"file.import_audio",
|
||||
"Audio...",
|
||||
["File", "Import"],
|
||||
Some("Cmd+Shift+I"),
|
||||
"{path | paths: [..], track?: target track, at?: position, new_tracks?: true}",
|
||||
always,
|
||||
import_audio
|
||||
),
|
||||
cmd!("file.import_midi", "MIDI...", ["File", "Import"], Some("Cmd+Alt+I"), "{path, at?}", always, |e, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.import_midi", "`path` required"))?.to_string();
|
||||
let at = position_param(e, "file.import_midi", p, "at")?.unwrap_or(0);
|
||||
let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let tracks = crate::io::import_midi_bytes(e, &bytes, at, bool_or(p, "tempo_map", true))?;
|
||||
Ok(json!({"tracks": tracks}))
|
||||
}),
|
||||
cmd!(noundo "file.bounce_mix", "Bounce Mix...", ["File"], Some("Cmd+Alt+B"), "{path, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, source?: main|bus name, normalize?: false, dither?: true}", always, bounce),
|
||||
cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string();
|
||||
let bytes = crate::io::export_midi(e)?;
|
||||
std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}),
|
||||
cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?}", always, |e, p| {
|
||||
let text = crate::inspect::session_text(e);
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
std::fs::write(path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
}
|
||||
Ok(json!({"text": text}))
|
||||
}),
|
||||
cmd!(noundo "file.export_clips", "Export Clips as Files...", [], None, "{dir, clips?, format?: wav}", always, |e, p| {
|
||||
let dir = str_param(p, "dir").ok_or_else(|| bad("file.export_clips", "`dir` required"))?.to_string();
|
||||
let ids = clip_ids_param(e, p);
|
||||
let n = crate::io::export_clips(e, &ids, &dir)?;
|
||||
Ok(json!({"written": n}))
|
||||
}),
|
||||
cmd!(noundo "file.export_selected_tracks_as_session", "Selected Tracks as New Session...", ["File", "Export"], None, "{path, tracks?}", has_selection, |e, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.export_selected_tracks_as_session", "`path` required"))?.to_string();
|
||||
let keep = tracks_required(e, "file.export_selected_tracks_as_session", p)?;
|
||||
let mut s = e.session().clone();
|
||||
s.tracks.retain(|t| keep.contains(&t.id) || t.kind == soundcraft_model::TrackKind::Master);
|
||||
let mut tmp = crate::Engine::new(s);
|
||||
crate::io::save_session(&mut tmp, &path)?;
|
||||
Ok(json!({"path": path}))
|
||||
}),
|
||||
cmd!(query "file.get_info", "Get Info...", ["File"], None, "{}", always, |e, _| Ok(crate::inspect::session(e, false))),
|
||||
cmd!(noundo "app.quit", "Quit", [], Some("Cmd+Q"), "{}", always, |_, _| Ok(json!({"quit": true}))),
|
||||
]
|
||||
}
|
||||
|
||||
fn new_session(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").unwrap_or("Untitled").to_string();
|
||||
let sr =
|
||||
SampleRate::new(i64_or(p, "sample_rate", 48_000).clamp(0, i64::from(u32::MAX)) as u32).map_err(|err| bad("session.new", err.to_string()))?;
|
||||
let mut s = if str_param(p, "template") == Some("demo") { crate::demo::demo_session() } else { Session::new(name.clone(), sr) };
|
||||
s.name = name;
|
||||
if let Some(b) = p.get("bit_depth") {
|
||||
s.bit_depth = match b.to_string().trim_matches('"') {
|
||||
"16" => soundcraft_model::BitDepthSetting::Int16,
|
||||
"32" | "32f" => soundcraft_model::BitDepthSetting::Float32,
|
||||
_ => soundcraft_model::BitDepthSetting::Int24,
|
||||
};
|
||||
}
|
||||
e.replace_session(s);
|
||||
e.path = None;
|
||||
Ok(json!({"name": e.session().name, "sample_rate": e.session().sample_rate.hz()}))
|
||||
}
|
||||
|
||||
fn import_audio(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let mut paths: Vec<String> =
|
||||
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
|
||||
if let Some(one) = str_param(p, "path") {
|
||||
paths.push(one.to_string());
|
||||
}
|
||||
if paths.is_empty() {
|
||||
return Err(bad("file.import_audio", "`path` or `paths` required"));
|
||||
}
|
||||
let at = position_param(e, "file.import_audio", p, "at")?.unwrap_or(0).max(0);
|
||||
let target = track_param(e, "file.import_audio", p, "track")?;
|
||||
let mut out = Vec::new();
|
||||
for path in &paths {
|
||||
let bytes = std::fs::read(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("Audio").to_string();
|
||||
let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?;
|
||||
out.push(r);
|
||||
}
|
||||
Ok(json!({"imported": out}))
|
||||
}
|
||||
|
||||
fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string();
|
||||
let r = range_param(e, "file.bounce_mix", p)?;
|
||||
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
|
||||
let ext = std::path::Path::new(&path).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase();
|
||||
let format = match str_param(p, "format").unwrap_or(ext.as_str()) {
|
||||
"aif" | "aiff" => soundcraft_audio_io::FileFormat::Aiff,
|
||||
"flac" => soundcraft_audio_io::FileFormat::Flac,
|
||||
_ => soundcraft_audio_io::FileFormat::Wav,
|
||||
};
|
||||
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
|
||||
Some("16") => soundcraft_audio_io::BitDepth::Int16,
|
||||
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
|
||||
_ => soundcraft_audio_io::BitDepth::Int24,
|
||||
};
|
||||
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None };
|
||||
let normalize = bool_or(p, "normalize", false);
|
||||
let (bytes, stats) = crate::io::bounce_bytes(e, r, &opts, normalize)?;
|
||||
std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1}))
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
//! Mixer, insert, send and automation commands.
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{AutoParam, AutomationMode, INSERT_SLOTS, Insert, SEND_SLOTS, SendSlot, Session, TrackId};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("mix.volume", "Set Volume", [], None, "{tracks?, db: -144..12 | delta_db}", has_selection, volume),
|
||||
cmd!("mix.pan", "Set Pan", [], None, "{tracks?, pan: -1..1, index?: 0|1}", has_selection, pan),
|
||||
cmd!("mix.mute", "Mute", [], Some("Shift+M"), "{tracks?, value?: bool}", has_selection, |e, p| toggle(
|
||||
e,
|
||||
p,
|
||||
"mix.mute",
|
||||
|s, t, v| set_grouped(s, t, "mute", |tr| tr.mixer.mute = v),
|
||||
|tr| tr.mixer.mute
|
||||
)),
|
||||
cmd!("mix.solo", "Solo", [], Some("Shift+S"), "{tracks?, value?: bool, exclusive?: bool}", has_selection, solo),
|
||||
cmd!("mix.solo_safe", "Solo Safe", [], None, "{tracks?, value?: bool}", has_selection, |e, p| toggle(
|
||||
e,
|
||||
p,
|
||||
"mix.solo_safe",
|
||||
|s, t, v| {
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
tr.mixer.solo_safe = v
|
||||
}
|
||||
},
|
||||
|tr| tr.mixer.solo_safe
|
||||
)),
|
||||
cmd!("mix.clear_solos", "Clear All Solos", [], None, "{}", always, |e, _| {
|
||||
for t in &mut e.session_mut().tracks {
|
||||
t.mixer.solo = false
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("mix.clear_mutes", "Clear All Mutes", [], None, "{}", always, |e, _| {
|
||||
for t in &mut e.session_mut().tracks {
|
||||
t.mixer.mute = false
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("mix.record_arm", "Record Enable", [], Some("Shift+R"), "{tracks?, value?: bool}", has_selection, |e, p| toggle(
|
||||
e,
|
||||
p,
|
||||
"mix.record_arm",
|
||||
|s, t, v| set_grouped(s, t, "record", |tr| if tr.kind.has_playlist() {
|
||||
tr.mixer.record_arm = v
|
||||
}),
|
||||
|tr| tr.mixer.record_arm
|
||||
)),
|
||||
cmd!("mix.input_monitor", "Input Monitoring", [], Some("Shift+I"), "{tracks?, value?: bool}", has_selection, |e, p| toggle(
|
||||
e,
|
||||
p,
|
||||
"mix.input_monitor",
|
||||
|s, t, v| {
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
tr.mixer.input_monitor = v
|
||||
}
|
||||
},
|
||||
|tr| tr.mixer.input_monitor
|
||||
)),
|
||||
cmd!("mix.phase_invert", "Phase Invert", [], None, "{tracks?, value?: bool}", has_selection, |e, p| toggle(
|
||||
e,
|
||||
p,
|
||||
"mix.phase_invert",
|
||||
|s, t, v| {
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
tr.mixer.phase_invert = v
|
||||
}
|
||||
},
|
||||
|tr| tr.mixer.phase_invert
|
||||
)),
|
||||
cmd!("mix.trim", "Input Trim", [], None, "{tracks?, db}", has_selection, |e, p| {
|
||||
let tracks = tracks_required(e, "mix.trim", p)?;
|
||||
let db = f32_or(p, "db", 0.0).clamp(-144.0, 24.0);
|
||||
let s = e.session_mut();
|
||||
for t in tracks {
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
tr.mixer.trim_db = db
|
||||
}
|
||||
}
|
||||
Ok(json!({"db": db}))
|
||||
}),
|
||||
cmd!(
|
||||
"mix.automation_mode",
|
||||
"Automation Mode",
|
||||
[],
|
||||
None,
|
||||
"{tracks?, mode: off|read|touch|latch|touch/latch|write|trim}",
|
||||
has_selection,
|
||||
automation_mode
|
||||
),
|
||||
cmd!("mix.insert", "Insert Plugin", [], None, "{track?, slot?: 0..9 | a..j, plugin: id, params?: {id: value}}", has_selection, insert),
|
||||
cmd!("mix.insert_remove", "Remove Insert", [], None, "{track?, slot}", has_selection, insert_remove),
|
||||
cmd!("mix.insert_bypass", "Bypass Insert", [], None, "{track?, slot, value?: bool}", has_selection, insert_bypass),
|
||||
cmd!("mix.insert_param", "Set Plugin Parameter", [], None, "{track?, slot, param, value}", has_selection, insert_param),
|
||||
cmd!("mix.insert_move", "Move Insert", [], None, "{track?, from, to}", has_selection, insert_move),
|
||||
cmd!("mix.send", "Assign Send", [], None, "{track?, slot?: 0..9|a..j, bus: name, level_db?: 0, pre_fader?: false}", has_selection, send),
|
||||
cmd!("mix.send_remove", "Remove Send", [], None, "{track?, slot}", has_selection, |e, p| {
|
||||
let (t, slot) = track_slot(e, p, "mix.send_remove", SEND_SLOTS)?;
|
||||
if let Some(tr) = e.session_mut().track_mut(t)
|
||||
&& let Some(s) = tr.mixer.sends.get_mut(slot)
|
||||
{
|
||||
*s = None
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("mix.send_level", "Send Level", [], None, "{track?, slot, db?, pan?, mute?, pre_fader?}", has_selection, send_level),
|
||||
cmd!("mix.new_bus", "New Bus", [], None, "{name, format?: stereo}", always, |e, p| {
|
||||
let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).ok_or_else(|| bad("mix.new_bus", "`name` required"))?.to_string();
|
||||
let fmt = str_param(p, "format").and_then(soundcraft_model::ChannelFormat::from_id).unwrap_or(soundcraft_model::ChannelFormat::Stereo);
|
||||
let id = e.session_mut().add_bus(&name, fmt);
|
||||
Ok(json!({"bus": id}))
|
||||
}),
|
||||
cmd!(
|
||||
"automation.set_point",
|
||||
"Add Automation Breakpoint",
|
||||
[],
|
||||
None,
|
||||
"{track?, param: volume|pan|mute|send_a_level|plugin:a:<id>, at, value}",
|
||||
has_selection,
|
||||
set_point
|
||||
),
|
||||
cmd!("automation.write_range", "Write Automation", [], None, "{track?, param, start?, end?, value}", has_selection, write_range),
|
||||
cmd!("automation.clear", "Clear Automation", [], None, "{track?, param?, start?, end?}", has_selection, clear_lane),
|
||||
cmd!("automation.thin", "Thin", ["Edit", "Automation"], Some("Cmd+Alt+T"), "{tracks?, tolerance?: 0.1}", has_selection, |e, p| thin(
|
||||
e, p, false
|
||||
)),
|
||||
cmd!("automation.thin_all", "Thin All", ["Edit", "Automation"], None, "{tolerance?: 0.1}", has_tracks, |e, p| thin(e, p, true)),
|
||||
cmd!("automation.write_to_current", "Write to Current", ["Edit", "Automation"], Some("Cmd+/"), "{tracks?}", has_range, |e, p| write_current(
|
||||
e, p, false
|
||||
)),
|
||||
cmd!("automation.write_to_all", "Write to All Enabled", ["Edit", "Automation"], Some("Cmd+Alt+/"), "{tracks?}", has_range, |e, p| {
|
||||
write_current(e, p, true)
|
||||
}),
|
||||
cmd!(
|
||||
"automation.volume_to_clip_gain",
|
||||
"Convert Volume Automation to Clip Gain",
|
||||
["Edit", "Automation"],
|
||||
None,
|
||||
"{tracks?}",
|
||||
has_range,
|
||||
vol_to_clip_gain
|
||||
),
|
||||
cmd!(
|
||||
"automation.clip_gain_to_volume",
|
||||
"Convert Clip Gain to Volume Automation",
|
||||
["Edit", "Automation"],
|
||||
None,
|
||||
"{tracks?}",
|
||||
has_range,
|
||||
clip_gain_to_vol
|
||||
),
|
||||
cmd!(
|
||||
"automation.copy_to_send",
|
||||
"Copy to Send...",
|
||||
["Edit", "Automation"],
|
||||
Some("Cmd+Alt+H"),
|
||||
"{tracks?, send: slot}",
|
||||
has_selection,
|
||||
copy_to_send
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Apply a change to `t` and its active mix-group siblings.
|
||||
fn set_grouped(s: &mut Session, t: TrackId, attr: &str, f: impl Fn(&mut soundcraft_model::Track)) {
|
||||
let mut targets = vec![t];
|
||||
for g in &s.groups {
|
||||
if g.active && g.mix && g.members.contains(&t) && g.attributes.iter().any(|a| a == attr) {
|
||||
for m in &g.members {
|
||||
if !targets.contains(m) {
|
||||
targets.push(*m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in targets {
|
||||
if let Some(tr) = s.track_mut(id) {
|
||||
f(tr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle(e: &mut Engine, p: &Value, cmd: &str, set: fn(&mut Session, TrackId, bool), get: fn(&soundcraft_model::Track) -> bool) -> Result<Value> {
|
||||
let tracks = tracks_required(e, cmd, p)?;
|
||||
let s = e.session_mut();
|
||||
let v = p.get("value").and_then(Value::as_bool).unwrap_or_else(|| !tracks.iter().all(|t| s.track(*t).is_some_and(get)));
|
||||
for t in &tracks {
|
||||
set(s, *t, v);
|
||||
}
|
||||
Ok(json!({"value": v}))
|
||||
}
|
||||
|
||||
fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "mix.volume", p)?;
|
||||
let abs = p.get("db").and_then(Value::as_f64);
|
||||
let delta = p.get("delta_db").and_then(Value::as_f64);
|
||||
if abs.is_none() && delta.is_none() {
|
||||
return Err(bad("mix.volume", "`db` or `delta_db` required"));
|
||||
}
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
let cur = s.track(*t).map_or(0.0, |tr| tr.mixer.volume_db);
|
||||
let new = match (abs, delta) {
|
||||
(Some(a), _) => a as f32,
|
||||
(None, Some(d)) => cur + d as f32,
|
||||
_ => cur,
|
||||
};
|
||||
let new = if new.is_finite() { new.clamp(-144.0, 12.0) } else { cur };
|
||||
let d = new - cur;
|
||||
set_grouped(s, *t, "volume", |tr| tr.mixer.volume_db = if tr.id == *t { new } else { (tr.mixer.volume_db + d).clamp(-144.0, 12.0) });
|
||||
}
|
||||
Ok(json!({"tracks": tracks.len()}))
|
||||
}
|
||||
|
||||
fn pan(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "mix.pan", p)?;
|
||||
let v = f32_or(p, "pan", 0.0).clamp(-1.0, 1.0);
|
||||
let idx = i64_or(p, "index", 0).clamp(0, 1) as usize;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t)
|
||||
&& let Some(slot) = tr.mixer.pan.get_mut(idx)
|
||||
{
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
Ok(json!({"pan": v}))
|
||||
}
|
||||
|
||||
fn solo(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "mix.solo", p)?;
|
||||
let exclusive = bool_or(p, "exclusive", e.session().edit.solo_mode.contains("xor"));
|
||||
let s = e.session_mut();
|
||||
let v = p.get("value").and_then(Value::as_bool).unwrap_or_else(|| !tracks.iter().all(|t| s.track(*t).is_some_and(|tr| tr.mixer.solo)));
|
||||
if exclusive && v {
|
||||
for tr in &mut s.tracks {
|
||||
tr.mixer.solo = false;
|
||||
}
|
||||
}
|
||||
for t in &tracks {
|
||||
set_grouped(s, *t, "solo", |tr| tr.mixer.solo = v);
|
||||
}
|
||||
Ok(json!({"value": v}))
|
||||
}
|
||||
|
||||
fn automation_mode(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "mix.automation_mode", p)?;
|
||||
let m = str_param(p, "mode").and_then(AutomationMode::from_id).ok_or_else(|| bad("mix.automation_mode", "unknown mode"))?;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
tr.mixer.automation_mode = m;
|
||||
}
|
||||
}
|
||||
Ok(json!({"mode": m}))
|
||||
}
|
||||
|
||||
fn slot_of(p: &Value, key: &str, max: usize) -> Option<usize> {
|
||||
let v = p.get(key)?;
|
||||
let i = if let Some(n) = v.as_u64() {
|
||||
n as usize
|
||||
} else {
|
||||
let s = v.as_str()?.trim().to_ascii_lowercase();
|
||||
let c = s.chars().next()?;
|
||||
if s.len() == 1 && c.is_ascii_lowercase() { (c as u8 - b'a') as usize } else { s.parse().ok()? }
|
||||
};
|
||||
(i < max).then_some(i)
|
||||
}
|
||||
|
||||
fn track_slot(e: &Engine, p: &Value, cmd: &str, max: usize) -> Result<(TrackId, usize)> {
|
||||
let t = tracks_required(e, cmd, p)?.first().copied().ok_or_else(|| bad(cmd, "no track"))?;
|
||||
let slot = slot_of(p, "slot", max).ok_or_else(|| bad(cmd, format!("`slot` must be 0..{} or a..{}", max - 1, (b'a' + max as u8 - 1) as char)))?;
|
||||
Ok((t, slot))
|
||||
}
|
||||
|
||||
fn insert(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let plugin = str_param(p, "plugin").ok_or_else(|| bad("mix.insert", "`plugin` required"))?.to_string();
|
||||
let info = soundcraft_dsp::plugin_info(&plugin).ok_or_else(|| bad("mix.insert", format!("unknown plugin `{plugin}`")))?;
|
||||
let t = tracks_required(e, "mix.insert", p)?.first().copied().ok_or_else(|| bad("mix.insert", "no track"))?;
|
||||
let explicit = slot_of(p, "slot", INSERT_SLOTS);
|
||||
let s = e.session_mut();
|
||||
let tr = s.track_mut(t).ok_or_else(|| bad("mix.insert", "no track"))?;
|
||||
let slot =
|
||||
explicit.or_else(|| tr.mixer.inserts.iter().position(Option::is_none)).ok_or_else(|| bad("mix.insert", "all insert slots are in use"))?;
|
||||
let mut ins = Insert::new(info.id);
|
||||
for pi in info.params {
|
||||
ins.params.insert(pi.id.to_string(), pi.default);
|
||||
}
|
||||
if let Some(obj) = p.get("params").and_then(Value::as_object) {
|
||||
for (k, v) in obj {
|
||||
let Some(pi) = info.params.iter().find(|x| x.id == k) else {
|
||||
return Err(bad("mix.insert", format!("`{plugin}` has no parameter `{k}`")));
|
||||
};
|
||||
let val = v.as_f64().map_or(pi.default, |f| (f as f32).clamp(pi.min, pi.max));
|
||||
ins.params.insert(k.clone(), val);
|
||||
}
|
||||
}
|
||||
if let Some(sl) = tr.mixer.inserts.get_mut(slot) {
|
||||
*sl = Some(ins);
|
||||
}
|
||||
Ok(json!({"track": t, "slot": slot, "plugin": info.id}))
|
||||
}
|
||||
|
||||
fn insert_remove(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let (t, slot) = track_slot(e, p, "mix.insert_remove", INSERT_SLOTS)?;
|
||||
if let Some(tr) = e.session_mut().track_mut(t)
|
||||
&& let Some(s) = tr.mixer.inserts.get_mut(slot)
|
||||
{
|
||||
*s = None;
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn insert_bypass(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let (t, slot) = track_slot(e, p, "mix.insert_bypass", INSERT_SLOTS)?;
|
||||
let val = p.get("value").and_then(Value::as_bool);
|
||||
let s = e.session_mut();
|
||||
let ins = s
|
||||
.track_mut(t)
|
||||
.and_then(|tr| tr.mixer.inserts.get_mut(slot))
|
||||
.and_then(Option::as_mut)
|
||||
.ok_or_else(|| bad("mix.insert_bypass", "empty slot"))?;
|
||||
ins.bypass = val.unwrap_or(!ins.bypass);
|
||||
Ok(json!({"bypass": ins.bypass}))
|
||||
}
|
||||
|
||||
fn insert_param(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let (t, slot) = track_slot(e, p, "mix.insert_param", INSERT_SLOTS)?;
|
||||
let param = str_param(p, "param").ok_or_else(|| bad("mix.insert_param", "`param` required"))?.to_string();
|
||||
let value =
|
||||
p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("mix.insert_param", "`value` must be a number"))? as f32;
|
||||
let s = e.session_mut();
|
||||
let ins =
|
||||
s.track_mut(t).and_then(|tr| tr.mixer.inserts.get_mut(slot)).and_then(Option::as_mut).ok_or_else(|| bad("mix.insert_param", "empty slot"))?;
|
||||
let info = soundcraft_dsp::plugin_info(&ins.plugin).ok_or_else(|| bad("mix.insert_param", "unknown plugin"))?;
|
||||
let pi =
|
||||
info.params.iter().find(|x| x.id == param).ok_or_else(|| bad("mix.insert_param", format!("`{}` has no parameter `{param}`", info.id)))?;
|
||||
let v = value.clamp(pi.min, pi.max);
|
||||
ins.params.insert(param, v);
|
||||
Ok(json!({"value": v}))
|
||||
}
|
||||
|
||||
fn insert_move(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let t = tracks_required(e, "mix.insert_move", p)?.first().copied().ok_or_else(|| bad("mix.insert_move", "no track"))?;
|
||||
let from = slot_of(p, "from", INSERT_SLOTS).ok_or_else(|| bad("mix.insert_move", "`from` slot"))?;
|
||||
let to = slot_of(p, "to", INSERT_SLOTS).ok_or_else(|| bad("mix.insert_move", "`to` slot"))?;
|
||||
if let Some(tr) = e.session_mut().track_mut(t) {
|
||||
tr.mixer.inserts.swap(from, to);
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn send(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let bus = str_param(p, "bus").ok_or_else(|| bad("mix.send", "`bus` required"))?.to_string();
|
||||
let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?;
|
||||
let explicit = slot_of(p, "slot", SEND_SLOTS);
|
||||
let level = f32_or(p, "level_db", 0.0).clamp(-144.0, 12.0);
|
||||
let pre = bool_or(p, "pre_fader", false);
|
||||
let s = e.session_mut();
|
||||
let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo);
|
||||
let tr = s.track_mut(t).ok_or_else(|| bad("mix.send", "no track"))?;
|
||||
let slot = explicit.or_else(|| tr.mixer.sends.iter().position(Option::is_none)).ok_or_else(|| bad("mix.send", "all send slots are in use"))?;
|
||||
let mut sl = SendSlot::new(soundcraft_model::Route::Bus(bid));
|
||||
sl.level_db = level;
|
||||
sl.pre_fader = pre;
|
||||
if let Some(x) = tr.mixer.sends.get_mut(slot) {
|
||||
*x = Some(sl);
|
||||
}
|
||||
Ok(json!({"slot": slot, "bus": bid}))
|
||||
}
|
||||
|
||||
fn send_level(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let (t, slot) = track_slot(e, p, "mix.send_level", SEND_SLOTS)?;
|
||||
let s = e.session_mut();
|
||||
let sl = s
|
||||
.track_mut(t)
|
||||
.and_then(|tr| tr.mixer.sends.get_mut(slot))
|
||||
.and_then(Option::as_mut)
|
||||
.ok_or_else(|| bad("mix.send_level", "empty send slot"))?;
|
||||
if let Some(db) = p.get("db").and_then(Value::as_f64) {
|
||||
sl.level_db = (db as f32).clamp(-144.0, 12.0);
|
||||
}
|
||||
if let Some(v) = p.get("pan").and_then(Value::as_f64) {
|
||||
sl.pan = (v as f32).clamp(-1.0, 1.0);
|
||||
}
|
||||
if let Some(m) = p.get("mute").and_then(Value::as_bool) {
|
||||
sl.mute = m;
|
||||
}
|
||||
if let Some(m) = p.get("pre_fader").and_then(Value::as_bool) {
|
||||
sl.pre_fader = m;
|
||||
}
|
||||
Ok(json!({"send": sl.clone()}))
|
||||
}
|
||||
|
||||
fn lane_param(p: &Value, cmd: &str) -> Result<AutoParam> {
|
||||
str_param(p, "param").map_or(Ok(AutoParam::Volume), |s| AutoParam::parse(s).ok_or_else(|| bad(cmd, format!("unknown automation param `{s}`"))))
|
||||
}
|
||||
|
||||
fn set_point(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "automation.set_point", p)?;
|
||||
let param = lane_param(p, "automation.set_point")?;
|
||||
let at = position_param(e, "automation.set_point", p, "at")?.unwrap_or(e.session().edit.selection.start).max(0);
|
||||
let v = p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("automation.set_point", "`value` required"))? as f32;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
tr.lane_mut(¶m).set_point(at, v);
|
||||
}
|
||||
}
|
||||
Ok(json!({"at": at, "value": v}))
|
||||
}
|
||||
|
||||
fn write_range(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "automation.write_range", p)?;
|
||||
let param = lane_param(p, "automation.write_range")?;
|
||||
let r = range_param(e, "automation.write_range", p)?;
|
||||
let v = p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("automation.write_range", "`value` required"))? as f32;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
let def = current_value(tr, ¶m);
|
||||
tr.lane_mut(¶m).write_range(r.start, r.end, v, def);
|
||||
}
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
/// The static (non-automated) value of a parameter on a track.
|
||||
pub(crate) fn current_value(tr: &soundcraft_model::Track, param: &AutoParam) -> f32 {
|
||||
match param {
|
||||
AutoParam::Volume => tr.mixer.volume_db,
|
||||
AutoParam::Pan(i) => tr.mixer.pan.get(usize::from(*i)).copied().unwrap_or(0.0),
|
||||
AutoParam::Mute => f32::from(u8::from(tr.mixer.mute)),
|
||||
AutoParam::SendLevel(i) => tr.mixer.sends.get(usize::from(*i)).and_then(Option::as_ref).map_or(-144.0, |s| s.level_db),
|
||||
AutoParam::SendPan(i) => tr.mixer.sends.get(usize::from(*i)).and_then(Option::as_ref).map_or(0.0, |s| s.pan),
|
||||
AutoParam::SendMute(i) => tr.mixer.sends.get(usize::from(*i)).and_then(Option::as_ref).map_or(0.0, |s| f32::from(u8::from(s.mute))),
|
||||
AutoParam::Plugin { slot, param } => {
|
||||
tr.mixer.inserts.get(usize::from(*slot)).and_then(Option::as_ref).and_then(|i| i.params.get(param).copied()).unwrap_or(0.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_lane(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "automation.clear", p)?;
|
||||
let param = str_param(p, "param").and_then(AutoParam::parse);
|
||||
let r = range_param(e, "automation.clear", p)?;
|
||||
let (a, b) = if r.is_empty() { (i64::MIN, i64::MAX) } else { (r.start, r.end) };
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
for l in &mut tr.automation {
|
||||
if param.as_ref().is_none_or(|x| x == &l.param) {
|
||||
n += l.clear_range(a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"removed": n}))
|
||||
}
|
||||
|
||||
fn thin(e: &mut Engine, p: &Value, all: bool) -> Result<Value> {
|
||||
let tracks = if all { e.session().tracks.iter().map(|t| t.id).collect() } else { tracks_required(e, "automation.thin", p)? };
|
||||
let tol = f32_or(p, "tolerance", 0.1).clamp(0.0, 100.0);
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
for l in &mut tr.automation {
|
||||
n += l.thin(tol);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"removed": n}))
|
||||
}
|
||||
|
||||
fn write_current(e: &mut Engine, p: &Value, all: bool) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "automation.write_to_current", p)?;
|
||||
let r = range_param(e, "automation.write_to_current", p)?;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
let params: Vec<AutoParam> = if all {
|
||||
let mut v = vec![AutoParam::Volume, AutoParam::Pan(0), AutoParam::Mute];
|
||||
v.extend(tr.automation.iter().map(|l| l.param.clone()));
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
} else {
|
||||
vec![AutoParam::parse(&tr.view).unwrap_or(AutoParam::Volume)]
|
||||
};
|
||||
for param in params {
|
||||
let v = current_value(tr, ¶m);
|
||||
tr.lane_mut(¶m).write_range(r.start, r.end, v, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "automation.volume_to_clip_gain", p)?;
|
||||
let r = range_param(e, "automation.volume_to_clip_gain", p)?;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
let Some(tr) = s.track_mut(*t) else { continue };
|
||||
let lane = tr.lane(&AutoParam::Volume).cloned();
|
||||
let base = tr.mixer.volume_db;
|
||||
let Some(lane) = lane else { continue };
|
||||
if let Some(pl) = tr.playlist_mut() {
|
||||
for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) {
|
||||
let mut env: Vec<(i64, f32)> =
|
||||
lane.points.iter().filter(|pt| c.range().contains(pt.at)).map(|pt| (pt.at - c.start, pt.value - base)).collect();
|
||||
env.insert(0, (0, lane.value_at(c.start, base) - base));
|
||||
env.push((c.length, lane.value_at(c.end(), base) - base));
|
||||
c.gain_env = env;
|
||||
}
|
||||
}
|
||||
tr.lane_mut(&AutoParam::Volume).clear_range(r.start, r.end);
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "automation.clip_gain_to_volume", p)?;
|
||||
let r = range_param(e, "automation.clip_gain_to_volume", p)?;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
let Some(tr) = s.track_mut(*t) else { continue };
|
||||
let base = tr.mixer.volume_db;
|
||||
let mut pts = Vec::new();
|
||||
if let Some(pl) = tr.playlist_mut() {
|
||||
for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) {
|
||||
if c.gain_env.is_empty() {
|
||||
pts.push((c.start, base + c.gain_db));
|
||||
pts.push((c.end() - 1, base + c.gain_db));
|
||||
} else {
|
||||
for (o, db) in &c.gain_env {
|
||||
pts.push((c.start + o, base + c.gain_db + db));
|
||||
}
|
||||
}
|
||||
c.gain_db = 0.0;
|
||||
c.gain_env.clear();
|
||||
}
|
||||
}
|
||||
let lane = tr.lane_mut(&AutoParam::Volume);
|
||||
for (at, v) in pts {
|
||||
lane.set_point(at, v);
|
||||
}
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn copy_to_send(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "automation.copy_to_send", p)?;
|
||||
let slot = slot_of(p, "send", SEND_SLOTS).unwrap_or(0) as u8;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
let pts = tr.lane(&AutoParam::Volume).map(|l| l.points.clone()).unwrap_or_default();
|
||||
let v = tr.mixer.volume_db;
|
||||
let lane = tr.lane_mut(&AutoParam::SendLevel(slot));
|
||||
if pts.is_empty() {
|
||||
lane.set_point(0, v);
|
||||
}
|
||||
for pt in pts {
|
||||
lane.set_point(pt.at, pt.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
//! The command registry. One module per menu area; each exposes `specs()`.
|
||||
|
||||
use crate::{Engine, EngineError, Result};
|
||||
use serde_json::Value;
|
||||
use soundcraft_model::{ClipId, TrackId};
|
||||
use soundcraft_time::{Range, Samples};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
mod clip;
|
||||
mod edit;
|
||||
mod event;
|
||||
mod file;
|
||||
mod mix;
|
||||
mod options;
|
||||
mod query;
|
||||
mod track;
|
||||
mod transport;
|
||||
mod view;
|
||||
|
||||
pub type Run = fn(&mut Engine, &Value) -> Result<Value>;
|
||||
/// `Err(reason)` = disabled.
|
||||
pub type Enabled = fn(&Engine) -> std::result::Result<(), String>;
|
||||
|
||||
/// A registered command.
|
||||
pub struct CommandSpec {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
/// Menu path, e.g. `["Edit", "Trim"]`; empty = not in menus.
|
||||
pub menu: &'static [&'static str],
|
||||
pub shortcut: Option<&'static str>,
|
||||
/// Human/agent parameter documentation.
|
||||
pub params: &'static str,
|
||||
pub enabled: Enabled,
|
||||
pub run: Run,
|
||||
pub journal: bool,
|
||||
pub undoable: bool,
|
||||
}
|
||||
|
||||
/// Serializable description of a command.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct CommandInfo {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub menu: Vec<String>,
|
||||
pub shortcut: Option<String>,
|
||||
pub params: String,
|
||||
pub enabled: bool,
|
||||
pub disabled_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
pub fn info(&self, e: &Engine) -> CommandInfo {
|
||||
let en = (self.enabled)(e);
|
||||
CommandInfo {
|
||||
id: self.id.into(),
|
||||
label: self.label.into(),
|
||||
menu: self.menu.iter().map(|s| s.to_string()).collect(),
|
||||
shortcut: self.shortcut.map(str::to_string),
|
||||
params: self.params.into(),
|
||||
enabled: en.is_ok(),
|
||||
disabled_reason: en.err(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo` variants.
|
||||
#[macro_export]
|
||||
macro_rules! cmd {
|
||||
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
|
||||
};
|
||||
(noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
|
||||
};
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
|
||||
};
|
||||
}
|
||||
|
||||
pub fn command_specs() -> &'static [CommandSpec] {
|
||||
static SPECS: OnceLock<Vec<CommandSpec>> = OnceLock::new();
|
||||
SPECS.get_or_init(|| {
|
||||
let mut v = Vec::new();
|
||||
v.extend(file::specs());
|
||||
v.extend(edit::specs());
|
||||
v.extend(view::specs());
|
||||
v.extend(track::specs());
|
||||
v.extend(clip::specs());
|
||||
v.extend(event::specs());
|
||||
v.extend(mix::specs());
|
||||
v.extend(options::specs());
|
||||
v.extend(transport::specs());
|
||||
v.extend(query::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
command_specs().iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
// ---- enablement --------------------------------------------------------------------------
|
||||
|
||||
pub fn always(_: &Engine) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn has_tracks(e: &Engine) -> std::result::Result<(), String> {
|
||||
if e.session().tracks.is_empty() { Err("the session has no tracks".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
pub fn has_selection(e: &Engine) -> std::result::Result<(), String> {
|
||||
let s = e.session();
|
||||
if s.edit.selected_tracks.is_empty() && s.edit.selected_clips.is_empty() { Err("nothing is selected".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
pub fn has_range(e: &Engine) -> std::result::Result<(), String> {
|
||||
let s = e.session();
|
||||
if (s.edit.selected_tracks.is_empty() || s.edit.selection.is_empty()) && s.edit.selected_clips.is_empty() {
|
||||
Err("make an edit selection first".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_undo(e: &Engine) -> std::result::Result<(), String> {
|
||||
if e.can_undo() { Ok(()) } else { Err("nothing to undo".into()) }
|
||||
}
|
||||
|
||||
pub fn can_redo(e: &Engine) -> std::result::Result<(), String> {
|
||||
if e.can_redo() { Ok(()) } else { Err("nothing to redo".into()) }
|
||||
}
|
||||
|
||||
pub fn has_clipboard(e: &Engine) -> std::result::Result<(), String> {
|
||||
if e.clipboard.tracks.is_empty() { Err("the clipboard is empty".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
// ---- params ------------------------------------------------------------------------------
|
||||
|
||||
pub fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams(cmd.to_string(), msg.into())
|
||||
}
|
||||
|
||||
pub fn f64_or(p: &Value, key: &str, d: f64) -> f64 {
|
||||
p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d)
|
||||
}
|
||||
|
||||
pub fn f32_or(p: &Value, key: &str, d: f32) -> f32 {
|
||||
f64_or(p, key, f64::from(d)) as f32
|
||||
}
|
||||
|
||||
pub fn i64_or(p: &Value, key: &str, d: i64) -> i64 {
|
||||
p.get(key).and_then(|v| v.as_i64().or_else(|| v.as_f64().filter(|f| f.is_finite()).map(|f| f.round() as i64))).unwrap_or(d)
|
||||
}
|
||||
|
||||
pub fn bool_or(p: &Value, key: &str, d: bool) -> bool {
|
||||
p.get(key).and_then(Value::as_bool).unwrap_or(d)
|
||||
}
|
||||
|
||||
pub fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
|
||||
p.get(key).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
/// Resolve a track from `track` (id number or name) params.
|
||||
pub fn track_param(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<Option<TrackId>> {
|
||||
let Some(v) = p.get(key) else { return Ok(None) };
|
||||
let s = e.session();
|
||||
if let Some(n) = v.as_u64() {
|
||||
return s.track(TrackId(n)).map(|t| Some(t.id)).ok_or_else(|| bad(cmd, format!("no track with id {n}")));
|
||||
}
|
||||
if let Some(name) = v.as_str() {
|
||||
return s.track_by_name(name).map(|t| Some(t.id)).ok_or_else(|| bad(cmd, format!("no track named `{name}`")));
|
||||
}
|
||||
Err(bad(cmd, format!("`{key}` must be a track id or name")))
|
||||
}
|
||||
|
||||
/// Tracks to act on: `tracks` (array of ids/names) or `track`, else the selected tracks.
|
||||
pub fn tracks_param(e: &Engine, cmd: &str, p: &Value) -> Result<Vec<TrackId>> {
|
||||
if let Some(arr) = p.get("tracks").and_then(Value::as_array) {
|
||||
let s = e.session();
|
||||
let mut out = Vec::new();
|
||||
for v in arr {
|
||||
let id = match (v.as_u64(), v.as_str()) {
|
||||
(Some(n), _) => s.track(TrackId(n)).map(|t| t.id),
|
||||
(_, Some(name)) => s.track_by_name(name).map(|t| t.id),
|
||||
_ => None,
|
||||
};
|
||||
out.push(id.ok_or_else(|| bad(cmd, format!("unknown track {v}")))?);
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
if let Some(t) = track_param(e, cmd, p, "track")? {
|
||||
return Ok(vec![t]);
|
||||
}
|
||||
Ok(e.target_tracks())
|
||||
}
|
||||
|
||||
/// Like `tracks_param` but errors when empty.
|
||||
pub fn tracks_required(e: &Engine, cmd: &str, p: &Value) -> Result<Vec<TrackId>> {
|
||||
let t = tracks_param(e, cmd, p)?;
|
||||
if t.is_empty() { Err(bad(cmd, "no tracks: pass `track`/`tracks` or select tracks")) } else { Ok(t) }
|
||||
}
|
||||
|
||||
/// Position from `key`: a number of samples, or `{"seconds": x}`, or a string in the main counter format.
|
||||
pub fn position_param(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<Option<Samples>> {
|
||||
let Some(v) = p.get(key) else { return Ok(None) };
|
||||
let s = e.session();
|
||||
if let Some(n) = v.as_i64() {
|
||||
return Ok(Some(n));
|
||||
}
|
||||
if let Some(f) = v.as_f64() {
|
||||
return Ok(Some(soundcraft_time::to_samples(f)));
|
||||
}
|
||||
if let Some(secs) = v.get("seconds").and_then(Value::as_f64) {
|
||||
return Ok(Some(s.sample_rate.samples(secs)));
|
||||
}
|
||||
if let Some(text) = v.as_str() {
|
||||
// Try each format, starting with an explicit prefix like "bars:5|1|000".
|
||||
let (fmt, body) = match text.split_once(':') {
|
||||
Some((f, b)) if soundcraft_time::TimeFormat::from_id(f).is_some() => (soundcraft_time::TimeFormat::from_id(f), b),
|
||||
_ => (None, text),
|
||||
};
|
||||
let fmts: Vec<soundcraft_time::TimeFormat> = match fmt {
|
||||
Some(f) => vec![f],
|
||||
None => {
|
||||
let mut v = vec![s.edit.main_counter];
|
||||
v.extend(soundcraft_time::TimeFormat::ALL);
|
||||
v
|
||||
}
|
||||
};
|
||||
for f in fmts {
|
||||
if let Ok(x) = soundcraft_time::parse_position(body, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start) {
|
||||
return Ok(Some(x));
|
||||
}
|
||||
}
|
||||
return Err(bad(cmd, format!("cannot parse position `{text}`")));
|
||||
}
|
||||
Err(bad(cmd, format!("`{key}` must be samples, {{seconds}}, or a time string")))
|
||||
}
|
||||
|
||||
/// Range from `start`/`end` params, else the edit selection.
|
||||
pub fn range_param(e: &Engine, cmd: &str, p: &Value) -> Result<Range> {
|
||||
let st = position_param(e, cmd, p, "start")?;
|
||||
let en = position_param(e, cmd, p, "end")?;
|
||||
let len = position_param(e, cmd, p, "length")?;
|
||||
match (st, en, len) {
|
||||
(Some(a), Some(b), _) => Ok(Range::new(a, b)),
|
||||
(Some(a), None, Some(l)) => Ok(Range::new(a, a.saturating_add(l))),
|
||||
(Some(a), None, None) => Ok(Range::point(a)),
|
||||
_ => Ok(e.session().edit.selection),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clip_ids_param(e: &Engine, p: &Value) -> Vec<ClipId> {
|
||||
if let Some(arr) = p.get("clips").and_then(Value::as_array) {
|
||||
return arr.iter().filter_map(Value::as_u64).map(ClipId).collect();
|
||||
}
|
||||
if let Some(n) = p.get("clip").and_then(Value::as_u64) {
|
||||
return vec![ClipId(n)];
|
||||
}
|
||||
let s = e.session();
|
||||
if !s.edit.selected_clips.is_empty() {
|
||||
return s.edit.selected_clips.clone();
|
||||
}
|
||||
// Clips touched by the edit selection on selected tracks.
|
||||
let r = s.edit.selection;
|
||||
s.tracks
|
||||
.iter()
|
||||
.filter(|t| s.edit.selected_tracks.contains(&t.id))
|
||||
.flat_map(|t| t.clips().iter().filter(move |c| if r.is_empty() { c.range().contains(r.start) } else { c.range().overlaps(&r) }).map(|c| c.id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ids_are_unique_and_documented() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for c in command_specs() {
|
||||
assert!(seen.insert(c.id), "duplicate command id {}", c.id);
|
||||
assert!(!c.label.is_empty());
|
||||
assert!(c.id.contains('.'), "ids are namespaced: {}", c.id);
|
||||
}
|
||||
assert!(command_specs().len() > 150, "only {} commands", command_specs().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_survives_empty_params() {
|
||||
// No command may panic with `{}` params on an empty or demo session.
|
||||
for demo in [false, true] {
|
||||
for c in command_specs() {
|
||||
if matches!(c.id, "app.quit") {
|
||||
continue;
|
||||
}
|
||||
let mut e = if demo { crate::demo::demo_engine() } else { Engine::default() };
|
||||
let r = e.execute(c.id, &serde_json::json!({}));
|
||||
if let Err(EngineError::Internal(id, msg)) = r {
|
||||
panic!("{id} panicked: {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Options menu toggles and Setup › Session settings.
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{BitDepthSetting, EditState};
|
||||
use soundcraft_time::FrameRate;
|
||||
|
||||
macro_rules! toggle {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $field:ident) => {
|
||||
cmd!(noundo $id, $label, [$($m),*], $sc, "{value?: bool}", always, |e, p| {
|
||||
let s = e.session_mut();
|
||||
s.edit.$field = p.get("value").and_then(Value::as_bool).unwrap_or(!s.edit.$field);
|
||||
Ok(json!({"value": s.edit.$field}))
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
toggle!("options.loop_record", "Loop Record", ["Options"], Some("Alt+L"), loop_record),
|
||||
toggle!("options.quickpunch", "QuickPunch", ["Options"], Some("Cmd+Shift+P"), quickpunch),
|
||||
toggle!("options.pre_post_roll", "Pre/Post-Roll", ["Options"], Some("Cmd+K"), pre_post_roll),
|
||||
toggle!("options.loop_playback", "Loop Playback", ["Options"], Some("Cmd+Shift+L"), loop_playback),
|
||||
toggle!("options.link_timeline_edit", "Link Timeline and Edit Selection", ["Options"], Some("Shift+/"), link_timeline_edit),
|
||||
toggle!("options.link_track_edit", "Link Track and Edit Selection", ["Options"], None, link_track_edit),
|
||||
toggle!("options.insertion_follows_playback", "Insertion Follows Playback", ["Options"], None, insertion_follows_playback),
|
||||
toggle!("options.tab_to_transient", "Tab to Transient", ["Options"], Some("Cmd+Alt+Tab"), tab_to_transient),
|
||||
toggle!("options.mirrored_midi", "Mirrored MIDI Editing", ["Options"], None, mirrored_midi),
|
||||
toggle!("options.automation_follows_edit", "Automation Follows Edit", ["Options"], None, automation_follows_edit),
|
||||
toggle!("options.markers_follow_edit", "Markers Follow Edit", ["Options"], None, markers_follow_edit),
|
||||
toggle!("options.layered_editing", "Layered Editing", ["Options"], None, layered_editing),
|
||||
toggle!("options.click", "Click", ["Options"], None, click),
|
||||
toggle!("options.countoff", "Count Off", [], None, countoff),
|
||||
toggle!("options.midi_merge", "MIDI Merge", [], None, midi_merge),
|
||||
toggle!("options.pre_fader_metering", "Pre-Fader Metering", ["Options"], None, pre_fader_metering),
|
||||
toggle!("options.delay_compensation", "Delay Compensation", ["Options"], None, delay_compensation),
|
||||
cmd!(noundo "options.destructive_record", "Destructive Record", ["Options"], None, "{value?: bool}", always, |e, p| record_mode(e, p, "destructive")),
|
||||
cmd!(noundo "options.trackpunch", "TrackPunch", ["Options"], Some("Cmd+Shift+T"), "{value?: bool}", always, |e, p| record_mode(e, p, "trackpunch")),
|
||||
cmd!(noundo "options.scrolling", "Edit Window Scrolling", ["Options", "Edit Window Scrolling"], None, "{mode: none|after_playback|page|continuous|center}", always, |e, p| {
|
||||
let m = str_param(p, "mode").filter(|m| ["none", "after_playback", "page", "continuous", "center"].contains(m)).ok_or_else(|| bad("options.scrolling", "unknown mode"))?.to_string();
|
||||
e.session_mut().edit.scrolling = m.clone();
|
||||
Ok(json!({"mode": m}))
|
||||
}),
|
||||
cmd!(noundo "options.solo_mode", "Solo Mode", ["Options", "Solo Mode"], None, "{mode: sip|afl|pfl, latch?: bool, xor?: bool}", always, |e, p| {
|
||||
let m = str_param(p, "mode").unwrap_or("sip").to_string();
|
||||
let mut v = m.clone();
|
||||
if bool_or(p, "xor", false) { v.push_str("+xor") }
|
||||
e.session_mut().edit.solo_mode = v.clone();
|
||||
Ok(json!({"mode": v}))
|
||||
}),
|
||||
cmd!(noundo "options.pre_roll", "Pre-Roll Amount", [], None, "{length: samples|{seconds}}", always, |e, p| {
|
||||
let v = position_param(e, "options.pre_roll", p, "length")?.unwrap_or(0).max(0);
|
||||
e.session_mut().edit.pre_roll = v;
|
||||
Ok(json!({"pre_roll": v}))
|
||||
}),
|
||||
cmd!(noundo "options.post_roll", "Post-Roll Amount", [], None, "{length}", always, |e, p| {
|
||||
let v = position_param(e, "options.post_roll", p, "length")?.unwrap_or(0).max(0);
|
||||
e.session_mut().edit.post_roll = v;
|
||||
Ok(json!({"post_roll": v}))
|
||||
}),
|
||||
cmd!(
|
||||
"setup.session",
|
||||
"Session",
|
||||
["Setup"],
|
||||
Some("Cmd+2"),
|
||||
"{frame_rate?: '29.97 Drop'…, bit_depth?: 16|24|32f, timecode_start?: position, name?}",
|
||||
always,
|
||||
session_setup
|
||||
),
|
||||
cmd!(noundo "setup.preferences", "Preferences...", ["Setup"], None, "{}", always, |e, _| Ok(json!({"edit": e.session().edit.clone()}))),
|
||||
cmd!(noundo "setup.reset_edit_state", "Reset Edit Settings", [], None, "{}", always, |e, _| {
|
||||
let s = e.session_mut();
|
||||
let keep = (s.edit.selection, s.edit.selected_tracks.clone());
|
||||
s.edit = EditState::default();
|
||||
s.edit.selection = keep.0;
|
||||
s.edit.selected_tracks = keep.1;
|
||||
Ok(json!({}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn record_mode(e: &mut Engine, p: &Value, mode: &str) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let on = p.get("value").and_then(Value::as_bool).unwrap_or(s.edit.record_mode != mode);
|
||||
s.edit.record_mode = if on { mode.to_string() } else { "normal".into() };
|
||||
Ok(json!({"record_mode": s.edit.record_mode}))
|
||||
}
|
||||
|
||||
fn session_setup(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let fr = match str_param(p, "frame_rate") {
|
||||
Some(f) => {
|
||||
Some(FrameRate::ALL.into_iter().find(|r| r.label().eq_ignore_ascii_case(f)).ok_or_else(|| bad("setup.session", "unknown frame rate"))?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let bd = match p.get("bit_depth").map(|v| v.to_string().trim_matches('"').to_ascii_lowercase()) {
|
||||
Some(b) if b == "16" => Some(BitDepthSetting::Int16),
|
||||
Some(b) if b == "24" => Some(BitDepthSetting::Int24),
|
||||
Some(b) if b.starts_with("32") => Some(BitDepthSetting::Float32),
|
||||
Some(_) => return Err(bad("setup.session", "bit_depth must be 16, 24 or 32f")),
|
||||
None => None,
|
||||
};
|
||||
let tc = position_param(e, "setup.session", p, "timecode_start")?;
|
||||
let s = e.session_mut();
|
||||
if let Some(f) = fr {
|
||||
s.frame_rate = f;
|
||||
}
|
||||
if let Some(b) = bd {
|
||||
s.bit_depth = b;
|
||||
}
|
||||
if let Some(t) = tc {
|
||||
s.timecode_start = t;
|
||||
}
|
||||
if let Some(n) = p.get("name").and_then(Value::as_str) {
|
||||
s.name = n.to_string();
|
||||
}
|
||||
Ok(json!({"frame_rate": s.frame_rate.label(), "bit_depth": s.bit_depth, "sample_rate": s.sample_rate.hz()}))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Read-only queries for agents.
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "session.inspect", "Inspect Session", [], None, "{detail?: summary|full}", always, |e, p| Ok(crate::inspect::session(e, str_param(p, "detail") == Some("full")))),
|
||||
cmd!(query "track.inspect", "Inspect Track", [], None, "{track}", has_tracks, |e, p| {
|
||||
let t = track_param(e, "track.inspect", p, "track")?.ok_or_else(|| bad("track.inspect", "`track` required"))?;
|
||||
crate::inspect::track(e, t).ok_or_else(|| bad("track.inspect", "no such track"))
|
||||
}),
|
||||
cmd!(query "clip.inspect", "Inspect Clip", [], None, "{clip}", always, |e, p| {
|
||||
let id = p.get("clip").and_then(Value::as_u64).ok_or_else(|| bad("clip.inspect", "`clip` id required"))?;
|
||||
crate::inspect::clip(e, soundcraft_model::ClipId(id)).ok_or_else(|| bad("clip.inspect", "no such clip"))
|
||||
}),
|
||||
cmd!(query "engine.commands", "List Commands", [], None, "{filter?}", always, |e, p| {
|
||||
let f = str_param(p, "filter").map(str::to_ascii_lowercase);
|
||||
let v: Vec<CommandInfo> = command_specs()
|
||||
.iter()
|
||||
.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| c.info(e))
|
||||
.collect();
|
||||
Ok(json!(v))
|
||||
}),
|
||||
cmd!(query "engine.history", "Undo History", ["Window"], None, "{}", always, |e, _| Ok(json!({"undo": e.undo_history(), "can_redo": e.can_redo()}))),
|
||||
cmd!(query "engine.plugins", "List Plugins", [], None, "{}", always, |_, _| {
|
||||
let v: Vec<_> = soundcraft_dsp::plugins().iter().map(|p| json!({"id": p.id, "name": p.name, "category": p.category, "instrument": p.is_instrument, "params": p.params})).collect();
|
||||
Ok(json!(v))
|
||||
}),
|
||||
cmd!(query "engine.parity", "Parity Report", [], None, "{}", always, |_, _| Ok(crate::catalog::parity_json())),
|
||||
cmd!(query "session.format_time", "Format Time", [], None, "{at: samples, format?: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| {
|
||||
let at = position_param(e, "session.format_time", p, "at")?.unwrap_or(0);
|
||||
let s = e.session();
|
||||
let f = str_param(p, "format").and_then(soundcraft_time::TimeFormat::from_id).unwrap_or(s.edit.main_counter);
|
||||
Ok(json!({"text": soundcraft_time::format_position(at, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start)}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
//! Track menu commands.
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, TrackHeight, TrackKind};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"track.new",
|
||||
"New...",
|
||||
["Track"],
|
||||
Some("Cmd+Shift+N"),
|
||||
"{count?: 1, format?: mono|stereo|5.1…, kind?: audio|aux|master|midi|instrument|vca|folder, name?, timebase?: samples|ticks, instrument?: plugin id}",
|
||||
always,
|
||||
new_track
|
||||
),
|
||||
cmd!("track.group", "Group...", ["Track"], Some("Cmd+G"), "{name?, tracks?, edit?: true, mix?: true}", has_selection, group),
|
||||
cmd!("track.ungroup", "Delete Group", [], None, "{group: id|name}", always, delete_group),
|
||||
cmd!("track.group_toggle", "Toggle Group Active", [], None, "{group: id|name}", always, toggle_group),
|
||||
cmd!(
|
||||
"track.duplicate",
|
||||
"Duplicate...",
|
||||
["Track"],
|
||||
Some("Alt+Shift+D"),
|
||||
"{tracks?, playlists?: true, automation?: true, inserts?: true, sends?: true}",
|
||||
has_selection,
|
||||
duplicate
|
||||
),
|
||||
cmd!("track.split_into_mono", "Split into Mono", ["Track"], None, "{tracks?}", has_selection, split_mono),
|
||||
cmd!("track.make_inactive", "Make Inactive", ["Track"], None, "{tracks?, inactive?: bool}", has_selection, |e, p| flag(
|
||||
e,
|
||||
p,
|
||||
"track.make_inactive",
|
||||
|t, v| t.inactive = v,
|
||||
|t| t.inactive
|
||||
)),
|
||||
cmd!(
|
||||
"track.move_to_new_folder",
|
||||
"Move to New Folder...",
|
||||
["Track"],
|
||||
Some("Cmd+Alt+Shift+N"),
|
||||
"{tracks?, name?}",
|
||||
has_selection,
|
||||
move_to_folder
|
||||
),
|
||||
cmd!(
|
||||
"track.change_width",
|
||||
"Change Track Width",
|
||||
["Track", "Change Track Width"],
|
||||
None,
|
||||
"{tracks?, format: mono|stereo|…}",
|
||||
has_selection,
|
||||
change_width
|
||||
),
|
||||
cmd!("track.delete", "Delete", ["Track"], None, "{tracks?}", has_selection, delete),
|
||||
cmd!("track.rename", "Rename Track", [], None, "{track, name}", has_tracks, rename),
|
||||
cmd!("track.comments", "Track Comments", [], None, "{track, comments}", has_tracks, comments),
|
||||
cmd!("track.color", "Track Color", [], None, "{tracks?, color: [r,g,b] | palette index}", has_selection, color),
|
||||
cmd!("track.height", "Track Height", [], None, "{tracks?, height: micro|mini|small|medium|large|jumbo|extreme}", has_selection, height),
|
||||
cmd!("track.view", "Track View", [], None, "{tracks?, view: waveform|blocks|notes|volume|pan|mute|<automation id>}", has_selection, view),
|
||||
cmd!("track.hide", "Hide Track", [], None, "{tracks?, hidden?: bool}", has_selection, |e, p| flag(
|
||||
e,
|
||||
p,
|
||||
"track.hide",
|
||||
|t, v| t.hidden = v,
|
||||
|t| t.hidden
|
||||
)),
|
||||
cmd!("track.pin", "Pin Track", [], None, "{tracks?}", has_selection, |e, p| flag(e, p, "track.pin", |t, v| t.pinned = v, |t| t.pinned)),
|
||||
cmd!("track.move", "Move Track", [], None, "{track, to: index}", has_tracks, move_track),
|
||||
cmd!("track.freeze", "Freeze", ["Track"], None, "{tracks?}", has_selection, |e, p| flag(
|
||||
e,
|
||||
p,
|
||||
"track.freeze",
|
||||
|t, v| t.frozen = v,
|
||||
|t| t.frozen
|
||||
)),
|
||||
cmd!("track.commit", "Commit...", ["Track"], Some("Alt+Shift+C"), "{tracks?}", has_selection, commit),
|
||||
cmd!("track.bounce", "Bounce...", ["Track"], Some("Cmd+Alt+Shift+B"), "{tracks?}", has_selection, commit),
|
||||
cmd!("track.bypass_inserts", "All", ["Track", "Bypass Inserts"], None, "{tracks?, bypass?: bool}", has_selection, bypass_inserts),
|
||||
cmd!("track.mute_sends", "All", ["Track", "Mute Sends"], None, "{tracks?, mute?: bool}", has_selection, mute_sends),
|
||||
cmd!("track.set_record_to_input_only", "Set Record Tracks to Input Only", ["Track"], Some("Alt+K"), "{}", always, |e, _| {
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for t in &mut s.tracks {
|
||||
if t.mixer.record_arm {
|
||||
t.mixer.input_monitor = true;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({"tracks": n}))
|
||||
}),
|
||||
cmd!(noundo "track.scroll_to", "Scroll to Track...", ["Track"], Some("Cmd+Alt+F"), "{track}", has_tracks, |e, p| {
|
||||
let t = track_param(e, "track.scroll_to", p, "track")?.ok_or_else(|| bad("track.scroll_to", "`track` required"))?;
|
||||
let s = e.session_mut();
|
||||
s.edit.selected_tracks = vec![t];
|
||||
Ok(json!({"track": t}))
|
||||
}),
|
||||
cmd!("track.clear_clip_indicators", "Clear All Clip Indicators", ["Track"], Some("Alt+C"), "{}", always, |_, _| Ok(json!({"cleared": true}))),
|
||||
cmd!("track.clear_trim_automation", "Clear Trim Automation", ["Track"], None, "{tracks?}", has_selection, |_, _| Ok(json!({}))),
|
||||
cmd!("track.create_click", "Create Click Track", ["Track"], None, "{}", always, create_click),
|
||||
cmd!("track.playlist_new", "New Playlist", [], None, "{track?, name?}", has_selection, |e, p| new_playlist(e, p, false)),
|
||||
cmd!("track.playlist_duplicate", "Duplicate Playlist", [], None, "{track?, name?}", has_selection, |e, p| new_playlist(e, p, true)),
|
||||
cmd!("track.playlist_select", "Select Playlist", [], None, "{track?, index | name}", has_selection, select_playlist),
|
||||
cmd!("track.playlist_delete_unused", "Delete Unused Playlists", [], None, "{track?}", has_selection, |e, p| {
|
||||
let tracks = tracks_required(e, "track.playlist_delete_unused", p)?;
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for t in tracks {
|
||||
if let Some(tr) = s.track_mut(t)
|
||||
&& let Some(active) = tr.playlists.get(tr.active_playlist).cloned()
|
||||
{
|
||||
n += tr.playlists.len() - 1;
|
||||
tr.playlists = vec![active];
|
||||
tr.active_playlist = 0;
|
||||
}
|
||||
}
|
||||
Ok(json!({"deleted": n}))
|
||||
}),
|
||||
cmd!("track.input", "Track Input", [], None, "{tracks?, input: none|bus name|hardware name}", has_selection, |e, p| route(e, p, true)),
|
||||
cmd!("track.output", "Track Output", [], None, "{tracks?, output: main|none|bus name}", has_selection, |e, p| route(e, p, false)),
|
||||
cmd!("track.timebase", "Track Timebase", [], None, "{tracks?, ticks: bool}", has_selection, |e, p| flag(
|
||||
e,
|
||||
p,
|
||||
"track.timebase",
|
||||
|t, v| t.ticks_timebase = v,
|
||||
|t| t.ticks_timebase
|
||||
)),
|
||||
cmd!(
|
||||
"track.elastic",
|
||||
"Elastic Audio",
|
||||
[],
|
||||
None,
|
||||
"{tracks?, algorithm?: polyphonic|rhythmic|monophonic|varispeed|x-form|none}",
|
||||
has_selection,
|
||||
|e, p| {
|
||||
let tracks = tracks_required(e, "track.elastic", p)?;
|
||||
let alg = str_param(p, "algorithm").filter(|a| *a != "none").map(str::to_string);
|
||||
let s = e.session_mut();
|
||||
for t in tracks {
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
tr.elastic = alg.clone();
|
||||
}
|
||||
}
|
||||
Ok(json!({"algorithm": alg}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn new_track(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let count = i64_or(p, "count", 1).clamp(1, 128);
|
||||
let kind = match str_param(p, "kind") {
|
||||
Some(k) => TrackKind::from_id(k).ok_or_else(|| bad("track.new", format!("unknown kind `{k}`")))?,
|
||||
None => TrackKind::Audio,
|
||||
};
|
||||
let default_fmt = if matches!(kind, TrackKind::Master | TrackKind::Aux | TrackKind::Instrument | TrackKind::Folder) {
|
||||
ChannelFormat::Stereo
|
||||
} else {
|
||||
ChannelFormat::Mono
|
||||
};
|
||||
let format = match str_param(p, "format") {
|
||||
Some(f) => ChannelFormat::from_id(f).ok_or_else(|| bad("track.new", format!("unknown format `{f}`")))?,
|
||||
None => default_fmt,
|
||||
};
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
let ticks = str_param(p, "timebase").map(|t| t == "ticks");
|
||||
let instrument = str_param(p, "instrument").map(str::to_string);
|
||||
let s = e.session_mut();
|
||||
let mut ids = Vec::new();
|
||||
for _ in 0..count {
|
||||
let id = s.add_track(kind, format, name.as_deref());
|
||||
if let Some(t) = s.track_mut(id) {
|
||||
if let Some(tb) = ticks {
|
||||
t.ticks_timebase = tb;
|
||||
}
|
||||
if kind == TrackKind::Instrument {
|
||||
t.instrument = Some(soundcraft_model::Insert::new(instrument.clone().unwrap_or_else(|| "subtractive_synth".into())));
|
||||
}
|
||||
}
|
||||
ids.push(id);
|
||||
}
|
||||
s.edit.selected_tracks = ids.clone();
|
||||
Ok(json!({"tracks": ids}))
|
||||
}
|
||||
|
||||
fn group(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.group", p)?;
|
||||
let s = e.session_mut();
|
||||
let id = GroupId(s.alloc());
|
||||
let letter = (b'a'..=b'z').map(char::from).find(|c| !s.groups.iter().any(|g| g.letter == *c)).unwrap_or('z');
|
||||
let name = str_param(p, "name").map_or_else(|| format!("Group {}", s.groups.len() + 1), str::to_string);
|
||||
let color = s.next_track_color();
|
||||
s.groups.push(Group {
|
||||
id,
|
||||
name,
|
||||
letter,
|
||||
members: tracks,
|
||||
edit: bool_or(p, "edit", true),
|
||||
mix: bool_or(p, "mix", true),
|
||||
active: true,
|
||||
color,
|
||||
attributes: vec!["volume".into(), "mute".into(), "solo".into(), "record".into()],
|
||||
});
|
||||
Ok(json!({"group": id}))
|
||||
}
|
||||
|
||||
fn find_group(e: &Engine, p: &Value, cmd: &str) -> Result<GroupId> {
|
||||
let s = e.session();
|
||||
let g = match (p.get("group").and_then(Value::as_u64), str_param(p, "group")) {
|
||||
(Some(n), _) => s.groups.iter().find(|g| g.id.0 == n),
|
||||
(_, Some(name)) => s.groups.iter().find(|g| g.name == name || g.letter.to_string() == name),
|
||||
_ => None,
|
||||
};
|
||||
g.map(|g| g.id).ok_or_else(|| bad(cmd, "unknown `group`"))
|
||||
}
|
||||
|
||||
fn delete_group(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let g = find_group(e, p, "track.ungroup")?;
|
||||
e.session_mut().groups.retain(|x| x.id != g);
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn toggle_group(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let g = find_group(e, p, "track.group_toggle")?;
|
||||
let s = e.session_mut();
|
||||
let mut active = false;
|
||||
if let Some(gr) = s.groups.iter_mut().find(|x| x.id == g) {
|
||||
gr.active = !gr.active;
|
||||
active = gr.active;
|
||||
}
|
||||
Ok(json!({"active": active}))
|
||||
}
|
||||
|
||||
fn duplicate(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.duplicate", p)?;
|
||||
let (pl, auto, ins, snd) =
|
||||
(bool_or(p, "playlists", true), bool_or(p, "automation", true), bool_or(p, "inserts", true), bool_or(p, "sends", true));
|
||||
let s = e.session_mut();
|
||||
let mut out = Vec::new();
|
||||
for t in tracks {
|
||||
let Some(idx) = s.track_index(t) else { continue };
|
||||
let Some(mut nt) = s.tracks.get(idx).cloned() else { continue };
|
||||
nt.id = soundcraft_model::TrackId(s.alloc());
|
||||
nt.name = s.unique_track_name(&format!("{}.dup", nt.name));
|
||||
if !pl {
|
||||
for p in &mut nt.playlists {
|
||||
p.clips.clear();
|
||||
}
|
||||
} else {
|
||||
for p in &mut nt.playlists {
|
||||
for c in &mut p.clips {
|
||||
c.id = s.new_clip_id();
|
||||
}
|
||||
}
|
||||
}
|
||||
if !auto {
|
||||
nt.automation.clear();
|
||||
}
|
||||
if !ins {
|
||||
nt.mixer.inserts.iter_mut().for_each(|i| *i = None);
|
||||
}
|
||||
if !snd {
|
||||
nt.mixer.sends.iter_mut().for_each(|i| *i = None);
|
||||
}
|
||||
out.push(nt.id);
|
||||
s.tracks.insert((idx + 1).min(s.tracks.len()), nt);
|
||||
}
|
||||
s.edit.selected_tracks = out.clone();
|
||||
Ok(json!({"tracks": out}))
|
||||
}
|
||||
|
||||
fn split_mono(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.split_into_mono", p)?;
|
||||
let s = e.session_mut();
|
||||
let mut out = Vec::new();
|
||||
for t in tracks {
|
||||
let Some(src) = s.track(t).cloned() else { continue };
|
||||
if src.channels() < 2 || src.kind != TrackKind::Audio {
|
||||
continue;
|
||||
}
|
||||
for ch in 0..src.channels() {
|
||||
let suffix = match (src.channels(), ch) {
|
||||
(2, 0) => "L".to_string(),
|
||||
(2, 1) => "R".to_string(),
|
||||
_ => format!("{}", ch + 1),
|
||||
};
|
||||
let id = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some(&format!("{}.{suffix}", src.name)));
|
||||
let mut clips = src.clips().to_vec();
|
||||
for c in &mut clips {
|
||||
c.id = s.new_clip_id();
|
||||
// Mono tracks read channel 0 of the source; record the channel in the name for now.
|
||||
c.name = format!("{}.{suffix}", c.name);
|
||||
}
|
||||
if let Some(tr) = s.track_mut(id) {
|
||||
tr.mixer.pan = vec![if ch == 0 { -1.0 } else { 1.0 }];
|
||||
tr.comments = format!("source_channel={ch}");
|
||||
if let Some(pl) = tr.playlist_mut() {
|
||||
pl.clips = clips;
|
||||
}
|
||||
}
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
Ok(json!({"tracks": out}))
|
||||
}
|
||||
|
||||
fn flag(
|
||||
e: &mut Engine,
|
||||
p: &Value,
|
||||
cmd: &str,
|
||||
set: fn(&mut soundcraft_model::Track, bool),
|
||||
get: fn(&soundcraft_model::Track) -> bool,
|
||||
) -> Result<Value> {
|
||||
let tracks = tracks_required(e, cmd, p)?;
|
||||
let explicit = p.get("inactive").or_else(|| p.get("hidden")).or_else(|| p.get("value")).or_else(|| p.get("ticks")).and_then(Value::as_bool);
|
||||
let s = e.session_mut();
|
||||
let target = explicit.unwrap_or_else(|| !tracks.iter().all(|t| s.track(*t).is_some_and(get)));
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
set(tr, target);
|
||||
}
|
||||
}
|
||||
Ok(json!({"value": target, "tracks": tracks}))
|
||||
}
|
||||
|
||||
fn move_to_folder(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.move_to_new_folder", p)?;
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
let s = e.session_mut();
|
||||
let first_idx = tracks.iter().filter_map(|t| s.track_index(*t)).min().unwrap_or(0);
|
||||
let fid = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, Some(name.as_deref().unwrap_or("Folder")));
|
||||
// Place the folder above its first member.
|
||||
if let Some(i) = s.track_index(fid) {
|
||||
let f = s.tracks.remove(i);
|
||||
s.tracks.insert(first_idx.min(s.tracks.len()), f);
|
||||
}
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
tr.folder = Some(fid);
|
||||
}
|
||||
}
|
||||
Ok(json!({"folder": fid}))
|
||||
}
|
||||
|
||||
fn change_width(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.change_width", p)?;
|
||||
let f = str_param(p, "format").and_then(ChannelFormat::from_id).ok_or_else(|| bad("track.change_width", "`format` required"))?;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
tr.format = f;
|
||||
tr.mixer.pan = soundcraft_model::Mixer::new(f).pan;
|
||||
}
|
||||
}
|
||||
Ok(json!({"format": f}))
|
||||
}
|
||||
|
||||
fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.delete", p)?;
|
||||
let s = e.session_mut();
|
||||
s.tracks.retain(|t| !tracks.contains(&t.id));
|
||||
s.edit.selected_tracks.retain(|t| !tracks.contains(t));
|
||||
for g in &mut s.groups {
|
||||
g.members.retain(|t| !tracks.contains(t));
|
||||
}
|
||||
for t in &mut s.tracks {
|
||||
if t.folder.is_some_and(|f| tracks.contains(&f)) {
|
||||
t.folder = None;
|
||||
}
|
||||
}
|
||||
Ok(json!({"deleted": tracks.len()}))
|
||||
}
|
||||
|
||||
fn rename(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let t = track_param(e, "track.rename", p, "track")?.ok_or_else(|| bad("track.rename", "`track` required"))?;
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("track.rename", "`name` required"))?;
|
||||
let s = e.session_mut();
|
||||
if s.tracks.iter().any(|x| x.name == name && x.id != t) {
|
||||
return Err(bad("track.rename", format!("a track named `{name}` already exists")));
|
||||
}
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
tr.name = name.chars().take(64).collect();
|
||||
}
|
||||
Ok(json!({"name": name}))
|
||||
}
|
||||
|
||||
fn comments(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let t = track_param(e, "track.comments", p, "track")?.ok_or_else(|| bad("track.comments", "`track` required"))?;
|
||||
let c = str_param(p, "comments").unwrap_or_default().to_string();
|
||||
if let Some(tr) = e.session_mut().track_mut(t) {
|
||||
tr.comments = c;
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn color(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.color", p)?;
|
||||
let c = match p.get("color") {
|
||||
Some(Value::Array(a)) if a.len() == 3 => {
|
||||
let v: Vec<u8> = a.iter().map(|x| x.as_u64().unwrap_or(0).min(255) as u8).collect();
|
||||
[v.first().copied().unwrap_or(0), v.get(1).copied().unwrap_or(0), v.get(2).copied().unwrap_or(0)]
|
||||
}
|
||||
Some(v) if v.is_u64() => soundcraft_model::TRACK_COLORS.get(v.as_u64().unwrap_or(0) as usize % 16).copied().unwrap_or([128, 128, 128]),
|
||||
_ => return Err(bad("track.color", "`color` must be [r,g,b] or a palette index")),
|
||||
};
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
tr.color = c;
|
||||
}
|
||||
}
|
||||
Ok(json!({"color": c}))
|
||||
}
|
||||
|
||||
fn height(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.height", p)?;
|
||||
let h = str_param(p, "height").and_then(TrackHeight::from_id).ok_or_else(|| bad("track.height", "unknown height"))?;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
tr.height = h;
|
||||
}
|
||||
}
|
||||
Ok(json!({"height": h}))
|
||||
}
|
||||
|
||||
fn view(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.view", p)?;
|
||||
let v = str_param(p, "view").ok_or_else(|| bad("track.view", "`view` required"))?.to_string();
|
||||
let ok = matches!(v.as_str(), "waveform" | "blocks" | "notes" | "regions" | "clip_gain") || soundcraft_model::AutoParam::parse(&v).is_some();
|
||||
if !ok {
|
||||
return Err(bad("track.view", format!("unknown view `{v}`")));
|
||||
}
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
tr.view = v.clone();
|
||||
}
|
||||
}
|
||||
Ok(json!({"view": v}))
|
||||
}
|
||||
|
||||
fn move_track(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let t = track_param(e, "track.move", p, "track")?.ok_or_else(|| bad("track.move", "`track` required"))?;
|
||||
let to = i64_or(p, "to", 0).max(0) as usize;
|
||||
let s = e.session_mut();
|
||||
if let Some(i) = s.track_index(t) {
|
||||
let tr = s.tracks.remove(i);
|
||||
s.tracks.insert(to.min(s.tracks.len()), tr);
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn commit(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.commit", p)?;
|
||||
let mut out = Vec::new();
|
||||
for t in tracks {
|
||||
if let Some(id) = crate::io::commit_track(e, t)? {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
Ok(json!({"tracks": out}))
|
||||
}
|
||||
|
||||
fn bypass_inserts(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.bypass_inserts", p)?;
|
||||
let s = e.session_mut();
|
||||
let target = p
|
||||
.get("bypass")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or_else(|| !tracks.iter().all(|t| s.track(*t).is_some_and(|tr| tr.mixer.inserts.iter().flatten().all(|i| i.bypass))));
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
for i in tr.mixer.inserts.iter_mut().flatten() {
|
||||
i.bypass = target;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"bypass": target}))
|
||||
}
|
||||
|
||||
fn mute_sends(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.mute_sends", p)?;
|
||||
let s = e.session_mut();
|
||||
let target = p.get("mute").and_then(Value::as_bool).unwrap_or(true);
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
for snd in tr.mixer.sends.iter_mut().flatten() {
|
||||
snd.mute = target;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"mute": target}))
|
||||
}
|
||||
|
||||
fn create_click(e: &mut Engine, _: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let id = s.add_track(TrackKind::Aux, ChannelFormat::Mono, Some("Click"));
|
||||
if let Some(t) = s.track_mut(id) {
|
||||
t.comments = "click".into();
|
||||
}
|
||||
s.edit.click = true;
|
||||
Ok(json!({"track": id}))
|
||||
}
|
||||
|
||||
fn new_playlist(e: &mut Engine, p: &Value, dup: bool) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.playlist_new", p)?;
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
let mut clips = Vec::new();
|
||||
if dup && let Some(tr) = s.track(*t) {
|
||||
clips = tr.clips().to_vec();
|
||||
}
|
||||
for c in &mut clips {
|
||||
c.id = s.new_clip_id();
|
||||
}
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
let n = name.clone().unwrap_or_else(|| format!("{}.{:02}", tr.name, tr.playlists.len() + 1));
|
||||
tr.playlists.push(Playlist { name: n, clips });
|
||||
tr.active_playlist = tr.playlists.len() - 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({"tracks": tracks}))
|
||||
}
|
||||
|
||||
fn select_playlist(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.playlist_select", p)?;
|
||||
let idx = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
let i = idx.or_else(|| name.as_ref().and_then(|n| tr.playlists.iter().position(|p| &p.name == n)));
|
||||
match i {
|
||||
Some(i) if i < tr.playlists.len() => tr.active_playlist = i,
|
||||
_ => return Err(bad("track.playlist_select", "no such playlist")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_route(s: &soundcraft_model::Session, v: &str) -> Route {
|
||||
match v.to_ascii_lowercase().as_str() {
|
||||
"" | "none" | "no input" | "no output" => Route::None,
|
||||
"main" | "out 1-2" | "master" => Route::Main,
|
||||
_ => s.bus_by_name(v).map_or_else(|| Route::Hardware(v.to_string()), |b| Route::Bus(b.id)),
|
||||
}
|
||||
}
|
||||
|
||||
fn route(e: &mut Engine, p: &Value, input: bool) -> Result<Value> {
|
||||
let key = if input { "input" } else { "output" };
|
||||
let tracks = tracks_required(e, "track.route", p)?;
|
||||
let v = str_param(p, key).ok_or_else(|| bad("track.route", format!("`{key}` required")))?.to_string();
|
||||
let create_bus = bool_or(p, "create_bus", true);
|
||||
let s = e.session_mut();
|
||||
let mut r = parse_route(s, &v);
|
||||
if let Route::Hardware(name) = &r
|
||||
&& create_bus
|
||||
&& !name.to_ascii_lowercase().starts_with("in ")
|
||||
{
|
||||
let fmt = tracks
|
||||
.first()
|
||||
.and_then(|t| s.track(*t))
|
||||
.map_or(ChannelFormat::Stereo, |t| if t.kind == TrackKind::Aux { t.format } else { ChannelFormat::Stereo });
|
||||
r = Route::Bus(s.add_bus(&name.clone(), fmt));
|
||||
}
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
if input {
|
||||
tr.mixer.input = r.clone();
|
||||
} else {
|
||||
tr.mixer.output = r.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({key: r}))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Transport commands. They update the session's stopped playhead and queue requests for the
|
||||
//! host's audio engine (which may be absent when headless).
|
||||
|
||||
use super::*;
|
||||
use crate::{TransportRequest, cmd};
|
||||
use serde_json::json;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "transport.play", "Play", [], Some("Space"), "{from?: position}", always, |e, p| {
|
||||
if let Some(at) = position_param(e, "transport.play", p, "from")? {
|
||||
e.transport_requests.push(TransportRequest::Locate(at.max(0)));
|
||||
}
|
||||
e.transport_requests.push(TransportRequest::Play);
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!(noundo "transport.stop", "Stop", [], Some("Space"), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::Stop); Ok(json!({})) }),
|
||||
cmd!(noundo "transport.toggle", "Play/Stop", [], Some("Space"), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::TogglePlay); Ok(json!({})) }),
|
||||
cmd!(noundo "transport.record", "Record", [], Some("Cmd+Space"), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::Record); Ok(json!({})) }),
|
||||
cmd!(noundo "transport.pause", "Pause", [], None, "{}", always, |e, _| { e.transport_requests.push(TransportRequest::Pause); Ok(json!({})) }),
|
||||
cmd!(noundo "transport.half_speed", "Half-Speed Playback", [], Some("Shift+Space"), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::HalfSpeed); Ok(json!({})) }),
|
||||
cmd!(noundo "transport.play_selection", "Play Edit", ["Edit", "Selection"], Some("Alt+["), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::PlaySelection); Ok(json!({})) }),
|
||||
cmd!(noundo "transport.rtz", "Return to Zero", [], Some("Home"), "{}", always, |e, _| locate(e, 0)),
|
||||
cmd!(noundo "transport.go_to_end", "Go to End", [], Some("End"), "{}", always, |e, _| { let end = e.session().content_end(); locate(e, end) }),
|
||||
cmd!(noundo "transport.rewind", "Rewind", [], None, "{seconds?: 1}", always, |e, p| {
|
||||
let s = e.session();
|
||||
let d = s.sample_rate.samples(f64_or(p, "seconds", 1.0));
|
||||
let at = (cur(e) - d).max(0);
|
||||
locate(e, at)
|
||||
}),
|
||||
cmd!(noundo "transport.fast_forward", "Fast Forward", [], None, "{seconds?: 1}", always, |e, p| {
|
||||
let s = e.session();
|
||||
let d = s.sample_rate.samples(f64_or(p, "seconds", 1.0));
|
||||
let at = cur(e) + d;
|
||||
locate(e, at)
|
||||
}),
|
||||
cmd!(noundo "transport.locate", "Locate", [], None, "{at: position}", always, |e, p| {
|
||||
let at = position_param(e, "transport.locate", p, "at")?.ok_or_else(|| bad("transport.locate", "`at` required"))?;
|
||||
locate(e, at.max(0))
|
||||
}),
|
||||
cmd!(noundo "transport.status", "Transport Status", [], None, "{}", always, |e, _| Ok(json!(e.transport))),
|
||||
]
|
||||
}
|
||||
|
||||
fn cur(e: &Engine) -> i64 {
|
||||
if e.transport.playing { e.transport.position } else { e.session().edit.selection.start }
|
||||
}
|
||||
|
||||
fn locate(e: &mut Engine, at: i64) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
s.edit.playhead = at;
|
||||
if s.edit.selection.is_empty() || !s.edit.selection.contains(at) {
|
||||
s.edit.selection = soundcraft_time::Range::point(at);
|
||||
}
|
||||
e.transport_requests.push(TransportRequest::Locate(at));
|
||||
Ok(json!({"at": at}))
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! View menu commands that change saved view state (zoom, rulers, counters, grid/nudge).
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_time::{GridValue, NoteValue, TimeFormat};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5)),
|
||||
cmd!(noundo "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0)),
|
||||
cmd!(noundo "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| {
|
||||
let v = f64_or(p, "samples_per_px", 1024.0).clamp(0.25, 1_000_000.0);
|
||||
e.session_mut().edit.zoom.samples_per_px = v;
|
||||
Ok(json!({"samples_per_px": v}))
|
||||
}),
|
||||
cmd!(noundo "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| {
|
||||
let i = (i64_or(p, "preset", 1).clamp(1, 5) - 1) as usize;
|
||||
let s = e.session_mut();
|
||||
if bool_or(p, "store", false) {
|
||||
if let Some(z) = s.edit.zoom.presets.get_mut(i) { *z = s.edit.zoom.samples_per_px }
|
||||
} else if let Some(z) = s.edit.zoom.presets.get(i) {
|
||||
s.edit.zoom.samples_per_px = *z;
|
||||
}
|
||||
Ok(json!({"samples_per_px": s.edit.zoom.samples_per_px}))
|
||||
}),
|
||||
cmd!(noundo "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| {
|
||||
let w = f64_or(p, "width_px", 1200.0).max(100.0);
|
||||
let s = e.session_mut();
|
||||
let r = s.edit.selection;
|
||||
if !r.is_empty() {
|
||||
s.edit.zoom.samples_per_px = (r.len() as f64 / w).max(0.25);
|
||||
s.edit.zoom.scroll = r.start;
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!(noundo "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| {
|
||||
let w = f64_or(p, "width_px", 1200.0).max(100.0);
|
||||
let s = e.session_mut();
|
||||
let end = s.content_end().max(s.sample_rate.samples(30.0));
|
||||
s.edit.zoom.samples_per_px = (end as f64 * 1.05 / w).max(0.25);
|
||||
s.edit.zoom.scroll = 0;
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!(noundo "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| {
|
||||
let to = position_param(e, "view.scroll", p, "to")?;
|
||||
let by = f64_or(p, "by_px", 0.0);
|
||||
let s = e.session_mut();
|
||||
let spp = s.edit.zoom.samples_per_px;
|
||||
s.edit.zoom.scroll = to.unwrap_or(s.edit.zoom.scroll + soundcraft_time::to_samples(by * spp)).max(0);
|
||||
Ok(json!({"scroll": s.edit.zoom.scroll}))
|
||||
}),
|
||||
cmd!(noundo "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| {
|
||||
let s = e.session_mut();
|
||||
let z = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { s.edit.zoom.waveform_zoom * f32_or(p, "factor", 2.0) };
|
||||
s.edit.zoom.waveform_zoom = if z.is_finite() { z.clamp(0.25, 64.0) } else { 1.0 };
|
||||
Ok(json!({"waveform_zoom": s.edit.zoom.waveform_zoom}))
|
||||
}),
|
||||
cmd!(noundo "view.ruler", "Rulers", ["View", "Rulers"], None, "{ruler: bars_beats|min_secs|timecode|feet_frames|samples|tempo|meter|markers|key|chords, visible?: bool}", always, ruler),
|
||||
cmd!(noundo "view.main_counter", "Main Counter", ["View", "Main Counter"], None, "{format: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| {
|
||||
let f = str_param(p, "format").and_then(TimeFormat::from_id).ok_or_else(|| bad("view.main_counter", "unknown format"))?;
|
||||
e.session_mut().edit.main_counter = f;
|
||||
Ok(json!({"format": f}))
|
||||
}),
|
||||
cmd!(noundo "view.sub_counter", "Sub Counter", [], None, "{format?: … | null}", always, |e, p| {
|
||||
let f = str_param(p, "format").and_then(TimeFormat::from_id);
|
||||
e.session_mut().edit.sub_counter = f;
|
||||
Ok(json!({"format": f}))
|
||||
}),
|
||||
cmd!(noundo "view.grid", "Grid Value", [], None, "{value: '1/16' | '1 bar' | '1/8t' | '1/4.' | {seconds} | {frames} | {samples}, lines?: bool}", always, |e, p| {
|
||||
let g = grid_param(p, "value").ok_or_else(|| bad("view.grid", "unknown grid value"))?;
|
||||
let lines = p.get("lines").and_then(Value::as_bool);
|
||||
let s = e.session_mut();
|
||||
s.edit.grid = g;
|
||||
if let Some(l) = lines { s.edit.grid_lines = l }
|
||||
Ok(json!({"grid": g.label()}))
|
||||
}),
|
||||
cmd!(noundo "view.grid_lines", "Show Grid Lines", [], None, "{value?: bool}", always, |e, p| {
|
||||
let s = e.session_mut();
|
||||
s.edit.grid_lines = p.get("value").and_then(Value::as_bool).unwrap_or(!s.edit.grid_lines);
|
||||
Ok(json!({"value": s.edit.grid_lines}))
|
||||
}),
|
||||
cmd!(noundo "view.nudge", "Nudge Value", [], None, "{value: like grid}", always, |e, p| {
|
||||
let g = grid_param(p, "value").ok_or_else(|| bad("view.nudge", "unknown nudge value"))?;
|
||||
e.session_mut().edit.nudge = g;
|
||||
Ok(json!({"nudge": g.label()}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn zoom(e: &mut Engine, f: f64) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let anchor = s.edit.selection.start;
|
||||
let old = s.edit.zoom.samples_per_px;
|
||||
let new = (old * f).clamp(0.25, 1_000_000.0);
|
||||
// Keep the insertion point at the same screen x when it is visible.
|
||||
let x = (anchor - s.edit.zoom.scroll) as f64 / old;
|
||||
if (0.0..4000.0).contains(&x) {
|
||||
s.edit.zoom.scroll = (anchor - soundcraft_time::to_samples(x * new)).max(0);
|
||||
}
|
||||
s.edit.zoom.samples_per_px = new;
|
||||
Ok(json!({"samples_per_px": new}))
|
||||
}
|
||||
|
||||
fn ruler(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
const ALL: [&str; 10] = ["bars_beats", "min_secs", "timecode", "feet_frames", "samples", "tempo", "meter", "markers", "key", "chords"];
|
||||
let r = str_param(p, "ruler").filter(|r| ALL.contains(r)).ok_or_else(|| bad("view.ruler", "unknown ruler"))?.to_string();
|
||||
let s = e.session_mut();
|
||||
let shown = s.edit.rulers.contains(&r);
|
||||
let vis = p.get("visible").and_then(Value::as_bool).unwrap_or(!shown);
|
||||
s.edit.rulers.retain(|x| x != &r);
|
||||
if vis {
|
||||
s.edit.rulers.push(r.clone());
|
||||
s.edit.rulers.sort_by_key(|x| ALL.iter().position(|a| a == x));
|
||||
}
|
||||
Ok(json!({"ruler": r, "visible": vis}))
|
||||
}
|
||||
|
||||
/// Parse grid/nudge values.
|
||||
pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
|
||||
let v = p.get(key)?;
|
||||
if let Some(sec) = v.get("seconds").and_then(Value::as_f64) {
|
||||
return sec.is_finite().then_some(GridValue::Seconds(sec.clamp(0.000_01, 3600.0)));
|
||||
}
|
||||
if let Some(f) = v.get("frames").and_then(Value::as_i64) {
|
||||
return Some(GridValue::Frames(f.clamp(1, 100_000)));
|
||||
}
|
||||
if let Some(n) = v.get("samples").and_then(Value::as_i64) {
|
||||
return Some(GridValue::Samples(n.clamp(1, i64::from(u32::MAX))));
|
||||
}
|
||||
let s = v.as_str()?.trim().to_ascii_lowercase();
|
||||
let triplet = s.ends_with('t') || s.contains("triplet");
|
||||
let dotted = s.ends_with('.') || s.contains("dotted");
|
||||
let core: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '/' || *c == ' ' || c.is_ascii_alphabetic() && *c != 't').collect();
|
||||
let value = match core.trim() {
|
||||
"1 bar" | "bar" | "1/1" | "1" => NoteValue::Bar,
|
||||
"1/2" => NoteValue::Half,
|
||||
"1/4" => NoteValue::Quarter,
|
||||
"1/8" => NoteValue::Eighth,
|
||||
"1/16" => NoteValue::Sixteenth,
|
||||
"1/32" => NoteValue::ThirtySecond,
|
||||
"1/64" => NoteValue::SixtyFourth,
|
||||
_ => return None,
|
||||
};
|
||||
Some(GridValue::Note { value, dotted, triplet })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grid_values_parse() {
|
||||
assert_eq!(grid_param(&json!({"v": "1/16"}), "v"), Some(GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }));
|
||||
assert_eq!(grid_param(&json!({"v": "1/8t"}), "v"), Some(GridValue::Note { value: NoteValue::Eighth, dotted: false, triplet: true }));
|
||||
assert_eq!(grid_param(&json!({"v": "1/4."}), "v"), Some(GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false }));
|
||||
assert_eq!(grid_param(&json!({"v": {"seconds": 0.5}}), "v"), Some(GridValue::Seconds(0.5)));
|
||||
assert_eq!(grid_param(&json!({"v": "nonsense"}), "v"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! A demo session whose audio is synthesised in code (no third-party media):
|
||||
//! a 24-bar groove at 104 BPM with drums, bass, keys, pad, lead, a reverb aux and a master.
|
||||
|
||||
use crate::Engine;
|
||||
use soundcraft_audio_io::{AudioBuffer, FileFormat};
|
||||
use soundcraft_midi::{Note, Sequence};
|
||||
use soundcraft_model::{ChannelFormat, Clip, Fade, FadeShape, Insert, MarkerKind, Route, SendSlot, Session, TrackKind};
|
||||
use soundcraft_time::{SampleRate, TICKS_PER_QUARTER};
|
||||
|
||||
const BPM: f64 = 104.0;
|
||||
const BARS: usize = 24;
|
||||
|
||||
/// A small deterministic PRNG for noise.
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> f32 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
((self.0 >> 40) as f32 / (1u64 << 24) as f32) * 2.0 - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(id: &str) -> Insert {
|
||||
let mut i = Insert::new(id);
|
||||
if let Some(info) = soundcraft_dsp::plugin_info(id) {
|
||||
for p in info.params {
|
||||
i.params.insert(p.id.to_string(), p.default);
|
||||
}
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn beat_len(sr: f64) -> usize {
|
||||
(sr * 60.0 / BPM) as usize
|
||||
}
|
||||
|
||||
fn kick(sr: f64, frames: usize, out: &mut [f32], at: usize, vel: f32) {
|
||||
let len = (sr * 0.45) as usize;
|
||||
let mut phase = 0.0f64;
|
||||
for i in 0..len.min(frames.saturating_sub(at)) {
|
||||
let t = i as f64 / sr;
|
||||
let f = 48.0 + 110.0 * (-t * 28.0).exp();
|
||||
phase += 2.0 * std::f64::consts::PI * f / sr;
|
||||
let env = (-t * 7.5).exp() as f32;
|
||||
let click = if i < 64 { (64 - i) as f32 / 64.0 * 0.3 } else { 0.0 };
|
||||
if let Some(o) = out.get_mut(at + i) {
|
||||
*o += ((phase.sin() as f32) * env + click) * vel * 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn snare(sr: f64, frames: usize, out: &mut [f32], at: usize, vel: f32, rng: &mut Rng) {
|
||||
let len = (sr * 0.3) as usize;
|
||||
let mut lp = 0.0f32;
|
||||
for i in 0..len.min(frames.saturating_sub(at)) {
|
||||
let t = i as f64 / sr;
|
||||
let n = rng.next();
|
||||
lp += 0.55 * (n - lp);
|
||||
let body = ((2.0 * std::f64::consts::PI * 185.0 * t).sin() * (-t * 22.0).exp()) as f32;
|
||||
let env = (-t * 14.0).exp() as f32;
|
||||
if let Some(o) = out.get_mut(at + i) {
|
||||
*o += ((n - lp) * env * 0.8 + body * 0.5) * vel * 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hat(sr: f64, frames: usize, out: &mut [f32], at: usize, vel: f32, open: bool, rng: &mut Rng) {
|
||||
let len = (sr * if open { 0.35 } else { 0.06 }) as usize;
|
||||
let mut prev = 0.0f32;
|
||||
for i in 0..len.min(frames.saturating_sub(at)) {
|
||||
let t = i as f64 / sr;
|
||||
let n = rng.next();
|
||||
let hp = n - prev;
|
||||
prev = n;
|
||||
let env = (-t * if open { 9.0 } else { 70.0 }).exp() as f32;
|
||||
if let Some(o) = out.get_mut(at + i) {
|
||||
*o += hp * env * vel * 0.25;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drums(sr: f64, frames: usize) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
|
||||
let mut k = vec![0.0; frames];
|
||||
let mut s = vec![0.0; frames];
|
||||
let mut h = vec![0.0; frames];
|
||||
let mut rng = Rng(0x9e37_79b9_7f4a_7c15);
|
||||
let beat = beat_len(sr);
|
||||
for bar in 0..BARS {
|
||||
let b0 = bar * beat * 4;
|
||||
// Intro: hats only for 4 bars.
|
||||
let full = bar >= 4;
|
||||
if full {
|
||||
for (pos, vel) in [(0usize, 1.0f32), (2 * beat + beat / 2, 0.8), (3 * beat, 0.6)] {
|
||||
kick(sr, frames, &mut k, b0 + pos, vel);
|
||||
}
|
||||
for pos in [beat, 3 * beat] {
|
||||
snare(sr, frames, &mut s, b0 + pos, 1.0, &mut rng);
|
||||
}
|
||||
if bar % 4 == 3 {
|
||||
snare(sr, frames, &mut s, b0 + 3 * beat + beat / 2, 0.5, &mut rng);
|
||||
snare(sr, frames, &mut s, b0 + 3 * beat + 3 * beat / 4, 0.7, &mut rng);
|
||||
}
|
||||
}
|
||||
for i in 0..8 {
|
||||
let open = full && i == 7;
|
||||
hat(sr, frames, &mut h, b0 + i * beat / 2, if i % 2 == 0 { 1.0 } else { 0.6 }, open, &mut rng);
|
||||
}
|
||||
}
|
||||
(k, s, h)
|
||||
}
|
||||
|
||||
fn midi_hz(n: f64) -> f64 {
|
||||
440.0 * 2f64.powf((n - 69.0) / 12.0)
|
||||
}
|
||||
|
||||
/// Chord roots per bar (A minor, F, C, G progression), as MIDI notes.
|
||||
const PROG: [[u8; 4]; 4] = [[57, 60, 64, 69], [53, 57, 60, 65], [48, 52, 55, 60], [55, 59, 62, 67]];
|
||||
|
||||
fn bass(sr: f64, frames: usize) -> Vec<f32> {
|
||||
let mut out = vec![0.0; frames];
|
||||
let beat = beat_len(sr);
|
||||
let mut lp = 0.0f64;
|
||||
for bar in 4..BARS {
|
||||
let root = f64::from(PROG[bar % 4][0]) - 24.0;
|
||||
let pattern = [(0usize, 0.0f64, 1.5f64), (2, 0.0, 0.5), (3, 12.0, 0.5), (5, 0.0, 0.5), (6, 7.0, 1.0)];
|
||||
for (eighth, semi, beats) in pattern {
|
||||
let at = bar * beat * 4 + eighth * beat / 2;
|
||||
let len = (beats * beat as f64) as usize;
|
||||
let f = midi_hz(root + semi);
|
||||
let mut ph = 0.0f64;
|
||||
for i in 0..len {
|
||||
let Some(o) = out.get_mut(at + i) else { break };
|
||||
ph = (ph + f / sr) % 1.0;
|
||||
let saw = 2.0 * ph - 1.0;
|
||||
let t = i as f64 / sr;
|
||||
let cutoff = 0.02 + 0.12 * (-t * 9.0).exp();
|
||||
lp += cutoff * (saw - lp);
|
||||
let env = (1.0 - (-t * 200.0).exp()) * if i + 400 > len { (len - i) as f64 / 400.0 } else { 1.0 };
|
||||
*o += (lp * env * 0.55) as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn pad(sr: f64, frames: usize) -> (Vec<f32>, Vec<f32>) {
|
||||
let mut l = vec![0.0; frames];
|
||||
let mut r = vec![0.0; frames];
|
||||
let bar_len = beat_len(sr) * 4;
|
||||
for bar in 0..BARS {
|
||||
let chord = PROG[bar % 4];
|
||||
let at = bar * bar_len;
|
||||
for (vi, n) in chord.iter().enumerate() {
|
||||
let f = midi_hz(f64::from(*n) + 12.0);
|
||||
for (side, buf) in [(-1.0f64, &mut l), (1.0, &mut r)] {
|
||||
let det = 1.0 + side * 0.0035 * (vi as f64 + 1.0);
|
||||
let mut ph = vi as f64 * 0.17;
|
||||
for i in 0..bar_len {
|
||||
let Some(o) = buf.get_mut(at + i) else { break };
|
||||
ph = (ph + f * det / sr) % 1.0;
|
||||
let tri = 1.0 - 4.0 * (ph - 0.5).abs();
|
||||
let t = i as f64 / bar_len as f64;
|
||||
let env = (t * 8.0).min(1.0) * ((1.0 - t) * 6.0).min(1.0);
|
||||
*o += (tri * env * 0.07) as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(l, r)
|
||||
}
|
||||
|
||||
fn note(pitch: u8, start: i64, len: i64, vel: u8) -> Note {
|
||||
Note { pitch, velocity: vel, release_velocity: 64, channel: 0, start, length: len }
|
||||
}
|
||||
|
||||
fn keys_sequence() -> Sequence {
|
||||
let q = TICKS_PER_QUARTER;
|
||||
let mut seq = Sequence::default();
|
||||
for bar in 0..(BARS - 4) as i64 {
|
||||
let chord = PROG[(bar as usize + 4) % 4];
|
||||
for (k, offs) in [(0i64, 0i64), (1, q + q / 2), (2, 3 * q)] {
|
||||
let _ = k;
|
||||
for n in &chord[1..] {
|
||||
seq.notes.push(note(*n, bar * 4 * q + offs, q - 40, 92));
|
||||
}
|
||||
}
|
||||
}
|
||||
seq.sort();
|
||||
seq
|
||||
}
|
||||
|
||||
fn lead_sequence() -> Sequence {
|
||||
let q = TICKS_PER_QUARTER;
|
||||
let e = q / 2;
|
||||
let phrase: [(u8, i64, i64); 10] =
|
||||
[(76, 0, 3), (74, 3, 1), (72, 4, 2), (74, 6, 2), (76, 8, 4), (79, 12, 2), (77, 14, 2), (76, 16, 6), (72, 22, 2), (74, 24, 8)];
|
||||
let mut seq = Sequence::default();
|
||||
for rep in 0..2i64 {
|
||||
for (p, st, ln) in phrase {
|
||||
seq.notes.push(note(p, rep * 32 * e + st * e, ln * e - 30, 100));
|
||||
}
|
||||
}
|
||||
seq.sort();
|
||||
seq
|
||||
}
|
||||
|
||||
/// Build the demo session.
|
||||
pub fn demo_session() -> Session {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let mut s = Session::new("Midnight Groove", sr);
|
||||
let _ = s.tempo.set_tempo(0, BPM);
|
||||
let srf = sr.as_f64();
|
||||
let bar = beat_len(srf) * 4;
|
||||
let total = bar * BARS + (srf * 2.0) as usize;
|
||||
let (k, sn, h) = drums(srf, total);
|
||||
let b = bass(srf, total);
|
||||
let (pl, pr) = pad(srf, total);
|
||||
let verb_bus = s.add_bus("Verb", ChannelFormat::Stereo);
|
||||
let add_audio = |s: &mut Session, name: &str, chans: Vec<Vec<f32>>, start_bar: usize, color: Option<usize>| {
|
||||
let fmt = ChannelFormat::for_channels(chans.len());
|
||||
let buf = AudioBuffer { sample_rate: sr.hz(), channels: chans };
|
||||
let src = crate::io::add_source(s, name, buf, None, FileFormat::Wav);
|
||||
let t = s.add_track(TrackKind::Audio, fmt, Some(name));
|
||||
if let (Some(c), Some(tr)) = (color, s.track_mut(t)) {
|
||||
tr.color = soundcraft_model::TRACK_COLORS[c % 16];
|
||||
}
|
||||
let start = (start_bar * bar) as i64;
|
||||
let len = (total - start_bar * bar) as i64;
|
||||
let cid = s.new_clip_id();
|
||||
let mut clip = Clip::audio(cid, name, src, start, start, len - (srf * 1.5) as i64);
|
||||
clip.fade_out = Fade { len: (srf * 0.5) as i64, shape: FadeShape::SCurve };
|
||||
crate::edit::place_clip(s, t, clip);
|
||||
t
|
||||
};
|
||||
let tk = add_audio(&mut s, "Kick", vec![k], 4, Some(7));
|
||||
let ts = add_audio(&mut s, "Snare", vec![sn], 4, Some(6));
|
||||
let th = add_audio(&mut s, "Hats", vec![h], 0, Some(5));
|
||||
let tb = add_audio(&mut s, "Bass", vec![b], 4, Some(10));
|
||||
let tp = add_audio(&mut s, "Pad", vec![pl, pr], 0, Some(1));
|
||||
// Split the snare into verse/chorus clips so the playlist shows several clips.
|
||||
for at in [12, 20] {
|
||||
crate::edit::separate_at(&mut s, ts, (at * bar) as i64);
|
||||
crate::edit::separate_at(&mut s, tk, (at * bar) as i64);
|
||||
}
|
||||
for (i, at) in [8usize, 16].iter().enumerate() {
|
||||
crate::edit::separate_at(&mut s, tb, (*at * bar) as i64);
|
||||
let _ = i;
|
||||
}
|
||||
// Instrument tracks with MIDI.
|
||||
let keys = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
|
||||
let lead = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Lead"));
|
||||
for (t, seq, start_bar, bars, color) in [(keys, keys_sequence(), 4usize, BARS - 4, 2usize), (lead, lead_sequence(), 12, 8, 9)] {
|
||||
let start = (start_bar * bar) as i64;
|
||||
let len = (bars * bar) as i64;
|
||||
let cid = s.new_clip_id();
|
||||
let name = s.track(t).map(|x| x.name.clone()).unwrap_or_default();
|
||||
crate::edit::place_clip(&mut s, t, Clip::midi(cid, name, start, len, seq));
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
tr.instrument = Some(insert("subtractive_synth"));
|
||||
tr.color = soundcraft_model::TRACK_COLORS[color];
|
||||
tr.mixer.volume_db = if t == keys { -9.0 } else { -12.0 };
|
||||
}
|
||||
}
|
||||
// Reverb aux.
|
||||
let aux = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("Verb"));
|
||||
if let Some(tr) = s.track_mut(aux) {
|
||||
tr.mixer.input = Route::Bus(verb_bus);
|
||||
tr.mixer.inserts[0] = Some(insert("plate_reverb"));
|
||||
tr.mixer.volume_db = -4.0;
|
||||
tr.color = soundcraft_model::TRACK_COLORS[12];
|
||||
}
|
||||
for (t, lvl) in [(ts, -10.0f32), (tp, -8.0), (keys, -12.0), (lead, -6.0)] {
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
let mut snd = SendSlot::new(Route::Bus(verb_bus));
|
||||
snd.level_db = lvl;
|
||||
tr.mixer.sends[0] = Some(snd);
|
||||
}
|
||||
}
|
||||
// Mix moves.
|
||||
for (t, db, pan) in [(tk, -3.0f32, 0.0f32), (ts, -4.0, 0.0), (th, -10.0, 0.35), (tb, -5.0, 0.0), (tp, -8.0, 0.0)] {
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
tr.mixer.volume_db = db;
|
||||
if tr.mixer.pan.len() == 1 {
|
||||
tr.mixer.pan = vec![pan];
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(tr) = s.track_mut(tk) {
|
||||
tr.mixer.inserts[0] = Some(insert("eq_7band"));
|
||||
tr.mixer.inserts[1] = Some(insert("compressor"));
|
||||
}
|
||||
if let Some(tr) = s.track_mut(tb) {
|
||||
tr.mixer.inserts[0] = Some(insert("compressor"));
|
||||
tr.mixer.inserts[1] = Some(insert("saturator"));
|
||||
}
|
||||
if let Some(tr) = s.track_mut(tp) {
|
||||
tr.mixer.inserts[0] = Some(insert("chorus"));
|
||||
// Pad swells in with volume automation.
|
||||
let l = tr.lane_mut(&soundcraft_model::AutoParam::Volume);
|
||||
l.set_point(0, -30.0);
|
||||
l.set_point((4 * bar) as i64, -8.0);
|
||||
l.set_point((20 * bar) as i64, -8.0);
|
||||
l.set_point((24 * bar) as i64, -24.0);
|
||||
}
|
||||
let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, Some("Master"));
|
||||
if let Some(tr) = s.track_mut(m) {
|
||||
tr.mixer.inserts[0] = Some(insert("maximizer"));
|
||||
}
|
||||
// Markers.
|
||||
for (name, at) in [("Intro", 0usize), ("Verse", 4), ("Chorus", 12), ("Outro", 20)] {
|
||||
s.add_marker(name, MarkerKind::Marker, (at * bar) as i64, (at * bar) as i64);
|
||||
}
|
||||
s.edit.selected_tracks = vec![tk];
|
||||
s.edit.selection = soundcraft_time::Range::point((4 * bar) as i64);
|
||||
s.edit.zoom.samples_per_px = (total as f64 / 1400.0).max(1.0);
|
||||
s.edit.main_counter = soundcraft_time::TimeFormat::BarsBeats;
|
||||
s
|
||||
}
|
||||
|
||||
/// The demo is deterministic; build it once per process and clone (sources are shared `Arc`s).
|
||||
pub fn cached_demo_session() -> Session {
|
||||
static DEMO: std::sync::OnceLock<Session> = std::sync::OnceLock::new();
|
||||
DEMO.get_or_init(demo_session).clone()
|
||||
}
|
||||
|
||||
pub fn demo_engine() -> Engine {
|
||||
Engine::new(cached_demo_session())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn demo_builds_and_renders_sound() {
|
||||
let s = demo_session();
|
||||
assert!(s.tracks.len() >= 9);
|
||||
assert!(s.master().is_some());
|
||||
let sr = s.sample_rate;
|
||||
let r = soundcraft_time::Range::new(sr.samples(10.0), sr.samples(12.0));
|
||||
let out = soundcraft_mix::render_range(&s, r, 1024);
|
||||
let pk = out.iter().flat_map(|c| c.iter()).fold(0.0f32, |m, v| m.max(v.abs()));
|
||||
assert!(pk > 0.05, "demo should be audible, peak {pk}");
|
||||
assert!(pk <= 1.5, "demo should not be wildly clipping, peak {pk}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
//! Editing algorithms on playlists, independent of the command layer.
|
||||
//!
|
||||
//! Model rules: clips on a playlist are sorted and do not overlap, except where two clips
|
||||
//! overlap exactly over a crossfade (the earlier clip fades out while the later fades in).
|
||||
//! Placing a clip overwrites whatever is under it (like the incumbent's Slip mode); Shuffle
|
||||
//! mode also ripples later clips.
|
||||
|
||||
use soundcraft_model::{Clip, ClipContent, ClipId, Fade, FadeShape, Session, TrackId};
|
||||
use soundcraft_time::{Range, SampleRate, Samples, TempoMap};
|
||||
|
||||
/// Context needed to split MIDI clips in musical time.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct TimeCtx<'a> {
|
||||
pub tempo: &'a TempoMap,
|
||||
pub sr: SampleRate,
|
||||
}
|
||||
|
||||
impl<'a> TimeCtx<'a> {
|
||||
pub fn of(s: &'a Session) -> Self {
|
||||
TimeCtx { tempo: &s.tempo, sr: s.sample_rate }
|
||||
}
|
||||
fn ticks(&self, s: Samples) -> i64 {
|
||||
self.tempo.samples_to_ticks(s, self.sr)
|
||||
}
|
||||
}
|
||||
|
||||
/// Split `clip` at absolute position `at` (strictly inside). Returns (left, right) where right gets `new_id`.
|
||||
pub fn split_clip(clip: &Clip, at: Samples, new_id: ClipId, t: TimeCtx<'_>) -> Option<(Clip, Clip)> {
|
||||
if at <= clip.start || at >= clip.end() {
|
||||
return None;
|
||||
}
|
||||
let mut left = clip.clone();
|
||||
let mut right = clip.clone();
|
||||
right.id = new_id;
|
||||
left.trim_end_to(at);
|
||||
right.trim_start_to(at);
|
||||
left.fade_out = Fade::default();
|
||||
right.fade_in = Fade::default();
|
||||
if let (ClipContent::Midi { sequence: ls }, ClipContent::Midi { sequence: rs }) = (&mut left.content, &mut right.content) {
|
||||
let boundary = t.ticks(at) - t.ticks(clip.start);
|
||||
ls.notes.retain(|n| n.start < boundary);
|
||||
ls.ctrls.retain(|c| c.tick < boundary);
|
||||
rs.notes.retain(|n| n.start >= boundary);
|
||||
rs.ctrls.retain(|c| c.tick >= boundary);
|
||||
for n in &mut rs.notes {
|
||||
n.start -= boundary;
|
||||
}
|
||||
for c in &mut rs.ctrls {
|
||||
c.tick -= boundary;
|
||||
}
|
||||
}
|
||||
Some((left, right))
|
||||
}
|
||||
|
||||
/// Trim a MIDI clip's start: drop notes before the new start and re-anchor the rest.
|
||||
fn rebase_midi_start(clip: &mut Clip, old_start: Samples, t: TimeCtx<'_>) {
|
||||
let delta = t.ticks(clip.start) - t.ticks(old_start);
|
||||
if let ClipContent::Midi { sequence } = &mut clip.content {
|
||||
if delta == 0 {
|
||||
return;
|
||||
}
|
||||
sequence.notes.retain(|n| n.start >= delta);
|
||||
sequence.ctrls.retain(|c| c.tick >= delta);
|
||||
for n in &mut sequence.notes {
|
||||
n.start -= delta;
|
||||
}
|
||||
for c in &mut sequence.ctrls {
|
||||
c.tick -= delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split every clip on a track's active playlist that spans `at`.
|
||||
pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
let tc_tempo = s.tempo.clone();
|
||||
let sr = s.sample_rate;
|
||||
let t = TimeCtx { tempo: &tc_tempo, sr };
|
||||
let mut ids = Vec::new();
|
||||
let n_split = s.track(track).map_or(0, |tr| tr.clips().iter().filter(|c| at > c.start && at < c.end()).count());
|
||||
for _ in 0..n_split {
|
||||
ids.push(s.new_clip_id());
|
||||
}
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut out = Vec::with_capacity(pl.clips.len() + ids.len());
|
||||
let mut count = 0;
|
||||
for c in pl.clips.drain(..) {
|
||||
if at > c.start
|
||||
&& at < c.end()
|
||||
&& let Some(id) = ids.pop()
|
||||
&& let Some((l, r)) = split_clip(&c, at, id, t)
|
||||
{
|
||||
out.push(l);
|
||||
out.push(r);
|
||||
count += 1;
|
||||
continue;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
pl.clips = out;
|
||||
pl.sort();
|
||||
count
|
||||
}
|
||||
|
||||
/// Remove material inside `range` on a track. In shuffle mode later clips move left.
|
||||
/// Returns the removed material as clips positioned relative to `range.start`.
|
||||
pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool) -> Vec<Clip> {
|
||||
if range.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
separate_at(s, track, range.start);
|
||||
separate_at(s, track, range.end);
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return Vec::new() };
|
||||
let mut removed = Vec::new();
|
||||
pl.clips.retain(|c| {
|
||||
if c.start >= range.start && c.end() <= range.end {
|
||||
let mut r = c.clone();
|
||||
r.start -= range.start;
|
||||
removed.push(r);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
if shuffle {
|
||||
let len = range.len();
|
||||
for c in &mut pl.clips {
|
||||
if c.start >= range.end {
|
||||
c.start -= len;
|
||||
}
|
||||
}
|
||||
}
|
||||
pl.sort();
|
||||
removed
|
||||
}
|
||||
|
||||
/// Copy the material inside `range` (clips trimmed to the range), relative to `range.start`.
|
||||
pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec<Clip> {
|
||||
let t = TimeCtx::of(s);
|
||||
let Some(tr) = s.track(track) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for c in tr.clips() {
|
||||
let Some(isect) = c.range().intersect(&range) else { continue };
|
||||
let mut part = c.clone();
|
||||
if isect.start > part.start {
|
||||
let old = part.start;
|
||||
part.trim_start_to(isect.start);
|
||||
rebase_midi_start(&mut part, old, t);
|
||||
}
|
||||
if isect.end < part.end() {
|
||||
part.trim_end_to(isect.end);
|
||||
}
|
||||
part.start -= range.start;
|
||||
out.push(part);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Insert `clips` (positions relative to 0) at `at`. Overwrites what's underneath unless
|
||||
/// `shuffle`, which first pushes later material right by `length`.
|
||||
pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], length: Samples, shuffle: bool) -> Vec<ClipId> {
|
||||
let length = length.max(clips.iter().map(Clip::end).max().unwrap_or(0));
|
||||
if shuffle {
|
||||
separate_at(s, track, at);
|
||||
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
|
||||
for c in &mut pl.clips {
|
||||
if c.start >= at {
|
||||
c.start += length;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
for c in clips {
|
||||
let mut nc = c.clone();
|
||||
nc.id = s.new_clip_id();
|
||||
nc.start += at;
|
||||
ids.push(nc.id);
|
||||
place_clip(s, track, nc);
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// Place one clip, overwriting whatever it covers.
|
||||
pub fn place_clip(s: &mut Session, track: TrackId, clip: Clip) {
|
||||
clear_range(s, track, clip.range(), false);
|
||||
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips.push(clip);
|
||||
pl.sort();
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert silence: shift material at/after `range.start` right by the range length.
|
||||
pub fn insert_silence(s: &mut Session, track: TrackId, range: Range) {
|
||||
if range.is_empty() {
|
||||
return;
|
||||
}
|
||||
separate_at(s, track, range.start);
|
||||
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
|
||||
for c in &mut pl.clips {
|
||||
if c.start >= range.start {
|
||||
c.start += range.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim clips to keep only material inside `range`.
|
||||
pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
let tempo = s.tempo.clone();
|
||||
let tc = TimeCtx { tempo: &tempo, sr: s.sample_rate };
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut n = 0;
|
||||
for c in &mut pl.clips {
|
||||
if let Some(i) = c.range().intersect(&range)
|
||||
&& i != c.range()
|
||||
{
|
||||
let old = c.start;
|
||||
c.trim_start_to(i.start);
|
||||
rebase_midi_start(c, old, tc);
|
||||
c.trim_end_to(i.end);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Trim the start of clips under `at` to `at` (Trim Start to Insertion).
|
||||
pub fn trim_start_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
let tempo = s.tempo.clone();
|
||||
let tc = TimeCtx { tempo: &tempo, sr: s.sample_rate };
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut n = 0;
|
||||
for c in &mut pl.clips {
|
||||
if c.range().contains(at) && at > c.start {
|
||||
let old = c.start;
|
||||
c.trim_start_to(at);
|
||||
rebase_midi_start(c, old, tc);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
pub fn trim_end_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut n = 0;
|
||||
for c in &mut pl.clips {
|
||||
if at > c.start && at < c.end() {
|
||||
c.trim_end_to(at);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Heal separations inside `range`: merge neighbouring audio clips that continue the same file.
|
||||
pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
pl.sort();
|
||||
let mut out: Vec<Clip> = Vec::with_capacity(pl.clips.len());
|
||||
let mut healed = 0;
|
||||
for c in pl.clips.drain(..) {
|
||||
if let Some(prev) = out.last_mut() {
|
||||
let contiguous = prev.end() == c.start && c.start >= range.start && c.start <= range.end;
|
||||
let same = match (&prev.content, &c.content) {
|
||||
(ClipContent::Audio { source: a, offset: oa }, ClipContent::Audio { source: b, offset: ob }) => a == b && oa + prev.length == *ob,
|
||||
_ => false,
|
||||
};
|
||||
if contiguous && same && (prev.gain_db - c.gain_db).abs() < 1e-6 {
|
||||
prev.length += c.length;
|
||||
prev.fade_out = c.fade_out;
|
||||
healed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
pl.clips = out;
|
||||
healed
|
||||
}
|
||||
|
||||
/// Move clips by `delta` (and optionally to another track), overwriting what they land on.
|
||||
pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, to_track: Option<TrackId>) -> usize {
|
||||
let mut moving: Vec<(TrackId, Clip)> = Vec::new();
|
||||
for t in &mut s.tracks {
|
||||
let tid = t.id;
|
||||
if let Some(pl) = t.playlist_mut() {
|
||||
pl.clips.retain(|c| {
|
||||
if ids.contains(&c.id) && !c.time_locked && !c.edit_locked {
|
||||
moving.push((tid, c.clone()));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let n = moving.len();
|
||||
for (tid, mut c) in moving {
|
||||
c.start = c.start.saturating_add(delta).max(0);
|
||||
place_clip(s, to_track.unwrap_or(tid), c);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Create fades from a selection (the incumbent's Edit › Fades › Create semantics):
|
||||
/// a range across a clip boundary makes a crossfade; at a clip start a fade-in; at its end a fade-out.
|
||||
pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeShape) -> usize {
|
||||
if range.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let handles: Vec<(ClipId, Samples)> = {
|
||||
let Some(tr) = s.track(track) else { return 0 };
|
||||
tr.clips()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let avail = match c.content {
|
||||
ClipContent::Audio { source, offset } => {
|
||||
let total = s.source(source).map_or(0, |src| i64::try_from(src.frames).unwrap_or(i64::MAX));
|
||||
total.saturating_sub(offset).saturating_sub(c.length)
|
||||
}
|
||||
ClipContent::Midi { .. } => 0,
|
||||
};
|
||||
(c.id, avail)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
pl.sort();
|
||||
let mut made = 0;
|
||||
let n = pl.clips.len();
|
||||
// Crossfades between adjacent clips whose boundary is inside the range.
|
||||
for i in 0..n.saturating_sub(1) {
|
||||
let (Some(a), Some(b)) = (pl.clips.get(i).cloned(), pl.clips.get(i + 1).cloned()) else { continue };
|
||||
let boundary = a.end();
|
||||
if b.start == boundary && range.start < boundary && range.end > boundary {
|
||||
let before = boundary - range.start.max(a.start);
|
||||
let after = range.end.min(b.end()) - boundary;
|
||||
let a_handle = handles.iter().find(|h| h.0 == a.id).map_or(0, |h| h.1);
|
||||
let b_handle = b.source_offset();
|
||||
// Extend each clip into the other's territory using its handles.
|
||||
let ext_a = after.min(a_handle).max(0);
|
||||
let ext_b = before.min(b_handle).max(0);
|
||||
if let Some(ca) = pl.clips.get_mut(i) {
|
||||
ca.length += ext_a;
|
||||
ca.fade_out = Fade { len: (ext_a + before).min(ca.length), shape };
|
||||
}
|
||||
if let Some(cb) = pl.clips.get_mut(i + 1) {
|
||||
let old = cb.start;
|
||||
cb.trim_start_to(old - ext_b);
|
||||
cb.fade_in = Fade { len: (ext_b + after).min(cb.length), shape };
|
||||
}
|
||||
made += 1;
|
||||
}
|
||||
}
|
||||
for c in &mut pl.clips {
|
||||
if range.start <= c.start && range.end > c.start && range.end < c.end() && c.fade_in.len == 0 {
|
||||
c.fade_in = Fade { len: range.end - c.start, shape };
|
||||
made += 1;
|
||||
} else if range.end >= c.end() && range.start > c.start && range.start < c.end() && c.fade_out.len == 0 {
|
||||
c.fade_out = Fade { len: c.end() - range.start, shape };
|
||||
made += 1;
|
||||
}
|
||||
}
|
||||
made
|
||||
}
|
||||
|
||||
/// Remove fades on clips touching `range`.
|
||||
pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut n = 0;
|
||||
for c in &mut pl.clips {
|
||||
if c.range().overlaps(&range) || (range.is_empty() && c.range().contains(range.start)) {
|
||||
if c.fade_in.len > 0 || c.fade_out.len > 0 {
|
||||
n += 1;
|
||||
}
|
||||
c.fade_in = Fade::default();
|
||||
c.fade_out = Fade::default();
|
||||
}
|
||||
}
|
||||
// Overlapping (crossfaded) neighbours are trimmed back to the midpoint.
|
||||
pl.sort();
|
||||
for i in 0..pl.clips.len().saturating_sub(1) {
|
||||
let (Some(a_end), Some(b_start)) = (pl.clips.get(i).map(Clip::end), pl.clips.get(i + 1).map(|c| c.start)) else { continue };
|
||||
if a_end > b_start {
|
||||
let mid = b_start + (a_end - b_start) / 2;
|
||||
if let Some(a) = pl.clips.get_mut(i) {
|
||||
a.trim_end_to(mid);
|
||||
}
|
||||
if let Some(b) = pl.clips.get_mut(i + 1) {
|
||||
b.trim_start_to(mid);
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use soundcraft_model::{ChannelFormat, Source, SourceId, TrackKind};
|
||||
|
||||
fn session_with_clip() -> (Session, TrackId) {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.sources.push(Source {
|
||||
id: SourceId(500),
|
||||
name: "src".into(),
|
||||
path: "src.wav".into(),
|
||||
channels: 1,
|
||||
frames: 10_000,
|
||||
sample_rate: 48_000,
|
||||
format: soundcraft_audio_io::FileFormat::Wav,
|
||||
time_reference: 0,
|
||||
unsaved: false,
|
||||
});
|
||||
let id = s.new_clip_id();
|
||||
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips.push(Clip::audio(id, "a", SourceId(500), 1000, 1000, 4000));
|
||||
}
|
||||
(s, t)
|
||||
}
|
||||
|
||||
fn clips(s: &Session, t: TrackId) -> Vec<(Samples, Samples, Samples)> {
|
||||
s.track(t).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_splits_and_heal_rejoins() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
assert_eq!(separate_at(&mut s, t, 3000), 1);
|
||||
assert_eq!(clips(&s, t), vec![(1000, 2000, 1000), (3000, 2000, 3000)]);
|
||||
assert_eq!(separate_at(&mut s, t, 1000), 0);
|
||||
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 1);
|
||||
assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_slip_and_shuffle() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
let removed = clear_range(&mut s, t, Range::new(2000, 3000), false);
|
||||
assert_eq!(removed.len(), 1);
|
||||
assert_eq!(removed[0].start, 0);
|
||||
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (3000, 2000, 3000)]);
|
||||
let (mut s, t) = session_with_clip();
|
||||
clear_range(&mut s, t, Range::new(2000, 3000), true);
|
||||
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 2000, 3000)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_overwrites() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
let copied = copy_range(&s, t, Range::new(1500, 2500));
|
||||
assert_eq!(copied.len(), 1);
|
||||
assert_eq!((copied[0].start, copied[0].length, copied[0].source_offset()), (0, 1000, 1500));
|
||||
paste(&mut s, t, 4000, &copied, 1000, false);
|
||||
assert_eq!(clips(&s, t), vec![(1000, 3000, 1000), (4000, 1000, 1500)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_shuffle_ripples() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
let copied = copy_range(&s, t, Range::new(1000, 2000));
|
||||
paste(&mut s, t, 1000, &copied, 1000, true);
|
||||
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 4000, 1000)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_silence_and_trim() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
insert_silence(&mut s, t, Range::new(2000, 2500));
|
||||
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2500, 3000, 2000)]);
|
||||
let (mut s, t) = session_with_clip();
|
||||
trim_to(&mut s, t, Range::new(1500, 2000));
|
||||
assert_eq!(clips(&s, t), vec![(1500, 500, 1500)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fades_and_crossfades() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
assert_eq!(create_fades(&mut s, t, Range::new(500, 1500), FadeShape::Linear), 1);
|
||||
assert_eq!(s.track(t).unwrap().clips()[0].fade_in.len, 500);
|
||||
let (mut s, t) = session_with_clip();
|
||||
separate_at(&mut s, t, 3000);
|
||||
assert_eq!(create_fades(&mut s, t, Range::new(2900, 3100), FadeShape::EqualPower), 1);
|
||||
let c = s.track(t).unwrap().clips();
|
||||
assert_eq!(c[0].end(), 3100);
|
||||
assert_eq!(c[1].start, 2900);
|
||||
assert_eq!(c[0].fade_out.len, 200);
|
||||
assert_eq!(c[1].fade_in.len, 200);
|
||||
delete_fades(&mut s, t, Range::new(2900, 3100));
|
||||
let c = s.track(t).unwrap().clips();
|
||||
assert_eq!((c[0].end(), c[1].start), (3000, 3000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_overwrite_and_respect_locks() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
let id = s.track(t).unwrap().clips()[0].id;
|
||||
assert_eq!(move_clips(&mut s, &[id], 500, None), 1);
|
||||
assert_eq!(clips(&s, t), vec![(1500, 4000, 1000)]);
|
||||
s.find_clip_mut(id).unwrap().time_locked = true;
|
||||
assert_eq!(move_clips(&mut s, &[id], 500, None), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! JSON views of the session for agents (so they can verify their work without screenshots).
|
||||
|
||||
use crate::Engine;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{Clip, ClipContent, ClipId, Track, TrackId};
|
||||
use soundcraft_time::{TimeFormat, format_position};
|
||||
|
||||
fn clip_json(e: &Engine, c: &Clip) -> Value {
|
||||
let s = e.session();
|
||||
let fmt = |x| format_position(x, TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
|
||||
let mut v = json!({
|
||||
"id": c.id, "name": c.name, "start": c.start, "end": c.end(), "length": c.length,
|
||||
"start_time": fmt(c.start), "gain_db": c.gain_db, "muted": c.muted,
|
||||
"fade_in": c.fade_in.len, "fade_out": c.fade_out.len, "locked": c.edit_locked || c.time_locked,
|
||||
});
|
||||
match &c.content {
|
||||
ClipContent::Audio { source, offset } => {
|
||||
v["source"] = json!(source);
|
||||
v["offset"] = json!(offset);
|
||||
}
|
||||
ClipContent::Midi { sequence } => {
|
||||
v["notes"] = json!(sequence.notes.len());
|
||||
}
|
||||
}
|
||||
if !c.gain_env.is_empty() {
|
||||
v["gain_envelope"] = json!(c.gain_env);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn track_json(e: &Engine, t: &Track, full: bool) -> Value {
|
||||
let inserts: Vec<Value> = t
|
||||
.mixer
|
||||
.inserts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, x)| {
|
||||
x.as_ref().map(|x| json!({"slot": i, "plugin": x.plugin, "bypass": x.bypass, "params": if full { json!(x.params) } else { Value::Null }}))
|
||||
})
|
||||
.collect();
|
||||
let sends: Vec<Value> = t
|
||||
.mixer
|
||||
.sends
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, x)| {
|
||||
x.as_ref().map(|x| json!({"slot": i, "target": x.target, "level_db": x.level_db, "pan": x.pan, "mute": x.mute, "pre_fader": x.pre_fader}))
|
||||
})
|
||||
.collect();
|
||||
let mut v = json!({
|
||||
"id": t.id, "name": t.name, "kind": t.kind.id(), "format": t.format.label(),
|
||||
"volume_db": t.mixer.volume_db, "pan": t.mixer.pan, "mute": t.mixer.mute, "solo": t.mixer.solo,
|
||||
"record_arm": t.mixer.record_arm, "input": t.mixer.input, "output": t.mixer.output,
|
||||
"automation_mode": t.mixer.automation_mode.label(), "inserts": inserts, "sends": sends,
|
||||
"clips": t.clips().iter().map(|c| clip_json(e, c)).collect::<Vec<_>>(),
|
||||
"playlist": t.playlist().map(|p| p.name.clone()), "playlists": t.playlists.len(),
|
||||
"hidden": t.hidden, "inactive": t.inactive, "view": t.view, "height": t.height.label(),
|
||||
"automation": t.automation.iter().filter(|l| !l.points.is_empty()).map(|l| json!({"param": l.param.label(), "points": if full { json!(l.points) } else { json!(l.points.len()) }})).collect::<Vec<_>>(),
|
||||
});
|
||||
if let Some(i) = &t.instrument {
|
||||
v["instrument"] = json!(i.plugin);
|
||||
}
|
||||
if !t.comments.is_empty() {
|
||||
v["comments"] = json!(t.comments);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub fn session(e: &Engine, full: bool) -> Value {
|
||||
let s = e.session();
|
||||
json!({
|
||||
"name": s.name, "path": e.path, "dirty": e.is_dirty(), "sample_rate": s.sample_rate.hz(), "bit_depth": s.bit_depth,
|
||||
"frame_rate": s.frame_rate.label(), "tempo": s.tempo.tempos(), "meter": s.tempo.meters(),
|
||||
"length_samples": s.content_end(),
|
||||
"tracks": s.tracks.iter().map(|t| track_json(e, t, full)).collect::<Vec<_>>(),
|
||||
"busses": s.busses.iter().map(|b| json!({"id": b.id, "name": b.name, "format": b.format.label()})).collect::<Vec<_>>(),
|
||||
"markers": s.markers.iter().map(|m| json!({"id": m.id, "number": m.number, "name": m.name, "kind": m.kind, "start": m.start, "end": m.end})).collect::<Vec<_>>(),
|
||||
"groups": s.groups.iter().map(|g| json!({"id": g.id, "name": g.name, "letter": g.letter, "members": g.members, "active": g.active})).collect::<Vec<_>>(),
|
||||
"sources": s.sources.iter().map(|x| json!({"id": x.id, "name": x.name, "channels": x.channels, "frames": x.frames, "path": x.path})).collect::<Vec<_>>(),
|
||||
"selection": {"start": s.edit.selection.start, "end": s.edit.selection.end, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips},
|
||||
"edit_mode": s.edit.edit_mode, "tool": s.edit.tool, "grid": s.edit.grid.label(), "nudge": s.edit.nudge.label(),
|
||||
"transport": e.transport,
|
||||
"undo": e.undo_label(), "redo": e.redo_label(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn track(e: &Engine, t: TrackId) -> Option<Value> {
|
||||
e.session().track(t).map(|t| track_json(e, t, true))
|
||||
}
|
||||
|
||||
pub fn clip(e: &Engine, id: ClipId) -> Option<Value> {
|
||||
e.session().find_clip(id).map(|(t, c)| {
|
||||
let mut v = clip_json(e, c);
|
||||
v["track"] = json!(t);
|
||||
if let ClipContent::Midi { sequence } = &c.content {
|
||||
v["note_list"] = json!(sequence.notes);
|
||||
}
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
/// Plain-text session report (File › Export › Session Info as Text).
|
||||
pub fn session_text(e: &Engine) -> String {
|
||||
let s = e.session();
|
||||
let fmt = |x| format_position(x, TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!("SESSION NAME:\t{}\nSAMPLE RATE:\t{}\nBIT DEPTH:\t{:?}\nTIMECODE FORMAT:\t{}\n# OF AUDIO TRACKS:\t{}\n# OF AUDIO CLIPS:\t{}\n# OF AUDIO FILES:\t{}\n\n",
|
||||
s.name, s.sample_rate.hz(), s.bit_depth, s.frame_rate.label(),
|
||||
s.tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).count(),
|
||||
s.tracks.iter().map(|t| t.clips().len()).sum::<usize>(), s.sources.len()));
|
||||
out.push_str("F I L E S I N S E S S I O N\nFilename\tLocation\n");
|
||||
for src in &s.sources {
|
||||
out.push_str(&format!("{}\t{}\n", src.name, src.path));
|
||||
}
|
||||
out.push_str("\nT R A C K L I S T I N G\n");
|
||||
for t in &s.tracks {
|
||||
out.push_str(&format!(
|
||||
"TRACK NAME:\t{}\nCOMMENTS:\t{}\nSTATE:\t{}{}{}\n",
|
||||
t.name,
|
||||
t.comments,
|
||||
if t.inactive { "Inactive " } else { "" },
|
||||
if t.mixer.mute { "Muted " } else { "" },
|
||||
if t.mixer.solo { "Solo" } else { "" }
|
||||
));
|
||||
out.push_str("CHANNEL\tEVENT\tCLIP NAME\tSTART TIME\tEND TIME\tDURATION\tSTATE\n");
|
||||
for (i, c) in t.clips().iter().enumerate() {
|
||||
out.push_str(&format!(
|
||||
"1\t{}\t{}\t{}\t{}\t{}\t{}\n",
|
||||
i + 1,
|
||||
c.name,
|
||||
fmt(c.start),
|
||||
fmt(c.end()),
|
||||
fmt(c.length),
|
||||
if c.muted { "Muted" } else { "Unmuted" }
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str("M A R K E R S L I S T I N G\n#\tLOCATION\tNAME\n");
|
||||
for m in &s.markers {
|
||||
out.push_str(&format!("{}\t{}\t{}\n", m.number, fmt(m.start), m.name));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! Import, export, save/load, rendering helpers (bounce, consolidate, commit) and analysis.
|
||||
|
||||
use crate::{Engine, EngineError, Result};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat};
|
||||
use soundcraft_model::{ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
|
||||
use soundcraft_time::{Range, Samples};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
|
||||
const MAX_IMPORT_SAMPLES: usize = 1 << 30;
|
||||
|
||||
/// Register decoded audio as a new source. Resamples to the session rate if needed.
|
||||
pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Option<&str>, format: FileFormat) -> SourceId {
|
||||
let sr = s.sample_rate.hz();
|
||||
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;
|
||||
}
|
||||
let id = SourceId(s.alloc());
|
||||
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or(name).to_string();
|
||||
s.sources.push(Source {
|
||||
id,
|
||||
name: stem.clone(),
|
||||
path: path.map_or_else(|| format!("Audio Files/{stem}.wav"), str::to_string),
|
||||
channels: u16::try_from(buf.num_channels()).unwrap_or(u16::MAX),
|
||||
frames: buf.frames() as u64,
|
||||
sample_rate: sr,
|
||||
format,
|
||||
time_reference: 0,
|
||||
unsaved: true,
|
||||
});
|
||||
s.pool.insert(id, Arc::new(SourceAudio::new(buf)));
|
||||
id
|
||||
}
|
||||
|
||||
/// Decode and place an audio file. With no target track, a new track of matching width is made.
|
||||
pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> {
|
||||
let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase);
|
||||
let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
|
||||
if buf.frames().saturating_mul(buf.num_channels().max(1)) > MAX_IMPORT_SAMPLES {
|
||||
return Err(EngineError::Io(format!("{name}: file is too large to import")));
|
||||
}
|
||||
if buf.frames() == 0 {
|
||||
return Err(EngineError::Io(format!("{name}: no audio")));
|
||||
}
|
||||
let channels = buf.num_channels();
|
||||
let s = e.session_mut();
|
||||
let src = add_source(s, name, buf, path, info.format);
|
||||
if let (Some(bwf), Some(sx)) = (info.bwf.as_ref(), s.sources.last_mut()) {
|
||||
sx.time_reference = bwf.time_reference;
|
||||
}
|
||||
let frames = s.source(src).map_or(0, |x| i64::try_from(x.frames).unwrap_or(0));
|
||||
let track = match target {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or("Audio").to_string();
|
||||
s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels.min(2)), Some(&stem))
|
||||
}
|
||||
};
|
||||
let cid = s.new_clip_id();
|
||||
let stem = s.source(src).map(|x| x.name.clone()).unwrap_or_default();
|
||||
crate::edit::place_clip(s, track, Clip::audio(cid, stem, src, 0, at, frames));
|
||||
Ok(json!({"track": track, "clip": cid, "source": src, "frames": frames, "channels": channels}))
|
||||
}
|
||||
|
||||
/// Import a Standard MIDI File as MIDI tracks (one per SMF track).
|
||||
pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: bool) -> Result<Vec<TrackId>> {
|
||||
let smf = soundcraft_midi::read_smf(bytes).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let s = e.session_mut();
|
||||
if tempo_map {
|
||||
for (tick, bpm) in &smf.tempos {
|
||||
let _ = s.tempo.set_tempo(*tick, *bpm);
|
||||
}
|
||||
for (tick, n, d) in &smf.meters {
|
||||
let _ = s.tempo.set_meter(*tick, u32::from(*n), u32::from(*d));
|
||||
}
|
||||
}
|
||||
let base_tick = s.tempo.samples_to_ticks(at, s.sample_rate);
|
||||
let mut out = Vec::new();
|
||||
for (i, tr) in smf.tracks.iter().enumerate() {
|
||||
if tr.sequence.notes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let name = if tr.name.trim().is_empty() { format!("MIDI {}", i + 1) } else { tr.name.clone() };
|
||||
let id = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some(&name));
|
||||
let drums = tr.sequence.notes.iter().all(|n| n.channel == 9);
|
||||
if let Some(t) = s.track_mut(id) {
|
||||
t.instrument = Some(soundcraft_model::Insert::new(if drums { "drum_synth" } else { "subtractive_synth" }));
|
||||
}
|
||||
let end_tick = base_tick + tr.sequence.end_tick().max(1);
|
||||
let len = s.tempo.tick_to_samples(end_tick, s.sample_rate) - at;
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(s, id, Clip::midi(cid, name, at, len.max(1), tr.sequence.clone()));
|
||||
out.push(id);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Export all MIDI clips as a format-1 SMF.
|
||||
pub fn export_midi(e: &Engine) -> Result<Vec<u8>> {
|
||||
let s = e.session();
|
||||
let mut smf = soundcraft_midi::Smf {
|
||||
format: 1,
|
||||
tracks: Vec::new(),
|
||||
tempos: s.tempo.tempos().iter().map(|t| (t.tick, t.bpm)).collect(),
|
||||
meters: s.tempo.meters().iter().map(|m| (m.tick, u8::try_from(m.numerator).unwrap_or(4), u8::try_from(m.denominator).unwrap_or(4))).collect(),
|
||||
markers: s.markers.iter().map(|m| (s.tempo.samples_to_ticks(m.start, s.sample_rate), m.name.clone())).collect(),
|
||||
key_sigs: Vec::new(),
|
||||
};
|
||||
for t in s.tracks.iter().filter(|t| t.kind.is_midi()) {
|
||||
let mut seq = soundcraft_midi::Sequence::default();
|
||||
for c in t.clips() {
|
||||
if let ClipContent::Midi { sequence } = &c.content {
|
||||
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
|
||||
for n in &sequence.notes {
|
||||
let mut n = *n;
|
||||
n.start += base;
|
||||
seq.notes.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
seq.sort();
|
||||
smf.tracks.push(soundcraft_midi::SmfTrack { name: t.name.clone(), sequence: seq });
|
||||
}
|
||||
soundcraft_midi::write_smf(&smf).map_err(|err| EngineError::Io(err.to_string()))
|
||||
}
|
||||
|
||||
/// 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))> {
|
||||
let s = e.session();
|
||||
if r.len() > s.sample_rate.samples(4.0 * 3600.0) {
|
||||
return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into()));
|
||||
}
|
||||
let mut ch = soundcraft_mix::render_range(s, r, 1024);
|
||||
if normalize {
|
||||
soundcraft_dsp::offline::normalize(&mut ch, -0.1, false);
|
||||
}
|
||||
let peak = soundcraft_dsp::offline::peak_db(&ch);
|
||||
let mut lm = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, ch.len());
|
||||
let frames = ch.first().map_or(0, Vec::len);
|
||||
lm.process(&ch, frames);
|
||||
let lufs = lm.integrated_lufs();
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch };
|
||||
let bytes = soundcraft_audio_io::encode(&buf, opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
Ok((bytes, (peak, lufs)))
|
||||
}
|
||||
|
||||
/// Transient positions (absolute) in a track's clips within `r`.
|
||||
pub fn transients_in(s: &Session, t: TrackId, r: Range, sens: f32) -> Vec<Samples> {
|
||||
let r = if r.is_empty() { Range::new(0, s.content_end()) } else { r };
|
||||
let audio = soundcraft_mix::render_clips(s, t, r);
|
||||
soundcraft_dsp::offline::detect_transients(&audio, s.sample_rate.as_f64() as f32, sens).into_iter().map(|i| r.start + i as i64).collect()
|
||||
}
|
||||
|
||||
/// Strip Silence: keep only the non-silent ranges of clips inside `r`.
|
||||
pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> usize {
|
||||
let audio = soundcraft_mix::render_clips(s, t, r);
|
||||
let keep = soundcraft_dsp::offline::non_silent_ranges(
|
||||
&audio,
|
||||
thr,
|
||||
usize::try_from(min_len).unwrap_or(0),
|
||||
usize::try_from(pre).unwrap_or(0),
|
||||
usize::try_from(post).unwrap_or(0),
|
||||
);
|
||||
let mut cut_points = vec![r.start];
|
||||
for (a, b) in &keep {
|
||||
cut_points.push(r.start + *a as i64);
|
||||
cut_points.push(r.start + *b as i64);
|
||||
}
|
||||
cut_points.push(r.end);
|
||||
for p in &cut_points {
|
||||
crate::edit::separate_at(s, t, *p);
|
||||
}
|
||||
// Remove pieces that fall in the gaps.
|
||||
let mut gaps = Vec::new();
|
||||
let mut prev = r.start;
|
||||
for (a, b) in &keep {
|
||||
let a = r.start + *a as i64;
|
||||
if a > prev {
|
||||
gaps.push(Range::new(prev, a));
|
||||
}
|
||||
prev = r.start + *b as i64;
|
||||
}
|
||||
if prev < r.end {
|
||||
gaps.push(Range::new(prev, r.end));
|
||||
}
|
||||
if let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) {
|
||||
pl.clips.retain(|c| !gaps.iter().any(|g| c.start >= g.start && c.end() <= g.end));
|
||||
}
|
||||
keep.len()
|
||||
}
|
||||
|
||||
/// Consolidate the selection on a track into one new clip backed by a rendered source.
|
||||
pub fn consolidate(e: &mut Engine, t: TrackId, r: Range) -> Result<bool> {
|
||||
let s = e.session();
|
||||
if s.track(t).is_none_or(|tr| !tr.kind.has_playlist() || tr.kind.is_midi()) {
|
||||
return Ok(false);
|
||||
}
|
||||
let audio = soundcraft_mix::render_clips(s, t, r);
|
||||
let name = format!("{}_consolidated", s.track(t).map_or("Audio", |x| x.name.as_str()));
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
|
||||
let s = e.session_mut();
|
||||
let src = add_source(s, &name, buf, None, FileFormat::Wav);
|
||||
crate::edit::clear_range(s, t, r, false);
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(s, t, Clip::audio(cid, name, src, 0, r.start, r.len()));
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Render clip gain into a new source and reset the clip's gain.
|
||||
pub fn render_clip_gain(e: &mut Engine, id: soundcraft_model::ClipId) -> Result<bool> {
|
||||
let s = e.session();
|
||||
let Some((t, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { return Ok(false) };
|
||||
if !c.is_audio() {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut c2 = c.clone();
|
||||
c2.fade_in = Default::default();
|
||||
c2.fade_out = Default::default();
|
||||
let mut tmp = s.clone();
|
||||
if let Some(pl) = tmp.track_mut(t).and_then(|x| x.playlist_mut()) {
|
||||
pl.clips = vec![c2];
|
||||
}
|
||||
let audio = soundcraft_mix::render_clips(&tmp, t, c.range());
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
|
||||
let s = e.session_mut();
|
||||
let src = add_source(s, &format!("{}_gain", c.name), buf, None, FileFormat::Wav);
|
||||
if let Some(cl) = s.find_clip_mut(id) {
|
||||
cl.content = ClipContent::Audio { source: src, offset: 0 };
|
||||
cl.gain_db = 0.0;
|
||||
cl.gain_env.clear();
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Commit a track's inserts into a new audio track (Track › Commit).
|
||||
pub fn commit_track(e: &mut Engine, t: TrackId) -> Result<Option<TrackId>> {
|
||||
let s = e.session();
|
||||
let Some(tr) = s.track(t) else { return Ok(None) };
|
||||
if !tr.kind.is_audio_path() || tr.kind == TrackKind::Master {
|
||||
return Ok(None);
|
||||
}
|
||||
let end = tr.clips().iter().map(Clip::end).max().unwrap_or(0);
|
||||
if end <= 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let range = Range::new(0, end + s.sample_rate.samples(2.0));
|
||||
let audio = soundcraft_mix::render_track_pre_fader(s, t, range);
|
||||
let name = format!("{}.cm", tr.name);
|
||||
let fmt = ChannelFormat::for_channels(audio.len().clamp(1, 2));
|
||||
let mixer = tr.mixer.clone();
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
|
||||
let s = e.session_mut();
|
||||
let src = add_source(s, &name, buf, None, FileFormat::Wav);
|
||||
let nt = s.add_track(TrackKind::Audio, fmt, Some(&name));
|
||||
if let (Some(idx), Some(new_idx)) = (s.track_index(t), s.track_index(nt)) {
|
||||
let tr = s.tracks.remove(new_idx);
|
||||
s.tracks.insert((idx + 1).min(s.tracks.len()), tr);
|
||||
}
|
||||
let cid = s.new_clip_id();
|
||||
if let Some(ntr) = s.track_mut(nt) {
|
||||
ntr.mixer.volume_db = mixer.volume_db;
|
||||
ntr.mixer.output = mixer.output.clone();
|
||||
ntr.mixer.sends = mixer.sends.clone();
|
||||
if let Some(pl) = ntr.playlist_mut() {
|
||||
pl.clips.push(Clip::audio(cid, name, src, 0, 0, range.len()));
|
||||
}
|
||||
}
|
||||
if let Some(old) = s.track_mut(t) {
|
||||
old.inactive = true;
|
||||
old.hidden = true;
|
||||
}
|
||||
Ok(Some(nt))
|
||||
}
|
||||
|
||||
pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) -> Result<usize> {
|
||||
let s = e.session();
|
||||
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?;
|
||||
let mut n = 0;
|
||||
for id in ids {
|
||||
let Some((t, c)) = s.find_clip(*id) else { continue };
|
||||
let audio = soundcraft_mix::render_clips(s, t, c.range());
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
|
||||
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let path = Path::new(dir).join(format!("{}.wav", sanitize_name(&c.name)));
|
||||
std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
pub fn sanitize_name(n: &str) -> String {
|
||||
let s: String = n.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect();
|
||||
let s = s.trim().trim_start_matches('.').to_string();
|
||||
if s.is_empty() { "untitled".into() } else { s.chars().take(100).collect() }
|
||||
}
|
||||
|
||||
/// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written
|
||||
/// as 32-bit float WAV into `Audio Files/` next to it. Returns how many audio files were written.
|
||||
pub fn save_session(e: &mut Engine, path: &str) -> Result<usize> {
|
||||
let p = PathBuf::from(path);
|
||||
let p = if p.extension().is_none() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p };
|
||||
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let audio_dir = dir.join("Audio Files");
|
||||
let mut s = e.session().clone();
|
||||
let mut written = 0;
|
||||
for src in &mut s.sources {
|
||||
let rel_target = format!("Audio Files/{}.wav", sanitize_name(&src.name));
|
||||
let exists_rel = !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists();
|
||||
if exists_rel && !src.unsaved {
|
||||
continue;
|
||||
}
|
||||
let Some(audio) = s.pool.get(src.id) else { continue };
|
||||
std::fs::create_dir_all(&audio_dir).map_err(|err| EngineError::Io(format!("{}: {err}", audio_dir.display())))?;
|
||||
let mut target = rel_target.clone();
|
||||
let mut k = 1;
|
||||
while dir.join(&target).exists() && !(exists_rel && target == src.path) {
|
||||
target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name));
|
||||
k += 1;
|
||||
if k > 999 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
std::fs::write(dir.join(&target), bytes).map_err(|err| EngineError::Io(format!("{target}: {err}")))?;
|
||||
src.path = target;
|
||||
src.unsaved = false;
|
||||
src.format = FileFormat::Wav;
|
||||
written += 1;
|
||||
}
|
||||
if let Some(stem) = p.file_stem().and_then(|x| x.to_str()) {
|
||||
s.name = stem.to_string();
|
||||
}
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
// Atomic-ish write: temp file then rename.
|
||||
let tmp = p.with_extension("scraft.tmp");
|
||||
std::fs::write(&tmp, text).map_err(|err| EngineError::Io(format!("{}: {err}", tmp.display())))?;
|
||||
std::fs::rename(&tmp, &p).map_err(|err| EngineError::Io(format!("{}: {err}", p.display())))?;
|
||||
// Keep the in-memory document in sync (paths, unsaved flags) without an undo step.
|
||||
let fresh = s;
|
||||
*e.session_mut() = fresh;
|
||||
e.path = Some(p.to_string_lossy().into_owned());
|
||||
e.mark_clean();
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Open a `.scraft` session and decode its audio files (missing files are reported, not fatal).
|
||||
pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
|
||||
let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let mut missing = Vec::new();
|
||||
for src in s.sources.clone() {
|
||||
let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
|
||||
match std::fs::read(&p) {
|
||||
Ok(bytes) => match soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())) {
|
||||
Ok((_, mut buf)) => {
|
||||
if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz());
|
||||
buf.sample_rate = s.sample_rate.hz();
|
||||
}
|
||||
s.pool.insert(src.id, Arc::new(SourceAudio::new(buf)));
|
||||
}
|
||||
Err(err) => missing.push(format!("{}: {err}", p.display())),
|
||||
},
|
||||
Err(_) => missing.push(p.display().to_string()),
|
||||
}
|
||||
}
|
||||
e.replace_session(s);
|
||||
e.path = Some(path.to_string());
|
||||
for m in &missing {
|
||||
e.message(format!("missing media: {m}"));
|
||||
}
|
||||
Ok(missing)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! The SoundCraft command engine.
|
||||
//!
|
||||
//! Every user-visible action is a registered command ([`cmd::CommandSpec`]): menus, shortcuts, the
|
||||
//! CLI, the JSON control channel and MCP all dispatch the same commands by id with JSON params.
|
||||
//! The engine owns the [`Session`] behind an `Arc` (copy-on-write), so undo snapshots are cheap.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod catalog;
|
||||
pub mod cmd;
|
||||
pub mod demo;
|
||||
pub mod edit;
|
||||
pub mod inspect;
|
||||
pub mod io;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{Clip, Session, TrackId};
|
||||
use soundcraft_time::Samples;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use soundcraft_model as model;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
#[error("unknown command `{0}`")]
|
||||
UnknownCommand(String),
|
||||
#[error("`{0}` is not available: {1}")]
|
||||
Disabled(String, String),
|
||||
#[error("bad parameters for `{0}`: {1}")]
|
||||
BadParams(String, String),
|
||||
#[error("{0}")]
|
||||
NotFound(String),
|
||||
#[error("{0}")]
|
||||
Io(String),
|
||||
#[error("unsupported: {0}")]
|
||||
Unsupported(String),
|
||||
#[error("internal error in `{0}`: {1}")]
|
||||
Internal(String, String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, EngineError>;
|
||||
|
||||
/// Requests for the transport/audio engine, drained by the host app each frame.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
|
||||
pub enum TransportRequest {
|
||||
Play,
|
||||
Stop,
|
||||
TogglePlay,
|
||||
Record,
|
||||
Locate(Samples),
|
||||
PlaySelection,
|
||||
Pause,
|
||||
HalfSpeed,
|
||||
Scrub(Samples),
|
||||
AllNotesOff,
|
||||
}
|
||||
|
||||
/// Snapshot of the transport reported back by the host (playback position etc.).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize)]
|
||||
pub struct TransportStatus {
|
||||
pub playing: bool,
|
||||
pub recording: bool,
|
||||
pub position: Samples,
|
||||
}
|
||||
|
||||
/// Clipboard contents: per-track clip copies relative to the copied range start.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Clipboard {
|
||||
pub length: Samples,
|
||||
pub tracks: Vec<Vec<Clip>>,
|
||||
pub automation: Vec<Vec<soundcraft_model::AutomationLane>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct HistoryEntry {
|
||||
label: String,
|
||||
doc: Arc<Session>,
|
||||
}
|
||||
|
||||
/// Engine state: the document, history, clipboard and transport plumbing.
|
||||
pub struct Engine {
|
||||
doc: Arc<Session>,
|
||||
undo: Vec<HistoryEntry>,
|
||||
redo: Vec<HistoryEntry>,
|
||||
pub history_limit: usize,
|
||||
pub clipboard: Clipboard,
|
||||
pub transport_requests: Vec<TransportRequest>,
|
||||
pub transport: TransportStatus,
|
||||
/// Command journal (id, params) for macro recording / agents.
|
||||
pub journal: Vec<(String, Value)>,
|
||||
/// Path of the session file, if saved.
|
||||
pub path: Option<String>,
|
||||
/// Revision counter; bumps on every document change (UI caches key on it).
|
||||
pub revision: u64,
|
||||
dirty: bool,
|
||||
/// Messages for the UI status line / agents.
|
||||
pub messages: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Engine {
|
||||
fn default() -> Self {
|
||||
Engine::new(Session::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub fn new(session: Session) -> Self {
|
||||
install_panic_hook();
|
||||
Engine {
|
||||
doc: Arc::new(session),
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
history_limit: 200,
|
||||
clipboard: Clipboard::default(),
|
||||
transport_requests: Vec::new(),
|
||||
transport: TransportStatus::default(),
|
||||
journal: Vec::new(),
|
||||
path: None,
|
||||
revision: 1,
|
||||
dirty: false,
|
||||
messages: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session(&self) -> &Session {
|
||||
&self.doc
|
||||
}
|
||||
|
||||
pub fn session_arc(&self) -> Arc<Session> {
|
||||
Arc::clone(&self.doc)
|
||||
}
|
||||
|
||||
/// Mutable access (copy-on-write). Callers outside commands should prefer `execute`.
|
||||
pub fn session_mut(&mut self) -> &mut Session {
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
self.dirty = true;
|
||||
Arc::make_mut(&mut self.doc)
|
||||
}
|
||||
|
||||
/// Replace the whole document (open / new); clears history.
|
||||
pub fn replace_session(&mut self, s: Session) {
|
||||
self.doc = Arc::new(s);
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
self.dirty = false;
|
||||
}
|
||||
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.dirty
|
||||
}
|
||||
pub fn mark_clean(&mut self) {
|
||||
self.dirty = false;
|
||||
}
|
||||
|
||||
pub fn can_undo(&self) -> bool {
|
||||
!self.undo.is_empty()
|
||||
}
|
||||
pub fn can_redo(&self) -> bool {
|
||||
!self.redo.is_empty()
|
||||
}
|
||||
pub fn undo_label(&self) -> Option<&str> {
|
||||
self.undo.last().map(|h| h.label.as_str())
|
||||
}
|
||||
pub fn redo_label(&self) -> Option<&str> {
|
||||
self.redo.last().map(|h| h.label.as_str())
|
||||
}
|
||||
pub fn undo_history(&self) -> Vec<String> {
|
||||
self.undo.iter().map(|h| h.label.clone()).collect()
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> bool {
|
||||
let Some(h) = self.undo.pop() else { return false };
|
||||
let cur = std::mem::replace(&mut self.doc, h.doc);
|
||||
self.redo.push(HistoryEntry { label: h.label, doc: cur });
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
self.dirty = true;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) -> bool {
|
||||
let Some(h) = self.redo.pop() else { return false };
|
||||
let cur = std::mem::replace(&mut self.doc, h.doc);
|
||||
self.undo.push(HistoryEntry { label: h.label, doc: cur });
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
self.dirty = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// Record an undo point manually (for interactive gestures committed by the UI).
|
||||
pub fn push_undo(&mut self, label: &str, before: Arc<Session>) {
|
||||
if Arc::ptr_eq(&before, &self.doc) {
|
||||
return;
|
||||
}
|
||||
self.undo.push(HistoryEntry { label: label.to_string(), doc: before });
|
||||
self.redo.clear();
|
||||
if self.undo.len() > self.history_limit {
|
||||
let excess = self.undo.len() - self.history_limit;
|
||||
self.undo.drain(..excess);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a command by id. Never panics: escaped panics become `EngineError::Internal` and the
|
||||
/// document is restored.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
if let Err(reason) = (spec.enabled)(self) {
|
||||
return Err(EngineError::Disabled(id.to_string(), reason));
|
||||
}
|
||||
let before = Arc::clone(&self.doc);
|
||||
let rev = self.revision;
|
||||
let dirty = self.dirty;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
|
||||
let result = match result {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
let msg =
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
|
||||
self.doc = before;
|
||||
self.revision = rev.wrapping_add(1);
|
||||
self.dirty = dirty;
|
||||
return Err(EngineError::Internal(id.to_string(), msg));
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(v) => {
|
||||
if spec.undoable && !Arc::ptr_eq(&before, &self.doc) {
|
||||
self.push_undo(spec.label, before);
|
||||
}
|
||||
if spec.journal {
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
// Commands must not leave half-applied edits behind.
|
||||
if !Arc::ptr_eq(&before, &self.doc) {
|
||||
self.doc = before;
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
self.dirty = dirty;
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: execute with `{}` params.
|
||||
pub fn run(&mut self, id: &str) -> Result<Value> {
|
||||
self.execute(id, &json!({}))
|
||||
}
|
||||
|
||||
pub fn message(&mut self, m: impl Into<String>) {
|
||||
let m = m.into();
|
||||
log::info!("{m}");
|
||||
self.messages.push(m);
|
||||
if self.messages.len() > 100 {
|
||||
self.messages.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks targeted by an edit: the selected tracks, or all tracks with playlists if none.
|
||||
pub fn target_tracks(&self) -> Vec<TrackId> {
|
||||
let s = self.session();
|
||||
if s.edit.selected_tracks.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
s.tracks.iter().filter(|t| s.edit.selected_tracks.contains(&t.id)).map(|t| t.id).collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn install_panic_hook() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
let prev = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
log::error!("panic: {info}");
|
||||
prev(info);
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "soundcraft-midi"
|
||||
description = "SoundCraft MIDI: note/controller sequences, Standard MIDI File import/export, MIDI editing operations, GM names"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,47 @@
|
||||
//! SoundCraft MIDI.
|
||||
//!
|
||||
//! - [`Note`], [`CtrlEvent`] and [`Sequence`]: the MIDI content of a region / track, in ticks at
|
||||
//! [`PPQ`] (960) ticks per quarter note.
|
||||
//! - [`read_smf`] / [`write_smf`]: Standard MIDI File import (format 0/1, format 2 best-effort) and
|
||||
//! export (format 1, 960 PPQ). The reader treats its input as hostile: every length is checked,
|
||||
//! variable-length quantities are bounded and event counts are capped.
|
||||
//! - [`ops`]: MIDI editing operations (quantize, transpose, velocity, duration, split).
|
||||
//! - [`note_name`], [`gm_drum_name`], [`gm_program_name`]: display names.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod names;
|
||||
pub mod ops;
|
||||
mod rng;
|
||||
mod sequence;
|
||||
mod smf;
|
||||
|
||||
pub use names::{gm_drum_name, gm_program_name, note_name};
|
||||
pub use rng::XorShift;
|
||||
pub use sequence::{CtrlEvent, CtrlKind, Note, Sequence};
|
||||
pub use smf::{MAX_EVENTS, MAX_TRACKS, Smf, SmfTrack, read_smf, write_smf};
|
||||
|
||||
/// Ticks per quarter note used throughout the model.
|
||||
pub const PPQ: u32 = 960;
|
||||
|
||||
/// Errors from reading or writing MIDI data.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum MidiError {
|
||||
#[error("not a Standard MIDI File (missing MThd header)")]
|
||||
NotSmf,
|
||||
#[error("unexpected end of data at byte {0}")]
|
||||
Truncated(usize),
|
||||
#[error("variable-length quantity longer than 4 bytes at byte {0}")]
|
||||
VlqTooLong(usize),
|
||||
#[error("chunk length {len} at byte {offset} exceeds the remaining data")]
|
||||
BadChunkLength { offset: usize, len: u64 },
|
||||
#[error("invalid MIDI data at byte {offset}: {reason}")]
|
||||
Invalid { offset: usize, reason: &'static str },
|
||||
#[error("unsupported MIDI file: {0}")]
|
||||
Unsupported(&'static str),
|
||||
#[error("too many events (limit {0})")]
|
||||
TooManyEvents(usize),
|
||||
#[error("too many tracks (limit {0})")]
|
||||
TooManyTracks(usize),
|
||||
#[error("cannot write MIDI file: {0}")]
|
||||
Write(String),
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
const PITCH_CLASSES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
||||
|
||||
/// Note name using the Pro Tools convention where middle C (60) is "C3" (so 0 is "C-2").
|
||||
pub fn note_name(pitch: u8) -> String {
|
||||
let class = PITCH_CLASSES.get(usize::from(pitch % 12)).copied().unwrap_or("?");
|
||||
let octave = i32::from(pitch / 12) - 2;
|
||||
format!("{class}{octave}")
|
||||
}
|
||||
|
||||
const GM_DRUMS: [&str; 47] = [
|
||||
"Acoustic Bass Drum",
|
||||
"Bass Drum 1",
|
||||
"Side Stick",
|
||||
"Acoustic Snare",
|
||||
"Hand Clap",
|
||||
"Electric Snare",
|
||||
"Low Floor Tom",
|
||||
"Closed Hi-Hat",
|
||||
"High Floor Tom",
|
||||
"Pedal Hi-Hat",
|
||||
"Low Tom",
|
||||
"Open Hi-Hat",
|
||||
"Low-Mid Tom",
|
||||
"Hi-Mid Tom",
|
||||
"Crash Cymbal 1",
|
||||
"High Tom",
|
||||
"Ride Cymbal 1",
|
||||
"Chinese Cymbal",
|
||||
"Ride Bell",
|
||||
"Tambourine",
|
||||
"Splash Cymbal",
|
||||
"Cowbell",
|
||||
"Crash Cymbal 2",
|
||||
"Vibraslap",
|
||||
"Ride Cymbal 2",
|
||||
"Hi Bongo",
|
||||
"Low Bongo",
|
||||
"Mute Hi Conga",
|
||||
"Open Hi Conga",
|
||||
"Low Conga",
|
||||
"High Timbale",
|
||||
"Low Timbale",
|
||||
"High Agogo",
|
||||
"Low Agogo",
|
||||
"Cabasa",
|
||||
"Maracas",
|
||||
"Short Whistle",
|
||||
"Long Whistle",
|
||||
"Short Guiro",
|
||||
"Long Guiro",
|
||||
"Claves",
|
||||
"Hi Wood Block",
|
||||
"Low Wood Block",
|
||||
"Mute Cuica",
|
||||
"Open Cuica",
|
||||
"Mute Triangle",
|
||||
"Open Triangle",
|
||||
];
|
||||
|
||||
/// General MIDI (level 1) percussion key-map name for a pitch on channel 10 (keys 35..=81).
|
||||
pub fn gm_drum_name(pitch: u8) -> Option<&'static str> {
|
||||
let idx = usize::from(pitch).checked_sub(35)?;
|
||||
GM_DRUMS.get(idx).copied()
|
||||
}
|
||||
|
||||
const GM_PROGRAMS: [&str; 128] = [
|
||||
"Acoustic Grand Piano",
|
||||
"Bright Acoustic Piano",
|
||||
"Electric Grand Piano",
|
||||
"Honky-tonk Piano",
|
||||
"Electric Piano 1",
|
||||
"Electric Piano 2",
|
||||
"Harpsichord",
|
||||
"Clavi",
|
||||
"Celesta",
|
||||
"Glockenspiel",
|
||||
"Music Box",
|
||||
"Vibraphone",
|
||||
"Marimba",
|
||||
"Xylophone",
|
||||
"Tubular Bells",
|
||||
"Dulcimer",
|
||||
"Drawbar Organ",
|
||||
"Percussive Organ",
|
||||
"Rock Organ",
|
||||
"Church Organ",
|
||||
"Reed Organ",
|
||||
"Accordion",
|
||||
"Harmonica",
|
||||
"Tango Accordion",
|
||||
"Acoustic Guitar (nylon)",
|
||||
"Acoustic Guitar (steel)",
|
||||
"Electric Guitar (jazz)",
|
||||
"Electric Guitar (clean)",
|
||||
"Electric Guitar (muted)",
|
||||
"Overdriven Guitar",
|
||||
"Distortion Guitar",
|
||||
"Guitar Harmonics",
|
||||
"Acoustic Bass",
|
||||
"Electric Bass (finger)",
|
||||
"Electric Bass (pick)",
|
||||
"Fretless Bass",
|
||||
"Slap Bass 1",
|
||||
"Slap Bass 2",
|
||||
"Synth Bass 1",
|
||||
"Synth Bass 2",
|
||||
"Violin",
|
||||
"Viola",
|
||||
"Cello",
|
||||
"Contrabass",
|
||||
"Tremolo Strings",
|
||||
"Pizzicato Strings",
|
||||
"Orchestral Harp",
|
||||
"Timpani",
|
||||
"String Ensemble 1",
|
||||
"String Ensemble 2",
|
||||
"SynthStrings 1",
|
||||
"SynthStrings 2",
|
||||
"Choir Aahs",
|
||||
"Voice Oohs",
|
||||
"Synth Voice",
|
||||
"Orchestra Hit",
|
||||
"Trumpet",
|
||||
"Trombone",
|
||||
"Tuba",
|
||||
"Muted Trumpet",
|
||||
"French Horn",
|
||||
"Brass Section",
|
||||
"SynthBrass 1",
|
||||
"SynthBrass 2",
|
||||
"Soprano Sax",
|
||||
"Alto Sax",
|
||||
"Tenor Sax",
|
||||
"Baritone Sax",
|
||||
"Oboe",
|
||||
"English Horn",
|
||||
"Bassoon",
|
||||
"Clarinet",
|
||||
"Piccolo",
|
||||
"Flute",
|
||||
"Recorder",
|
||||
"Pan Flute",
|
||||
"Blown Bottle",
|
||||
"Shakuhachi",
|
||||
"Whistle",
|
||||
"Ocarina",
|
||||
"Lead 1 (square)",
|
||||
"Lead 2 (sawtooth)",
|
||||
"Lead 3 (calliope)",
|
||||
"Lead 4 (chiff)",
|
||||
"Lead 5 (charang)",
|
||||
"Lead 6 (voice)",
|
||||
"Lead 7 (fifths)",
|
||||
"Lead 8 (bass + lead)",
|
||||
"Pad 1 (new age)",
|
||||
"Pad 2 (warm)",
|
||||
"Pad 3 (polysynth)",
|
||||
"Pad 4 (choir)",
|
||||
"Pad 5 (bowed)",
|
||||
"Pad 6 (metallic)",
|
||||
"Pad 7 (halo)",
|
||||
"Pad 8 (sweep)",
|
||||
"FX 1 (rain)",
|
||||
"FX 2 (soundtrack)",
|
||||
"FX 3 (crystal)",
|
||||
"FX 4 (atmosphere)",
|
||||
"FX 5 (brightness)",
|
||||
"FX 6 (goblins)",
|
||||
"FX 7 (echoes)",
|
||||
"FX 8 (sci-fi)",
|
||||
"Sitar",
|
||||
"Banjo",
|
||||
"Shamisen",
|
||||
"Koto",
|
||||
"Kalimba",
|
||||
"Bag pipe",
|
||||
"Fiddle",
|
||||
"Shanai",
|
||||
"Tinkle Bell",
|
||||
"Agogo",
|
||||
"Steel Drums",
|
||||
"Woodblock",
|
||||
"Taiko Drum",
|
||||
"Melodic Tom",
|
||||
"Synth Drum",
|
||||
"Reverse Cymbal",
|
||||
"Guitar Fret Noise",
|
||||
"Breath Noise",
|
||||
"Seashore",
|
||||
"Bird Tweet",
|
||||
"Telephone Ring",
|
||||
"Helicopter",
|
||||
"Applause",
|
||||
"Gunshot",
|
||||
];
|
||||
|
||||
/// General MIDI (level 1) instrument name for a 0-based program number. Values above 127
|
||||
/// return `"Unknown"`.
|
||||
pub fn gm_program_name(program: u8) -> &'static str {
|
||||
GM_PROGRAMS.get(usize::from(program)).copied().unwrap_or("Unknown")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names() {
|
||||
assert_eq!(note_name(60), "C3");
|
||||
assert_eq!(note_name(0), "C-2");
|
||||
assert_eq!(note_name(127), "G8");
|
||||
assert_eq!(note_name(61), "C#3");
|
||||
assert_eq!(gm_drum_name(36), Some("Bass Drum 1"));
|
||||
assert_eq!(gm_drum_name(81), Some("Open Triangle"));
|
||||
assert_eq!(gm_drum_name(34), None);
|
||||
assert_eq!(gm_drum_name(82), None);
|
||||
assert_eq!(gm_program_name(0), "Acoustic Grand Piano");
|
||||
assert_eq!(gm_program_name(127), "Gunshot");
|
||||
assert_eq!(gm_program_name(200), "Unknown");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! MIDI editing operations (Event Operations): quantize, transpose, velocity, duration, split.
|
||||
//!
|
||||
//! All operations work in place on a slice of notes and never change the slice length, so they
|
||||
//! can be applied to a selection. Randomised operations use a deterministic [`XorShift`] seeded
|
||||
//! with [`DEFAULT_SEED`] (or an explicit seed via the `*_with_seed` variants).
|
||||
|
||||
use crate::{Note, XorShift};
|
||||
|
||||
/// Seed used by [`quantize`] and [`change_velocity`] for their random components.
|
||||
pub const DEFAULT_SEED: u64 = 0x5EED_50DC_4AF7_0001;
|
||||
|
||||
fn unit(x: f32) -> f64 {
|
||||
if x.is_finite() { f64::from(x).clamp(0.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
fn round_i64(x: f64) -> i64 {
|
||||
if x.is_finite() { x.round().clamp(i64::MIN as f64, i64::MAX as f64) as i64 } else { 0 }
|
||||
}
|
||||
|
||||
/// Options for [`quantize`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct QuantizeOptions {
|
||||
/// Grid spacing in ticks (e.g. 240 = 1/16 note at 960 PPQ). Values <= 0 make quantize a no-op.
|
||||
pub grid_ticks: i64,
|
||||
/// How far notes move towards the grid line, 0..=1 (1 = all the way).
|
||||
pub strength: f32,
|
||||
/// Swing, 0..=1: every second grid line is delayed by `swing * grid / 3`, so 1.0 gives a
|
||||
/// triplet feel.
|
||||
pub swing: f32,
|
||||
/// Only notes within this fraction of half a grid step of their target are moved
|
||||
/// (1 = all notes).
|
||||
pub include_within: f32,
|
||||
/// Notes closer than this fraction of half a grid step to their target are left alone
|
||||
/// (0 = none excluded).
|
||||
pub exclude_within: f32,
|
||||
/// Also quantize note ends to the grid.
|
||||
pub quantize_ends: bool,
|
||||
/// Keep each note's length when its start moves (otherwise the end stays put). Ignored when
|
||||
/// `quantize_ends` is set.
|
||||
pub preserve_duration: bool,
|
||||
/// Random offset after quantizing, 0..=1 fraction of half a grid step.
|
||||
pub random: f32,
|
||||
}
|
||||
|
||||
impl Default for QuantizeOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
grid_ticks: 240,
|
||||
strength: 1.0,
|
||||
swing: 0.0,
|
||||
include_within: 1.0,
|
||||
exclude_within: 0.0,
|
||||
quantize_ends: false,
|
||||
preserve_duration: true,
|
||||
random: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The swung grid line nearest to `t`.
|
||||
fn nearest_line(t: i64, grid: i64, swing: f64) -> i64 {
|
||||
let swing_off = round_i64(swing * grid as f64 / 3.0);
|
||||
let k = t.saturating_add(grid / 2).div_euclid(grid);
|
||||
let mut best = i64::MAX;
|
||||
let mut best_d = u64::MAX;
|
||||
for kk in [k.saturating_sub(1), k, k.saturating_add(1)] {
|
||||
let line = kk.saturating_mul(grid).saturating_add(if kk.rem_euclid(2) == 1 { swing_off } else { 0 });
|
||||
let d = line.abs_diff(t);
|
||||
if d < best_d {
|
||||
best_d = d;
|
||||
best = line;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Quantizes note starts (and optionally ends) to a grid. See [`QuantizeOptions`].
|
||||
pub fn quantize(notes: &mut [Note], opts: &QuantizeOptions) {
|
||||
quantize_with_seed(notes, opts, DEFAULT_SEED);
|
||||
}
|
||||
|
||||
/// [`quantize`] with an explicit seed for the `random` component.
|
||||
pub fn quantize_with_seed(notes: &mut [Note], opts: &QuantizeOptions, seed: u64) {
|
||||
let grid = opts.grid_ticks;
|
||||
if grid <= 0 {
|
||||
return;
|
||||
}
|
||||
let strength = unit(opts.strength);
|
||||
let swing = unit(opts.swing);
|
||||
let include = unit(opts.include_within);
|
||||
let exclude = unit(opts.exclude_within);
|
||||
let random = unit(opts.random);
|
||||
let half = grid as f64 / 2.0;
|
||||
let mut rng = XorShift::new(seed);
|
||||
|
||||
for n in notes.iter_mut() {
|
||||
let old_start = n.start;
|
||||
let old_end = n.end();
|
||||
let target = nearest_line(old_start, grid, swing);
|
||||
let d = target.saturating_sub(old_start);
|
||||
let dist = d.unsigned_abs() as f64;
|
||||
if include < 1.0 && dist > include * half {
|
||||
continue;
|
||||
}
|
||||
if dist < exclude * half {
|
||||
continue;
|
||||
}
|
||||
let mut new_start = old_start.saturating_add(round_i64(d as f64 * strength));
|
||||
if random > 0.0 {
|
||||
new_start = new_start.saturating_add(round_i64(rng.next_signed() * random * half));
|
||||
}
|
||||
n.start = new_start;
|
||||
n.length = if opts.quantize_ends {
|
||||
let end_target = nearest_line(old_end, grid, swing);
|
||||
let new_end = old_end.saturating_add(round_i64(end_target.saturating_sub(old_end) as f64 * strength));
|
||||
new_end.saturating_sub(new_start)
|
||||
} else if opts.preserve_duration {
|
||||
n.length
|
||||
} else {
|
||||
old_end.saturating_sub(new_start)
|
||||
}
|
||||
.max(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transposes notes by `semitones`. Pitches are **clamped** to 0..=127 (notes are never
|
||||
/// dropped, so the slice length is unchanged); notes pushed past either end pile up on 0 or 127.
|
||||
pub fn transpose(notes: &mut [Note], semitones: i32) {
|
||||
for n in notes.iter_mut() {
|
||||
n.pitch = i32::from(n.pitch).saturating_add(semitones).clamp(0, 127) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
/// Velocity change for [`change_velocity`]. Results are clamped to 1..=127.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum VelocityOp {
|
||||
Set(u8),
|
||||
Add(i32),
|
||||
/// Multiply (non-finite factors leave velocities unchanged).
|
||||
Scale(f32),
|
||||
/// Clamp into `min..=max` (bounds are swapped if reversed).
|
||||
Limit {
|
||||
min: u8,
|
||||
max: u8,
|
||||
},
|
||||
/// Linear ramp over the notes' time span: the earliest note gets `from`, the latest `to`.
|
||||
Ramp {
|
||||
from: u8,
|
||||
to: u8,
|
||||
},
|
||||
/// Random offset of up to ± the amount.
|
||||
Humanize(u8),
|
||||
}
|
||||
|
||||
fn clamp_vel(v: i64) -> u8 {
|
||||
v.clamp(1, 127) as u8
|
||||
}
|
||||
|
||||
/// Changes note-on velocities. See [`VelocityOp`].
|
||||
pub fn change_velocity(notes: &mut [Note], op: VelocityOp) {
|
||||
change_velocity_with_seed(notes, op, DEFAULT_SEED);
|
||||
}
|
||||
|
||||
/// [`change_velocity`] with an explicit seed for [`VelocityOp::Humanize`].
|
||||
pub fn change_velocity_with_seed(notes: &mut [Note], op: VelocityOp, seed: u64) {
|
||||
match op {
|
||||
VelocityOp::Set(v) => notes.iter_mut().for_each(|n| n.velocity = clamp_vel(i64::from(v))),
|
||||
VelocityOp::Add(d) => notes.iter_mut().for_each(|n| n.velocity = clamp_vel(i64::from(n.velocity) + i64::from(d))),
|
||||
VelocityOp::Scale(f) => {
|
||||
if f.is_finite() {
|
||||
notes.iter_mut().for_each(|n| n.velocity = clamp_vel(round_i64(f64::from(n.velocity) * f64::from(f))));
|
||||
}
|
||||
}
|
||||
VelocityOp::Limit { min, max } => {
|
||||
let (lo, hi) = if min <= max { (min, max) } else { (max, min) };
|
||||
notes.iter_mut().for_each(|n| n.velocity = clamp_vel(i64::from(n.velocity.clamp(lo, hi))));
|
||||
}
|
||||
VelocityOp::Ramp { from, to } => {
|
||||
let first = notes.iter().map(|n| n.start).min().unwrap_or(0);
|
||||
let last = notes.iter().map(|n| n.start).max().unwrap_or(0);
|
||||
let span = last.saturating_sub(first) as f64;
|
||||
for n in notes.iter_mut() {
|
||||
let t = if span > 0.0 { n.start.saturating_sub(first) as f64 / span } else { 0.0 };
|
||||
let v = f64::from(from) + (f64::from(to) - f64::from(from)) * t;
|
||||
n.velocity = clamp_vel(round_i64(v));
|
||||
}
|
||||
}
|
||||
VelocityOp::Humanize(amount) => {
|
||||
let mut rng = XorShift::new(seed);
|
||||
for n in notes.iter_mut() {
|
||||
let d = round_i64(rng.next_signed() * f64::from(amount));
|
||||
n.velocity = clamp_vel(i64::from(n.velocity) + d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Duration change for [`change_duration`]. Lengths are always kept >= 1 tick.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum DurationOp {
|
||||
Set(i64),
|
||||
Add(i64),
|
||||
/// Multiply (non-finite factors leave lengths unchanged).
|
||||
Scale(f32),
|
||||
/// Each note extends to `gap` ticks before the next later note start (any pitch). The last
|
||||
/// note(s) keep their length.
|
||||
Legato {
|
||||
gap: i64,
|
||||
},
|
||||
/// Shortens notes so they end no later than the next note of the same pitch and channel.
|
||||
RemoveOverlap,
|
||||
}
|
||||
|
||||
/// Changes note lengths. See [`DurationOp`].
|
||||
pub fn change_duration(notes: &mut [Note], op: DurationOp) {
|
||||
match op {
|
||||
DurationOp::Set(l) => notes.iter_mut().for_each(|n| n.length = l.max(1)),
|
||||
DurationOp::Add(d) => notes.iter_mut().for_each(|n| n.length = n.length.saturating_add(d).max(1)),
|
||||
DurationOp::Scale(f) => {
|
||||
if f.is_finite() {
|
||||
notes.iter_mut().for_each(|n| n.length = round_i64(n.length as f64 * f64::from(f)).max(1));
|
||||
}
|
||||
}
|
||||
DurationOp::Legato { gap } => {
|
||||
let mut starts: Vec<i64> = notes.iter().map(|n| n.start).collect();
|
||||
starts.sort_unstable();
|
||||
starts.dedup();
|
||||
for n in notes.iter_mut() {
|
||||
let idx = starts.partition_point(|&s| s <= n.start);
|
||||
if let Some(&next) = starts.get(idx) {
|
||||
n.length = next.saturating_sub(gap).saturating_sub(n.start).max(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
DurationOp::RemoveOverlap => {
|
||||
let mut order: Vec<usize> = (0..notes.len()).collect();
|
||||
order.sort_by_key(|&i| notes.get(i).map(|n| (n.channel, n.pitch, n.start)));
|
||||
for w in order.windows(2) {
|
||||
let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue };
|
||||
let Some(&nb) = notes.get(b) else { continue };
|
||||
if let Some(na) = notes.get_mut(a)
|
||||
&& na.channel == nb.channel
|
||||
&& na.pitch == nb.pitch
|
||||
&& na.end() > nb.start
|
||||
{
|
||||
na.length = nb.start.saturating_sub(na.start).max(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Select/Split Notes by pitch: returns `(notes with pitch in pitch_from..=pitch_to, the rest)`,
|
||||
/// both in input order. The bounds are swapped if reversed.
|
||||
pub fn split_notes(notes: &[Note], pitch_from: u8, pitch_to: u8) -> (Vec<Note>, Vec<Note>) {
|
||||
let (lo, hi) = if pitch_from <= pitch_to { (pitch_from, pitch_to) } else { (pitch_to, pitch_from) };
|
||||
notes.iter().partition(|n| (lo..=hi).contains(&n.pitch))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// Small deterministic PRNG (xorshift64*) used for randomize / humanize so results are
|
||||
/// reproducible for a given seed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct XorShift(u64);
|
||||
|
||||
impl XorShift {
|
||||
/// Creates a generator. A zero seed is replaced by a fixed non-zero constant.
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self(if seed == 0 { 0x9E37_79B9_7F4A_7C15 } else { seed })
|
||||
}
|
||||
|
||||
/// Next raw 64-bit value.
|
||||
pub fn next_u64(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.0 = x;
|
||||
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
||||
}
|
||||
|
||||
/// Uniform value in `[-1.0, 1.0)`.
|
||||
pub fn next_signed(&mut self) -> f64 {
|
||||
// 53 random bits -> [0,1)
|
||||
let unit = (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
|
||||
unit * 2.0 - 1.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A MIDI note. Times are in model ticks ([`crate::PPQ`] per quarter note).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Note {
|
||||
/// MIDI pitch 0..=127 (60 = middle C, shown as "C3").
|
||||
pub pitch: u8,
|
||||
/// Note-on velocity 1..=127.
|
||||
pub velocity: u8,
|
||||
/// Note-off (release) velocity 0..=127; 64 when the source had none.
|
||||
pub release_velocity: u8,
|
||||
/// MIDI channel 0..=15.
|
||||
pub channel: u8,
|
||||
/// Start in ticks.
|
||||
pub start: i64,
|
||||
/// Length in ticks; always > 0.
|
||||
pub length: i64,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
/// Tick at which the note ends (`start + length`, saturating).
|
||||
pub fn end(&self) -> i64 {
|
||||
self.start.saturating_add(self.length)
|
||||
}
|
||||
}
|
||||
|
||||
/// Kind of a continuous / channel controller event.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum CtrlKind {
|
||||
/// Control change with the controller number 0..=127. Value 0..=127.
|
||||
Cc(u8),
|
||||
/// Pitch bend. Value is centred: -8192..=8191 (0 = no bend).
|
||||
PitchBend,
|
||||
/// Channel (mono) aftertouch. Value 0..=127.
|
||||
ChannelPressure,
|
||||
/// Polyphonic aftertouch for the given pitch. Value 0..=127.
|
||||
PolyPressure(u8),
|
||||
/// Program change. Value is the program number 0..=127.
|
||||
Program,
|
||||
}
|
||||
|
||||
/// A controller / channel event at a tick.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CtrlEvent {
|
||||
pub tick: i64,
|
||||
pub channel: u8,
|
||||
pub kind: CtrlKind,
|
||||
pub value: i32,
|
||||
}
|
||||
|
||||
/// The MIDI content of a region or track.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Sequence {
|
||||
/// Notes, sorted by `(start, pitch)` after [`Sequence::sort`].
|
||||
pub notes: Vec<Note>,
|
||||
/// Controller events, sorted by tick after [`Sequence::sort`].
|
||||
pub ctrls: Vec<CtrlEvent>,
|
||||
}
|
||||
|
||||
fn kind_key(k: CtrlKind) -> (u8, u8) {
|
||||
match k {
|
||||
CtrlKind::Cc(n) => (0, n),
|
||||
CtrlKind::PitchBend => (1, 0),
|
||||
CtrlKind::ChannelPressure => (2, 0),
|
||||
CtrlKind::PolyPressure(p) => (3, p),
|
||||
CtrlKind::Program => (4, 0),
|
||||
}
|
||||
}
|
||||
|
||||
impl Sequence {
|
||||
/// Sorts notes by `(start, pitch, channel, length, velocity)` and controller events by tick
|
||||
/// (stable, so same-tick events keep their order).
|
||||
pub fn sort(&mut self) {
|
||||
self.notes.sort_by_key(|a| (a.start, a.pitch, a.channel, a.length, a.velocity));
|
||||
self.ctrls.sort_by_key(|c| c.tick);
|
||||
}
|
||||
|
||||
/// The last tick covered by any note end or controller event (0 when empty).
|
||||
pub fn end_tick(&self) -> i64 {
|
||||
let n = self.notes.iter().map(Note::end).max();
|
||||
let c = self.ctrls.iter().map(|c| c.tick).max();
|
||||
match (n, c) {
|
||||
(Some(a), Some(b)) => a.max(b),
|
||||
(Some(a), None) | (None, Some(a)) => a,
|
||||
(None, None) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes whose start lies in the half-open range `[start, end)`.
|
||||
pub fn notes_in(&self, start: i64, end: i64) -> impl Iterator<Item = &Note> + '_ {
|
||||
self.notes.iter().filter(move |n| n.start >= start && n.start < end)
|
||||
}
|
||||
|
||||
/// Notes that sound anywhere inside the half-open range `[start, end)`.
|
||||
pub fn notes_overlapping(&self, start: i64, end: i64) -> impl Iterator<Item = &Note> + '_ {
|
||||
self.notes.iter().filter(move |n| n.start < end && n.end() > start)
|
||||
}
|
||||
|
||||
/// Removes duplicate notes (same channel, pitch and start; the longest one is kept) and
|
||||
/// duplicate controller events (same tick, channel and kind; the last one is kept, matching
|
||||
/// what a synth would end up with). Returns how many events were removed. The sequence is
|
||||
/// left sorted.
|
||||
pub fn remove_duplicates(&mut self) -> usize {
|
||||
let before = self.notes.len() + self.ctrls.len();
|
||||
// Longest first within each (start, pitch, channel) group, then keep the first.
|
||||
self.notes.sort_by(|a, b| (a.start, a.pitch, a.channel, b.length).cmp(&(b.start, b.pitch, b.channel, a.length)));
|
||||
self.notes.dedup_by(|later, kept| later.start == kept.start && later.pitch == kept.pitch && later.channel == kept.channel);
|
||||
|
||||
self.ctrls.sort_by_key(|c| c.tick);
|
||||
let mut kept: Vec<CtrlEvent> = Vec::with_capacity(self.ctrls.len());
|
||||
for c in self.ctrls.drain(..) {
|
||||
// Within a tick, replace an earlier event of the same channel+kind.
|
||||
// Only events at the same tick (the tail of `kept`) can match.
|
||||
let slot =
|
||||
kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && kind_key(k.kind) == kind_key(c.kind));
|
||||
match slot {
|
||||
Some(slot) => *slot = c,
|
||||
None => kept.push(c),
|
||||
}
|
||||
}
|
||||
self.ctrls = kept;
|
||||
self.sort();
|
||||
before - (self.notes.len() + self.ctrls.len())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
//! Standard MIDI File reading and writing (from the public SMF 1.0 specification).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::{CtrlEvent, CtrlKind, MidiError, Note, PPQ, Sequence};
|
||||
|
||||
/// Maximum number of events (across all tracks) [`read_smf`] will parse.
|
||||
pub const MAX_EVENTS: usize = 5_000_000;
|
||||
/// Maximum number of track chunks [`read_smf`] will accept.
|
||||
pub const MAX_TRACKS: usize = 4096;
|
||||
|
||||
/// Release velocity used when a note ends with a note-on of velocity 0 (per the spec, a
|
||||
/// note-on with velocity 0 is a note-off with velocity 64).
|
||||
const DEFAULT_RELEASE: u8 = 64;
|
||||
|
||||
/// One track of a Standard MIDI File.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct SmfTrack {
|
||||
/// Name from the first Sequence/Track Name meta event (empty if none).
|
||||
pub name: String,
|
||||
pub sequence: Sequence,
|
||||
}
|
||||
|
||||
/// A Standard MIDI File in model terms. All ticks are at [`PPQ`] (960) per quarter note.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Smf {
|
||||
/// Format of the source file (0, 1 or 2). [`write_smf`] always writes format 1.
|
||||
pub format: u16,
|
||||
pub tracks: Vec<SmfTrack>,
|
||||
/// `(tick, beats per minute)`.
|
||||
pub tempos: Vec<(i64, f64)>,
|
||||
/// `(tick, numerator, denominator)`, e.g. `(0, 6, 8)`.
|
||||
pub meters: Vec<(i64, u8, u8)>,
|
||||
/// `(tick, text)` from Marker meta events.
|
||||
pub markers: Vec<(i64, String)>,
|
||||
/// `(tick, sharps (+) / flats (-) -7..=7, is_minor)`.
|
||||
pub key_sigs: Vec<(i64, i8, bool)>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Reading
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
struct Cursor<'a> {
|
||||
data: &'a [u8],
|
||||
pos: usize,
|
||||
/// Absolute offset of `data[0]` in the file, for error messages.
|
||||
base: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cursor<'a> {
|
||||
fn at(&self) -> usize {
|
||||
self.base.saturating_add(self.pos)
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.pos >= self.data.len()
|
||||
}
|
||||
|
||||
fn u8(&mut self) -> Result<u8, MidiError> {
|
||||
let b = *self.data.get(self.pos).ok_or(MidiError::Truncated(self.at()))?;
|
||||
self.pos += 1;
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
fn peek(&self) -> Result<u8, MidiError> {
|
||||
self.data.get(self.pos).copied().ok_or(MidiError::Truncated(self.at()))
|
||||
}
|
||||
|
||||
fn bytes(&mut self, n: usize) -> Result<&'a [u8], MidiError> {
|
||||
let end = self.pos.checked_add(n).ok_or(MidiError::Truncated(self.at()))?;
|
||||
let s = self.data.get(self.pos..end).ok_or(MidiError::Truncated(self.at()))?;
|
||||
self.pos = end;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn u16(&mut self) -> Result<u16, MidiError> {
|
||||
let b = self.bytes(2)?;
|
||||
Ok(u16::from_be_bytes([b.first().copied().unwrap_or(0), b.get(1).copied().unwrap_or(0)]))
|
||||
}
|
||||
|
||||
fn u32(&mut self) -> Result<u32, MidiError> {
|
||||
let b = self.bytes(4)?;
|
||||
let mut a = [0u8; 4];
|
||||
a.copy_from_slice(b);
|
||||
Ok(u32::from_be_bytes(a))
|
||||
}
|
||||
|
||||
/// Variable-length quantity: at most 4 bytes (28 bits) per the spec.
|
||||
fn vlq(&mut self) -> Result<u32, MidiError> {
|
||||
let start = self.at();
|
||||
let mut v: u32 = 0;
|
||||
for _ in 0..4 {
|
||||
let b = self.u8()?;
|
||||
v = (v << 7) | u32::from(b & 0x7F);
|
||||
if b & 0x80 == 0 {
|
||||
return Ok(v);
|
||||
}
|
||||
}
|
||||
Err(MidiError::VlqTooLong(start))
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts file ticks to model ticks: `model = file * num / den` (rounded).
|
||||
#[derive(Clone, Copy)]
|
||||
struct TickConv {
|
||||
num: i128,
|
||||
den: i128,
|
||||
}
|
||||
|
||||
impl TickConv {
|
||||
fn from_division(division: u16, offset: usize) -> Result<Self, MidiError> {
|
||||
if division & 0x8000 == 0 {
|
||||
if division == 0 {
|
||||
return Err(MidiError::Invalid { offset, reason: "division of 0 ticks per quarter note" });
|
||||
}
|
||||
Ok(Self { num: i128::from(PPQ), den: i128::from(division) })
|
||||
} else {
|
||||
// SMPTE: high byte is negative frames/sec (two's complement), low byte ticks/frame.
|
||||
// Approximated at 120 bpm: one quarter note = 0.5 s, so
|
||||
// model = file / ticks_per_sec * 2 * PPQ.
|
||||
let [hi, lo] = division.to_be_bytes();
|
||||
let fps = -i128::from(hi as i8);
|
||||
let tpf = i128::from(lo);
|
||||
if fps <= 0 || tpf == 0 {
|
||||
return Err(MidiError::Invalid { offset, reason: "invalid SMPTE division" });
|
||||
}
|
||||
let two_ppq = 2 * i128::from(PPQ);
|
||||
if fps == 29 {
|
||||
// 29.97 drop-frame: ticks/sec = 30000/1001 * tpf
|
||||
Ok(Self { num: two_ppq * 1001, den: 30000 * tpf })
|
||||
} else {
|
||||
Ok(Self { num: two_ppq, den: fps * tpf })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(&self, file_tick: u64) -> i64 {
|
||||
let v = (i128::from(file_tick) * self.num + self.den / 2) / self.den;
|
||||
i64::try_from(v).unwrap_or(i64::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
struct Globals {
|
||||
tempos: Vec<(i64, f64)>,
|
||||
meters: Vec<(i64, u8, u8)>,
|
||||
markers: Vec<(i64, String)>,
|
||||
key_sigs: Vec<(i64, i8, bool)>,
|
||||
}
|
||||
|
||||
/// Parses a Standard MIDI File.
|
||||
///
|
||||
/// - Formats 0 and 1 are fully supported; format 2 is read best-effort (all tracks share one
|
||||
/// timeline). Each `MTrk` chunk becomes one [`SmfTrack`]; for format 1 a leading conductor
|
||||
/// track without notes or controllers is folded into the global tempo/meter/marker lists.
|
||||
/// - Ticks are converted to [`PPQ`]; an SMPTE division is approximated at 120 bpm.
|
||||
/// - Running status, note-on velocity 0 as note-off, unmatched note-offs (ignored), overlapping
|
||||
/// same-pitch notes (matched first-in first-out) and hanging notes (closed at the end of the
|
||||
/// track) are handled. Zero-length notes get a length of 1 tick.
|
||||
/// - Unknown chunks and meta events are skipped. Any malformed structure (bad header, truncated
|
||||
/// chunk, VLQ longer than 4 bytes, data byte without running status, ...) returns an error.
|
||||
pub fn read_smf(bytes: &[u8]) -> Result<Smf, MidiError> {
|
||||
let mut cur = Cursor { data: bytes, pos: 0, base: 0 };
|
||||
if cur.bytes(4).map_err(|_| MidiError::NotSmf)? != b"MThd" {
|
||||
return Err(MidiError::NotSmf);
|
||||
}
|
||||
let header_len = cur.u32()?;
|
||||
if header_len < 6 {
|
||||
return Err(MidiError::Invalid { offset: 4, reason: "header chunk shorter than 6 bytes" });
|
||||
}
|
||||
let header_len = usize::try_from(header_len).map_err(|_| MidiError::BadChunkLength { offset: 4, len: u64::from(header_len) })?;
|
||||
let header = cur.bytes(header_len).map_err(|_| MidiError::BadChunkLength { offset: 4, len: u64::try_from(header_len).unwrap_or(u64::MAX) })?;
|
||||
let mut hc = Cursor { data: header, pos: 0, base: 8 };
|
||||
let format = hc.u16()?;
|
||||
let _ntrks = hc.u16()?;
|
||||
let division = hc.u16()?;
|
||||
if format > 2 {
|
||||
return Err(MidiError::Unsupported("SMF format greater than 2"));
|
||||
}
|
||||
let conv = TickConv::from_division(division, 12)?;
|
||||
|
||||
let mut globals = Globals { tempos: Vec::new(), meters: Vec::new(), markers: Vec::new(), key_sigs: Vec::new() };
|
||||
let mut tracks = Vec::new();
|
||||
let mut event_budget = MAX_EVENTS;
|
||||
|
||||
while !cur.is_empty() {
|
||||
let chunk_at = cur.at();
|
||||
let id = cur.bytes(4)?;
|
||||
let len = cur.u32()?;
|
||||
let remaining = bytes.len().saturating_sub(cur.pos);
|
||||
let len_usize = usize::try_from(len).unwrap_or(usize::MAX);
|
||||
if len_usize > remaining {
|
||||
return Err(MidiError::BadChunkLength { offset: chunk_at, len: u64::from(len) });
|
||||
}
|
||||
let body_at = cur.at();
|
||||
let body = cur.bytes(len_usize)?;
|
||||
if id != b"MTrk" {
|
||||
continue; // unknown chunk types must be ignored
|
||||
}
|
||||
if tracks.len() >= MAX_TRACKS {
|
||||
return Err(MidiError::TooManyTracks(MAX_TRACKS));
|
||||
}
|
||||
let track = read_track(body, body_at, conv, &mut globals, &mut event_budget)?;
|
||||
tracks.push(track);
|
||||
}
|
||||
|
||||
if format == 1 && tracks.len() > 1 && tracks.first().is_some_and(|t| t.sequence.notes.is_empty() && t.sequence.ctrls.is_empty()) {
|
||||
tracks.remove(0);
|
||||
}
|
||||
|
||||
// Keep global lists ordered by tick (stable: file order within a tick).
|
||||
globals.tempos.sort_by_key(|t| t.0);
|
||||
globals.meters.sort_by_key(|t| t.0);
|
||||
globals.markers.sort_by_key(|t| t.0);
|
||||
globals.key_sigs.sort_by_key(|t| t.0);
|
||||
|
||||
Ok(Smf { format, tracks, tempos: globals.tempos, meters: globals.meters, markers: globals.markers, key_sigs: globals.key_sigs })
|
||||
}
|
||||
|
||||
/// Open note-ons for one (channel, pitch), oldest first: `(model tick, velocity)`.
|
||||
type OpenNotes = Vec<VecDeque<(i64, u8)>>;
|
||||
|
||||
fn close_note(open: &mut OpenNotes, notes: &mut Vec<Note>, channel: u8, pitch: u8, tick: i64, release: u8) {
|
||||
let key = usize::from(channel & 0x0F) * 128 + usize::from(pitch & 0x7F);
|
||||
let Some(stack) = open.get_mut(key) else { return };
|
||||
if stack.is_empty() {
|
||||
return; // unmatched note-off: ignored
|
||||
}
|
||||
let Some((start, velocity)) = stack.pop_front() else { return };
|
||||
let length = tick.saturating_sub(start).max(1);
|
||||
notes.push(Note { pitch, velocity, release_velocity: release, channel, start, length });
|
||||
}
|
||||
|
||||
fn read_track(body: &[u8], base: usize, conv: TickConv, g: &mut Globals, budget: &mut usize) -> Result<SmfTrack, MidiError> {
|
||||
let mut cur = Cursor { data: body, pos: 0, base };
|
||||
let mut name: Option<String> = None;
|
||||
let mut seq = Sequence::default();
|
||||
let mut open: OpenNotes = vec![VecDeque::new(); 16 * 128];
|
||||
let mut file_tick: u64 = 0;
|
||||
let mut tick: i64 = 0;
|
||||
let mut running: Option<u8> = None;
|
||||
|
||||
while !cur.is_empty() {
|
||||
if *budget == 0 {
|
||||
return Err(MidiError::TooManyEvents(MAX_EVENTS));
|
||||
}
|
||||
*budget -= 1;
|
||||
|
||||
let delta = cur.vlq()?;
|
||||
file_tick = file_tick.saturating_add(u64::from(delta));
|
||||
tick = conv.convert(file_tick);
|
||||
|
||||
let event_at = cur.at();
|
||||
let first = cur.peek()?;
|
||||
let status = if first & 0x80 != 0 {
|
||||
cur.pos += 1;
|
||||
first
|
||||
} else {
|
||||
running.ok_or(MidiError::Invalid { offset: event_at, reason: "data byte without running status" })?
|
||||
};
|
||||
|
||||
match status {
|
||||
0xFF => {
|
||||
running = None;
|
||||
let kind = cur.u8()?;
|
||||
let len = cur.vlq()?;
|
||||
let data = cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?;
|
||||
match kind {
|
||||
0x2F => break, // End of Track
|
||||
0x03 => {
|
||||
if name.is_none() {
|
||||
name = Some(String::from_utf8_lossy(data).into_owned());
|
||||
}
|
||||
}
|
||||
0x06 => g.markers.push((tick, String::from_utf8_lossy(data).into_owned())),
|
||||
0x51 => {
|
||||
if let [a, b, c] = data {
|
||||
let us = (u32::from(*a) << 16) | (u32::from(*b) << 8) | u32::from(*c);
|
||||
if us > 0 {
|
||||
g.tempos.push((tick, 60_000_000.0 / f64::from(us)));
|
||||
}
|
||||
}
|
||||
}
|
||||
0x58 => {
|
||||
if let [nn, dd, ..] = data
|
||||
&& *nn > 0
|
||||
&& *dd <= 7
|
||||
{
|
||||
g.meters.push((tick, *nn, 1u8 << *dd));
|
||||
}
|
||||
}
|
||||
0x59 => {
|
||||
if let [sf, mi, ..] = data {
|
||||
let sf = *sf as i8;
|
||||
if (-7..=7).contains(&sf) {
|
||||
g.key_sigs.push((tick, sf, *mi == 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
0xF0 | 0xF7 => {
|
||||
running = None;
|
||||
let len = cur.vlq()?;
|
||||
cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?;
|
||||
}
|
||||
0x80..=0xEF => {
|
||||
running = Some(status);
|
||||
let channel = status & 0x0F;
|
||||
let d1 = cur.u8()?;
|
||||
if d1 & 0x80 != 0 {
|
||||
return Err(MidiError::Invalid { offset: event_at, reason: "status byte where a data byte was expected" });
|
||||
}
|
||||
let needs_two = !matches!(status & 0xF0, 0xC0 | 0xD0);
|
||||
let d2 = if needs_two {
|
||||
let d = cur.u8()?;
|
||||
if d & 0x80 != 0 {
|
||||
return Err(MidiError::Invalid { offset: event_at, reason: "status byte where a data byte was expected" });
|
||||
}
|
||||
d
|
||||
} else {
|
||||
0
|
||||
};
|
||||
match status & 0xF0 {
|
||||
0x80 => close_note(&mut open, &mut seq.notes, channel, d1, tick, d2),
|
||||
0x90 if d2 == 0 => close_note(&mut open, &mut seq.notes, channel, d1, tick, DEFAULT_RELEASE),
|
||||
0x90 => {
|
||||
if let Some(stack) = open.get_mut(usize::from(channel) * 128 + usize::from(d1)) {
|
||||
stack.push_back((tick, d2));
|
||||
}
|
||||
}
|
||||
0xA0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::PolyPressure(d1), value: i32::from(d2) }),
|
||||
0xB0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::Cc(d1), value: i32::from(d2) }),
|
||||
0xC0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::Program, value: i32::from(d1) }),
|
||||
0xD0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::ChannelPressure, value: i32::from(d1) }),
|
||||
_ => {
|
||||
// 0xE0 pitch bend: LSB then MSB, centred on 8192.
|
||||
let v = (i32::from(d2) << 7 | i32::from(d1)) - 8192;
|
||||
seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::PitchBend, value: v });
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(MidiError::Invalid { offset: event_at, reason: "system real-time/common status in a track" }),
|
||||
}
|
||||
}
|
||||
|
||||
// Close hanging notes at the end of the track.
|
||||
for (key, stack) in open.iter_mut().enumerate() {
|
||||
let channel = u8::try_from(key / 128).unwrap_or(0);
|
||||
let pitch = u8::try_from(key % 128).unwrap_or(0);
|
||||
for (start, velocity) in stack.drain(..) {
|
||||
let length = tick.saturating_sub(start).max(1);
|
||||
seq.notes.push(Note { pitch, velocity, release_velocity: DEFAULT_RELEASE, channel, start, length });
|
||||
}
|
||||
}
|
||||
seq.sort();
|
||||
Ok(SmfTrack { name: name.unwrap_or_default(), sequence: seq })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Writing
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
const MAX_VLQ: u64 = 0x0FFF_FFFF;
|
||||
|
||||
fn push_vlq(out: &mut Vec<u8>, v: u64) -> Result<(), MidiError> {
|
||||
if v > MAX_VLQ {
|
||||
return Err(MidiError::Write(format!("value {v} does not fit in a MIDI variable-length quantity")));
|
||||
}
|
||||
let mut buf = [0u8; 4];
|
||||
let mut n = 0;
|
||||
let mut x = v;
|
||||
loop {
|
||||
if let Some(slot) = buf.get_mut(n) {
|
||||
*slot = (x & 0x7F) as u8;
|
||||
}
|
||||
n += 1;
|
||||
x >>= 7;
|
||||
if x == 0 || n == 4 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for i in (0..n).rev() {
|
||||
let b = buf.get(i).copied().unwrap_or(0);
|
||||
out.push(if i > 0 { b | 0x80 } else { b });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An event ready to be serialised: `(tick, order, bytes)`. `order` breaks ties at one tick.
|
||||
struct WEvent {
|
||||
tick: u64,
|
||||
order: u8,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
fn clamp_tick(t: i64) -> u64 {
|
||||
u64::try_from(t.max(0)).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn meta(kind: u8, data: &[u8]) -> Result<Vec<u8>, MidiError> {
|
||||
let mut b = vec![0xFF, kind];
|
||||
push_vlq(&mut b, u64::try_from(data.len()).unwrap_or(u64::MAX))?;
|
||||
b.extend_from_slice(data);
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
fn write_track(out: &mut Vec<u8>, mut events: Vec<WEvent>) -> Result<(), MidiError> {
|
||||
events.sort_by_key(|e| (e.tick, e.order));
|
||||
let mut body = Vec::new();
|
||||
let mut last_tick = 0u64;
|
||||
let mut running: Option<u8> = None;
|
||||
for e in &events {
|
||||
push_vlq(&mut body, e.tick - last_tick)?;
|
||||
last_tick = e.tick;
|
||||
let status = e.bytes.first().copied().unwrap_or(0);
|
||||
if (0x80..=0xEF).contains(&status) {
|
||||
if running == Some(status) {
|
||||
body.extend_from_slice(e.bytes.get(1..).unwrap_or(&[]));
|
||||
} else {
|
||||
body.extend_from_slice(&e.bytes);
|
||||
}
|
||||
running = Some(status);
|
||||
} else {
|
||||
running = None;
|
||||
body.extend_from_slice(&e.bytes);
|
||||
}
|
||||
}
|
||||
// End of Track.
|
||||
body.extend_from_slice(&[0x00, 0xFF, 0x2F, 0x00]);
|
||||
let len = u32::try_from(body.len()).map_err(|_| MidiError::Write("track larger than 4 GiB".into()))?;
|
||||
out.extend_from_slice(b"MTrk");
|
||||
out.extend_from_slice(&len.to_be_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes a format-1 Standard MIDI File at [`PPQ`] (960) ticks per quarter note.
|
||||
///
|
||||
/// Track 1 is a conductor track holding tempos, meters, key signatures and markers; every
|
||||
/// [`SmfTrack`] follows as its own track. Negative ticks are clamped to 0, data values are
|
||||
/// clamped to their MIDI ranges, and note-on velocity 0 is written as 1 (0 would mean
|
||||
/// note-off). At each tick note-offs are written before controllers, then note-ons.
|
||||
///
|
||||
/// Errors: invalid tempo (non-finite or <= 0 bpm), meter denominator that is not a power of two
|
||||
/// (1..=128), more than 65535 tracks, or a gap between events beyond the 28-bit delta limit.
|
||||
pub fn write_smf(smf: &Smf) -> Result<Vec<u8>, MidiError> {
|
||||
let ntrks = u16::try_from(smf.tracks.len().saturating_add(1)).map_err(|_| MidiError::Write("more than 65535 tracks".into()))?;
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(b"MThd");
|
||||
out.extend_from_slice(&6u32.to_be_bytes());
|
||||
out.extend_from_slice(&1u16.to_be_bytes());
|
||||
out.extend_from_slice(&ntrks.to_be_bytes());
|
||||
out.extend_from_slice(&u16::try_from(PPQ).unwrap_or(960).to_be_bytes());
|
||||
|
||||
// Conductor track.
|
||||
let mut ev = Vec::new();
|
||||
for &(t, bpm) in &smf.tempos {
|
||||
if !bpm.is_finite() || bpm <= 0.0 {
|
||||
return Err(MidiError::Write(format!("invalid tempo {bpm} bpm")));
|
||||
}
|
||||
let us = (60_000_000.0 / bpm).round().clamp(1.0, f64::from(0x00FF_FFFFu32)) as u32;
|
||||
let [_, a, b, c] = us.to_be_bytes();
|
||||
ev.push(WEvent { tick: clamp_tick(t), order: 0, bytes: meta(0x51, &[a, b, c])? });
|
||||
}
|
||||
for &(t, num, den) in &smf.meters {
|
||||
if den == 0 || !den.is_power_of_two() {
|
||||
return Err(MidiError::Write(format!("meter denominator {den} is not a power of two")));
|
||||
}
|
||||
let dd = den.trailing_zeros() as u8;
|
||||
ev.push(WEvent { tick: clamp_tick(t), order: 1, bytes: meta(0x58, &[num, dd, 24, 8])? });
|
||||
}
|
||||
for &(t, sf, minor) in &smf.key_sigs {
|
||||
let sf = sf.clamp(-7, 7);
|
||||
ev.push(WEvent { tick: clamp_tick(t), order: 2, bytes: meta(0x59, &[sf as u8, u8::from(minor)])? });
|
||||
}
|
||||
for (t, text) in &smf.markers {
|
||||
ev.push(WEvent { tick: clamp_tick(*t), order: 3, bytes: meta(0x06, text.as_bytes())? });
|
||||
}
|
||||
write_track(&mut out, ev)?;
|
||||
|
||||
for track in &smf.tracks {
|
||||
let mut ev = Vec::new();
|
||||
if !track.name.is_empty() {
|
||||
ev.push(WEvent { tick: 0, order: 0, bytes: meta(0x03, track.name.as_bytes())? });
|
||||
}
|
||||
for n in &track.sequence.notes {
|
||||
let ch = n.channel & 0x0F;
|
||||
let pitch = n.pitch & 0x7F;
|
||||
let start = clamp_tick(n.start);
|
||||
let end = clamp_tick(n.end()).max(start.saturating_add(1));
|
||||
ev.push(WEvent { tick: start, order: 3, bytes: vec![0x90 | ch, pitch, n.velocity.clamp(1, 127)] });
|
||||
ev.push(WEvent { tick: end, order: 1, bytes: vec![0x80 | ch, pitch, n.release_velocity & 0x7F] });
|
||||
}
|
||||
for c in &track.sequence.ctrls {
|
||||
let ch = c.channel & 0x0F;
|
||||
let v7 = c.value.clamp(0, 127) as u8;
|
||||
let bytes = match c.kind {
|
||||
CtrlKind::Cc(num) => vec![0xB0 | ch, num & 0x7F, v7],
|
||||
CtrlKind::Program => vec![0xC0 | ch, v7],
|
||||
CtrlKind::ChannelPressure => vec![0xD0 | ch, v7],
|
||||
CtrlKind::PolyPressure(p) => vec![0xA0 | ch, p & 0x7F, v7],
|
||||
CtrlKind::PitchBend => {
|
||||
let v = (c.value.saturating_add(8192)).clamp(0, 16383);
|
||||
vec![0xE0 | ch, (v & 0x7F) as u8, ((v >> 7) & 0x7F) as u8]
|
||||
}
|
||||
};
|
||||
ev.push(WEvent { tick: clamp_tick(c.tick), order: 2, bytes });
|
||||
}
|
||||
write_track(&mut out, ev)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vlq_round_trip() {
|
||||
for v in [0u64, 0x40, 0x7F, 0x80, 0x2000, 0x3FFF, 0x4000, 0x1F_FFFF, 0x20_0000, MAX_VLQ] {
|
||||
let mut b = Vec::new();
|
||||
push_vlq(&mut b, v).unwrap();
|
||||
let mut c = Cursor { data: &b, pos: 0, base: 0 };
|
||||
assert_eq!(u64::from(c.vlq().unwrap()), v);
|
||||
assert!(c.is_empty());
|
||||
}
|
||||
assert!(push_vlq(&mut Vec::new(), MAX_VLQ + 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spec_vlq_examples() {
|
||||
let mut b = Vec::new();
|
||||
push_vlq(&mut b, 0x0800_0000).unwrap();
|
||||
assert_eq!(b, [0xC0, 0x80, 0x80, 0x00]);
|
||||
let mut b = Vec::new();
|
||||
push_vlq(&mut b, 0x2000).unwrap();
|
||||
assert_eq!(b, [0xC0, 0x00]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use soundcraft_midi::ops::*;
|
||||
use soundcraft_midi::*;
|
||||
|
||||
fn n(pitch: u8, start: i64, length: i64) -> Note {
|
||||
Note { pitch, velocity: 100, release_velocity: 64, channel: 0, start, length }
|
||||
}
|
||||
|
||||
fn starts(notes: &[Note]) -> Vec<i64> {
|
||||
notes.iter().map(|n| n.start).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantize_full_strength() {
|
||||
let mut notes = vec![n(60, 10, 200), n(60, 230, 100), n(60, 365, 50), n(60, -20, 40)];
|
||||
quantize(&mut notes, &QuantizeOptions { grid_ticks: 240, ..Default::default() });
|
||||
assert_eq!(starts(¬es), vec![0, 240, 480, 0]);
|
||||
// Durations preserved by default.
|
||||
assert_eq!(notes.iter().map(|n| n.length).collect::<Vec<_>>(), vec![200, 100, 50, 40]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantize_half_strength() {
|
||||
let mut notes = vec![n(60, 20, 100), n(60, 220, 100)];
|
||||
quantize(&mut notes, &QuantizeOptions { grid_ticks: 240, strength: 0.5, ..Default::default() });
|
||||
assert_eq!(starts(¬es), vec![10, 230]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantize_swing() {
|
||||
// 100% swing on a 1/8 (480) grid moves odd lines by 480/3 = 160 (triplet feel).
|
||||
let mut notes = vec![n(60, 0, 10), n(60, 470, 10), n(60, 650, 10), n(60, 950, 10)];
|
||||
quantize(&mut notes, &QuantizeOptions { grid_ticks: 480, swing: 1.0, ..Default::default() });
|
||||
assert_eq!(starts(¬es), vec![0, 640, 640, 960]);
|
||||
let mut notes = vec![n(60, 480, 10)];
|
||||
quantize(&mut notes, &QuantizeOptions { grid_ticks: 480, swing: 0.5, ..Default::default() });
|
||||
assert_eq!(starts(¬es), vec![560]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantize_include_exclude() {
|
||||
let opts = QuantizeOptions { grid_ticks: 240, include_within: 0.5, ..Default::default() };
|
||||
// Half grid = 120; include within 60 ticks.
|
||||
let mut notes = vec![n(60, 50, 10), n(60, 100, 10)];
|
||||
quantize(&mut notes, &opts);
|
||||
assert_eq!(starts(¬es), vec![0, 100]);
|
||||
|
||||
let opts = QuantizeOptions { grid_ticks: 240, exclude_within: 0.25, ..Default::default() };
|
||||
// Exclude closer than 30 ticks.
|
||||
let mut notes = vec![n(60, 20, 10), n(60, 50, 10)];
|
||||
quantize(&mut notes, &opts);
|
||||
assert_eq!(starts(¬es), vec![20, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantize_ends_and_duration() {
|
||||
let mut notes = vec![n(60, 10, 220)];
|
||||
quantize(&mut notes, &QuantizeOptions { grid_ticks: 240, quantize_ends: true, ..Default::default() });
|
||||
assert_eq!((notes[0].start, notes[0].length), (0, 240));
|
||||
|
||||
let mut notes = vec![n(60, 10, 220)];
|
||||
quantize(&mut notes, &QuantizeOptions { grid_ticks: 240, preserve_duration: false, ..Default::default() });
|
||||
assert_eq!((notes[0].start, notes[0].length), (0, 230));
|
||||
|
||||
// Ends quantized onto the start line still keep a length of at least 1.
|
||||
let mut notes = vec![n(60, 10, 5)];
|
||||
quantize(&mut notes, &QuantizeOptions { grid_ticks: 240, quantize_ends: true, ..Default::default() });
|
||||
assert_eq!(notes[0].length, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantize_random_is_deterministic_and_bounded() {
|
||||
let opts = QuantizeOptions { grid_ticks: 240, random: 0.5, ..Default::default() };
|
||||
let mut a: Vec<Note> = (0..50).map(|i| n(60, i * 240 + 7, 10)).collect();
|
||||
let mut b = a.clone();
|
||||
quantize(&mut a, &opts);
|
||||
quantize(&mut b, &opts);
|
||||
assert_eq!(a, b);
|
||||
for (i, note) in a.iter().enumerate() {
|
||||
assert!((note.start - i as i64 * 240).abs() <= 60);
|
||||
}
|
||||
let mut c: Vec<Note> = (0..50).map(|i| n(60, i * 240 + 7, 10)).collect();
|
||||
quantize_with_seed(&mut c, &opts, 42);
|
||||
assert_ne!(a, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantize_degenerate_options() {
|
||||
let mut notes = vec![n(60, 17, 10)];
|
||||
quantize(&mut notes, &QuantizeOptions { grid_ticks: 0, ..Default::default() });
|
||||
quantize(&mut notes, &QuantizeOptions { grid_ticks: -5, ..Default::default() });
|
||||
assert_eq!(notes[0].start, 17);
|
||||
quantize(&mut notes, &QuantizeOptions { grid_ticks: 240, strength: f32::NAN, swing: f32::INFINITY, ..Default::default() });
|
||||
assert_eq!(notes[0].start, 17);
|
||||
let mut extreme = vec![n(60, i64::MAX - 10, 1), n(60, i64::MIN + 10, 1)];
|
||||
quantize(&mut extreme, &QuantizeOptions { grid_ticks: i64::MAX, random: 1.0, ..Default::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transpose_clamps() {
|
||||
let mut notes = vec![n(0, 0, 1), n(60, 0, 1), n(127, 0, 1)];
|
||||
transpose(&mut notes, 12);
|
||||
assert_eq!(notes.iter().map(|n| n.pitch).collect::<Vec<_>>(), vec![12, 72, 127]);
|
||||
transpose(&mut notes, -100);
|
||||
assert_eq!(notes.iter().map(|n| n.pitch).collect::<Vec<_>>(), vec![0, 0, 27]);
|
||||
transpose(&mut notes, i32::MAX);
|
||||
assert!(notes.iter().all(|n| n.pitch == 127));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn velocity_ops() {
|
||||
let base = vec![n(60, 0, 1), n(60, 100, 1), n(60, 200, 1)];
|
||||
let vels = |v: &[Note]| v.iter().map(|n| n.velocity).collect::<Vec<_>>();
|
||||
|
||||
let mut v = base.clone();
|
||||
change_velocity(&mut v, VelocityOp::Set(0));
|
||||
assert_eq!(vels(&v), vec![1, 1, 1]);
|
||||
change_velocity(&mut v, VelocityOp::Set(200));
|
||||
assert_eq!(vels(&v), vec![127, 127, 127]);
|
||||
|
||||
let mut v = base.clone();
|
||||
change_velocity(&mut v, VelocityOp::Add(50));
|
||||
assert_eq!(vels(&v), vec![127; 3]);
|
||||
change_velocity(&mut v, VelocityOp::Add(-500));
|
||||
assert_eq!(vels(&v), vec![1; 3]);
|
||||
|
||||
let mut v = base.clone();
|
||||
change_velocity(&mut v, VelocityOp::Scale(0.5));
|
||||
assert_eq!(vels(&v), vec![50; 3]);
|
||||
change_velocity(&mut v, VelocityOp::Scale(f32::NAN));
|
||||
assert_eq!(vels(&v), vec![50; 3]);
|
||||
|
||||
let mut v = vec![n(60, 0, 1), n(60, 0, 1)];
|
||||
v[0].velocity = 10;
|
||||
v[1].velocity = 120;
|
||||
change_velocity(&mut v, VelocityOp::Limit { min: 100, max: 30 });
|
||||
assert_eq!(vels(&v), vec![30, 100]);
|
||||
|
||||
let mut v = base.clone();
|
||||
change_velocity(&mut v, VelocityOp::Ramp { from: 20, to: 120 });
|
||||
assert_eq!(vels(&v), vec![20, 70, 120]);
|
||||
|
||||
let mut a = base.clone();
|
||||
let mut b = base.clone();
|
||||
change_velocity(&mut a, VelocityOp::Humanize(10));
|
||||
change_velocity(&mut b, VelocityOp::Humanize(10));
|
||||
assert_eq!(a, b);
|
||||
assert!(a.iter().all(|n| (90..=110).contains(&n.velocity)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_ops() {
|
||||
let lens = |v: &[Note]| v.iter().map(|n| n.length).collect::<Vec<_>>();
|
||||
let base = vec![n(60, 0, 100), n(62, 480, 100), n(64, 480, 50), n(60, 960, 100)];
|
||||
|
||||
let mut v = base.clone();
|
||||
change_duration(&mut v, DurationOp::Set(0));
|
||||
assert_eq!(lens(&v), vec![1; 4]);
|
||||
|
||||
let mut v = base.clone();
|
||||
change_duration(&mut v, DurationOp::Add(-75));
|
||||
assert_eq!(lens(&v), vec![25, 25, 1, 25]);
|
||||
|
||||
let mut v = base.clone();
|
||||
change_duration(&mut v, DurationOp::Scale(2.0));
|
||||
assert_eq!(lens(&v), vec![200, 200, 100, 200]);
|
||||
|
||||
let mut v = base.clone();
|
||||
change_duration(&mut v, DurationOp::Legato { gap: 10 });
|
||||
assert_eq!(lens(&v), vec![470, 470, 470, 100]);
|
||||
|
||||
let mut v = vec![n(60, 0, 500), n(60, 200, 500), n(61, 100, 500), Note { channel: 1, ..n(60, 100, 500) }];
|
||||
change_duration(&mut v, DurationOp::RemoveOverlap);
|
||||
assert_eq!(lens(&v), vec![200, 500, 500, 500]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_by_pitch() {
|
||||
let notes = vec![n(36, 0, 1), n(60, 1, 1), n(72, 2, 1), n(48, 3, 1)];
|
||||
let (inside, outside) = split_notes(¬es, 60, 40);
|
||||
assert_eq!(inside.iter().map(|n| n.pitch).collect::<Vec<_>>(), vec![60, 48]);
|
||||
assert_eq!(outside.iter().map(|n| n.pitch).collect::<Vec<_>>(), vec![36, 72]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_helpers() {
|
||||
let mut seq = Sequence {
|
||||
notes: vec![n(64, 100, 50), n(60, 100, 10), n(60, 100, 30), n(60, 0, 10), Note { channel: 2, ..n(60, 0, 10) }],
|
||||
ctrls: vec![
|
||||
CtrlEvent { tick: 500, channel: 0, kind: CtrlKind::Cc(1), value: 1 },
|
||||
CtrlEvent { tick: 5, channel: 0, kind: CtrlKind::Cc(1), value: 2 },
|
||||
CtrlEvent { tick: 500, channel: 0, kind: CtrlKind::Cc(1), value: 3 },
|
||||
CtrlEvent { tick: 500, channel: 0, kind: CtrlKind::Cc(2), value: 4 },
|
||||
],
|
||||
};
|
||||
assert_eq!(seq.end_tick(), 500);
|
||||
seq.sort();
|
||||
assert_eq!(starts(&seq.notes), vec![0, 0, 100, 100, 100]);
|
||||
assert_eq!(seq.notes[2].pitch, 60);
|
||||
assert_eq!(seq.notes_in(0, 100).count(), 2);
|
||||
assert_eq!(seq.notes_overlapping(105, 106).count(), 3);
|
||||
|
||||
let removed = seq.remove_duplicates();
|
||||
assert_eq!(removed, 2);
|
||||
assert_eq!(seq.notes.len(), 4);
|
||||
let kept = seq.notes.iter().find(|n| n.start == 100 && n.pitch == 60).unwrap();
|
||||
assert_eq!(kept.length, 30);
|
||||
assert_eq!(seq.ctrls.iter().map(|c| c.value).collect::<Vec<_>>(), vec![2, 3, 4]);
|
||||
assert_eq!(seq.remove_duplicates(), 0);
|
||||
assert_eq!(Sequence::default().end_tick(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_serde_round_trip() {
|
||||
let seq = Sequence { notes: vec![n(60, 0, 1)], ctrls: vec![CtrlEvent { tick: 1, channel: 0, kind: CtrlKind::PolyPressure(60), value: 9 }] };
|
||||
let json = serde_json::to_string(&seq).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Sequence>(&json).unwrap(), seq);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
use proptest::prelude::*;
|
||||
use soundcraft_midi::*;
|
||||
|
||||
fn vlq(mut v: u32) -> Vec<u8> {
|
||||
let mut out = vec![(v & 0x7F) as u8];
|
||||
v >>= 7;
|
||||
while v > 0 {
|
||||
out.insert(0, (v & 0x7F) as u8 | 0x80);
|
||||
v >>= 7;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn header(format: u16, ntrks: u16, division: u16) -> Vec<u8> {
|
||||
let mut b = b"MThd".to_vec();
|
||||
b.extend_from_slice(&6u32.to_be_bytes());
|
||||
b.extend_from_slice(&format.to_be_bytes());
|
||||
b.extend_from_slice(&ntrks.to_be_bytes());
|
||||
b.extend_from_slice(&division.to_be_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
fn track(body: &[u8]) -> Vec<u8> {
|
||||
let mut b = b"MTrk".to_vec();
|
||||
b.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
b.extend_from_slice(body);
|
||||
b
|
||||
}
|
||||
|
||||
fn file(format: u16, division: u16, bodies: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut b = header(format, bodies.len() as u16, division);
|
||||
for body in bodies {
|
||||
b.extend(track(body));
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn note(pitch: u8, start: i64, length: i64) -> Note {
|
||||
Note { pitch, velocity: 100, release_velocity: 64, channel: 0, start, length }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let mut seq = Sequence {
|
||||
notes: vec![
|
||||
note(60, 0, 480),
|
||||
Note { pitch: 64, velocity: 90, release_velocity: 30, channel: 3, start: 480, length: 960 },
|
||||
note(67, 960, 1),
|
||||
note(60, 1920, 240),
|
||||
],
|
||||
ctrls: vec![
|
||||
CtrlEvent { tick: 0, channel: 0, kind: CtrlKind::Program, value: 5 },
|
||||
CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 100 },
|
||||
CtrlEvent { tick: 20, channel: 1, kind: CtrlKind::PitchBend, value: -8192 },
|
||||
CtrlEvent { tick: 30, channel: 1, kind: CtrlKind::PitchBend, value: 8191 },
|
||||
CtrlEvent { tick: 40, channel: 1, kind: CtrlKind::PitchBend, value: 0 },
|
||||
CtrlEvent { tick: 50, channel: 2, kind: CtrlKind::ChannelPressure, value: 77 },
|
||||
CtrlEvent { tick: 60, channel: 2, kind: CtrlKind::PolyPressure(61), value: 12 },
|
||||
],
|
||||
};
|
||||
seq.sort();
|
||||
let smf = Smf {
|
||||
format: 1,
|
||||
tracks: vec![
|
||||
SmfTrack { name: "Piano".into(), sequence: seq.clone() },
|
||||
SmfTrack { name: "Bass ♪".into(), sequence: Sequence { notes: vec![note(36, 100, 50)], ctrls: vec![] } },
|
||||
],
|
||||
tempos: vec![(0, 120.0), (3840, 90.0)],
|
||||
meters: vec![(0, 4, 4), (3840, 6, 8)],
|
||||
markers: vec![(0, "Intro".into()), (7680, "Verse".into())],
|
||||
key_sigs: vec![(0, -3, true), (3840, 2, false)],
|
||||
};
|
||||
let bytes = write_smf(&smf).unwrap();
|
||||
let back = read_smf(&bytes).unwrap();
|
||||
assert_eq!(back.format, 1);
|
||||
assert_eq!(back.tracks.len(), 2);
|
||||
assert_eq!(back.tracks[0].name, "Piano");
|
||||
assert_eq!(back.tracks[0].sequence, seq);
|
||||
assert_eq!(back.tracks[1].name, "Bass ♪");
|
||||
assert_eq!(back.tracks[1].sequence.notes, vec![note(36, 100, 50)]);
|
||||
assert_eq!(back.tempos.len(), 2);
|
||||
assert!((back.tempos[0].1 - 120.0).abs() < 1e-9);
|
||||
assert!((back.tempos[1].1 - 90.0).abs() < 1e-3);
|
||||
assert_eq!(back.tempos[1].0, 3840);
|
||||
assert_eq!(back.meters, smf.meters);
|
||||
assert_eq!(back.markers, smf.markers);
|
||||
assert_eq!(back.key_sigs, smf.key_sigs);
|
||||
// Writing again is byte-identical.
|
||||
let mut again = back.clone();
|
||||
again.tempos = smf.tempos.clone();
|
||||
assert_eq!(write_smf(&again).unwrap(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_bad_values() {
|
||||
let bad_tempo = Smf { tempos: vec![(0, 0.0)], ..Default::default() };
|
||||
assert!(write_smf(&bad_tempo).is_err());
|
||||
let nan_tempo = Smf { tempos: vec![(0, f64::NAN)], ..Default::default() };
|
||||
assert!(write_smf(&nan_tempo).is_err());
|
||||
let bad_meter = Smf { meters: vec![(0, 4, 3)], ..Default::default() };
|
||||
assert!(write_smf(&bad_meter).is_err());
|
||||
let huge_gap = Smf {
|
||||
tracks: vec![SmfTrack { name: String::new(), sequence: Sequence { notes: vec![note(60, 0x1000_0000, 10)], ctrls: vec![] } }],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(write_smf(&huge_gap).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn division_conversion_480_to_960() {
|
||||
// Note at tick 480 (one quarter at 480 PPQ), length 240.
|
||||
let body = [vlq(480), vec![0x90, 60, 100], vlq(240), vec![0x80, 60, 0], vec![0, 0xFF, 0x2F, 0]].concat();
|
||||
let smf = read_smf(&file(0, 480, &[body])).unwrap();
|
||||
assert_eq!(smf.tracks.len(), 1);
|
||||
assert_eq!(smf.tracks[0].sequence.notes, vec![Note { pitch: 60, velocity: 100, release_velocity: 0, channel: 0, start: 960, length: 480 }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn division_conversion_96_and_smpte() {
|
||||
let body = [vlq(96), vec![0x90, 60, 100], vlq(48), vec![0x80, 60, 64]].concat();
|
||||
let smf = read_smf(&file(0, 96, std::slice::from_ref(&body))).unwrap();
|
||||
assert_eq!(smf.tracks[0].sequence.notes[0].start, 960);
|
||||
assert_eq!(smf.tracks[0].sequence.notes[0].length, 480);
|
||||
|
||||
// SMPTE 25 fps, 40 ticks/frame = 1000 ticks/s; 96 ticks = 96 ms; at 120 bpm 0.5 s = 960 ticks.
|
||||
let div = u16::from_be_bytes([(-25i8) as u8, 40]);
|
||||
let body = [vlq(500), vec![0x90, 60, 100], vlq(250), vec![0x80, 60, 64]].concat();
|
||||
let smf = read_smf(&file(0, div, &[body])).unwrap();
|
||||
assert_eq!(smf.tracks[0].sequence.notes[0].start, 960);
|
||||
assert_eq!(smf.tracks[0].sequence.notes[0].length, 480);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_status_and_velocity_zero_note_off() {
|
||||
// Note-on with running status for both on and off (velocity 0).
|
||||
let body = [
|
||||
vec![0x00, 0x91, 60, 100], // on C3 ch1
|
||||
vec![0x00, 64, 90], // running: on E3
|
||||
vlq(960),
|
||||
vec![60, 0], // running: note-on vel 0 = off C3
|
||||
vec![0x00, 64, 0],
|
||||
vec![0x00, 0xB1, 7, 100], // CC7
|
||||
vec![0x10, 10, 64], // running CC10
|
||||
]
|
||||
.concat();
|
||||
let smf = read_smf(&file(0, 960, &[body])).unwrap();
|
||||
let seq = &smf.tracks[0].sequence;
|
||||
assert_eq!(
|
||||
seq.notes,
|
||||
vec![
|
||||
Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 1, start: 0, length: 960 },
|
||||
Note { pitch: 64, velocity: 90, release_velocity: 64, channel: 1, start: 0, length: 960 },
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
seq.ctrls,
|
||||
vec![
|
||||
CtrlEvent { tick: 960, channel: 1, kind: CtrlKind::Cc(7), value: 100 },
|
||||
CtrlEvent { tick: 976, channel: 1, kind: CtrlKind::Cc(10), value: 64 },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_cancels_running_status() {
|
||||
let body = [vec![0x00, 0x90, 60, 100], vec![0x00, 0xFF, 0x01, 0x01, b'x'], vec![0x00, 60, 0]].concat();
|
||||
assert!(read_smf(&file(0, 960, &[body])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_note_off_ignored_and_hanging_notes_closed() {
|
||||
let body = [
|
||||
vec![0x00, 0x80, 50, 0], // unmatched off
|
||||
vec![0x00, 0x90, 60, 100], // hanging
|
||||
vlq(100),
|
||||
vec![0x90, 62, 100],
|
||||
vlq(100),
|
||||
vec![0x80, 62, 10],
|
||||
vlq(800),
|
||||
vec![0xFF, 0x2F, 0x00],
|
||||
]
|
||||
.concat();
|
||||
let smf = read_smf(&file(0, 960, &[body])).unwrap();
|
||||
let notes = &smf.tracks[0].sequence.notes;
|
||||
assert_eq!(notes.len(), 2);
|
||||
assert_eq!(notes[0], Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 1000 });
|
||||
assert_eq!(notes[1], Note { pitch: 62, velocity: 100, release_velocity: 10, channel: 0, start: 100, length: 100 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_same_pitch_fifo_and_zero_length() {
|
||||
let body = [
|
||||
vec![0x00, 0x90, 60, 100],
|
||||
vec![0x10, 0x90, 60, 80],
|
||||
vec![0x10, 0x80, 60, 0],
|
||||
vec![0x10, 0x80, 60, 0],
|
||||
vec![0x00, 0x90, 70, 1],
|
||||
vec![0x00, 0x80, 70, 0],
|
||||
]
|
||||
.concat();
|
||||
let smf = read_smf(&file(0, 960, &[body])).unwrap();
|
||||
let notes = &smf.tracks[0].sequence.notes;
|
||||
assert_eq!((notes[0].start, notes[0].length, notes[0].velocity), (0, 32, 100));
|
||||
assert_eq!((notes[1].start, notes[1].length, notes[1].velocity), (16, 32, 80));
|
||||
assert_eq!((notes[2].pitch, notes[2].length), (70, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format1_conductor_folded_and_unknown_chunks_skipped() {
|
||||
let conductor = [
|
||||
vec![0x00, 0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20], // 500000 us = 120 bpm
|
||||
vec![0x00, 0xFF, 0x58, 0x04, 3, 2, 24, 8], // 3/4
|
||||
vec![0x00, 0xFF, 0x2F, 0x00],
|
||||
]
|
||||
.concat();
|
||||
let t1 = [vec![0x00, 0xFF, 0x03, 0x03], b"Org".to_vec(), vec![0x00, 0x90, 60, 100, 0x60, 0x80, 60, 0]].concat();
|
||||
let mut bytes = header(1, 2, 960);
|
||||
bytes.extend(track(&conductor));
|
||||
bytes.extend(b"XFIH\x00\x00\x00\x03abc");
|
||||
bytes.extend(track(&t1));
|
||||
let smf = read_smf(&bytes).unwrap();
|
||||
assert_eq!(smf.tracks.len(), 1);
|
||||
assert_eq!(smf.tracks[0].name, "Org");
|
||||
assert_eq!(smf.tempos, vec![(0, 120.0)]);
|
||||
assert_eq!(smf.meters, vec![(0, 3, 4)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sysex_skipped() {
|
||||
let body = [vec![0x00, 0xF0, 0x03, 0x7E, 0x7F, 0xF7], vec![0x00, 0x90, 60, 100, 0x10, 0x80, 60, 0]].concat();
|
||||
let smf = read_smf(&file(0, 960, &[body])).unwrap();
|
||||
assert_eq!(smf.tracks[0].sequence.notes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_inputs_are_errors() {
|
||||
assert!(matches!(read_smf(b""), Err(MidiError::NotSmf)));
|
||||
assert!(matches!(read_smf(b"RIFF\x00\x00\x00\x06"), Err(MidiError::NotSmf)));
|
||||
// Header truncated.
|
||||
assert!(read_smf(b"MThd\x00\x00\x00\x06\x00\x01").is_err());
|
||||
// Header length < 6.
|
||||
assert!(read_smf(b"MThd\x00\x00\x00\x02\x00\x01").is_err());
|
||||
// Division 0.
|
||||
assert!(read_smf(&header(0, 0, 0)).is_err());
|
||||
// Format 3.
|
||||
assert!(read_smf(&header(3, 0, 96)).is_err());
|
||||
// Chunk length larger than the data.
|
||||
let mut b = header(0, 1, 96);
|
||||
b.extend(b"MTrk\xFF\xFF\xFF\xF0\x00\x90");
|
||||
assert!(matches!(read_smf(&b), Err(MidiError::BadChunkLength { .. })));
|
||||
// Truncated chunk header.
|
||||
let mut b = header(0, 1, 96);
|
||||
b.extend(b"MTr");
|
||||
assert!(read_smf(&b).is_err());
|
||||
// Huge VLQ (5 continuation bytes).
|
||||
let body = vec![0xFF, 0xFF, 0xFF, 0xFF, 0x7F, 0x90, 60, 100];
|
||||
assert!(matches!(read_smf(&file(0, 96, &[body])), Err(MidiError::VlqTooLong(_))));
|
||||
// Meta length past the end of the track.
|
||||
let body = vec![0x00, 0xFF, 0x03, 0x7F, b'a'];
|
||||
assert!(matches!(read_smf(&file(0, 96, &[body])), Err(MidiError::Truncated(_))));
|
||||
// Truncated channel message.
|
||||
let body = vec![0x00, 0x90, 60];
|
||||
assert!(read_smf(&file(0, 96, &[body])).is_err());
|
||||
// Data byte with no running status.
|
||||
let body = vec![0x00, 60, 100];
|
||||
assert!(read_smf(&file(0, 96, &[body])).is_err());
|
||||
// System common inside a track.
|
||||
let body = vec![0x00, 0xF2, 0, 0];
|
||||
assert!(read_smf(&file(0, 96, &[body])).is_err());
|
||||
// Header only (no tracks) is fine.
|
||||
assert_eq!(read_smf(&header(1, 0, 96)).unwrap().tracks.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_cap_reached_is_error() {
|
||||
// MAX_EVENTS + 1 one-byte running-status program changes (2 bytes each with delta).
|
||||
let mut body = vec![0x00, 0xC0, 0x00];
|
||||
body.reserve(MAX_EVENTS * 2);
|
||||
for _ in 0..MAX_EVENTS {
|
||||
body.extend_from_slice(&[0x00, 0x01]);
|
||||
}
|
||||
assert!(matches!(read_smf(&file(0, 96, &[body])), Err(MidiError::TooManyEvents(_))));
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(512))]
|
||||
|
||||
#[test]
|
||||
fn random_bytes_never_panic(data in proptest::collection::vec(any::<u8>(), 0..512)) {
|
||||
let _ = read_smf(&data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_track_bodies_never_panic(body in proptest::collection::vec(any::<u8>(), 0..512), div in any::<u16>(), fmt in 0u16..4) {
|
||||
let _ = read_smf(&file(fmt, div, &[body]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_files_read_back(notes in proptest::collection::vec((0u8..128, 1u8..128, 0u8..16, 0i64..100_000, 1i64..10_000), 0..64)) {
|
||||
let mut seq = Sequence {
|
||||
notes: notes.iter().map(|&(pitch, velocity, channel, start, length)| Note { pitch, velocity, release_velocity: 64, channel, start, length }).collect(),
|
||||
ctrls: vec![],
|
||||
};
|
||||
seq.remove_duplicates();
|
||||
let smf = Smf { format: 1, tracks: vec![SmfTrack { name: "t".into(), sequence: seq.clone() }], ..Default::default() };
|
||||
let back = read_smf(&write_smf(&smf).unwrap()).unwrap();
|
||||
// Overlapping same-pitch notes may re-pair, but count and starts survive.
|
||||
let got = back.tracks.first().map(|t| t.sequence.notes.clone()).unwrap_or_default();
|
||||
prop_assert_eq!(got.len(), seq.notes.len());
|
||||
let mut a: Vec<_> = got.iter().map(|n| (n.start, n.pitch, n.channel)).collect();
|
||||
let mut b: Vec<_> = seq.notes.iter().map(|n| (n.start, n.pitch, n.channel)).collect();
|
||||
a.sort();
|
||||
b.sort();
|
||||
prop_assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "soundcraft-mix"
|
||||
description = "SoundCraft mix engine: renders a session's tracks, plugins, sends, busses and automation to audio"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
soundcraft-time.workspace = true
|
||||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-midi.workspace = true
|
||||
soundcraft-dsp.workspace = true
|
||||
soundcraft-model.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,809 @@
|
||||
//! The SoundCraft mix engine.
|
||||
//!
|
||||
//! [`MixEngine::render`] produces one block of the main stereo output for a session at a timeline
|
||||
//! position: clips (with clip gain and fades) → trim/phase → inserts → pre-fader sends → fader and
|
||||
//! mute (with automation) → pan → post-fader sends → busses → aux inputs → main → master faders.
|
||||
//! The same code drives realtime playback and offline bounces, so what you hear is what you get.
|
||||
//! Steady-state rendering does not allocate.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use soundcraft_dsp::pan::{PanLaw, gains};
|
||||
use soundcraft_dsp::{Plugin, db_to_gain};
|
||||
use soundcraft_model::{AutoParam, BusId, Clip, ClipContent, Route, Session, Track, TrackId, TrackKind};
|
||||
use soundcraft_time::{Range, Samples};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Main mix width.
|
||||
pub const MAIN_CHANNELS: usize = 2;
|
||||
|
||||
/// Peak meter values for one strip (linear, per channel, max over the last block).
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct StripMeter {
|
||||
pub peak: [f32; 2],
|
||||
/// Gain reduction of the first dynamics insert, dB.
|
||||
pub gain_reduction_db: f32,
|
||||
}
|
||||
|
||||
struct PluginSlot {
|
||||
id: String,
|
||||
plugin: Box<dyn Plugin>,
|
||||
}
|
||||
|
||||
struct Strip {
|
||||
buf: Vec<Vec<f32>>,
|
||||
plugins: Vec<Option<PluginSlot>>,
|
||||
instrument: Option<PluginSlot>,
|
||||
/// MIDI notes currently sounding on the instrument (pitch), for note-offs at stops/seek.
|
||||
held: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Strip {
|
||||
fn new() -> Self {
|
||||
Strip { buf: Vec::new(), plugins: Vec::new(), instrument: None, held: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders sessions. Keep one per playback stream; plugin state persists across blocks.
|
||||
pub struct MixEngine {
|
||||
sample_rate: f32,
|
||||
max_block: usize,
|
||||
strips: HashMap<TrackId, Strip>,
|
||||
busses: HashMap<BusId, Vec<Vec<f32>>>,
|
||||
main: Vec<Vec<f32>>,
|
||||
scratch: Vec<f32>,
|
||||
pub meters: HashMap<TrackId, StripMeter>,
|
||||
pub main_meter: StripMeter,
|
||||
pub pan_law: PanLaw,
|
||||
/// Last rendered position end (to detect seeks).
|
||||
last_end: Samples,
|
||||
}
|
||||
|
||||
impl MixEngine {
|
||||
pub fn new(sample_rate: f32, max_block: usize) -> Self {
|
||||
let max_block = max_block.clamp(16, 16_384);
|
||||
MixEngine {
|
||||
sample_rate: if sample_rate.is_finite() && sample_rate > 0.0 { sample_rate } else { 48_000.0 },
|
||||
max_block,
|
||||
strips: HashMap::new(),
|
||||
busses: HashMap::new(),
|
||||
main: vec![vec![0.0; max_block]; MAIN_CHANNELS],
|
||||
scratch: vec![0.0; max_block],
|
||||
meters: HashMap::new(),
|
||||
main_meter: StripMeter::default(),
|
||||
pan_law: PanLaw::Minus3,
|
||||
last_end: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_block(&self) -> usize {
|
||||
self.max_block
|
||||
}
|
||||
|
||||
/// Reset all plugin state (on stop / seek).
|
||||
pub fn reset(&mut self) {
|
||||
for s in self.strips.values_mut() {
|
||||
for p in s.plugins.iter_mut().flatten() {
|
||||
p.plugin.reset();
|
||||
}
|
||||
if let Some(i) = &mut s.instrument {
|
||||
i.plugin.all_notes_off();
|
||||
i.plugin.reset();
|
||||
}
|
||||
s.held.clear();
|
||||
}
|
||||
self.meters.clear();
|
||||
self.main_meter = StripMeter::default();
|
||||
}
|
||||
|
||||
/// Bring plugin instances in line with the session (creates/destroys as needed). Allocates;
|
||||
/// call outside the realtime thread when the document changes, or let `render` do it.
|
||||
pub fn sync(&mut self, s: &Session) {
|
||||
let sr = self.sample_rate;
|
||||
let mb = self.max_block;
|
||||
self.strips.retain(|id, _| s.track(*id).is_some());
|
||||
for t in &s.tracks {
|
||||
let ch = strip_channels(t);
|
||||
let strip = self.strips.entry(t.id).or_insert_with(Strip::new);
|
||||
if strip.buf.len() != ch || strip.buf.first().map_or(0, Vec::len) != mb {
|
||||
strip.buf = vec![vec![0.0; mb]; ch];
|
||||
}
|
||||
strip.plugins.resize_with(t.mixer.inserts.len(), || None);
|
||||
for (slot, ins) in strip.plugins.iter_mut().zip(t.mixer.inserts.iter()) {
|
||||
match ins {
|
||||
Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin) => {
|
||||
*slot = soundcraft_dsp::create(&i.plugin).map(|mut p| {
|
||||
p.prepare(sr, mb, ch);
|
||||
PluginSlot { id: i.plugin.clone(), plugin: p }
|
||||
});
|
||||
}
|
||||
None => *slot = None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let want = t.instrument.as_ref().map(|i| i.plugin.as_str());
|
||||
if strip.instrument.as_ref().map(|p| p.id.as_str()) != want {
|
||||
strip.instrument = want.and_then(|id| {
|
||||
soundcraft_dsp::create(id).map(|mut p| {
|
||||
p.prepare(sr, mb, ch.max(2));
|
||||
PluginSlot { id: id.to_string(), plugin: p }
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
for b in &s.busses {
|
||||
self.busses.entry(b.id).or_insert_with(|| vec![vec![0.0; mb]; MAIN_CHANNELS]);
|
||||
}
|
||||
self.busses.retain(|id, _| s.bus(*id).is_some());
|
||||
}
|
||||
|
||||
/// Render `frames` (≤ max_block) samples starting at `pos` into `out` (≥ 2 channels).
|
||||
pub fn render(&mut self, s: &Session, pos: Samples, frames: usize, out: &mut [Vec<f32>]) {
|
||||
let frames = frames.min(self.max_block);
|
||||
if pos != self.last_end {
|
||||
// Seek: silence held notes and ringing plugins.
|
||||
for st in self.strips.values_mut() {
|
||||
if let Some(i) = &mut st.instrument {
|
||||
i.plugin.all_notes_off();
|
||||
}
|
||||
st.held.clear();
|
||||
}
|
||||
}
|
||||
self.last_end = pos.saturating_add(frames as i64);
|
||||
self.sync(s);
|
||||
for b in self.busses.values_mut() {
|
||||
for c in b.iter_mut() {
|
||||
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||||
}
|
||||
}
|
||||
for c in &mut self.main {
|
||||
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||||
}
|
||||
let any_solo = s.tracks.iter().any(|t| t.mixer.solo && !t.inactive);
|
||||
let order = processing_order(s);
|
||||
for idx in order {
|
||||
let Some(t) = s.tracks.get(idx) else { continue };
|
||||
if t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca {
|
||||
continue;
|
||||
}
|
||||
self.render_track(s, t, pos, frames, any_solo);
|
||||
}
|
||||
// Master faders process the main mix.
|
||||
for t in s.tracks.iter().filter(|t| t.kind == TrackKind::Master && !t.inactive) {
|
||||
if let Some(strip) = self.strips.get_mut(&t.id) {
|
||||
for (dst, src) in strip.buf.iter_mut().zip(self.main.iter()) {
|
||||
if let (Some(d), Some(sr)) = (dst.get_mut(..frames), src.get(..frames)) {
|
||||
d.copy_from_slice(sr);
|
||||
}
|
||||
}
|
||||
for (i, ins) in t.mixer.inserts.iter().enumerate() {
|
||||
if let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i))
|
||||
&& ins.active
|
||||
&& !ins.bypass
|
||||
{
|
||||
apply_params(slot, ins, t, i, pos);
|
||||
slot.plugin.process(&mut strip.buf, frames);
|
||||
}
|
||||
}
|
||||
let v0 = volume_db_at(t, pos);
|
||||
let v1 = volume_db_at(t, pos + frames as i64);
|
||||
for (ch, src) in strip.buf.iter().enumerate().take(MAIN_CHANNELS) {
|
||||
if let Some(m) = self.main.get_mut(ch) {
|
||||
for i in 0..frames {
|
||||
let g = lerp_gain(v0, v1, i, frames);
|
||||
if let (Some(d), Some(x)) = (m.get_mut(i), src.get(i)) {
|
||||
*d = x * g;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut pk = [0.0f32; 2];
|
||||
for (c, p) in pk.iter_mut().enumerate() {
|
||||
*p = self.main.get(c).map_or(0.0, |m| peak(m.get(..frames).unwrap_or(&[])));
|
||||
}
|
||||
self.meters.insert(t.id, StripMeter { peak: pk, gain_reduction_db: 0.0 });
|
||||
}
|
||||
}
|
||||
let mut pk = [0.0f32; 2];
|
||||
for (c, o) in out.iter_mut().enumerate() {
|
||||
let src = self.main.get(c.min(MAIN_CHANNELS - 1));
|
||||
for i in 0..frames {
|
||||
let v = src.and_then(|m| m.get(i)).copied().unwrap_or(0.0);
|
||||
if let Some(d) = o.get_mut(i) {
|
||||
*d = if v.is_finite() { v } else { 0.0 };
|
||||
}
|
||||
}
|
||||
if let Some(p) = pk.get_mut(c) {
|
||||
*p = peak(o.get(..frames).unwrap_or(&[]));
|
||||
}
|
||||
}
|
||||
self.main_meter = StripMeter { peak: pk, gain_reduction_db: 0.0 };
|
||||
}
|
||||
|
||||
fn render_track(&mut self, s: &Session, t: &Track, pos: Samples, frames: usize, any_solo: bool) {
|
||||
let Some(strip) = self.strips.get_mut(&t.id) else { return };
|
||||
for c in strip.buf.iter_mut() {
|
||||
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||||
}
|
||||
// 1. Source: clips, bus input or instrument.
|
||||
match t.kind {
|
||||
TrackKind::Audio => {
|
||||
for clip in t.clips() {
|
||||
if clip.muted || !clip.is_audio() {
|
||||
continue;
|
||||
}
|
||||
render_clip(s, clip, pos, frames, &mut strip.buf, &mut self.scratch);
|
||||
}
|
||||
}
|
||||
TrackKind::Aux | TrackKind::Folder => {
|
||||
if let Route::Bus(b) = &t.mixer.input
|
||||
&& let Some(src) = self.busses.get(b)
|
||||
{
|
||||
let nch = strip.buf.len();
|
||||
for (ch, dst) in strip.buf.iter_mut().enumerate() {
|
||||
for i in 0..frames {
|
||||
let v = if nch == 1 {
|
||||
0.5 * (src.first().and_then(|c| c.get(i)).copied().unwrap_or(0.0)
|
||||
+ src.get(1).and_then(|c| c.get(i)).copied().unwrap_or(0.0))
|
||||
} else {
|
||||
src.get(ch.min(1)).and_then(|c| c.get(i)).copied().unwrap_or(0.0)
|
||||
};
|
||||
if let Some(d) = dst.get_mut(i) {
|
||||
*d = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TrackKind::Instrument | TrackKind::Midi => {
|
||||
if let Some(inst) = &mut strip.instrument {
|
||||
schedule_notes(s, t, pos, frames, inst, &mut strip.held);
|
||||
let mut tmp = std::mem::take(&mut strip.buf);
|
||||
if tmp.len() >= 2 {
|
||||
inst.plugin.process(&mut tmp, frames);
|
||||
} else if let Some(c0) = tmp.first_mut() {
|
||||
// Mono MIDI track: render the instrument in stereo into scratch and fold.
|
||||
let mut st = [std::mem::take(c0), std::mem::take(&mut self.scratch)];
|
||||
inst.plugin.process(&mut st, frames);
|
||||
let [a, b] = st;
|
||||
*c0 = a;
|
||||
self.scratch = b;
|
||||
for (x, y) in c0.iter_mut().zip(self.scratch.iter()).take(frames) {
|
||||
*x = 0.5 * (*x + *y);
|
||||
}
|
||||
}
|
||||
strip.buf = tmp;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// 2. Trim + phase.
|
||||
let trim = db_to_gain(t.mixer.trim_db) * if t.mixer.phase_invert { -1.0 } else { 1.0 };
|
||||
if (trim - 1.0).abs() > f32::EPSILON {
|
||||
for c in strip.buf.iter_mut() {
|
||||
c.iter_mut().take(frames).for_each(|x| *x *= trim);
|
||||
}
|
||||
}
|
||||
// 3. Inserts.
|
||||
let mut gr = 0.0f32;
|
||||
for (i, ins) in t.mixer.inserts.iter().enumerate() {
|
||||
let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue };
|
||||
if !ins.active || ins.bypass {
|
||||
continue;
|
||||
}
|
||||
apply_params(slot, ins, t, i, pos);
|
||||
slot.plugin.process(&mut strip.buf, frames);
|
||||
if gr == 0.0 {
|
||||
gr = slot.plugin.gain_reduction_db();
|
||||
}
|
||||
}
|
||||
// 4. Mute (static, automation, solo).
|
||||
let auto_mute = t.mixer.automation_mode.reads() && t.lane(&AutoParam::Mute).is_some_and(|l| l.value_at(pos, 0.0) >= 0.5);
|
||||
let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t);
|
||||
let muted = t.mixer.mute || auto_mute || soloed_out;
|
||||
// Pre-fader sends.
|
||||
for (i, snd) in t.mixer.sends.iter().enumerate() {
|
||||
let Some(snd) = snd else { continue };
|
||||
if !snd.pre_fader || snd.mute || muted {
|
||||
continue;
|
||||
}
|
||||
let lvl = send_level(t, i, snd.level_db, pos);
|
||||
mix_to_bus(&strip.buf, frames, &mut self.busses, &snd.target, db_to_gain(lvl), snd.pan, self.pan_law, &t.mixer.pan);
|
||||
}
|
||||
// 5. Fader.
|
||||
let v0 = volume_db_at(t, pos);
|
||||
let v1 = volume_db_at(t, pos + frames as i64);
|
||||
let (g0, g1) = if muted { (-144.0, -144.0) } else { (v0, v1) };
|
||||
for c in strip.buf.iter_mut() {
|
||||
for i in 0..frames {
|
||||
if let Some(x) = c.get_mut(i) {
|
||||
*x *= lerp_gain(g0, g1, i, frames);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Meters (post-fader, pre-pan).
|
||||
let mut pk = [0.0f32; 2];
|
||||
for (c, p) in pk.iter_mut().enumerate() {
|
||||
let ch = c.min(strip.buf.len().saturating_sub(1));
|
||||
*p = strip.buf.get(ch).map_or(0.0, |b| peak(b.get(..frames).unwrap_or(&[])));
|
||||
}
|
||||
self.meters.insert(t.id, StripMeter { peak: pk, gain_reduction_db: gr });
|
||||
// Post-fader sends.
|
||||
for (i, snd) in t.mixer.sends.iter().enumerate() {
|
||||
let Some(snd) = snd else { continue };
|
||||
if snd.pre_fader || snd.mute || muted {
|
||||
continue;
|
||||
}
|
||||
let lvl = send_level(t, i, snd.level_db, pos);
|
||||
mix_to_bus(&strip.buf, frames, &mut self.busses, &snd.target, db_to_gain(lvl), snd.pan, self.pan_law, &t.mixer.pan);
|
||||
}
|
||||
// 6. Pan to output.
|
||||
let pans: [f32; 2] = [pan_at(t, 0, pos), pan_at(t, 1, pos)];
|
||||
match &t.mixer.output {
|
||||
Route::Main => pan_into(&strip.buf, frames, &mut self.main, &pans, self.pan_law),
|
||||
Route::Bus(b) => {
|
||||
if let Some(dst) = self.busses.get_mut(b) {
|
||||
pan_into(&strip.buf, frames, dst, &pans, self.pan_law);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_channels(t: &Track) -> usize {
|
||||
match t.kind {
|
||||
TrackKind::Master => MAIN_CHANNELS,
|
||||
TrackKind::Instrument => t.channels().max(2),
|
||||
_ => t.channels().clamp(1, 16),
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio/instrument tracks first, then auxes ordered so bus producers come before consumers.
|
||||
fn processing_order(s: &Session) -> Vec<usize> {
|
||||
let mut order: Vec<usize> =
|
||||
(0..s.tracks.len()).filter(|&i| s.tracks.get(i).is_some_and(|t| !matches!(t.kind, TrackKind::Aux | TrackKind::Folder))).collect();
|
||||
let mut auxes: Vec<usize> =
|
||||
(0..s.tracks.len()).filter(|&i| s.tracks.get(i).is_some_and(|t| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))).collect();
|
||||
// Depth = how many aux hops feed this aux's input bus (bounded; cycles cap out).
|
||||
let depth = |i: usize| -> usize {
|
||||
let mut d = 0;
|
||||
let mut cur = vec![i];
|
||||
for _ in 0..8 {
|
||||
let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect();
|
||||
let feeders: Vec<usize> = auxes_feeding(s, &inputs);
|
||||
if feeders.is_empty() {
|
||||
break;
|
||||
}
|
||||
d += 1;
|
||||
cur = feeders;
|
||||
}
|
||||
d
|
||||
};
|
||||
auxes.sort_by_key(|&i| depth(i));
|
||||
order.extend(auxes);
|
||||
order
|
||||
}
|
||||
|
||||
fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec<usize> {
|
||||
s.tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))
|
||||
.filter(|(_, t)| {
|
||||
inputs.iter().any(|r| matches!(r, Route::Bus(_)) && (&&t.mixer.output == r || t.mixer.sends.iter().flatten().any(|sn| &&sn.target == r)))
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Implicit solo: an aux is not muted when a soloed track feeds its input bus.
|
||||
fn receives_solo(s: &Session, t: &Track) -> bool {
|
||||
let Route::Bus(b) = &t.mixer.input else { return false };
|
||||
s.tracks
|
||||
.iter()
|
||||
.any(|o| o.mixer.solo && (o.mixer.output == Route::Bus(*b) || o.mixer.sends.iter().flatten().any(|sn| sn.target == Route::Bus(*b))))
|
||||
}
|
||||
|
||||
fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut [Vec<f32>], _scratch: &mut [f32]) {
|
||||
let ClipContent::Audio { source, offset } = clip.content else { return };
|
||||
let block = Range { start: pos, end: pos.saturating_add(frames as i64) };
|
||||
let Some(isect) = clip.range().intersect(&block) else { return };
|
||||
let Some(audio) = s.pool.get(source) else { return };
|
||||
let nsrc = audio.buffer.channels.len();
|
||||
if nsrc == 0 {
|
||||
return;
|
||||
}
|
||||
let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 };
|
||||
let i0 = usize::try_from(isect.start - pos).unwrap_or(0);
|
||||
let i1 = usize::try_from(isect.end - pos).unwrap_or(0).min(frames);
|
||||
// Gain is evaluated every 32 samples and interpolated (fades and envelopes stay smooth).
|
||||
const STEP: usize = 32;
|
||||
for (ch, dst) in buf.iter_mut().enumerate() {
|
||||
let src = audio.buffer.channels.get(ch % nsrc).map(Vec::as_slice).unwrap_or(&[]);
|
||||
let mut i = i0;
|
||||
while i < i1 {
|
||||
let seg_end = (i + STEP).min(i1);
|
||||
let rel0 = pos + i as i64 - clip.start;
|
||||
let rel1 = pos + seg_end as i64 - clip.start;
|
||||
let g0 = clip.gain_at(rel0);
|
||||
let g1 = clip.gain_at(rel1.min(clip.length));
|
||||
let n = (seg_end - i) as f32;
|
||||
for k in i..seg_end {
|
||||
let rel = pos + k as i64 - clip.start;
|
||||
let v = if (stretch - 1.0).abs() < 1e-9 {
|
||||
usize::try_from(offset + rel).ok().and_then(|x| src.get(x)).copied().unwrap_or(0.0)
|
||||
} else {
|
||||
let f = offset as f64 + rel as f64 / stretch;
|
||||
let x0 = f.floor();
|
||||
let fr = (f - x0) as f32;
|
||||
let a = if x0 >= 0.0 { src.get(x0 as usize).copied().unwrap_or(0.0) } else { 0.0 };
|
||||
let b = if x0 + 1.0 >= 0.0 { src.get(x0 as usize + 1).copied().unwrap_or(0.0) } else { 0.0 };
|
||||
a + (b - a) * fr
|
||||
};
|
||||
let t = (k - i) as f32 / n;
|
||||
if let Some(d) = dst.get_mut(k) {
|
||||
*d += v * (g0 + (g1 - g0) * t);
|
||||
}
|
||||
}
|
||||
i = seg_end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send note on/offs for MIDI clips overlapping the block.
|
||||
fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mut PluginSlot, held: &mut Vec<u8>) {
|
||||
let sr = s.sample_rate;
|
||||
let end = pos.saturating_add(frames as i64);
|
||||
for clip in t.clips() {
|
||||
if clip.muted {
|
||||
continue;
|
||||
}
|
||||
let ClipContent::Midi { sequence } = &clip.content else { continue };
|
||||
if !clip.range().overlaps(&Range { start: pos, end }) {
|
||||
continue;
|
||||
}
|
||||
let base = s.tempo.samples_to_ticks(clip.start, sr);
|
||||
for n in &sequence.notes {
|
||||
let on = s.tempo.tick_to_samples(base + n.start, sr);
|
||||
let off = s.tempo.tick_to_samples(base + n.start + n.length, sr).min(clip.end());
|
||||
if on >= clip.end() {
|
||||
continue;
|
||||
}
|
||||
if on >= pos && on < end {
|
||||
inst.plugin.note_on(usize::try_from(on - pos).unwrap_or(0), n.pitch, n.velocity);
|
||||
if !held.contains(&n.pitch) && held.len() < 128 {
|
||||
held.push(n.pitch);
|
||||
}
|
||||
}
|
||||
if off >= pos && off < end {
|
||||
inst.plugin.note_off(usize::try_from(off - pos).unwrap_or(0), n.pitch);
|
||||
held.retain(|p| *p != n.pitch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) {
|
||||
for (k, v) in &ins.params {
|
||||
let mut val = *v;
|
||||
if t.mixer.automation_mode.reads() {
|
||||
let slot_u8 = u8::try_from(i).unwrap_or(0);
|
||||
if let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k))
|
||||
&& !l.points.is_empty()
|
||||
{
|
||||
val = l.value_at(pos, val);
|
||||
}
|
||||
}
|
||||
if slot.plugin.param(k) != Some(val) {
|
||||
slot.plugin.set_param(k, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn volume_db_at(t: &Track, pos: Samples) -> f32 {
|
||||
if t.mixer.automation_mode.reads()
|
||||
&& let Some(l) = t.lane(&AutoParam::Volume)
|
||||
&& !l.points.is_empty()
|
||||
{
|
||||
return l.value_at(pos, t.mixer.volume_db);
|
||||
}
|
||||
t.mixer.volume_db
|
||||
}
|
||||
|
||||
fn pan_at(t: &Track, idx: usize, pos: Samples) -> f32 {
|
||||
let base = t.mixer.pan.get(idx).copied().unwrap_or(if idx == 0 { 0.0 } else { 1.0 });
|
||||
if t.mixer.automation_mode.reads()
|
||||
&& let Some(l) = t.lane(&AutoParam::Pan(u8::try_from(idx).unwrap_or(0)))
|
||||
&& !l.points.is_empty()
|
||||
{
|
||||
return l.value_at(pos, base);
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 {
|
||||
if t.mixer.automation_mode.reads()
|
||||
&& let Some(l) = t.lane(&AutoParam::SendLevel(u8::try_from(i).unwrap_or(0)))
|
||||
&& !l.points.is_empty()
|
||||
{
|
||||
return l.value_at(pos, base);
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn lerp_gain(db0: f32, db1: f32, i: usize, n: usize) -> f32 {
|
||||
let g0 = db_to_gain(db0);
|
||||
let g1 = db_to_gain(db1);
|
||||
if n == 0 { g0 } else { g0 + (g1 - g0) * (i as f32 / n as f32) }
|
||||
}
|
||||
|
||||
fn peak(x: &[f32]) -> f32 {
|
||||
x.iter().fold(0.0f32, |m, v| if v.is_finite() { m.max(v.abs()) } else { m })
|
||||
}
|
||||
|
||||
/// Pan a strip (1, 2 or N channels) into a stereo destination.
|
||||
fn pan_into(src: &[Vec<f32>], frames: usize, dst: &mut [Vec<f32>], pans: &[f32; 2], law: PanLaw) {
|
||||
let (l, r) = dst.split_at_mut(1);
|
||||
let (Some(dl), Some(dr)) = (l.first_mut(), r.first_mut()) else { return };
|
||||
match src.len() {
|
||||
0 => {}
|
||||
1 => {
|
||||
let (gl, gr) = gains(pans[0], law);
|
||||
if let Some(s0) = src.first() {
|
||||
for i in 0..frames {
|
||||
let v = s0.get(i).copied().unwrap_or(0.0);
|
||||
if let (Some(a), Some(b)) = (dl.get_mut(i), dr.get_mut(i)) {
|
||||
*a += v * gl;
|
||||
*b += v * gr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
n => {
|
||||
// Stereo: each side has its own panner. Wider formats fold extra channels to centre.
|
||||
let (a_l, a_r) = gains(pans[0], PanLaw::Zero);
|
||||
let (b_l, b_r) = gains(pans[1], PanLaw::Zero);
|
||||
for i in 0..frames {
|
||||
let x0 = src.first().and_then(|c| c.get(i)).copied().unwrap_or(0.0);
|
||||
let x1 = src.get(1).and_then(|c| c.get(i)).copied().unwrap_or(0.0);
|
||||
let mut c = 0.0;
|
||||
for ch in src.iter().skip(2).take(n.saturating_sub(2)) {
|
||||
c += ch.get(i).copied().unwrap_or(0.0) * std::f32::consts::FRAC_1_SQRT_2;
|
||||
}
|
||||
if let (Some(a), Some(b)) = (dl.get_mut(i), dr.get_mut(i)) {
|
||||
*a += x0 * a_l + x1 * b_l + c;
|
||||
*b += x0 * a_r + x1 * b_r + c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mix_to_bus(
|
||||
src: &[Vec<f32>],
|
||||
frames: usize,
|
||||
busses: &mut HashMap<BusId, Vec<Vec<f32>>>,
|
||||
target: &Route,
|
||||
gain: f32,
|
||||
send_pan: f32,
|
||||
law: PanLaw,
|
||||
track_pan: &[f32],
|
||||
) {
|
||||
let Route::Bus(b) = target else { return };
|
||||
let Some(dst) = busses.get_mut(b) else { return };
|
||||
let pans = if src.len() == 1 { [send_pan, 0.0] } else { [track_pan.first().copied().unwrap_or(-1.0), track_pan.get(1).copied().unwrap_or(1.0)] };
|
||||
if gain <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// Scale via a temporary pass over the destination: add gain*src.
|
||||
let (l, r) = dst.split_at_mut(1);
|
||||
let (Some(dl), Some(dr)) = (l.first_mut(), r.first_mut()) else { return };
|
||||
if src.len() == 1 {
|
||||
let (gl, gr) = gains(pans[0], law);
|
||||
if let Some(s0) = src.first() {
|
||||
for i in 0..frames {
|
||||
let v = s0.get(i).copied().unwrap_or(0.0) * gain;
|
||||
if let (Some(a), Some(bb)) = (dl.get_mut(i), dr.get_mut(i)) {
|
||||
*a += v * gl;
|
||||
*bb += v * gr;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i in 0..frames {
|
||||
let x0 = src.first().and_then(|c| c.get(i)).copied().unwrap_or(0.0) * gain;
|
||||
let x1 = src.get(1).and_then(|c| c.get(i)).copied().unwrap_or(0.0) * gain;
|
||||
if let (Some(a), Some(bb)) = (dl.get_mut(i), dr.get_mut(i)) {
|
||||
*a += x0;
|
||||
*bb += x1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Offline render of the main mix over `range` (stereo, planar).
|
||||
pub fn render_range(s: &Session, range: Range, block: usize) -> Vec<Vec<f32>> {
|
||||
let len = usize::try_from(range.len().max(0)).unwrap_or(0);
|
||||
let mut out = vec![vec![0.0f32; len]; MAIN_CHANNELS];
|
||||
let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
|
||||
let b = eng.max_block();
|
||||
let mut tmp = vec![vec![0.0f32; b]; MAIN_CHANNELS];
|
||||
let mut done = 0usize;
|
||||
while done < len {
|
||||
let n = (len - done).min(b);
|
||||
eng.render(s, range.start + done as i64, n, &mut tmp);
|
||||
for (o, t) in out.iter_mut().zip(tmp.iter()) {
|
||||
if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) {
|
||||
d.copy_from_slice(sr);
|
||||
}
|
||||
}
|
||||
done += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render only one track's clips (no mixer processing) — Consolidate.
|
||||
pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>> {
|
||||
let Some(t) = s.track(track) else { return Vec::new() };
|
||||
let len = usize::try_from(range.len().max(0)).unwrap_or(0);
|
||||
let mut out = vec![vec![0.0f32; len]; t.channels().max(1)];
|
||||
let mut scratch = Vec::new();
|
||||
for c in t.clips() {
|
||||
if c.muted {
|
||||
continue;
|
||||
}
|
||||
render_clip(s, c, range.start, len, &mut out, &mut scratch);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render one track through its inserts (pre-fader) — Commit / Freeze.
|
||||
pub fn render_track_pre_fader(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>> {
|
||||
let mut solo = s.clone();
|
||||
for t in &mut solo.tracks {
|
||||
if t.id == track {
|
||||
t.mixer.volume_db = 0.0;
|
||||
t.mixer.mute = false;
|
||||
t.mixer.solo = false;
|
||||
t.mixer.output = Route::Main;
|
||||
t.mixer.pan = match t.channels() {
|
||||
1 => vec![0.0],
|
||||
_ => vec![-1.0, 1.0],
|
||||
};
|
||||
t.automation.retain(|l| matches!(l.param, AutoParam::Plugin { .. }));
|
||||
t.mixer.sends.iter_mut().for_each(|x| *x = None);
|
||||
} else {
|
||||
t.inactive = true;
|
||||
}
|
||||
}
|
||||
let mut out = render_range(&solo, range, 1024);
|
||||
// Mono tracks: fold back to one channel (undo the centre pan law).
|
||||
if s.track(track).is_some_and(|t| t.channels() == 1)
|
||||
&& let Some(l) = out.first().cloned()
|
||||
{
|
||||
let g = std::f32::consts::SQRT_2;
|
||||
out = vec![l.iter().map(|x| x * g).collect()];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_model::{ChannelFormat, Insert, SourceAudio, SourceId};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn session_with_dc(level: f32, frames: usize) -> (Session, TrackId) {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![level; frames]] };
|
||||
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(buf)));
|
||||
let id = s.new_clip_id();
|
||||
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips.push(Clip::audio(id, "dc", SourceId(900), 0, 0, frames as i64));
|
||||
}
|
||||
(s, t)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mono_center_pan_is_minus_3db() {
|
||||
let (s, _) = session_with_dc(0.5, 4800);
|
||||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||||
let expect = 0.5 * std::f32::consts::FRAC_1_SQRT_2;
|
||||
assert!((out[0][1000] - expect).abs() < 1e-3, "{}", out[0][1000]);
|
||||
assert!((out[1][1000] - expect).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fader_mute_and_solo() {
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
s.track_mut(t).unwrap().mixer.volume_db = -6.0;
|
||||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||||
let expect = 0.5 * std::f32::consts::FRAC_1_SQRT_2 * db_to_gain(-6.0);
|
||||
assert!((out[0][2000] - expect).abs() < 1e-3);
|
||||
s.track_mut(t).unwrap().mixer.mute = true;
|
||||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||||
assert!(out[0][2000].abs() < 1e-6);
|
||||
// Soloing another track mutes this one.
|
||||
s.track_mut(t).unwrap().mixer.mute = false;
|
||||
let other = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.track_mut(other).unwrap().mixer.solo = true;
|
||||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||||
assert!(out[0][2000].abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_pans() {
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
s.track_mut(t).unwrap().mixer.pan = vec![-1.0];
|
||||
let out = render_range(&s, Range::new(0, 4800), 256);
|
||||
assert!(out[0][100] > 0.49 && out[1][100].abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fades_and_clip_gain_apply() {
|
||||
let (mut s, t) = session_with_dc(1.0, 48_000);
|
||||
{
|
||||
let c = &mut s.track_mut(t).unwrap().playlist_mut().unwrap().clips[0];
|
||||
c.fade_in = soundcraft_model::Fade { len: 1000, shape: soundcraft_model::FadeShape::Linear };
|
||||
c.gain_db = -6.0;
|
||||
}
|
||||
let out = render_range(&s, Range::new(0, 2000), 512);
|
||||
let g = std::f32::consts::FRAC_1_SQRT_2 * db_to_gain(-6.0);
|
||||
assert!(out[0][0].abs() < 1e-3);
|
||||
assert!((out[0][500] - 0.5 * g).abs() < 0.02, "{}", out[0][500]);
|
||||
assert!((out[0][1500] - g).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sends_feed_aux_via_bus() {
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
let bus = s.add_bus("Verb", ChannelFormat::Stereo);
|
||||
let aux = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("Verb"));
|
||||
s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus);
|
||||
{
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
tr.mixer.output = Route::None;
|
||||
let mut snd = soundcraft_model::SendSlot::new(Route::Bus(bus));
|
||||
snd.level_db = 0.0;
|
||||
tr.mixer.sends[0] = Some(snd);
|
||||
}
|
||||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||||
assert!(out[0][1000] > 0.1, "aux should carry the send: {}", out[0][1000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_automation_ramps() {
|
||||
let (mut s, t) = session_with_dc(0.5, 48_000);
|
||||
{
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
let l = tr.lane_mut(&AutoParam::Volume);
|
||||
l.set_point(0, 0.0);
|
||||
l.set_point(24_000, -144.0);
|
||||
}
|
||||
let out = render_range(&s, Range::new(0, 48_000), 512);
|
||||
assert!(out[0][100] > 0.3);
|
||||
assert!(out[0][30_000].abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_process_and_survive_unknown_plugins() {
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("does-not-exist"));
|
||||
let out = render_range(&s, Range::new(0, 4800), 512);
|
||||
assert!(out[0][100] > 0.3);
|
||||
let id = soundcraft_dsp::plugins().iter().find(|p| p.id.contains("gain")).map(|p| p.id).unwrap_or("gain");
|
||||
let mut ins = Insert::new(id);
|
||||
ins.params.insert("gain".into(), -6.0);
|
||||
s.track_mut(t).unwrap().mixer.inserts[0] = Some(ins);
|
||||
let _ = render_range(&s, Range::new(0, 4800), 512);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_session_is_silent() {
|
||||
let s = Session::default();
|
||||
let out = render_range(&s, Range::new(0, 1000), 128);
|
||||
assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "soundcraft-model"
|
||||
description = "SoundCraft session document: tracks, playlists, clips, mixer, routing, automation, markers"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
soundcraft-time.workspace = true
|
||||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-midi.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Automation lanes and modes.
|
||||
|
||||
use soundcraft_time::Samples;
|
||||
|
||||
/// Automation modes (per track).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum AutomationMode {
|
||||
Off,
|
||||
#[default]
|
||||
Read,
|
||||
Touch,
|
||||
Latch,
|
||||
TouchLatch,
|
||||
Write,
|
||||
Trim,
|
||||
}
|
||||
|
||||
impl AutomationMode {
|
||||
pub const ALL: [AutomationMode; 7] = [
|
||||
AutomationMode::Off,
|
||||
AutomationMode::Read,
|
||||
AutomationMode::Touch,
|
||||
AutomationMode::Latch,
|
||||
AutomationMode::TouchLatch,
|
||||
AutomationMode::Write,
|
||||
AutomationMode::Trim,
|
||||
];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
AutomationMode::Off => "off",
|
||||
AutomationMode::Read => "read",
|
||||
AutomationMode::Touch => "touch",
|
||||
AutomationMode::Latch => "latch",
|
||||
AutomationMode::TouchLatch => "touch/latch",
|
||||
AutomationMode::Write => "write",
|
||||
AutomationMode::Trim => "trim",
|
||||
}
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<AutomationMode> {
|
||||
let s = s.to_ascii_lowercase();
|
||||
AutomationMode::ALL.into_iter().find(|m| m.label() == s || m.label().replace('/', "_") == s || format!("{m:?}").to_ascii_lowercase() == s)
|
||||
}
|
||||
pub fn reads(self) -> bool {
|
||||
!matches!(self, AutomationMode::Off)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which parameter an automation lane controls.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
|
||||
pub enum AutoParam {
|
||||
/// Fader level, dB.
|
||||
Volume,
|
||||
/// Pan, -1..1 (index = which panner of a stereo track: 0 left, 1 right).
|
||||
Pan(u8),
|
||||
/// Mute (0/1).
|
||||
Mute,
|
||||
/// Send level, dB.
|
||||
SendLevel(u8),
|
||||
SendPan(u8),
|
||||
SendMute(u8),
|
||||
/// Insert slot + parameter id.
|
||||
Plugin {
|
||||
slot: u8,
|
||||
param: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl AutoParam {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
AutoParam::Volume => "volume".into(),
|
||||
AutoParam::Pan(0) => "pan".into(),
|
||||
AutoParam::Pan(i) => format!("pan {}", i + 1),
|
||||
AutoParam::Mute => "mute".into(),
|
||||
AutoParam::SendLevel(i) => format!("send {} level", slot_letter(*i)),
|
||||
AutoParam::SendPan(i) => format!("send {} pan", slot_letter(*i)),
|
||||
AutoParam::SendMute(i) => format!("send {} mute", slot_letter(*i)),
|
||||
AutoParam::Plugin { slot, param } => format!("insert {} {param}", slot_letter(*slot)),
|
||||
}
|
||||
}
|
||||
/// Parse ids like `volume`, `pan`, `pan2`, `mute`, `send_a_level`, `plugin:a:threshold`.
|
||||
pub fn parse(s: &str) -> Option<AutoParam> {
|
||||
let s = s.trim().to_ascii_lowercase();
|
||||
match s.as_str() {
|
||||
"volume" | "vol" => return Some(AutoParam::Volume),
|
||||
"pan" | "pan1" => return Some(AutoParam::Pan(0)),
|
||||
"pan2" => return Some(AutoParam::Pan(1)),
|
||||
"mute" => return Some(AutoParam::Mute),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(rest) = s.strip_prefix("send_") {
|
||||
let mut it = rest.splitn(2, '_');
|
||||
let slot = letter_slot(it.next()?)?;
|
||||
return match it.next()? {
|
||||
"level" => Some(AutoParam::SendLevel(slot)),
|
||||
"pan" => Some(AutoParam::SendPan(slot)),
|
||||
"mute" => Some(AutoParam::SendMute(slot)),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if let Some(rest) = s.strip_prefix("plugin:") {
|
||||
let (slot, param) = rest.split_once(':')?;
|
||||
return Some(AutoParam::Plugin { slot: letter_slot(slot)?, param: param.to_string() });
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Value range and default.
|
||||
pub fn range(&self) -> (f32, f32, f32) {
|
||||
match self {
|
||||
AutoParam::Volume | AutoParam::SendLevel(_) => (-144.0, 12.0, 0.0),
|
||||
AutoParam::Pan(_) | AutoParam::SendPan(_) => (-1.0, 1.0, 0.0),
|
||||
AutoParam::Mute | AutoParam::SendMute(_) => (0.0, 1.0, 0.0),
|
||||
AutoParam::Plugin { .. } => (f32::MIN, f32::MAX, 0.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn slot_letter(i: u8) -> char {
|
||||
char::from(b'A'.saturating_add(i.min(25)))
|
||||
}
|
||||
|
||||
fn letter_slot(s: &str) -> Option<u8> {
|
||||
let c = s.chars().next()?.to_ascii_uppercase();
|
||||
if s.len() == 1 && c.is_ascii_uppercase() { Some(c as u8 - b'A') } else { s.parse().ok() }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AutomationPoint {
|
||||
pub at: Samples,
|
||||
pub value: f32,
|
||||
}
|
||||
|
||||
/// A breakpoint automation lane. Points are kept sorted by time.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AutomationLane {
|
||||
pub param: AutoParam,
|
||||
pub points: Vec<AutomationPoint>,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl AutomationLane {
|
||||
pub fn new(param: AutoParam) -> Self {
|
||||
AutomationLane { param, points: Vec::new(), enabled: true }
|
||||
}
|
||||
|
||||
/// Value at `at` (linear interpolation; `default` when empty).
|
||||
pub fn value_at(&self, at: Samples, default: f32) -> f32 {
|
||||
let pts = &self.points;
|
||||
let Some(first) = pts.first() else { return default };
|
||||
if at <= first.at {
|
||||
return first.value;
|
||||
}
|
||||
let idx = pts.partition_point(|p| p.at <= at);
|
||||
match (pts.get(idx.wrapping_sub(1)), pts.get(idx)) {
|
||||
(Some(a), Some(b)) if b.at > a.at => {
|
||||
// Mute-like lanes step instead of ramping.
|
||||
if matches!(self.param, AutoParam::Mute | AutoParam::SendMute(_)) {
|
||||
return a.value;
|
||||
}
|
||||
a.value + (b.value - a.value) * ((at - a.at) as f32 / (b.at - a.at) as f32)
|
||||
}
|
||||
(Some(a), _) => a.value,
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert or replace a breakpoint.
|
||||
pub fn set_point(&mut self, at: Samples, value: f32) {
|
||||
let (lo, hi, _) = self.param.range();
|
||||
let value = if value.is_nan() { 0.0 } else { value.clamp(lo, hi) };
|
||||
match self.points.binary_search_by(|p| p.at.cmp(&at)) {
|
||||
Ok(i) => {
|
||||
if let Some(p) = self.points.get_mut(i) {
|
||||
p.value = value;
|
||||
}
|
||||
}
|
||||
Err(i) => self.points.insert(i, AutomationPoint { at, value }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove points in `[start, end)`.
|
||||
pub fn clear_range(&mut self, start: Samples, end: Samples) -> usize {
|
||||
let before = self.points.len();
|
||||
self.points.retain(|p| p.at < start || p.at >= end);
|
||||
before - self.points.len()
|
||||
}
|
||||
|
||||
/// Write a constant value over a range, keeping the surrounding curve intact with anchor points.
|
||||
pub fn write_range(&mut self, start: Samples, end: Samples, value: f32, default: f32) {
|
||||
if end <= start {
|
||||
return;
|
||||
}
|
||||
let before = self.value_at(start.saturating_sub(1), default);
|
||||
let after = self.value_at(end, default);
|
||||
self.clear_range(start, end.saturating_add(1));
|
||||
if start > 0 {
|
||||
self.set_point(start - 1, before);
|
||||
}
|
||||
self.set_point(start, value);
|
||||
self.set_point(end, value);
|
||||
self.set_point(end.saturating_add(1), after);
|
||||
}
|
||||
|
||||
/// Thin: remove points whose removal changes the curve by less than `tolerance`.
|
||||
pub fn thin(&mut self, tolerance: f32) -> usize {
|
||||
if self.points.len() < 3 {
|
||||
return 0;
|
||||
}
|
||||
let mut keep = vec![true; self.points.len()];
|
||||
let mut last = 0usize;
|
||||
for i in 1..self.points.len() - 1 {
|
||||
let (Some(a), Some(b), Some(c)) = (self.points.get(last), self.points.get(i), self.points.get(i + 1)) else { continue };
|
||||
let span = (c.at - a.at) as f32;
|
||||
let interp = if span > 0.0 { a.value + (c.value - a.value) * ((b.at - a.at) as f32 / span) } else { a.value };
|
||||
if (interp - b.value).abs() <= tolerance {
|
||||
if let Some(k) = keep.get_mut(i) {
|
||||
*k = false;
|
||||
}
|
||||
} else {
|
||||
last = i;
|
||||
}
|
||||
}
|
||||
let before = self.points.len();
|
||||
let mut it = keep.iter();
|
||||
self.points.retain(|_| it.next().copied().unwrap_or(true));
|
||||
before - self.points.len()
|
||||
}
|
||||
|
||||
/// Shift points at or after `from` by `delta` (Shuffle edits, Insert Time).
|
||||
pub fn shift_from(&mut self, from: Samples, delta: Samples) {
|
||||
for p in &mut self.points {
|
||||
if p.at >= from {
|
||||
p.at = p.at.saturating_add(delta).max(0);
|
||||
}
|
||||
}
|
||||
self.points.sort_by_key(|p| p.at);
|
||||
self.points.dedup_by_key(|p| p.at);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn interpolation_and_steps() {
|
||||
let mut l = AutomationLane::new(AutoParam::Volume);
|
||||
assert_eq!(l.value_at(10, -3.0), -3.0);
|
||||
l.set_point(0, 0.0);
|
||||
l.set_point(100, -10.0);
|
||||
assert!((l.value_at(50, 0.0) + 5.0).abs() < 1e-6);
|
||||
assert_eq!(l.value_at(1000, 0.0), -10.0);
|
||||
let mut m = AutomationLane::new(AutoParam::Mute);
|
||||
m.set_point(0, 0.0);
|
||||
m.set_point(100, 1.0);
|
||||
assert_eq!(m.value_at(99, 0.0), 0.0);
|
||||
assert_eq!(m.value_at(100, 0.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_range_preserves_outside() {
|
||||
let mut l = AutomationLane::new(AutoParam::Volume);
|
||||
l.set_point(0, 0.0);
|
||||
l.set_point(1000, 0.0);
|
||||
l.write_range(200, 400, -20.0, 0.0);
|
||||
assert_eq!(l.value_at(100, 0.0), 0.0);
|
||||
assert_eq!(l.value_at(300, 0.0), -20.0);
|
||||
assert_eq!(l.value_at(600, 0.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_removes_collinear() {
|
||||
let mut l = AutomationLane::new(AutoParam::Volume);
|
||||
for i in 0..=10 {
|
||||
l.set_point(i * 10, -(i as f32));
|
||||
}
|
||||
assert_eq!(l.thin(0.01), 9);
|
||||
assert_eq!(l.points.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_ids() {
|
||||
assert_eq!(AutoParam::parse("send_b_level"), Some(AutoParam::SendLevel(1)));
|
||||
assert_eq!(AutoParam::parse("plugin:a:threshold"), Some(AutoParam::Plugin { slot: 0, param: "threshold".into() }));
|
||||
assert_eq!(AutoParam::parse("nope"), None);
|
||||
assert_eq!(AutomationMode::from_id("touch/latch"), Some(AutomationMode::TouchLatch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_point_clamps_nan() {
|
||||
let mut l = AutomationLane::new(AutoParam::Pan(0));
|
||||
l.set_point(0, f32::NAN);
|
||||
l.set_point(1, 5.0);
|
||||
assert_eq!(l.points[0].value, 0.0);
|
||||
assert_eq!(l.points[1].value, 1.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Clips: windows onto audio sources or MIDI sequences, placed on a playlist.
|
||||
|
||||
use crate::{ClipId, SourceId};
|
||||
use soundcraft_midi::Sequence;
|
||||
use soundcraft_time::{Range, Samples};
|
||||
|
||||
/// Fade curve shapes (Pro Tools offers standard, equal-power, S-curve and custom presets).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FadeShape {
|
||||
Linear,
|
||||
#[default]
|
||||
EqualPower,
|
||||
SCurve,
|
||||
Exponential,
|
||||
Logarithmic,
|
||||
}
|
||||
|
||||
impl FadeShape {
|
||||
pub const ALL: [FadeShape; 5] = [FadeShape::Linear, FadeShape::EqualPower, FadeShape::SCurve, FadeShape::Exponential, FadeShape::Logarithmic];
|
||||
|
||||
/// Fade-in gain at `t` ∈ [0,1] (fade-outs use `gain(1 - t)`).
|
||||
pub fn gain(self, t: f32) -> f32 {
|
||||
let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };
|
||||
match self {
|
||||
FadeShape::Linear => t,
|
||||
FadeShape::EqualPower => (t * std::f32::consts::FRAC_PI_2).sin(),
|
||||
FadeShape::SCurve => 0.5 - 0.5 * (t * std::f32::consts::PI).cos(),
|
||||
FadeShape::Exponential => t * t * t,
|
||||
FadeShape::Logarithmic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FadeShape::Linear => "Linear",
|
||||
FadeShape::EqualPower => "Equal Power",
|
||||
FadeShape::SCurve => "S-Curve",
|
||||
FadeShape::Exponential => "Exponential",
|
||||
FadeShape::Logarithmic => "Logarithmic",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(s: &str) -> Option<FadeShape> {
|
||||
let s = s.to_ascii_lowercase().replace(['-', '_', ' '], "");
|
||||
FadeShape::ALL.into_iter().find(|f| f.label().to_ascii_lowercase().replace(['-', ' '], "") == s)
|
||||
}
|
||||
}
|
||||
|
||||
/// A fade at a clip boundary, `len` samples long.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Fade {
|
||||
pub len: Samples,
|
||||
pub shape: FadeShape,
|
||||
}
|
||||
|
||||
/// What a clip plays.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ClipContent {
|
||||
/// Audio from `source`, starting `offset` samples into the file.
|
||||
Audio { source: SourceId, offset: Samples },
|
||||
/// MIDI notes, with ticks relative to the clip start.
|
||||
Midi { sequence: Sequence },
|
||||
}
|
||||
|
||||
/// A clip on a playlist.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Clip {
|
||||
pub id: ClipId,
|
||||
pub name: String,
|
||||
/// Timeline start (samples).
|
||||
pub start: Samples,
|
||||
/// Length (samples, > 0).
|
||||
pub length: Samples,
|
||||
pub content: ClipContent,
|
||||
/// Static clip gain in dB.
|
||||
#[serde(default)]
|
||||
pub gain_db: f32,
|
||||
/// Clip gain envelope breakpoints: (offset from clip start, dB). Empty = flat `gain_db`.
|
||||
#[serde(default)]
|
||||
pub gain_env: Vec<(Samples, f32)>,
|
||||
#[serde(default)]
|
||||
pub fade_in: Fade,
|
||||
#[serde(default)]
|
||||
pub fade_out: Fade,
|
||||
#[serde(default)]
|
||||
pub muted: bool,
|
||||
#[serde(default)]
|
||||
pub edit_locked: bool,
|
||||
#[serde(default)]
|
||||
pub time_locked: bool,
|
||||
#[serde(default)]
|
||||
pub rating: u8,
|
||||
/// Sync point offset from clip start.
|
||||
#[serde(default)]
|
||||
pub sync_point: Samples,
|
||||
/// Optional colour override (RGB).
|
||||
#[serde(default)]
|
||||
pub color: Option<[u8; 3]>,
|
||||
/// Elastic / Vari-speed ratio (1.0 = none) — applied by rendered processing.
|
||||
#[serde(default = "one")]
|
||||
pub stretch: f64,
|
||||
/// Reversed playback (AudioSuite Reverse renders into a new source; this is for previews).
|
||||
#[serde(default)]
|
||||
pub group: Option<u64>,
|
||||
}
|
||||
|
||||
fn one() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
impl Clip {
|
||||
pub fn audio(id: ClipId, name: impl Into<String>, source: SourceId, offset: Samples, start: Samples, length: Samples) -> Clip {
|
||||
Clip {
|
||||
id,
|
||||
name: name.into(),
|
||||
start,
|
||||
length: length.max(1),
|
||||
content: ClipContent::Audio { source, offset },
|
||||
gain_db: 0.0,
|
||||
gain_env: Vec::new(),
|
||||
fade_in: Fade::default(),
|
||||
fade_out: Fade::default(),
|
||||
muted: false,
|
||||
edit_locked: false,
|
||||
time_locked: false,
|
||||
rating: 0,
|
||||
sync_point: 0,
|
||||
color: None,
|
||||
stretch: 1.0,
|
||||
group: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn midi(id: ClipId, name: impl Into<String>, start: Samples, length: Samples, sequence: Sequence) -> Clip {
|
||||
let mut c = Clip::audio(id, name, SourceId(0), 0, start, length);
|
||||
c.content = ClipContent::Midi { sequence };
|
||||
c
|
||||
}
|
||||
|
||||
pub fn end(&self) -> Samples {
|
||||
self.start.saturating_add(self.length)
|
||||
}
|
||||
|
||||
pub fn range(&self) -> Range {
|
||||
Range { start: self.start, end: self.end() }
|
||||
}
|
||||
|
||||
pub fn is_audio(&self) -> bool {
|
||||
matches!(self.content, ClipContent::Audio { .. })
|
||||
}
|
||||
|
||||
pub fn source(&self) -> Option<SourceId> {
|
||||
match self.content {
|
||||
ClipContent::Audio { source, .. } => Some(source),
|
||||
ClipContent::Midi { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn source_offset(&self) -> Samples {
|
||||
match self.content {
|
||||
ClipContent::Audio { offset, .. } => offset,
|
||||
ClipContent::Midi { .. } => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gain (linear) at `rel` samples from the clip start: clip gain × envelope × fades.
|
||||
pub fn gain_at(&self, rel: Samples) -> f32 {
|
||||
let mut db = self.gain_db;
|
||||
if !self.gain_env.is_empty() {
|
||||
db += env_value(&self.gain_env, rel);
|
||||
}
|
||||
let mut g = db_to_gain(db);
|
||||
if self.fade_in.len > 0 && rel < self.fade_in.len {
|
||||
g *= self.fade_in.shape.gain(rel as f32 / self.fade_in.len as f32);
|
||||
}
|
||||
let from_end = self.length - rel;
|
||||
if self.fade_out.len > 0 && from_end <= self.fade_out.len {
|
||||
g *= self.fade_out.shape.gain(from_end as f32 / self.fade_out.len as f32);
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// Move the start edge to `new_start` keeping material anchored (trim start).
|
||||
pub fn trim_start_to(&mut self, new_start: Samples) {
|
||||
let new_start = new_start.min(self.end() - 1);
|
||||
let delta = new_start - self.start;
|
||||
if let ClipContent::Audio { offset, .. } = &mut self.content {
|
||||
*offset = offset.saturating_add(delta);
|
||||
}
|
||||
// MIDI note ticks are re-anchored by the engine, which knows the tempo map.
|
||||
self.length -= delta;
|
||||
self.start = new_start;
|
||||
self.fade_in.len = self.fade_in.len.min(self.length);
|
||||
self.gain_env = self.gain_env.iter().filter_map(|&(t, v)| (t - delta >= 0).then_some((t - delta, v))).collect();
|
||||
}
|
||||
|
||||
/// Move the end edge to `new_end`.
|
||||
pub fn trim_end_to(&mut self, new_end: Samples) {
|
||||
let new_end = new_end.max(self.start + 1);
|
||||
self.length = new_end - self.start;
|
||||
self.fade_out.len = self.fade_out.len.min(self.length);
|
||||
}
|
||||
}
|
||||
|
||||
fn env_value(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)
|
||||
}
|
||||
|
||||
pub(crate) fn db_to_gain(db: f32) -> f32 {
|
||||
if db.is_nan() || db <= -144.0 { 0.0 } else { 10f32.powf(db.min(48.0) / 20.0) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fade_shapes_hit_endpoints() {
|
||||
for s in FadeShape::ALL {
|
||||
assert!(s.gain(0.0).abs() < 1e-6, "{s:?}");
|
||||
assert!((s.gain(1.0) - 1.0).abs() < 1e-6, "{s:?}");
|
||||
assert_eq!(s.gain(f32::NAN), 0.0);
|
||||
assert_eq!(FadeShape::from_id(s.label()), Some(s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gain_with_fades() {
|
||||
let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 100, 1000);
|
||||
c.fade_in = Fade { len: 100, shape: FadeShape::Linear };
|
||||
c.fade_out = Fade { len: 100, shape: FadeShape::Linear };
|
||||
assert!(c.gain_at(0).abs() < 1e-6);
|
||||
assert!((c.gain_at(50) - 0.5).abs() < 1e-6);
|
||||
assert!((c.gain_at(500) - 1.0).abs() < 1e-6);
|
||||
assert!((c.gain_at(950) - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gain_envelope_interpolates() {
|
||||
let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 0, 1000);
|
||||
c.gain_env = vec![(0, 0.0), (1000, -20.0)];
|
||||
assert!((c.gain_at(500) - db_to_gain(-10.0)).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_keep_material_anchored() {
|
||||
let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 1000, 100, 1000);
|
||||
c.trim_start_to(300);
|
||||
assert_eq!((c.start, c.length, c.source_offset()), (300, 800, 1200));
|
||||
c.trim_end_to(500);
|
||||
assert_eq!(c.length, 200);
|
||||
c.trim_end_to(-50);
|
||||
assert_eq!(c.length, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Typed ids. All ids come from one session-wide counter so they never collide across kinds.
|
||||
|
||||
macro_rules! id_type {
|
||||
($name:ident) => {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct $name(pub u64);
|
||||
impl std::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
id_type!(TrackId);
|
||||
id_type!(ClipId);
|
||||
id_type!(SourceId);
|
||||
id_type!(BusId);
|
||||
id_type!(GroupId);
|
||||
id_type!(MarkerId);
|
||||
@@ -0,0 +1,26 @@
|
||||
//! The SoundCraft session document.
|
||||
//!
|
||||
//! A [`Session`] is pure data: tracks with playlists of clips, the mixer (inserts, sends, routing,
|
||||
//! automation), tempo/meter, memory locations and groups. Audio sample data lives in
|
||||
//! [`SourcePool`] behind `Arc`s, so cloning a session for undo is cheap.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod automation;
|
||||
mod clip;
|
||||
mod ids;
|
||||
mod markers;
|
||||
mod mixer;
|
||||
mod session;
|
||||
mod source;
|
||||
mod track;
|
||||
|
||||
pub use automation::{AutoParam, AutomationLane, AutomationMode, AutomationPoint};
|
||||
pub use clip::{Clip, ClipContent, Fade, FadeShape};
|
||||
pub use ids::{BusId, ClipId, GroupId, MarkerId, SourceId, TrackId};
|
||||
pub use markers::{Group, MarkerKind, MemoryLocation};
|
||||
pub use mixer::{Bus, ChannelFormat, INSERT_SLOTS, Insert, Mixer, OutputPath, Route, SEND_SLOTS, SendSlot, fader_db_to_pos, fader_pos_to_db};
|
||||
pub use session::{BitDepthSetting, EditMode, EditState, SESSION_EXTENSION, Session, SessionError, Tool, TrackView, ZoomState};
|
||||
pub use source::{Source, SourceAudio, SourcePool};
|
||||
pub use track::{Playlist, TRACK_COLORS, Track, TrackHeight, TrackKind};
|
||||
|
||||
pub use soundcraft_time as time;
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Memory locations (markers and stored selections) and mix/edit groups.
|
||||
|
||||
use crate::{GroupId, MarkerId, TrackId};
|
||||
use soundcraft_time::Samples;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum MarkerKind {
|
||||
/// A point marker on a ruler.
|
||||
#[default]
|
||||
Marker,
|
||||
/// A stored selection range.
|
||||
Selection,
|
||||
/// A location that only recalls view settings.
|
||||
None,
|
||||
}
|
||||
|
||||
/// A memory location (Window › Memory Locations).
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct MemoryLocation {
|
||||
pub id: MarkerId,
|
||||
/// User-visible number (1-based, unique).
|
||||
pub number: u32,
|
||||
pub name: String,
|
||||
pub kind: MarkerKind,
|
||||
pub start: Samples,
|
||||
pub end: Samples,
|
||||
#[serde(default)]
|
||||
pub comments: String,
|
||||
#[serde(default)]
|
||||
pub color: Option<[u8; 3]>,
|
||||
/// Marker ruler lane 1..5.
|
||||
#[serde(default = "one")]
|
||||
pub ruler: u8,
|
||||
/// Track-lane marker on this track, if any.
|
||||
#[serde(default)]
|
||||
pub track: Option<TrackId>,
|
||||
}
|
||||
|
||||
fn one() -> u8 {
|
||||
1
|
||||
}
|
||||
|
||||
/// A track group (Track › Group…).
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Group {
|
||||
pub id: GroupId,
|
||||
pub name: String,
|
||||
/// Group letter id shown in the Groups list ("a", "b", …).
|
||||
pub letter: char,
|
||||
pub members: Vec<TrackId>,
|
||||
pub edit: bool,
|
||||
pub mix: bool,
|
||||
pub active: bool,
|
||||
pub color: [u8; 3],
|
||||
/// Shared attributes linked by the group (volume, mute, solo, rec, etc.).
|
||||
#[serde(default)]
|
||||
pub attributes: Vec<String>,
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Per-track mixer state: inserts, sends, routing, fader, pan, mute/solo.
|
||||
|
||||
use crate::{AutomationMode, BusId};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const INSERT_SLOTS: usize = 10;
|
||||
pub const SEND_SLOTS: usize = 10;
|
||||
|
||||
/// Channel formats (track widths).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ChannelFormat {
|
||||
#[default]
|
||||
Mono,
|
||||
Stereo,
|
||||
Lcr,
|
||||
Quad,
|
||||
Lcrs,
|
||||
Surround50,
|
||||
Surround51,
|
||||
Surround70,
|
||||
Surround71,
|
||||
Atmos712,
|
||||
Atmos714,
|
||||
}
|
||||
|
||||
impl ChannelFormat {
|
||||
pub const ALL: [ChannelFormat; 11] = [
|
||||
ChannelFormat::Mono,
|
||||
ChannelFormat::Stereo,
|
||||
ChannelFormat::Lcr,
|
||||
ChannelFormat::Quad,
|
||||
ChannelFormat::Lcrs,
|
||||
ChannelFormat::Surround50,
|
||||
ChannelFormat::Surround51,
|
||||
ChannelFormat::Surround70,
|
||||
ChannelFormat::Surround71,
|
||||
ChannelFormat::Atmos712,
|
||||
ChannelFormat::Atmos714,
|
||||
];
|
||||
pub fn channels(self) -> usize {
|
||||
match self {
|
||||
ChannelFormat::Mono => 1,
|
||||
ChannelFormat::Stereo => 2,
|
||||
ChannelFormat::Lcr => 3,
|
||||
ChannelFormat::Quad | ChannelFormat::Lcrs => 4,
|
||||
ChannelFormat::Surround50 => 5,
|
||||
ChannelFormat::Surround51 => 6,
|
||||
ChannelFormat::Surround70 => 7,
|
||||
ChannelFormat::Surround71 => 8,
|
||||
ChannelFormat::Atmos712 => 10,
|
||||
ChannelFormat::Atmos714 => 12,
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ChannelFormat::Mono => "Mono",
|
||||
ChannelFormat::Stereo => "Stereo",
|
||||
ChannelFormat::Lcr => "LCR",
|
||||
ChannelFormat::Quad => "Quad",
|
||||
ChannelFormat::Lcrs => "LCRS",
|
||||
ChannelFormat::Surround50 => "5.0",
|
||||
ChannelFormat::Surround51 => "5.1",
|
||||
ChannelFormat::Surround70 => "7.0",
|
||||
ChannelFormat::Surround71 => "7.1",
|
||||
ChannelFormat::Atmos712 => "7.1.2",
|
||||
ChannelFormat::Atmos714 => "7.1.4",
|
||||
}
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<ChannelFormat> {
|
||||
ChannelFormat::ALL.into_iter().find(|f| f.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
/// Best format for a channel count.
|
||||
pub fn for_channels(n: usize) -> ChannelFormat {
|
||||
ChannelFormat::ALL.into_iter().find(|f| f.channels() == n).unwrap_or(if n <= 1 { ChannelFormat::Mono } else { ChannelFormat::Stereo })
|
||||
}
|
||||
}
|
||||
|
||||
/// An internal mix bus (Pro Tools "bus" paths).
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Bus {
|
||||
pub id: BusId,
|
||||
pub name: String,
|
||||
pub format: ChannelFormat,
|
||||
}
|
||||
|
||||
/// Physical output paths (from the I/O setup).
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct OutputPath {
|
||||
pub name: String,
|
||||
/// First hardware channel (0-based).
|
||||
pub first_channel: u16,
|
||||
pub format: ChannelFormat,
|
||||
}
|
||||
|
||||
/// Where a signal goes or comes from.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Route {
|
||||
#[default]
|
||||
None,
|
||||
/// The main output (hardware outputs 1-2, or the bounce source).
|
||||
Main,
|
||||
Bus(BusId),
|
||||
/// Hardware input/output by path name.
|
||||
Hardware(String),
|
||||
}
|
||||
|
||||
/// A plugin instance on an insert slot.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Insert {
|
||||
/// Registry id from `soundcraft-dsp` (e.g. `eq7`, `comp`).
|
||||
pub plugin: String,
|
||||
#[serde(default)]
|
||||
pub params: BTreeMap<String, f32>,
|
||||
#[serde(default)]
|
||||
pub bypass: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub active: bool,
|
||||
/// Preset name shown in the plugin window header.
|
||||
#[serde(default)]
|
||||
pub preset: String,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Insert {
|
||||
pub fn new(plugin: impl Into<String>) -> Self {
|
||||
Insert { plugin: plugin.into(), params: BTreeMap::new(), bypass: false, active: true, preset: String::from("<factory default>") }
|
||||
}
|
||||
}
|
||||
|
||||
/// A send to a bus.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SendSlot {
|
||||
pub target: Route,
|
||||
pub level_db: f32,
|
||||
pub pan: f32,
|
||||
pub mute: bool,
|
||||
pub pre_fader: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub follow_main_pan: bool,
|
||||
}
|
||||
|
||||
impl SendSlot {
|
||||
pub fn new(target: Route) -> Self {
|
||||
SendSlot { target, level_db: -144.0, pan: 0.0, mute: false, pre_fader: false, follow_main_pan: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// The channel strip.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Mixer {
|
||||
pub volume_db: f32,
|
||||
/// One panner per source channel for mono/stereo (stereo tracks: two panners, default hard L/R).
|
||||
pub pan: Vec<f32>,
|
||||
pub mute: bool,
|
||||
pub solo: bool,
|
||||
#[serde(default)]
|
||||
pub solo_safe: bool,
|
||||
#[serde(default)]
|
||||
pub record_arm: bool,
|
||||
#[serde(default)]
|
||||
pub input_monitor: bool,
|
||||
#[serde(default)]
|
||||
pub phase_invert: bool,
|
||||
pub input: Route,
|
||||
pub output: Route,
|
||||
pub inserts: Vec<Option<Insert>>,
|
||||
pub sends: Vec<Option<SendSlot>>,
|
||||
pub automation_mode: AutomationMode,
|
||||
/// Input gain / trim, dB.
|
||||
#[serde(default)]
|
||||
pub trim_db: f32,
|
||||
/// VCA master that controls this track, if any (track id of the VCA).
|
||||
#[serde(default)]
|
||||
pub vca: Option<u64>,
|
||||
}
|
||||
|
||||
impl Mixer {
|
||||
pub fn new(format: ChannelFormat) -> Self {
|
||||
let pan = match format {
|
||||
ChannelFormat::Mono => vec![0.0],
|
||||
ChannelFormat::Stereo => vec![-1.0, 1.0],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Mixer {
|
||||
volume_db: 0.0,
|
||||
pan,
|
||||
mute: false,
|
||||
solo: false,
|
||||
solo_safe: false,
|
||||
record_arm: false,
|
||||
input_monitor: false,
|
||||
phase_invert: false,
|
||||
input: Route::None,
|
||||
output: Route::Main,
|
||||
inserts: vec![None; INSERT_SLOTS],
|
||||
sends: vec![None; SEND_SLOTS],
|
||||
automation_mode: AutomationMode::Read,
|
||||
trim_db: 0.0,
|
||||
vca: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp everything into range (after deserialising untrusted files).
|
||||
pub fn sanitize(&mut self) {
|
||||
self.volume_db = finite_or(self.volume_db, 0.0).clamp(-144.0, 12.0);
|
||||
for p in &mut self.pan {
|
||||
*p = finite_or(*p, 0.0).clamp(-1.0, 1.0);
|
||||
}
|
||||
self.inserts.resize(INSERT_SLOTS, None);
|
||||
self.sends.resize(SEND_SLOTS, None);
|
||||
for s in self.sends.iter_mut().flatten() {
|
||||
s.level_db = finite_or(s.level_db, -144.0).clamp(-144.0, 12.0);
|
||||
s.pan = finite_or(s.pan, 0.0).clamp(-1.0, 1.0);
|
||||
}
|
||||
self.trim_db = finite_or(self.trim_db, 0.0).clamp(-144.0, 24.0);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn finite_or(v: f32, d: f32) -> f32 {
|
||||
if v.is_finite() { v } else { d }
|
||||
}
|
||||
|
||||
/// Fader taper used by Pro Tools-style faders: position 0..1 ↔ dB (-inf..+12).
|
||||
pub fn fader_pos_to_db(pos: f32) -> f32 {
|
||||
let p = finite_or(pos, 0.0).clamp(0.0, 1.0);
|
||||
if p <= 0.0 {
|
||||
return -144.0;
|
||||
}
|
||||
// Piecewise: top 75 % covers -24..+12 linearly-ish, the bottom compresses to -inf.
|
||||
if p >= 0.25 { -24.0 + (p - 0.25) / 0.75 * 36.0 } else { -24.0 - (1.0 - p / 0.25).powf(1.5) * 120.0 }
|
||||
}
|
||||
|
||||
pub fn fader_db_to_pos(db: f32) -> f32 {
|
||||
let db = finite_or(db, -144.0);
|
||||
if db <= -144.0 {
|
||||
return 0.0;
|
||||
}
|
||||
if db >= -24.0 {
|
||||
(0.25 + (db.min(12.0) + 24.0) / 36.0 * 0.75).min(1.0)
|
||||
} else {
|
||||
let x = ((-24.0 - db) / 120.0).clamp(0.0, 1.0).powf(1.0 / 1.5);
|
||||
((1.0 - x) * 0.25).max(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fader_taper_round_trips() {
|
||||
for db in [-100.0f32, -60.0, -24.0, -10.0, 0.0, 6.0, 12.0] {
|
||||
let p = fader_db_to_pos(db);
|
||||
assert!((fader_pos_to_db(p) - db).abs() < 0.05, "{db} → {p}");
|
||||
}
|
||||
assert_eq!(fader_pos_to_db(0.0), -144.0);
|
||||
assert_eq!(fader_db_to_pos(f32::NAN), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixer_defaults_and_sanitize() {
|
||||
let mut m = Mixer::new(ChannelFormat::Stereo);
|
||||
assert_eq!(m.pan, vec![-1.0, 1.0]);
|
||||
m.volume_db = f32::INFINITY;
|
||||
m.inserts.truncate(2);
|
||||
m.sanitize();
|
||||
assert_eq!(m.volume_db, 0.0);
|
||||
assert_eq!(m.inserts.len(), INSERT_SLOTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats() {
|
||||
assert_eq!(ChannelFormat::for_channels(6), ChannelFormat::Surround51);
|
||||
assert_eq!(ChannelFormat::from_id("stereo"), Some(ChannelFormat::Stereo));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
//! The session: the whole document.
|
||||
|
||||
use crate::{
|
||||
Bus, BusId, ChannelFormat, Clip, ClipId, Group, GroupId, MarkerId, MarkerKind, MemoryLocation, OutputPath, Source, SourceId, SourcePool,
|
||||
TRACK_COLORS, Track, TrackId, TrackKind,
|
||||
};
|
||||
use soundcraft_time::{FrameRate, GridValue, Range, SampleRate, Samples, TempoMap, TimeFormat};
|
||||
|
||||
/// Native session file extension.
|
||||
pub const SESSION_EXTENSION: &str = "scraft";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum SessionError {
|
||||
#[error("not a SoundCraft session: {0}")]
|
||||
Format(String),
|
||||
#[error("session file version {0} is newer than this SoundCraft supports")]
|
||||
Version(u32),
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
/// Session bit depth for recording/rendering.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum BitDepthSetting {
|
||||
Int16,
|
||||
#[default]
|
||||
Int24,
|
||||
Float32,
|
||||
}
|
||||
|
||||
/// Edit modes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum EditMode {
|
||||
Shuffle,
|
||||
#[default]
|
||||
Slip,
|
||||
Spot,
|
||||
Grid,
|
||||
/// Relative grid: moves keep their offset from the grid.
|
||||
GridRelative,
|
||||
}
|
||||
|
||||
impl EditMode {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
EditMode::Shuffle => "SHUFFLE",
|
||||
EditMode::Slip => "SLIP",
|
||||
EditMode::Spot => "SPOT",
|
||||
EditMode::Grid => "GRID",
|
||||
EditMode::GridRelative => "REL GRID",
|
||||
}
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<EditMode> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"shuffle" => Some(EditMode::Shuffle),
|
||||
"slip" => Some(EditMode::Slip),
|
||||
"spot" => Some(EditMode::Spot),
|
||||
"grid" | "absolute_grid" => Some(EditMode::Grid),
|
||||
"relative_grid" | "rel_grid" | "grid_relative" => Some(EditMode::GridRelative),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn is_grid(self) -> bool {
|
||||
matches!(self, EditMode::Grid | EditMode::GridRelative)
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit tools.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Tool {
|
||||
Zoom,
|
||||
Trim,
|
||||
#[default]
|
||||
Selector,
|
||||
Grabber,
|
||||
Scrubber,
|
||||
Pencil,
|
||||
Smart,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
pub const ALL: [Tool; 7] = [Tool::Zoom, Tool::Trim, Tool::Selector, Tool::Grabber, Tool::Scrubber, Tool::Pencil, Tool::Smart];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Tool::Zoom => "zoom",
|
||||
Tool::Trim => "trim",
|
||||
Tool::Selector => "selector",
|
||||
Tool::Grabber => "grabber",
|
||||
Tool::Scrubber => "scrubber",
|
||||
Tool::Pencil => "pencil",
|
||||
Tool::Smart => "smart",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Tool::Zoom => "Zoomer",
|
||||
Tool::Trim => "Trim",
|
||||
Tool::Selector => "Selector",
|
||||
Tool::Grabber => "Grabber",
|
||||
Tool::Scrubber => "Scrubber",
|
||||
Tool::Pencil => "Pencil",
|
||||
Tool::Smart => "Smart Tool",
|
||||
}
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<Tool> {
|
||||
Tool::ALL.into_iter().find(|t| t.id() == s.to_ascii_lowercase())
|
||||
}
|
||||
/// F-key shortcut (F5..F10) like the incumbent.
|
||||
pub fn fkey(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Tool::Zoom => Some("F5"),
|
||||
Tool::Trim => Some("F6"),
|
||||
Tool::Selector => Some("F7"),
|
||||
Tool::Grabber => Some("F8"),
|
||||
Tool::Scrubber => Some("F9"),
|
||||
Tool::Pencil => Some("F10"),
|
||||
Tool::Smart => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Track view lanes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum TrackView {
|
||||
Waveform,
|
||||
Blocks,
|
||||
Volume,
|
||||
Pan,
|
||||
Mute,
|
||||
}
|
||||
|
||||
/// Horizontal zoom + scroll.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ZoomState {
|
||||
/// Samples per pixel (point).
|
||||
pub samples_per_px: f64,
|
||||
/// Left edge of the visible timeline.
|
||||
pub scroll: Samples,
|
||||
/// Waveform vertical zoom factor.
|
||||
pub waveform_zoom: f32,
|
||||
/// Zoom preset memories 1..5.
|
||||
pub presets: [f64; 5],
|
||||
}
|
||||
|
||||
impl Default for ZoomState {
|
||||
fn default() -> Self {
|
||||
ZoomState { samples_per_px: 1024.0, scroll: 0, waveform_zoom: 1.0, presets: [64.0, 256.0, 1024.0, 4096.0, 16384.0] }
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit-window state saved with the session (selection, tools, modes, view settings).
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct EditState {
|
||||
pub tool: Tool,
|
||||
pub edit_mode: EditMode,
|
||||
/// The edit selection (a point = insertion position).
|
||||
pub selection: Range,
|
||||
/// Tracks in the edit selection.
|
||||
pub selected_tracks: Vec<TrackId>,
|
||||
/// Clips explicitly selected (Grabber selection).
|
||||
#[serde(default)]
|
||||
pub selected_clips: Vec<ClipId>,
|
||||
/// Playhead position when stopped.
|
||||
pub playhead: Samples,
|
||||
pub grid: GridValue,
|
||||
pub nudge: GridValue,
|
||||
#[serde(default = "yes")]
|
||||
pub grid_lines: bool,
|
||||
pub main_counter: TimeFormat,
|
||||
pub sub_counter: Option<TimeFormat>,
|
||||
pub zoom: ZoomState,
|
||||
/// Visible rulers.
|
||||
pub rulers: Vec<String>,
|
||||
pub link_timeline_edit: bool,
|
||||
pub link_track_edit: bool,
|
||||
pub insertion_follows_playback: bool,
|
||||
pub loop_playback: bool,
|
||||
pub pre_post_roll: bool,
|
||||
pub pre_roll: Samples,
|
||||
pub post_roll: Samples,
|
||||
pub tab_to_transient: bool,
|
||||
pub automation_follows_edit: bool,
|
||||
pub markers_follow_edit: bool,
|
||||
pub layered_editing: bool,
|
||||
pub mirrored_midi: bool,
|
||||
pub click: bool,
|
||||
pub countoff: bool,
|
||||
pub countoff_bars: u32,
|
||||
pub midi_merge: bool,
|
||||
pub record_mode: String,
|
||||
pub loop_record: bool,
|
||||
pub quickpunch: bool,
|
||||
pub scrolling: String,
|
||||
pub solo_mode: String,
|
||||
pub pre_fader_metering: bool,
|
||||
pub delay_compensation: bool,
|
||||
pub keyboard_focus: String,
|
||||
/// Timeline selection when it is unlinked from the edit selection.
|
||||
pub timeline_selection: Range,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for EditState {
|
||||
fn default() -> Self {
|
||||
EditState {
|
||||
tool: Tool::Smart,
|
||||
edit_mode: EditMode::Slip,
|
||||
selection: Range::default(),
|
||||
selected_tracks: Vec::new(),
|
||||
selected_clips: Vec::new(),
|
||||
playhead: 0,
|
||||
grid: GridValue::default(),
|
||||
nudge: GridValue::Seconds(1.0),
|
||||
grid_lines: true,
|
||||
main_counter: TimeFormat::MinSecs,
|
||||
sub_counter: Some(TimeFormat::Samples),
|
||||
zoom: ZoomState::default(),
|
||||
rulers: vec!["bars_beats".into(), "min_secs".into(), "tempo".into(), "meter".into(), "markers".into()],
|
||||
link_timeline_edit: true,
|
||||
link_track_edit: false,
|
||||
insertion_follows_playback: false,
|
||||
loop_playback: false,
|
||||
pre_post_roll: false,
|
||||
pre_roll: 96_000,
|
||||
post_roll: 96_000,
|
||||
tab_to_transient: false,
|
||||
automation_follows_edit: true,
|
||||
markers_follow_edit: false,
|
||||
layered_editing: false,
|
||||
mirrored_midi: false,
|
||||
click: false,
|
||||
countoff: false,
|
||||
countoff_bars: 2,
|
||||
midi_merge: false,
|
||||
record_mode: "normal".into(),
|
||||
loop_record: false,
|
||||
quickpunch: false,
|
||||
scrolling: "page".into(),
|
||||
solo_mode: "sip".into(),
|
||||
pre_fader_metering: false,
|
||||
delay_compensation: true,
|
||||
keyboard_focus: "commands".into(),
|
||||
timeline_selection: Range::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The document.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Session {
|
||||
pub format_version: u32,
|
||||
pub name: String,
|
||||
pub sample_rate: SampleRate,
|
||||
pub bit_depth: BitDepthSetting,
|
||||
pub frame_rate: FrameRate,
|
||||
/// Timecode of sample 0 (session start), as a sample count.
|
||||
pub timecode_start: Samples,
|
||||
pub tempo: TempoMap,
|
||||
#[serde(default)]
|
||||
pub key_signatures: Vec<(Samples, String)>,
|
||||
pub tracks: Vec<Track>,
|
||||
pub sources: Vec<Source>,
|
||||
pub busses: Vec<Bus>,
|
||||
pub outputs: Vec<OutputPath>,
|
||||
pub markers: Vec<MemoryLocation>,
|
||||
pub groups: Vec<Group>,
|
||||
pub edit: EditState,
|
||||
pub next_id: u64,
|
||||
#[serde(default)]
|
||||
pub comments: String,
|
||||
#[serde(skip)]
|
||||
pub pool: SourcePool,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
fn default() -> Self {
|
||||
Session::new("Untitled", SampleRate::HZ_48000)
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
pub fn new(name: impl Into<String>, sample_rate: SampleRate) -> Self {
|
||||
let mut s = Session {
|
||||
format_version: Self::FORMAT_VERSION,
|
||||
name: name.into(),
|
||||
sample_rate,
|
||||
bit_depth: BitDepthSetting::Int24,
|
||||
frame_rate: FrameRate::Fps30,
|
||||
timecode_start: 0,
|
||||
tempo: TempoMap::default(),
|
||||
key_signatures: Vec::new(),
|
||||
tracks: Vec::new(),
|
||||
sources: Vec::new(),
|
||||
busses: Vec::new(),
|
||||
outputs: vec![OutputPath { name: "Out 1-2".into(), first_channel: 0, format: ChannelFormat::Stereo }],
|
||||
markers: Vec::new(),
|
||||
groups: Vec::new(),
|
||||
edit: EditState::default(),
|
||||
next_id: 1,
|
||||
comments: String::new(),
|
||||
pool: SourcePool::default(),
|
||||
};
|
||||
s.edit.pre_roll = sample_rate.samples(2.0);
|
||||
s.edit.post_roll = sample_rate.samples(2.0);
|
||||
s
|
||||
}
|
||||
|
||||
/// Allocate a fresh id.
|
||||
pub fn alloc(&mut self) -> u64 {
|
||||
let id = self.next_id.max(1);
|
||||
self.next_id = id.saturating_add(1);
|
||||
id
|
||||
}
|
||||
|
||||
// ---- lookups -------------------------------------------------------------------------
|
||||
|
||||
pub fn track(&self, id: TrackId) -> Option<&Track> {
|
||||
self.tracks.iter().find(|t| t.id == id)
|
||||
}
|
||||
pub fn track_mut(&mut self, id: TrackId) -> Option<&mut Track> {
|
||||
self.tracks.iter_mut().find(|t| t.id == id)
|
||||
}
|
||||
pub fn track_index(&self, id: TrackId) -> Option<usize> {
|
||||
self.tracks.iter().position(|t| t.id == id)
|
||||
}
|
||||
pub fn track_by_name(&self, name: &str) -> Option<&Track> {
|
||||
self.tracks.iter().find(|t| t.name == name).or_else(|| self.tracks.iter().find(|t| t.name.eq_ignore_ascii_case(name)))
|
||||
}
|
||||
pub fn master(&self) -> Option<&Track> {
|
||||
self.tracks.iter().find(|t| t.kind == TrackKind::Master)
|
||||
}
|
||||
pub fn source(&self, id: SourceId) -> Option<&Source> {
|
||||
self.sources.iter().find(|s| s.id == id)
|
||||
}
|
||||
pub fn bus(&self, id: BusId) -> Option<&Bus> {
|
||||
self.busses.iter().find(|b| b.id == id)
|
||||
}
|
||||
pub fn bus_by_name(&self, name: &str) -> Option<&Bus> {
|
||||
self.busses.iter().find(|b| b.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
pub fn group(&self, id: GroupId) -> Option<&Group> {
|
||||
self.groups.iter().find(|g| g.id == id)
|
||||
}
|
||||
pub fn marker(&self, id: MarkerId) -> Option<&MemoryLocation> {
|
||||
self.markers.iter().find(|m| m.id == id)
|
||||
}
|
||||
|
||||
/// Find a clip anywhere (active playlists).
|
||||
pub fn find_clip(&self, id: ClipId) -> Option<(TrackId, &Clip)> {
|
||||
self.tracks.iter().find_map(|t| t.clips().iter().find(|c| c.id == id).map(|c| (t.id, c)))
|
||||
}
|
||||
|
||||
pub fn find_clip_mut(&mut self, id: ClipId) -> Option<&mut Clip> {
|
||||
self.tracks.iter_mut().find_map(|t| t.playlist_mut().and_then(|p| p.clip_mut(id)))
|
||||
}
|
||||
|
||||
/// End of the last clip on any track (session length).
|
||||
pub fn content_end(&self) -> Samples {
|
||||
self.tracks.iter().filter_map(|t| t.playlist().map(|p| p.end())).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn next_track_color(&self) -> [u8; 3] {
|
||||
let n = self.tracks.iter().filter(|t| t.kind != TrackKind::Master).count();
|
||||
TRACK_COLORS.get(n % TRACK_COLORS.len()).copied().unwrap_or([128, 128, 128])
|
||||
}
|
||||
|
||||
/// A unique track name: `base`, or `base 1`, `base 2` … like the incumbent ("Audio 1").
|
||||
pub fn unique_track_name(&self, base: &str) -> String {
|
||||
let taken = |n: &str| self.tracks.iter().any(|t| t.name == n);
|
||||
if !base.is_empty() && !taken(base) && !matches!(base, "Audio" | "Aux" | "MIDI" | "Inst" | "VCA" | "Folder" | "Master") {
|
||||
return base.to_string();
|
||||
}
|
||||
let mut i = 1;
|
||||
loop {
|
||||
let cand = format!("{base} {i}");
|
||||
if !taken(&cand) {
|
||||
return cand;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a track with defaults; returns its id.
|
||||
pub fn add_track(&mut self, kind: TrackKind, format: ChannelFormat, name: Option<&str>) -> TrackId {
|
||||
let id = TrackId(self.alloc());
|
||||
let name = self.unique_track_name(name.unwrap_or(kind.default_name()));
|
||||
let color = if kind == TrackKind::Master { [140, 140, 140] } else { self.next_track_color() };
|
||||
let mut t = Track::new(id, name, kind, format, color);
|
||||
if kind == TrackKind::Master {
|
||||
t.mixer.output = crate::Route::Main;
|
||||
t.mixer.input = crate::Route::Main;
|
||||
}
|
||||
// Master faders sit at the bottom like the incumbent; others go before any master.
|
||||
let pos = if kind == TrackKind::Master {
|
||||
self.tracks.len()
|
||||
} else {
|
||||
self.tracks.iter().position(|t| t.kind == TrackKind::Master).unwrap_or(self.tracks.len())
|
||||
};
|
||||
self.tracks.insert(pos, t);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn add_bus(&mut self, name: &str, format: ChannelFormat) -> BusId {
|
||||
if let Some(b) = self.bus_by_name(name) {
|
||||
return b.id;
|
||||
}
|
||||
let id = BusId(self.alloc());
|
||||
self.busses.push(Bus { id, name: name.to_string(), format });
|
||||
id
|
||||
}
|
||||
|
||||
/// Add a memory location; returns its id.
|
||||
pub fn add_marker(&mut self, name: &str, kind: MarkerKind, start: Samples, end: Samples) -> MarkerId {
|
||||
let id = MarkerId(self.alloc());
|
||||
let number = self.markers.iter().map(|m| m.number).max().unwrap_or(0).saturating_add(1);
|
||||
self.markers.push(MemoryLocation {
|
||||
id,
|
||||
number,
|
||||
name: name.to_string(),
|
||||
kind,
|
||||
start,
|
||||
end: end.max(start),
|
||||
comments: String::new(),
|
||||
color: None,
|
||||
ruler: 1,
|
||||
track: None,
|
||||
});
|
||||
self.markers.sort_by_key(|m| m.start);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn new_clip_id(&mut self) -> ClipId {
|
||||
ClipId(self.alloc())
|
||||
}
|
||||
|
||||
// ---- persistence ---------------------------------------------------------------------
|
||||
|
||||
pub fn to_json(&self) -> Result<String, SessionError> {
|
||||
serde_json::to_string_pretty(self).map_err(|e| SessionError::Format(e.to_string()))
|
||||
}
|
||||
|
||||
/// Parse a session; decoded audio must be attached separately (`pool`).
|
||||
pub fn from_json(text: &str) -> Result<Session, SessionError> {
|
||||
let v: serde_json::Value = serde_json::from_str(text).map_err(|e| SessionError::Format(e.to_string()))?;
|
||||
let ver = v.get("format_version").and_then(serde_json::Value::as_u64).ok_or_else(|| SessionError::Format("missing format_version".into()))?;
|
||||
if ver > u64::from(Self::FORMAT_VERSION) {
|
||||
return Err(SessionError::Version(u32::try_from(ver).unwrap_or(u32::MAX)));
|
||||
}
|
||||
let mut s: Session = serde_json::from_value(v).map_err(|e| SessionError::Format(e.to_string()))?;
|
||||
s.sanitize();
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Repair invariants after loading untrusted data.
|
||||
pub fn sanitize(&mut self) {
|
||||
let max_id = self
|
||||
.tracks
|
||||
.iter()
|
||||
.map(|t| t.id.0)
|
||||
.chain(self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0))))
|
||||
.chain(self.sources.iter().map(|s| s.id.0))
|
||||
.chain(self.busses.iter().map(|b| b.id.0))
|
||||
.chain(self.markers.iter().map(|m| m.id.0))
|
||||
.chain(self.groups.iter().map(|g| g.id.0))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
self.next_id = self.next_id.max(max_id.saturating_add(1));
|
||||
for t in &mut self.tracks {
|
||||
t.mixer.sanitize();
|
||||
if t.playlists.is_empty() {
|
||||
t.playlists.push(crate::Playlist::new(t.name.clone()));
|
||||
}
|
||||
if t.active_playlist >= t.playlists.len() {
|
||||
t.active_playlist = 0;
|
||||
}
|
||||
for p in &mut t.playlists {
|
||||
p.clips.retain(|c| c.length > 0);
|
||||
for c in &mut p.clips {
|
||||
c.fade_in.len = c.fade_in.len.clamp(0, c.length);
|
||||
c.fade_out.len = c.fade_out.len.clamp(0, c.length);
|
||||
if !c.gain_db.is_finite() {
|
||||
c.gain_db = 0.0;
|
||||
}
|
||||
if !c.stretch.is_finite() || c.stretch <= 0.0 {
|
||||
c.stretch = 1.0;
|
||||
}
|
||||
}
|
||||
p.sort();
|
||||
}
|
||||
for l in &mut t.automation {
|
||||
l.points.retain(|p| p.value.is_finite());
|
||||
l.points.sort_by_key(|p| p.at);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn add_tracks_names_and_order() {
|
||||
let mut s = Session::default();
|
||||
let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None);
|
||||
let a = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let b = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let v = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Vocal"));
|
||||
assert_eq!(s.track(a).map(|t| t.name.as_str()), Some("Audio 1"));
|
||||
assert_eq!(s.track(b).map(|t| t.name.as_str()), Some("Audio 2"));
|
||||
assert_eq!(s.track(v).map(|t| t.name.as_str()), Some("Vocal"));
|
||||
assert_eq!(s.tracks.last().map(|t| t.id), Some(m));
|
||||
let v2 = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Vocal"));
|
||||
assert_eq!(s.track(v2).map(|t| t.name.as_str()), Some("Vocal 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trip() {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
|
||||
let cid = s.new_clip_id();
|
||||
if let Some(p) = s.track_mut(t).and_then(Track::playlist_mut) {
|
||||
p.clips.push(Clip::audio(cid, "c", SourceId(99), 0, 10, 100));
|
||||
}
|
||||
s.add_marker("Verse", MarkerKind::Marker, 48_000, 48_000);
|
||||
let text = s.to_json().unwrap();
|
||||
let back = Session::from_json(&text).unwrap();
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage_and_future_versions() {
|
||||
assert!(Session::from_json("not json").is_err());
|
||||
assert!(Session::from_json("{}").is_err());
|
||||
let mut v = serde_json::to_value(Session::default()).unwrap();
|
||||
v["format_version"] = serde_json::json!(999);
|
||||
assert!(matches!(Session::from_json(&v.to_string()), Err(SessionError::Version(999))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_repairs() {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
tr.active_playlist = 7;
|
||||
tr.mixer.volume_db = f32::NAN;
|
||||
}
|
||||
s.next_id = 0;
|
||||
s.sanitize();
|
||||
let tr = s.track(t).unwrap();
|
||||
assert_eq!(tr.active_playlist, 0);
|
||||
assert_eq!(tr.mixer.volume_db, 0.0);
|
||||
assert!(s.next_id > t.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Audio sources (files in the session's Audio Files folder) and their decoded data.
|
||||
|
||||
use crate::SourceId;
|
||||
use soundcraft_audio_io::{AudioBuffer, FileFormat, peaks::Peaks};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Metadata for an audio file used by the session (serialised).
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Source {
|
||||
pub id: SourceId,
|
||||
pub name: String,
|
||||
/// Path relative to the session folder (or absolute for referenced media).
|
||||
pub path: String,
|
||||
pub channels: u16,
|
||||
pub frames: u64,
|
||||
pub sample_rate: u32,
|
||||
pub format: FileFormat,
|
||||
/// Original BWF time stamp (samples), if any.
|
||||
#[serde(default)]
|
||||
pub time_reference: u64,
|
||||
/// True when the file has not been written to disk yet (recorded/rendered this session).
|
||||
#[serde(default)]
|
||||
pub unsaved: bool,
|
||||
}
|
||||
|
||||
/// Decoded audio plus waveform overviews (not serialised).
|
||||
#[derive(Debug)]
|
||||
pub struct SourceAudio {
|
||||
pub buffer: AudioBuffer,
|
||||
pub peaks: Vec<Peaks>,
|
||||
}
|
||||
|
||||
impl SourceAudio {
|
||||
pub fn new(buffer: AudioBuffer) -> Self {
|
||||
let peaks = buffer.channels.iter().map(|c| Peaks::build(c)).collect();
|
||||
SourceAudio { buffer, peaks }
|
||||
}
|
||||
pub fn frames(&self) -> usize {
|
||||
self.buffer.frames()
|
||||
}
|
||||
pub fn channel(&self, ch: usize) -> Option<&[f32]> {
|
||||
self.buffer.channels.get(ch).map(Vec::as_slice)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared decoded audio, keyed by source id.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SourcePool {
|
||||
map: BTreeMap<SourceId, Arc<SourceAudio>>,
|
||||
}
|
||||
|
||||
impl SourcePool {
|
||||
pub fn insert(&mut self, id: SourceId, audio: Arc<SourceAudio>) {
|
||||
self.map.insert(id, audio);
|
||||
}
|
||||
pub fn get(&self, id: SourceId) -> Option<&Arc<SourceAudio>> {
|
||||
self.map.get(&id)
|
||||
}
|
||||
pub fn remove(&mut self, id: SourceId) -> Option<Arc<SourceAudio>> {
|
||||
self.map.remove(&id)
|
||||
}
|
||||
pub fn contains(&self, id: SourceId) -> bool {
|
||||
self.map.contains_key(&id)
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.map.is_empty()
|
||||
}
|
||||
pub fn ids(&self) -> impl Iterator<Item = SourceId> + '_ {
|
||||
self.map.keys().copied()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for SourcePool {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.map.len() == other.map.len() && self.map.iter().zip(other.map.iter()).all(|((a, x), (b, y))| a == b && Arc::ptr_eq(x, y))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! Tracks and playlists.
|
||||
|
||||
use crate::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipId, Mixer, TrackId};
|
||||
use soundcraft_time::{Range, Samples};
|
||||
|
||||
/// Track types (Track › New).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum TrackKind {
|
||||
#[default]
|
||||
Audio,
|
||||
Aux,
|
||||
Master,
|
||||
Midi,
|
||||
Instrument,
|
||||
Vca,
|
||||
Folder,
|
||||
}
|
||||
|
||||
impl TrackKind {
|
||||
pub const ALL: [TrackKind; 7] =
|
||||
[TrackKind::Audio, TrackKind::Aux, TrackKind::Master, TrackKind::Midi, TrackKind::Instrument, TrackKind::Vca, TrackKind::Folder];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TrackKind::Audio => "Audio Track",
|
||||
TrackKind::Aux => "Aux Input",
|
||||
TrackKind::Master => "Master Fader",
|
||||
TrackKind::Midi => "MIDI Track",
|
||||
TrackKind::Instrument => "Instrument Track",
|
||||
TrackKind::Vca => "VCA Master",
|
||||
TrackKind::Folder => "Routing Folder Track",
|
||||
}
|
||||
}
|
||||
pub fn default_name(self) -> &'static str {
|
||||
match self {
|
||||
TrackKind::Audio => "Audio",
|
||||
TrackKind::Aux => "Aux",
|
||||
TrackKind::Master => "Master",
|
||||
TrackKind::Midi => "MIDI",
|
||||
TrackKind::Instrument => "Inst",
|
||||
TrackKind::Vca => "VCA",
|
||||
TrackKind::Folder => "Folder",
|
||||
}
|
||||
}
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
TrackKind::Audio => "audio",
|
||||
TrackKind::Aux => "aux",
|
||||
TrackKind::Master => "master",
|
||||
TrackKind::Midi => "midi",
|
||||
TrackKind::Instrument => "instrument",
|
||||
TrackKind::Vca => "vca",
|
||||
TrackKind::Folder => "folder",
|
||||
}
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<TrackKind> {
|
||||
let s = s.to_ascii_lowercase();
|
||||
TrackKind::ALL.into_iter().find(|k| k.id() == s || k.label().eq_ignore_ascii_case(&s))
|
||||
}
|
||||
/// Track kinds that hold clips on a timeline.
|
||||
pub fn has_playlist(self) -> bool {
|
||||
matches!(self, TrackKind::Audio | TrackKind::Midi | TrackKind::Instrument)
|
||||
}
|
||||
pub fn is_midi(self) -> bool {
|
||||
matches!(self, TrackKind::Midi | TrackKind::Instrument)
|
||||
}
|
||||
/// Kinds that pass audio through the mixer.
|
||||
pub fn is_audio_path(self) -> bool {
|
||||
matches!(self, TrackKind::Audio | TrackKind::Aux | TrackKind::Master | TrackKind::Instrument | TrackKind::Folder)
|
||||
}
|
||||
}
|
||||
|
||||
/// Track heights (View › Track Height presets).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum TrackHeight {
|
||||
Micro,
|
||||
Mini,
|
||||
Small,
|
||||
#[default]
|
||||
Medium,
|
||||
Large,
|
||||
Jumbo,
|
||||
Extreme,
|
||||
Fit,
|
||||
}
|
||||
|
||||
impl TrackHeight {
|
||||
pub const ALL: [TrackHeight; 7] = [
|
||||
TrackHeight::Micro,
|
||||
TrackHeight::Mini,
|
||||
TrackHeight::Small,
|
||||
TrackHeight::Medium,
|
||||
TrackHeight::Large,
|
||||
TrackHeight::Jumbo,
|
||||
TrackHeight::Extreme,
|
||||
];
|
||||
pub fn points(self) -> f32 {
|
||||
match self {
|
||||
TrackHeight::Micro => 16.0,
|
||||
TrackHeight::Mini => 24.0,
|
||||
TrackHeight::Small => 46.0,
|
||||
TrackHeight::Medium => 88.0,
|
||||
TrackHeight::Large => 132.0,
|
||||
TrackHeight::Jumbo => 220.0,
|
||||
TrackHeight::Extreme => 440.0,
|
||||
TrackHeight::Fit => 88.0,
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TrackHeight::Micro => "micro",
|
||||
TrackHeight::Mini => "mini",
|
||||
TrackHeight::Small => "small",
|
||||
TrackHeight::Medium => "medium",
|
||||
TrackHeight::Large => "large",
|
||||
TrackHeight::Jumbo => "jumbo",
|
||||
TrackHeight::Extreme => "extreme",
|
||||
TrackHeight::Fit => "fit to window",
|
||||
}
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<TrackHeight> {
|
||||
TrackHeight::ALL.into_iter().find(|h| h.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// The default track colour palette (original, chosen for SoundCraft).
|
||||
pub const TRACK_COLORS: [[u8; 3]; 16] = [
|
||||
[86, 132, 214],
|
||||
[94, 170, 214],
|
||||
[80, 190, 168],
|
||||
[104, 186, 98],
|
||||
[168, 196, 76],
|
||||
[222, 196, 72],
|
||||
[230, 152, 62],
|
||||
[222, 104, 72],
|
||||
[214, 82, 110],
|
||||
[196, 86, 168],
|
||||
[150, 98, 206],
|
||||
[110, 104, 214],
|
||||
[130, 150, 170],
|
||||
[178, 140, 110],
|
||||
[120, 170, 140],
|
||||
[190, 120, 150],
|
||||
];
|
||||
|
||||
/// A playlist: an alternative take of a track's clips.
|
||||
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Playlist {
|
||||
pub name: String,
|
||||
/// Clips sorted by start; non-overlapping except at crossfades.
|
||||
pub clips: Vec<Clip>,
|
||||
}
|
||||
|
||||
impl Playlist {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Playlist { name: name.into(), clips: Vec::new() }
|
||||
}
|
||||
pub fn sort(&mut self) {
|
||||
self.clips.sort_by_key(|c| (c.start, c.id.0));
|
||||
}
|
||||
pub fn clip(&self, id: ClipId) -> Option<&Clip> {
|
||||
self.clips.iter().find(|c| c.id == id)
|
||||
}
|
||||
pub fn clip_mut(&mut self, id: ClipId) -> Option<&mut Clip> {
|
||||
self.clips.iter_mut().find(|c| c.id == id)
|
||||
}
|
||||
pub fn clips_in(&self, r: Range) -> impl Iterator<Item = &Clip> {
|
||||
self.clips.iter().filter(move |c| c.range().overlaps(&r))
|
||||
}
|
||||
pub fn clip_at(&self, at: Samples) -> Option<&Clip> {
|
||||
self.clips.iter().rev().find(|c| c.range().contains(at))
|
||||
}
|
||||
pub fn end(&self) -> Samples {
|
||||
self.clips.iter().map(Clip::end).max().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A track.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Track {
|
||||
pub id: TrackId,
|
||||
pub name: String,
|
||||
pub kind: TrackKind,
|
||||
pub format: ChannelFormat,
|
||||
pub color: [u8; 3],
|
||||
pub height: TrackHeight,
|
||||
pub playlists: Vec<Playlist>,
|
||||
pub active_playlist: usize,
|
||||
pub mixer: Mixer,
|
||||
pub automation: Vec<AutomationLane>,
|
||||
/// Which lane the track shows: `waveform`/`blocks`/`notes`/`regions` or an automation param id.
|
||||
pub view: String,
|
||||
#[serde(default)]
|
||||
pub hidden: bool,
|
||||
#[serde(default)]
|
||||
pub inactive: bool,
|
||||
#[serde(default)]
|
||||
pub frozen: bool,
|
||||
#[serde(default)]
|
||||
pub comments: String,
|
||||
/// Folder that contains this track.
|
||||
#[serde(default)]
|
||||
pub folder: Option<TrackId>,
|
||||
#[serde(default = "yes")]
|
||||
pub folder_open: bool,
|
||||
/// Instrument plugin (instrument tracks) — registry id.
|
||||
#[serde(default)]
|
||||
pub instrument: Option<crate::Insert>,
|
||||
/// Ticks (musical) or samples timebase for clips.
|
||||
#[serde(default)]
|
||||
pub ticks_timebase: bool,
|
||||
/// Elastic Audio algorithm, if enabled.
|
||||
#[serde(default)]
|
||||
pub elastic: Option<String>,
|
||||
/// Pinned to top in the Edit window.
|
||||
#[serde(default)]
|
||||
pub pinned: bool,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Track {
|
||||
pub fn new(id: TrackId, name: impl Into<String>, kind: TrackKind, format: ChannelFormat, color: [u8; 3]) -> Self {
|
||||
let name = name.into();
|
||||
let format = if kind.is_midi() && kind == TrackKind::Midi { ChannelFormat::Mono } else { format };
|
||||
let view = if kind.is_midi() {
|
||||
"notes"
|
||||
} else if kind.has_playlist() {
|
||||
"waveform"
|
||||
} else {
|
||||
"volume"
|
||||
};
|
||||
Track {
|
||||
id,
|
||||
playlists: vec![Playlist::new(name.clone())],
|
||||
name,
|
||||
kind,
|
||||
format,
|
||||
color,
|
||||
height: TrackHeight::Medium,
|
||||
active_playlist: 0,
|
||||
mixer: Mixer::new(format),
|
||||
automation: Vec::new(),
|
||||
view: view.into(),
|
||||
hidden: false,
|
||||
inactive: false,
|
||||
frozen: false,
|
||||
comments: String::new(),
|
||||
folder: None,
|
||||
folder_open: true,
|
||||
instrument: None,
|
||||
ticks_timebase: kind.is_midi(),
|
||||
elastic: None,
|
||||
pinned: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn channels(&self) -> usize {
|
||||
self.format.channels()
|
||||
}
|
||||
|
||||
pub fn playlist(&self) -> Option<&Playlist> {
|
||||
self.playlists.get(self.active_playlist)
|
||||
}
|
||||
|
||||
pub fn playlist_mut(&mut self) -> Option<&mut Playlist> {
|
||||
self.playlists.get_mut(self.active_playlist)
|
||||
}
|
||||
|
||||
pub fn clips(&self) -> &[Clip] {
|
||||
self.playlist().map_or(&[], |p| p.clips.as_slice())
|
||||
}
|
||||
|
||||
pub fn lane(&self, p: &AutoParam) -> Option<&AutomationLane> {
|
||||
self.automation.iter().find(|l| &l.param == p)
|
||||
}
|
||||
|
||||
pub fn lane_mut(&mut self, p: &AutoParam) -> &mut AutomationLane {
|
||||
let idx = match self.automation.iter().position(|l| &l.param == p) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
self.automation.push(AutomationLane::new(p.clone()));
|
||||
self.automation.len() - 1
|
||||
}
|
||||
};
|
||||
let len = self.automation.len();
|
||||
// `idx` is in range: it came from `position` or is the index of the element just pushed.
|
||||
&mut self.automation[idx.min(len - 1)]
|
||||
}
|
||||
|
||||
pub fn is_folder(&self) -> bool {
|
||||
self.kind == TrackKind::Folder
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_track_defaults() {
|
||||
let t = Track::new(TrackId(1), "Gtr", TrackKind::Audio, ChannelFormat::Stereo, TRACK_COLORS[0]);
|
||||
assert_eq!(t.channels(), 2);
|
||||
assert_eq!(t.view, "waveform");
|
||||
assert_eq!(t.playlists.len(), 1);
|
||||
let m = Track::new(TrackId(2), "Keys", TrackKind::Midi, ChannelFormat::Stereo, TRACK_COLORS[0]);
|
||||
assert_eq!(m.format, ChannelFormat::Mono);
|
||||
assert_eq!(m.view, "notes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lane_mut_creates_once() {
|
||||
let mut t = Track::new(TrackId(1), "Gtr", TrackKind::Audio, ChannelFormat::Mono, TRACK_COLORS[0]);
|
||||
t.lane_mut(&AutoParam::Volume).set_point(0, -3.0);
|
||||
t.lane_mut(&AutoParam::Volume).set_point(10, -6.0);
|
||||
assert_eq!(t.automation.len(), 1);
|
||||
assert_eq!(t.lane(&AutoParam::Volume).map(|l| l.points.len()), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_ids() {
|
||||
for k in TrackKind::ALL {
|
||||
assert_eq!(TrackKind::from_id(k.id()), Some(k));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "soundcraft-playback"
|
||||
description = "SoundCraft audio device I/O: realtime playback and recording through the mix engine"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
soundcraft-time.workspace = true
|
||||
soundcraft-model.workspace = true
|
||||
soundcraft-mix.workspace = true
|
||||
soundcraft-dsp.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
cpal.workspace = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
cpal = { workspace = true, features = ["wasm-bindgen"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,404 @@
|
||||
//! Realtime playback through the system audio device (cpal: CoreAudio, WASAPI, ALSA/Pulse/
|
||||
//! PipeWire via ALSA, JACK, WebAudio).
|
||||
//!
|
||||
//! The UI thread owns a [`Player`]; the audio callback owns the [`MixEngine`] and a snapshot of
|
||||
//! the session (`Arc<Session>`) that the UI swaps whenever the document changes. Commands go to
|
||||
//! the callback through a channel; position and meters come back through atomics and a
|
||||
//! `try_lock`ed snapshot, so the callback never blocks. When no device is available the player
|
||||
//! falls back to a silent clock thread so the transport, meters and automation still run.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use soundcraft_mix::{MixEngine, StripMeter};
|
||||
use soundcraft_model::{Session, TrackId};
|
||||
use soundcraft_time::{Range, Samples};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
const BLOCK: usize = 512;
|
||||
|
||||
/// Meter values for the UI.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MeterSnapshot {
|
||||
pub tracks: HashMap<TrackId, StripMeter>,
|
||||
pub main: StripMeter,
|
||||
}
|
||||
|
||||
enum Cmd {
|
||||
Session(Arc<Session>),
|
||||
Play { from: Samples, end: Option<Samples>, looped: Option<Range> },
|
||||
Stop,
|
||||
Locate(Samples),
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
position: AtomicI64,
|
||||
playing: AtomicBool,
|
||||
/// Speed × 1000.
|
||||
speed: AtomicU32,
|
||||
meters: Mutex<MeterSnapshot>,
|
||||
}
|
||||
|
||||
/// State owned by the audio callback.
|
||||
struct AudioState {
|
||||
rx: Receiver<Cmd>,
|
||||
shared: Arc<Shared>,
|
||||
session: Arc<Session>,
|
||||
mix: MixEngine,
|
||||
pos: Samples,
|
||||
playing: bool,
|
||||
end: Option<Samples>,
|
||||
looped: Option<Range>,
|
||||
block: Vec<Vec<f32>>,
|
||||
/// Rendered frames waiting to be consumed (stereo planar) and the fractional read index.
|
||||
pending: Vec<Vec<f32>>,
|
||||
pending_len: usize,
|
||||
read: f64,
|
||||
device_rate: f64,
|
||||
}
|
||||
|
||||
impl AudioState {
|
||||
fn new(rx: Receiver<Cmd>, shared: Arc<Shared>, session: Arc<Session>, device_rate: f64) -> Self {
|
||||
let sr = session.sample_rate.as_f64() as f32;
|
||||
AudioState {
|
||||
rx,
|
||||
shared,
|
||||
mix: MixEngine::new(sr, BLOCK),
|
||||
session,
|
||||
pos: 0,
|
||||
playing: false,
|
||||
end: None,
|
||||
looped: None,
|
||||
block: vec![vec![0.0; BLOCK]; 2],
|
||||
pending: vec![vec![0.0; BLOCK]; 2],
|
||||
pending_len: 0,
|
||||
read: 0.0,
|
||||
device_rate,
|
||||
}
|
||||
}
|
||||
|
||||
fn drain(&mut self) {
|
||||
while let Ok(c) = self.rx.try_recv() {
|
||||
match c {
|
||||
Cmd::Session(s) => {
|
||||
if (s.sample_rate.as_f64() - self.session.sample_rate.as_f64()).abs() > 0.5 {
|
||||
self.mix = MixEngine::new(s.sample_rate.as_f64() as f32, BLOCK);
|
||||
}
|
||||
self.session = s;
|
||||
}
|
||||
Cmd::Play { from, end, looped } => {
|
||||
self.pos = from;
|
||||
self.end = end;
|
||||
self.looped = looped.filter(|r| r.len() > 64);
|
||||
self.playing = true;
|
||||
self.pending_len = 0;
|
||||
self.read = 0.0;
|
||||
self.mix.reset();
|
||||
}
|
||||
Cmd::Stop => {
|
||||
self.playing = false;
|
||||
self.mix.reset();
|
||||
}
|
||||
Cmd::Locate(at) => {
|
||||
self.pos = at;
|
||||
self.pending_len = 0;
|
||||
self.read = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.shared.playing.store(self.playing, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Render the next block at the session rate into `pending`.
|
||||
fn render_next(&mut self) {
|
||||
let mut n = BLOCK;
|
||||
if let Some(l) = self.looped
|
||||
&& self.pos >= l.end
|
||||
{
|
||||
self.pos = l.start;
|
||||
}
|
||||
if let Some(l) = self.looped {
|
||||
n = n.min(usize::try_from((l.end - self.pos).max(1)).unwrap_or(BLOCK));
|
||||
} else if let Some(e) = self.end {
|
||||
if self.pos >= e {
|
||||
self.playing = false;
|
||||
self.shared.playing.store(false, Ordering::Relaxed);
|
||||
n = 0;
|
||||
} else {
|
||||
n = n.min(usize::try_from(e - self.pos).unwrap_or(BLOCK));
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
for c in &mut self.pending {
|
||||
c.iter_mut().for_each(|x| *x = 0.0);
|
||||
}
|
||||
self.pending_len = BLOCK;
|
||||
return;
|
||||
}
|
||||
self.mix.render(&self.session, self.pos, n, &mut self.block);
|
||||
for (d, s) in self.pending.iter_mut().zip(self.block.iter()) {
|
||||
if let (Some(dd), Some(ss)) = (d.get_mut(..n), s.get(..n)) {
|
||||
dd.copy_from_slice(ss);
|
||||
}
|
||||
}
|
||||
self.pending_len = n;
|
||||
self.pos += n as i64;
|
||||
self.shared.position.store(self.pos, Ordering::Relaxed);
|
||||
if let Ok(mut m) = self.shared.meters.try_lock() {
|
||||
m.main = self.mix.main_meter;
|
||||
for (k, v) in &self.mix.meters {
|
||||
m.tracks.insert(*k, *v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill an interleaved device buffer.
|
||||
fn fill(&mut self, out: &mut [f32], channels: usize) {
|
||||
self.drain();
|
||||
let channels = channels.max(1);
|
||||
if !self.playing {
|
||||
out.iter_mut().for_each(|x| *x = 0.0);
|
||||
if let Ok(mut m) = self.shared.meters.try_lock() {
|
||||
m.main = StripMeter::default();
|
||||
m.tracks.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let speed = f64::from(self.shared.speed.load(Ordering::Relaxed)) / 1000.0;
|
||||
let step = self.session.sample_rate.as_f64() / self.device_rate.max(1.0) * speed.clamp(0.1, 4.0);
|
||||
for frame in out.chunks_mut(channels) {
|
||||
while self.read >= self.pending_len as f64 {
|
||||
self.read -= self.pending_len as f64;
|
||||
if !self.playing {
|
||||
break;
|
||||
}
|
||||
self.render_next();
|
||||
if self.pending_len == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let i0 = self.read.floor() as usize;
|
||||
let fr = (self.read - i0 as f64) as f32;
|
||||
for (c, o) in frame.iter_mut().enumerate() {
|
||||
let ch = self.pending.get(c.min(1));
|
||||
let a = ch.and_then(|v| v.get(i0)).copied().unwrap_or(0.0);
|
||||
let b = ch.and_then(|v| v.get(i0 + 1)).copied().unwrap_or(a);
|
||||
*o = if c < 2 && self.playing { (a + (b - a) * fr).clamp(-1.0, 1.0) } else { 0.0 };
|
||||
}
|
||||
self.read += step;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The transport and audio device.
|
||||
pub struct Player {
|
||||
shared: Arc<Shared>,
|
||||
tx: Sender<Cmd>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
_stream: Option<cpal::Stream>,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
_stream: Option<cpal::Stream>,
|
||||
pub device_name: String,
|
||||
pub device_rate: u32,
|
||||
/// True when no audio device could be opened (silent clock).
|
||||
pub silent: bool,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
/// Open the default output device. Never fails: without a device it runs a silent clock.
|
||||
pub fn new(session: Arc<Session>) -> Player {
|
||||
let shared = Arc::new(Shared {
|
||||
position: AtomicI64::new(0),
|
||||
playing: AtomicBool::new(false),
|
||||
speed: AtomicU32::new(1000),
|
||||
meters: Mutex::new(MeterSnapshot::default()),
|
||||
});
|
||||
let (tx, rx) = channel();
|
||||
let want = session.sample_rate.hz();
|
||||
match open_device(want) {
|
||||
Ok((device, config, name)) => {
|
||||
let rate = config.sample_rate.0;
|
||||
let channels = usize::from(config.channels);
|
||||
let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate));
|
||||
let err_fn = |e: cpal::StreamError| log::warn!("audio stream error: {e}");
|
||||
use cpal::traits::{DeviceTrait, StreamTrait};
|
||||
match device.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| state.fill(data, channels),
|
||||
err_fn,
|
||||
None,
|
||||
) {
|
||||
Ok(stream) => {
|
||||
if let Err(e) = stream.play() {
|
||||
log::warn!("audio stream failed to start: {e}");
|
||||
}
|
||||
log::info!("audio device: {name} @ {rate} Hz, {channels} ch");
|
||||
Player { shared, tx, _stream: Some(stream), device_name: name, device_rate: rate, silent: false }
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("cannot open audio stream: {e}; using a silent clock");
|
||||
let (tx2, rx2) = channel();
|
||||
// The state (and its receiver) moved into the failed closure; rebuild.
|
||||
Player::silent_clock(shared, tx2, rx2, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("no audio output: {e}; using a silent clock");
|
||||
let s2 = Arc::clone(&shared);
|
||||
Player::silent_clock_with(s2, tx, rx, session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn silent_clock(shared: Arc<Shared>, tx: Sender<Cmd>, rx: Receiver<Cmd>, rate: u32) -> Player {
|
||||
let s = Arc::new(Session::new("Untitled", soundcraft_time::SampleRate::new(rate).unwrap_or_default()));
|
||||
Player::silent_clock_with(shared, tx, rx, s)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn silent_clock_with(shared: Arc<Shared>, tx: Sender<Cmd>, rx: Receiver<Cmd>, session: Arc<Session>) -> Player {
|
||||
let rate = session.sample_rate.hz();
|
||||
let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate));
|
||||
let spawn = std::thread::Builder::new().name("soundcraft-clock".into()).spawn(move || {
|
||||
let mut buf = vec![0.0f32; 4096];
|
||||
let mut last = std::time::Instant::now();
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
let now = std::time::Instant::now();
|
||||
let frames = ((now - last).as_secs_f64() * state.device_rate) as usize;
|
||||
last = now;
|
||||
let n = (frames * 2).min(buf.len());
|
||||
if let Some(b) = buf.get_mut(..n) {
|
||||
state.fill(b, 2);
|
||||
}
|
||||
if Arc::strong_count(&state.shared) <= 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawn {
|
||||
log::warn!("clock thread failed: {e}");
|
||||
}
|
||||
Player { shared, tx, _stream: None, device_name: "No audio device (silent)".into(), device_rate: rate, silent: true }
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn silent_clock_with(shared: Arc<Shared>, tx: Sender<Cmd>, _rx: Receiver<Cmd>, session: Arc<Session>) -> Player {
|
||||
Player { shared, tx, _stream: None, device_name: "No audio device".into(), device_rate: session.sample_rate.hz(), silent: true }
|
||||
}
|
||||
|
||||
pub fn update_session(&self, s: Arc<Session>) {
|
||||
let _ = self.tx.send(Cmd::Session(s));
|
||||
}
|
||||
|
||||
/// Start playback at `from`, stopping at `end`, or looping `looped`.
|
||||
pub fn play(&self, from: Samples, end: Option<Samples>, looped: Option<Range>) {
|
||||
self.shared.position.store(from, Ordering::Relaxed);
|
||||
self.shared.playing.store(true, Ordering::Relaxed);
|
||||
let _ = self.tx.send(Cmd::Play { from, end, looped });
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
self.shared.playing.store(false, Ordering::Relaxed);
|
||||
let _ = self.tx.send(Cmd::Stop);
|
||||
}
|
||||
|
||||
pub fn locate(&self, at: Samples) {
|
||||
self.shared.position.store(at, Ordering::Relaxed);
|
||||
let _ = self.tx.send(Cmd::Locate(at));
|
||||
}
|
||||
|
||||
pub fn set_speed(&self, speed: f32) {
|
||||
let v = if speed.is_finite() { (speed.clamp(0.1, 4.0) * 1000.0) as u32 } else { 1000 };
|
||||
self.shared.speed.store(v, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn position(&self) -> Samples {
|
||||
self.shared.position.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn is_playing(&self) -> bool {
|
||||
self.shared.playing.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn meters(&self) -> MeterSnapshot {
|
||||
self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner).clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn open_device(want_rate: u32) -> Result<(cpal::Device, cpal::StreamConfig, String), String> {
|
||||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
let host = cpal::default_host();
|
||||
let device = host.default_output_device().ok_or_else(|| "no default output device".to_string())?;
|
||||
#[allow(deprecated)]
|
||||
let name = device.name().unwrap_or_else(|_| "Audio device".into());
|
||||
// Prefer an f32 config at the session rate; else the device default (we resample).
|
||||
let mut chosen: Option<cpal::StreamConfig> = None;
|
||||
if let Ok(configs) = device.supported_output_configs() {
|
||||
for c in configs {
|
||||
if c.sample_format() == cpal::SampleFormat::F32
|
||||
&& c.min_sample_rate().0 <= want_rate
|
||||
&& c.max_sample_rate().0 >= want_rate
|
||||
&& c.channels() >= 2
|
||||
{
|
||||
chosen = Some(c.with_sample_rate(cpal::SampleRate(want_rate)).config());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let config = match chosen {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
let d = device.default_output_config().map_err(|e| e.to_string())?;
|
||||
if d.sample_format() != cpal::SampleFormat::F32 {
|
||||
return Err(format!("unsupported device sample format {:?}", d.sample_format()));
|
||||
}
|
||||
d.config()
|
||||
}
|
||||
};
|
||||
Ok((device, config, name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn audio_state_plays_and_stops_at_end() {
|
||||
let s = Arc::new(soundcraft_model::Session::default());
|
||||
let shared = Arc::new(Shared {
|
||||
position: AtomicI64::new(0),
|
||||
playing: AtomicBool::new(false),
|
||||
speed: AtomicU32::new(1000),
|
||||
meters: Mutex::new(MeterSnapshot::default()),
|
||||
});
|
||||
let (tx, rx) = channel();
|
||||
let mut st = AudioState::new(rx, Arc::clone(&shared), s, 48_000.0);
|
||||
tx.send(Cmd::Play { from: 0, end: Some(1000), looped: None }).unwrap();
|
||||
let mut out = vec![0.0f32; 4096];
|
||||
st.fill(&mut out, 2);
|
||||
assert!(shared.position.load(Ordering::Relaxed) >= 1000);
|
||||
assert!(!shared.playing.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looping_wraps() {
|
||||
let s = Arc::new(soundcraft_model::Session::default());
|
||||
let shared = Arc::new(Shared {
|
||||
position: AtomicI64::new(0),
|
||||
playing: AtomicBool::new(false),
|
||||
speed: AtomicU32::new(1000),
|
||||
meters: Mutex::new(MeterSnapshot::default()),
|
||||
});
|
||||
let (tx, rx) = channel();
|
||||
let mut st = AudioState::new(rx, Arc::clone(&shared), s, 48_000.0);
|
||||
tx.send(Cmd::Play { from: 0, end: None, looped: Some(Range::new(0, 1000)) }).unwrap();
|
||||
let mut out = vec![0.0f32; 20_000];
|
||||
st.fill(&mut out, 2);
|
||||
let p = shared.position.load(Ordering::Relaxed);
|
||||
assert!(p <= 1000, "position {p} should stay inside the loop");
|
||||
assert!(shared.playing.load(Ordering::Relaxed));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "soundcraft-time"
|
||||
description = "SoundCraft timebases: samples, Bars|Beats, Min:Secs, Timecode, Feet+Frames, tempo and meter maps, grid"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Formatting and parsing positions in the five timebases.
|
||||
|
||||
use crate::{BarBeat, FrameRate, SampleRate, Samples, TempoMap, TimeError, Timecode, timecode::feet_frames};
|
||||
|
||||
/// The counters' and rulers' timebases.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum TimeFormat {
|
||||
BarsBeats,
|
||||
#[default]
|
||||
MinSecs,
|
||||
Timecode,
|
||||
FeetFrames,
|
||||
Samples,
|
||||
}
|
||||
|
||||
impl TimeFormat {
|
||||
pub const ALL: [TimeFormat; 5] = [TimeFormat::BarsBeats, TimeFormat::MinSecs, TimeFormat::Timecode, TimeFormat::FeetFrames, TimeFormat::Samples];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TimeFormat::BarsBeats => "Bars|Beats",
|
||||
TimeFormat::MinSecs => "Min:Secs",
|
||||
TimeFormat::Timecode => "Timecode",
|
||||
TimeFormat::FeetFrames => "Feet+Frames",
|
||||
TimeFormat::Samples => "Samples",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
TimeFormat::BarsBeats => "bars_beats",
|
||||
TimeFormat::MinSecs => "min_secs",
|
||||
TimeFormat::Timecode => "timecode",
|
||||
TimeFormat::FeetFrames => "feet_frames",
|
||||
TimeFormat::Samples => "samples",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(id: &str) -> Option<TimeFormat> {
|
||||
TimeFormat::ALL.into_iter().find(|f| f.id() == id || f.label().eq_ignore_ascii_case(id))
|
||||
}
|
||||
}
|
||||
|
||||
fn min_secs(s: Samples, sr: SampleRate) -> String {
|
||||
let neg = s < 0;
|
||||
let ms_total = (sr.seconds(s.saturating_abs()) * 1000.0).floor() as i64;
|
||||
let mins = ms_total / 60_000;
|
||||
let secs = (ms_total / 1000) % 60;
|
||||
let ms = ms_total % 1000;
|
||||
format!("{}{mins}:{secs:02}.{ms:03}", if neg { "-" } else { "" })
|
||||
}
|
||||
|
||||
/// Format an absolute position.
|
||||
pub fn format_position(s: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples) -> String {
|
||||
match fmt {
|
||||
TimeFormat::Samples => s.to_string(),
|
||||
TimeFormat::MinSecs => min_secs(s, sr),
|
||||
TimeFormat::Timecode => Timecode::from_samples(s.saturating_add(tc_start), rate, sr).format(rate),
|
||||
TimeFormat::FeetFrames => {
|
||||
let (ft, fr) = feet_frames(s, rate, sr);
|
||||
format!("{ft}+{fr:02}")
|
||||
}
|
||||
TimeFormat::BarsBeats => {
|
||||
let bb = map.bar_beat_at(s, sr);
|
||||
format!("{}| {}| {:03}", bb.bar, bb.beat, bb.tick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a length starting at `start` (Bars|Beats lengths count from 0|0|000).
|
||||
pub fn format_length(start: Samples, len: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> String {
|
||||
match fmt {
|
||||
TimeFormat::BarsBeats => {
|
||||
let t0 = map.samples_to_ticks(start, sr);
|
||||
let t1 = map.samples_to_ticks(start.saturating_add(len), sr);
|
||||
let m = map.meter_at_tick(t0);
|
||||
let d = (t1 - t0).max(0);
|
||||
let bars = d / m.ticks_per_bar().max(1);
|
||||
let rem = d % m.ticks_per_bar().max(1);
|
||||
format!("{bars}| {}| {:03}", rem / m.ticks_per_beat().max(1), rem % m.ticks_per_beat().max(1))
|
||||
}
|
||||
TimeFormat::Timecode => Timecode::from_samples(len, rate, sr).format(rate),
|
||||
other => format_position(len, other, sr, map, rate, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a position typed into a counter or dialog.
|
||||
pub fn parse_position(text: &str, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples) -> Result<Samples, TimeError> {
|
||||
let t = text.trim();
|
||||
let err = || TimeError::Parse(t.to_string(), fmt.label());
|
||||
match fmt {
|
||||
TimeFormat::Samples => t.replace([',', '_'], "").parse::<i64>().map_err(|_| err()),
|
||||
TimeFormat::MinSecs => {
|
||||
let (neg, body) = match t.strip_prefix('-') {
|
||||
Some(r) => (true, r),
|
||||
None => (false, t),
|
||||
};
|
||||
let (m, s) = match body.split_once(':') {
|
||||
Some((m, s)) => (m.trim().parse::<f64>().map_err(|_| err())?, s.trim().parse::<f64>().map_err(|_| err())?),
|
||||
None => (0.0, body.parse::<f64>().map_err(|_| err())?),
|
||||
};
|
||||
if !m.is_finite() || !s.is_finite() || m < 0.0 || s < 0.0 {
|
||||
return Err(err());
|
||||
}
|
||||
let v = sr.samples(m * 60.0 + s);
|
||||
Ok(if neg { -v } else { v })
|
||||
}
|
||||
TimeFormat::Timecode => Ok(Timecode::parse(t)?.to_samples(rate, sr).saturating_sub(tc_start)),
|
||||
TimeFormat::FeetFrames => {
|
||||
let (ft, fr) = t.split_once('+').ok_or_else(err)?;
|
||||
let ft: i64 = ft.trim().parse().map_err(|_| err())?;
|
||||
let fr: i64 = fr.trim().parse().map_err(|_| err())?;
|
||||
Ok(rate.samples_at_frame(ft.saturating_mul(16).saturating_add(fr), sr))
|
||||
}
|
||||
TimeFormat::BarsBeats => {
|
||||
let parts: Vec<i64> =
|
||||
t.split(['|', '.', ' ']).filter(|p| !p.is_empty()).map(|p| p.parse::<i64>()).collect::<Result<_, _>>().map_err(|_| err())?;
|
||||
let bb = BarBeat {
|
||||
bar: *parts.first().ok_or_else(err)?,
|
||||
beat: parts.get(1).copied().unwrap_or(1).max(1),
|
||||
tick: parts.get(2).copied().unwrap_or(0).max(0),
|
||||
};
|
||||
Ok(map.samples_at_bar_beat(bb, sr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ctx() -> (SampleRate, TempoMap, FrameRate) {
|
||||
(SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps30)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_like_pro_tools_counters() {
|
||||
let (sr, map, rate) = ctx();
|
||||
assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0), "0:00.000");
|
||||
assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0), "1:15.500");
|
||||
assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0), "1| 1| 000");
|
||||
assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0), "00:00:01:00");
|
||||
assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0), "12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_round_trips() {
|
||||
let (sr, map, rate) = ctx();
|
||||
for fmt in TimeFormat::ALL {
|
||||
for s in [0i64, 48_000, 96_000 * 3, 1_440_000] {
|
||||
let text = format_position(s, fmt, sr, &map, rate, 0);
|
||||
let back = parse_position(&text, fmt, sr, &map, rate, 0).unwrap();
|
||||
assert!((back - s).abs() <= 48, "{fmt:?} {s} → {text} → {back}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_garbage() {
|
||||
let (sr, map, rate) = ctx();
|
||||
assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0).is_err());
|
||||
assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0).is_err());
|
||||
assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_lengths() {
|
||||
let (sr, map, rate) = ctx();
|
||||
assert_eq!(format_length(0, 96_000 + 24_000, TimeFormat::BarsBeats, sr, &map, rate), "1| 1| 000");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Grid and Nudge values: musical or absolute step sizes, grid lines and snapping.
|
||||
|
||||
use crate::{FrameRate, SampleRate, Samples, TICKS_PER_QUARTER, TempoMap, to_samples};
|
||||
|
||||
/// Note values for Bars|Beats grids.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum NoteValue {
|
||||
Bar,
|
||||
Half,
|
||||
Quarter,
|
||||
Eighth,
|
||||
Sixteenth,
|
||||
ThirtySecond,
|
||||
SixtyFourth,
|
||||
}
|
||||
|
||||
impl NoteValue {
|
||||
pub const ALL: [NoteValue; 7] = [
|
||||
NoteValue::Bar,
|
||||
NoteValue::Half,
|
||||
NoteValue::Quarter,
|
||||
NoteValue::Eighth,
|
||||
NoteValue::Sixteenth,
|
||||
NoteValue::ThirtySecond,
|
||||
NoteValue::SixtyFourth,
|
||||
];
|
||||
pub fn ticks(self) -> i64 {
|
||||
match self {
|
||||
NoteValue::Bar => TICKS_PER_QUARTER * 4,
|
||||
NoteValue::Half => TICKS_PER_QUARTER * 2,
|
||||
NoteValue::Quarter => TICKS_PER_QUARTER,
|
||||
NoteValue::Eighth => TICKS_PER_QUARTER / 2,
|
||||
NoteValue::Sixteenth => TICKS_PER_QUARTER / 4,
|
||||
NoteValue::ThirtySecond => TICKS_PER_QUARTER / 8,
|
||||
NoteValue::SixtyFourth => TICKS_PER_QUARTER / 16,
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
NoteValue::Bar => "1 bar",
|
||||
NoteValue::Half => "1/2 note",
|
||||
NoteValue::Quarter => "1/4 note",
|
||||
NoteValue::Eighth => "1/8 note",
|
||||
NoteValue::Sixteenth => "1/16 note",
|
||||
NoteValue::ThirtySecond => "1/32 note",
|
||||
NoteValue::SixtyFourth => "1/64 note",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A grid or nudge step.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum GridValue {
|
||||
/// Musical: note value, optionally dotted or triplet.
|
||||
Note { value: NoteValue, dotted: bool, triplet: bool },
|
||||
/// Absolute seconds (Min:Secs grids: 0.001 … 10 s).
|
||||
Seconds(f64),
|
||||
/// Frames at the session timecode rate.
|
||||
Frames(i64),
|
||||
/// Raw samples.
|
||||
Samples(i64),
|
||||
}
|
||||
|
||||
impl Default for GridValue {
|
||||
fn default() -> Self {
|
||||
GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl GridValue {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
GridValue::Note { value, dotted, triplet } => {
|
||||
let mut s = value.label().to_string();
|
||||
if *dotted {
|
||||
s.push_str(" dotted");
|
||||
}
|
||||
if *triplet {
|
||||
s.push_str(" triplet");
|
||||
}
|
||||
s
|
||||
}
|
||||
GridValue::Seconds(s) => format!("{s:.3} sec"),
|
||||
GridValue::Frames(f) => format!("{f} frame{}", if *f == 1 { "" } else { "s" }),
|
||||
GridValue::Samples(n) => format!("{n} samples"),
|
||||
}
|
||||
}
|
||||
|
||||
fn note_ticks(value: NoteValue, dotted: bool, triplet: bool, map: &TempoMap, at_tick: i64) -> i64 {
|
||||
let base = if value == NoteValue::Bar { map.meter_at_tick(at_tick).ticks_per_bar() } else { value.ticks() };
|
||||
let mut t = base;
|
||||
if dotted {
|
||||
t = t * 3 / 2;
|
||||
}
|
||||
if triplet {
|
||||
t = t * 2 / 3;
|
||||
}
|
||||
t.max(1)
|
||||
}
|
||||
|
||||
/// The step in samples at position `at` (musical steps depend on tempo).
|
||||
pub fn step_samples(&self, at: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
match *self {
|
||||
GridValue::Note { value, dotted, triplet } => {
|
||||
let t0 = map.samples_to_ticks(at, sr);
|
||||
let dt = Self::note_ticks(value, dotted, triplet, map, t0);
|
||||
(map.tick_to_samples(t0 + dt, sr) - map.tick_to_samples(t0, sr)).max(1)
|
||||
}
|
||||
GridValue::Seconds(s) => sr.samples(s.clamp(0.000_01, 3600.0)).max(1),
|
||||
GridValue::Frames(f) => to_samples(rate.samples_per_frame(sr) * f.clamp(1, 1_000_000) as f64).max(1),
|
||||
GridValue::Samples(n) => n.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nearest grid line to `pos`.
|
||||
pub fn snap(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
let lo = self.line_at_or_before(pos, sr, map, rate);
|
||||
let hi = self.next_line(lo, sr, map, rate);
|
||||
if (pos - lo) <= (hi - pos) { lo } else { hi }
|
||||
}
|
||||
|
||||
/// The grid line at or before `pos`.
|
||||
pub fn line_at_or_before(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
match *self {
|
||||
GridValue::Note { value, dotted, triplet } => {
|
||||
let t = map.samples_to_ticks_f(pos, sr).floor() as i64;
|
||||
// Grid lines restart at each bar line so odd meters stay aligned.
|
||||
let bb = map.bar_beat_at_tick(t);
|
||||
let bar_tick = map.bar_start_tick(bb.bar);
|
||||
let step = Self::note_ticks(value, dotted, triplet, map, bar_tick);
|
||||
let k = (t - bar_tick).div_euclid(step);
|
||||
map.tick_to_samples(bar_tick + k * step, sr)
|
||||
}
|
||||
_ => {
|
||||
let step = self.step_samples(pos, sr, map, rate);
|
||||
pos.div_euclid(step) * step
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first grid line strictly after `line`.
|
||||
pub fn next_line(&self, line: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
|
||||
let step = self.step_samples(line, sr, map, rate);
|
||||
let candidate = self.line_at_or_before(line.saturating_add(step), sr, map, rate);
|
||||
if candidate > line { candidate } else { line.saturating_add(step) }
|
||||
}
|
||||
|
||||
/// All grid lines within `[start, end)`, capped at `max` lines.
|
||||
pub fn lines(&self, start: Samples, end: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate, max: usize) -> Vec<Samples> {
|
||||
let mut out = Vec::new();
|
||||
let mut p = self.line_at_or_before(start, sr, map, rate);
|
||||
while p < end && out.len() < max {
|
||||
if p >= start {
|
||||
out.push(p);
|
||||
}
|
||||
let n = self.next_line(p, sr, map, rate);
|
||||
if n <= p {
|
||||
break;
|
||||
}
|
||||
p = n;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sixteenth_grid_at_120() {
|
||||
let g = GridValue::default();
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps30);
|
||||
assert_eq!(g.step_samples(0, sr, &map, rate), 6000);
|
||||
assert_eq!(g.snap(2900, sr, &map, rate), 0);
|
||||
assert_eq!(g.snap(3100, sr, &map, rate), 6000);
|
||||
assert_eq!(g.lines(0, 24_000, sr, &map, rate, 100), vec![0, 6000, 12000, 18000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_grids() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
|
||||
assert_eq!(GridValue::Seconds(1.0).snap(70_000, sr, &map, rate), 48_000);
|
||||
assert_eq!(GridValue::Frames(1).step_samples(0, sr, &map, rate), 1920);
|
||||
assert_eq!(GridValue::Samples(0).step_samples(0, sr, &map, rate), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_are_capped() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
|
||||
assert_eq!(GridValue::Samples(1).lines(0, 1_000_000, sr, &map, rate, 50).len(), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triplets_and_dots() {
|
||||
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
|
||||
let t = GridValue::Note { value: NoteValue::Quarter, dotted: false, triplet: true };
|
||||
assert_eq!(t.step_samples(0, sr, &map, rate), 16_000);
|
||||
let d = GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false };
|
||||
assert_eq!(d.step_samples(0, sr, &map, rate), 36_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! SoundCraft timebases.
|
||||
//!
|
||||
//! Every position in a session is an absolute sample index (`i64`) at the session sample rate.
|
||||
//! This crate converts those positions to and from the five Pro Tools-style timebases
|
||||
//! (Bars|Beats, Min:Secs, Timecode, Feet+Frames, Samples) through a [`TempoMap`], and computes
|
||||
//! grid lines and snapping for the Grid / Nudge values.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod format;
|
||||
mod grid;
|
||||
mod tempo;
|
||||
mod timecode;
|
||||
|
||||
pub use format::{TimeFormat, format_length, format_position, parse_position};
|
||||
pub use grid::{GridValue, NoteValue};
|
||||
pub use tempo::{BarBeat, MeterEvent, TICKS_PER_QUARTER, TempoEvent, TempoMap};
|
||||
pub use timecode::{FrameRate, Timecode};
|
||||
|
||||
/// A position or length in samples.
|
||||
pub type Samples = i64;
|
||||
|
||||
/// Errors from parsing or converting time values.
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum TimeError {
|
||||
#[error("cannot parse `{0}` as a {1} value")]
|
||||
Parse(String, &'static str),
|
||||
#[error("invalid sample rate {0}")]
|
||||
SampleRate(u32),
|
||||
#[error("invalid tempo {0}")]
|
||||
Tempo(f64),
|
||||
#[error("invalid meter {0}/{1}")]
|
||||
Meter(u32, u32),
|
||||
}
|
||||
|
||||
/// Sample rates supported by sessions (Pro Tools offers 44.1 k – 192 k; we accept 8 k – 768 k).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SampleRate(u32);
|
||||
|
||||
impl SampleRate {
|
||||
pub const HZ_44100: SampleRate = SampleRate(44_100);
|
||||
pub const HZ_48000: SampleRate = SampleRate(48_000);
|
||||
pub const COMMON: [u32; 8] = [44_100, 48_000, 88_200, 96_000, 176_400, 192_000, 352_800, 384_000];
|
||||
|
||||
pub fn new(hz: u32) -> Result<Self, TimeError> {
|
||||
if (8_000..=768_000).contains(&hz) { Ok(SampleRate(hz)) } else { Err(TimeError::SampleRate(hz)) }
|
||||
}
|
||||
pub fn hz(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
pub fn as_f64(self) -> f64 {
|
||||
f64::from(self.0)
|
||||
}
|
||||
/// Samples → seconds.
|
||||
pub fn seconds(self, s: Samples) -> f64 {
|
||||
s as f64 / self.as_f64()
|
||||
}
|
||||
/// Seconds → samples, rounded to nearest and saturated.
|
||||
pub fn samples(self, seconds: f64) -> Samples {
|
||||
to_samples(seconds * self.as_f64())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SampleRate {
|
||||
fn default() -> Self {
|
||||
SampleRate::HZ_48000
|
||||
}
|
||||
}
|
||||
|
||||
/// Round a float sample count to `i64`, mapping NaN to 0 and saturating infinities.
|
||||
pub fn to_samples(v: f64) -> Samples {
|
||||
if v.is_nan() {
|
||||
0
|
||||
} else if v >= i64::MAX as f64 {
|
||||
i64::MAX
|
||||
} else if v <= i64::MIN as f64 {
|
||||
i64::MIN
|
||||
} else {
|
||||
v.round() as i64
|
||||
}
|
||||
}
|
||||
|
||||
/// A half-open sample range `[start, end)`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Range {
|
||||
pub start: Samples,
|
||||
pub end: Samples,
|
||||
}
|
||||
|
||||
impl Range {
|
||||
pub fn new(a: Samples, b: Samples) -> Self {
|
||||
if a <= b { Range { start: a, end: b } } else { Range { start: b, end: a } }
|
||||
}
|
||||
pub fn point(at: Samples) -> Self {
|
||||
Range { start: at, end: at }
|
||||
}
|
||||
pub fn len(&self) -> Samples {
|
||||
self.end.saturating_sub(self.start)
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.end <= self.start
|
||||
}
|
||||
pub fn contains(&self, s: Samples) -> bool {
|
||||
s >= self.start && s < self.end
|
||||
}
|
||||
pub fn overlaps(&self, o: &Range) -> bool {
|
||||
self.start < o.end && o.start < self.end
|
||||
}
|
||||
pub fn intersect(&self, o: &Range) -> Option<Range> {
|
||||
let s = self.start.max(o.start);
|
||||
let e = self.end.min(o.end);
|
||||
(s < e).then_some(Range { start: s, end: e })
|
||||
}
|
||||
pub fn shifted(&self, by: Samples) -> Range {
|
||||
Range { start: self.start.saturating_add(by), end: self.end.saturating_add(by) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sample_rate_bounds() {
|
||||
assert!(SampleRate::new(48_000).is_ok());
|
||||
assert!(SampleRate::new(0).is_err());
|
||||
assert!(SampleRate::new(10_000_000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seconds_round_trip() {
|
||||
let sr = SampleRate::HZ_44100;
|
||||
assert_eq!(sr.samples(1.0), 44_100);
|
||||
assert!((sr.seconds(22_050) - 0.5).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_samples_is_total() {
|
||||
assert_eq!(to_samples(f64::NAN), 0);
|
||||
assert_eq!(to_samples(f64::INFINITY), i64::MAX);
|
||||
assert_eq!(to_samples(f64::NEG_INFINITY), i64::MIN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_ops() {
|
||||
let a = Range::new(10, 0);
|
||||
assert_eq!(a, Range { start: 0, end: 10 });
|
||||
assert_eq!(a.intersect(&Range::new(5, 20)), Some(Range::new(5, 10)));
|
||||
assert_eq!(a.intersect(&Range::new(10, 20)), None);
|
||||
assert!(a.contains(9) && !a.contains(10));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Tempo and meter maps: the conversion between musical time (ticks) and samples.
|
||||
|
||||
use crate::{SampleRate, Samples, TimeError, to_samples};
|
||||
|
||||
/// Ticks per quarter note (Pro Tools' resolution).
|
||||
pub const TICKS_PER_QUARTER: i64 = 960;
|
||||
|
||||
/// A tempo change at a tick position. `bpm` is quarter notes per minute.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TempoEvent {
|
||||
pub tick: i64,
|
||||
pub bpm: f64,
|
||||
}
|
||||
|
||||
/// A meter (time signature) change at a tick position, which must fall on a bar line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct MeterEvent {
|
||||
pub tick: i64,
|
||||
pub numerator: u32,
|
||||
pub denominator: u32,
|
||||
}
|
||||
|
||||
impl MeterEvent {
|
||||
pub fn ticks_per_beat(&self) -> i64 {
|
||||
let d = i64::from(self.denominator.max(1));
|
||||
(TICKS_PER_QUARTER * 4 / d).max(1)
|
||||
}
|
||||
pub fn ticks_per_bar(&self) -> i64 {
|
||||
self.ticks_per_beat().saturating_mul(i64::from(self.numerator.max(1)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A musical position: 1-based bar and beat plus ticks within the beat.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BarBeat {
|
||||
pub bar: i64,
|
||||
pub beat: i64,
|
||||
pub tick: i64,
|
||||
}
|
||||
|
||||
/// Tempo and meter changes for a session. Always has at least one of each, at tick 0.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TempoMap {
|
||||
tempos: Vec<TempoEvent>,
|
||||
meters: Vec<MeterEvent>,
|
||||
}
|
||||
|
||||
impl Default for TempoMap {
|
||||
fn default() -> Self {
|
||||
TempoMap { tempos: vec![TempoEvent { tick: 0, bpm: 120.0 }], meters: vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }] }
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_bpm(bpm: f64) -> Result<f64, TimeError> {
|
||||
if bpm.is_finite() && (5.0..=1000.0).contains(&bpm) { Ok(bpm) } else { Err(TimeError::Tempo(bpm)) }
|
||||
}
|
||||
|
||||
impl TempoMap {
|
||||
pub fn new(bpm: f64, numerator: u32, denominator: u32) -> Result<Self, TimeError> {
|
||||
let mut m = TempoMap::default();
|
||||
m.set_tempo(0, bpm)?;
|
||||
m.set_meter(0, numerator, denominator)?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
pub fn tempos(&self) -> &[TempoEvent] {
|
||||
&self.tempos
|
||||
}
|
||||
pub fn meters(&self) -> &[MeterEvent] {
|
||||
&self.meters
|
||||
}
|
||||
|
||||
/// Insert or replace the tempo at `tick`.
|
||||
pub fn set_tempo(&mut self, tick: i64, bpm: f64) -> Result<(), TimeError> {
|
||||
let bpm = valid_bpm(bpm)?;
|
||||
let tick = tick.max(0);
|
||||
match self.tempos.binary_search_by(|e| e.tick.cmp(&tick)) {
|
||||
Ok(i) => {
|
||||
if let Some(e) = self.tempos.get_mut(i) {
|
||||
e.bpm = bpm;
|
||||
}
|
||||
}
|
||||
Err(i) => self.tempos.insert(i, TempoEvent { tick, bpm }),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a tempo event (the one at tick 0 is kept).
|
||||
pub fn remove_tempo(&mut self, tick: i64) -> bool {
|
||||
if tick == 0 {
|
||||
return false;
|
||||
}
|
||||
let before = self.tempos.len();
|
||||
self.tempos.retain(|e| e.tick != tick);
|
||||
before != self.tempos.len()
|
||||
}
|
||||
|
||||
/// Insert or replace the meter at `tick` (snapped to the bar line at or before it).
|
||||
pub fn set_meter(&mut self, tick: i64, numerator: u32, denominator: u32) -> Result<(), TimeError> {
|
||||
if !(1..=64).contains(&numerator) || ![1, 2, 4, 8, 16, 32, 64].contains(&denominator) {
|
||||
return Err(TimeError::Meter(numerator, denominator));
|
||||
}
|
||||
let tick = if tick <= 0 { 0 } else { self.bar_start_tick(self.bar_beat_at_tick(tick).bar) };
|
||||
let ev = MeterEvent { tick, numerator, denominator };
|
||||
match self.meters.binary_search_by(|e| e.tick.cmp(&tick)) {
|
||||
Ok(i) => {
|
||||
if let Some(e) = self.meters.get_mut(i) {
|
||||
*e = ev;
|
||||
}
|
||||
}
|
||||
Err(i) => self.meters.insert(i, ev),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_meter(&mut self, tick: i64) -> bool {
|
||||
if tick == 0 {
|
||||
return false;
|
||||
}
|
||||
let before = self.meters.len();
|
||||
self.meters.retain(|e| e.tick != tick);
|
||||
before != self.meters.len()
|
||||
}
|
||||
|
||||
/// Tempo in effect at `tick`.
|
||||
pub fn tempo_at_tick(&self, tick: i64) -> f64 {
|
||||
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
|
||||
for e in &self.tempos {
|
||||
if e.tick <= tick {
|
||||
bpm = e.bpm;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bpm
|
||||
}
|
||||
|
||||
/// Meter in effect at `tick`.
|
||||
pub fn meter_at_tick(&self, tick: i64) -> MeterEvent {
|
||||
let mut m = self.meters.first().copied().unwrap_or(MeterEvent { tick: 0, numerator: 4, denominator: 4 });
|
||||
for e in &self.meters {
|
||||
if e.tick <= tick {
|
||||
m = *e;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn samples_per_tick(bpm: f64, sr: SampleRate) -> f64 {
|
||||
sr.as_f64() * 60.0 / (bpm * TICKS_PER_QUARTER as f64)
|
||||
}
|
||||
|
||||
/// Ticks → samples (exact for constant-tempo segments).
|
||||
pub fn tick_to_samples(&self, tick: i64, sr: SampleRate) -> Samples {
|
||||
to_samples(self.tick_to_samples_f(tick, sr))
|
||||
}
|
||||
|
||||
fn tick_to_samples_f(&self, tick: i64, sr: SampleRate) -> f64 {
|
||||
let mut acc = 0.0;
|
||||
let mut seg_tick = 0i64;
|
||||
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
|
||||
for e in self.tempos.iter().skip(1) {
|
||||
if e.tick >= tick {
|
||||
break;
|
||||
}
|
||||
acc += (e.tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr);
|
||||
seg_tick = e.tick;
|
||||
bpm = e.bpm;
|
||||
}
|
||||
acc + (tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr)
|
||||
}
|
||||
|
||||
/// Samples → ticks (fractional).
|
||||
pub fn samples_to_ticks_f(&self, s: Samples, sr: SampleRate) -> f64 {
|
||||
let s = s as f64;
|
||||
let mut acc = 0.0;
|
||||
let mut seg_tick = 0i64;
|
||||
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
|
||||
for e in self.tempos.iter().skip(1) {
|
||||
let seg = (e.tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr);
|
||||
if acc + seg > s {
|
||||
break;
|
||||
}
|
||||
acc += seg;
|
||||
seg_tick = e.tick;
|
||||
bpm = e.bpm;
|
||||
}
|
||||
seg_tick as f64 + (s - acc) / Self::samples_per_tick(bpm, sr)
|
||||
}
|
||||
|
||||
pub fn samples_to_ticks(&self, s: Samples, sr: SampleRate) -> i64 {
|
||||
to_samples(self.samples_to_ticks_f(s, sr))
|
||||
}
|
||||
|
||||
/// Tick of the first beat of `bar` (1-based; bars before 1 extend the first meter backwards).
|
||||
pub fn bar_start_tick(&self, bar: i64) -> i64 {
|
||||
let mut cur_bar = 1i64;
|
||||
let mut cur_tick = 0i64;
|
||||
let first = self.meter_at_tick(0);
|
||||
if bar < 1 {
|
||||
return (bar - 1).saturating_mul(first.ticks_per_bar());
|
||||
}
|
||||
for (i, m) in self.meters.iter().enumerate() {
|
||||
let next_tick = self.meters.get(i + 1).map(|n| n.tick);
|
||||
let tpb = m.ticks_per_bar();
|
||||
let bars_in_seg = next_tick.map(|nt| ((nt - cur_tick) + tpb - 1) / tpb);
|
||||
match bars_in_seg {
|
||||
Some(n) if bar >= cur_bar + n => {
|
||||
cur_bar += n;
|
||||
cur_tick = next_tick.unwrap_or(cur_tick);
|
||||
}
|
||||
_ => return cur_tick.saturating_add((bar - cur_bar).saturating_mul(tpb)),
|
||||
}
|
||||
}
|
||||
cur_tick
|
||||
}
|
||||
|
||||
/// Bar|beat|tick at `tick`.
|
||||
pub fn bar_beat_at_tick(&self, tick: i64) -> BarBeat {
|
||||
let first = self.meter_at_tick(0);
|
||||
if tick < 0 {
|
||||
let tpb = first.ticks_per_bar();
|
||||
let bars_back = (-tick + tpb - 1) / tpb;
|
||||
let bar_tick = -bars_back * tpb;
|
||||
let within = tick - bar_tick;
|
||||
let beat_t = first.ticks_per_beat();
|
||||
return BarBeat { bar: 1 - bars_back, beat: within / beat_t + 1, tick: within % beat_t };
|
||||
}
|
||||
let mut bar = 1i64;
|
||||
let mut seg_tick = 0i64;
|
||||
for (i, m) in self.meters.iter().enumerate() {
|
||||
let tpb = m.ticks_per_bar();
|
||||
if let Some(n) = self.meters.get(i + 1)
|
||||
&& tick >= n.tick
|
||||
{
|
||||
bar += ((n.tick - seg_tick) + tpb - 1) / tpb;
|
||||
seg_tick = n.tick;
|
||||
continue;
|
||||
}
|
||||
let rel = tick - seg_tick;
|
||||
let bars = rel / tpb;
|
||||
let within = rel % tpb;
|
||||
let beat_t = m.ticks_per_beat();
|
||||
return BarBeat { bar: bar + bars, beat: within / beat_t + 1, tick: within % beat_t };
|
||||
}
|
||||
BarBeat { bar, beat: 1, tick: 0 }
|
||||
}
|
||||
|
||||
/// Bar|beat|tick → tick.
|
||||
pub fn tick_at_bar_beat(&self, bb: BarBeat) -> i64 {
|
||||
let start = self.bar_start_tick(bb.bar);
|
||||
let m = self.meter_at_tick(start);
|
||||
start.saturating_add((bb.beat - 1).max(0).saturating_mul(m.ticks_per_beat())).saturating_add(bb.tick)
|
||||
}
|
||||
|
||||
pub fn bar_beat_at(&self, s: Samples, sr: SampleRate) -> BarBeat {
|
||||
self.bar_beat_at_tick(self.samples_to_ticks(s, sr))
|
||||
}
|
||||
|
||||
pub fn samples_at_bar_beat(&self, bb: BarBeat, sr: SampleRate) -> Samples {
|
||||
self.tick_to_samples(self.tick_at_bar_beat(bb), sr)
|
||||
}
|
||||
|
||||
/// Scale every tempo by `factor` (Tempo Operations › Scale).
|
||||
pub fn scale_tempos(&mut self, factor: f64) -> Result<(), TimeError> {
|
||||
let mut out = self.tempos.clone();
|
||||
for e in &mut out {
|
||||
e.bpm = valid_bpm(e.bpm * factor)?;
|
||||
}
|
||||
self.tempos = out;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_map_is_120_4_4() {
|
||||
let m = TempoMap::default();
|
||||
let sr = SampleRate::HZ_48000;
|
||||
// One bar of 4/4 at 120 bpm = 2 seconds.
|
||||
assert_eq!(m.samples_at_bar_beat(BarBeat { bar: 2, beat: 1, tick: 0 }, sr), 96_000);
|
||||
assert_eq!(m.bar_beat_at(96_000, sr), BarBeat { bar: 2, beat: 1, tick: 0 });
|
||||
assert_eq!(m.bar_beat_at(24_000, sr), BarBeat { bar: 1, beat: 2, tick: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tempo_change_is_integrated() {
|
||||
let mut m = TempoMap::default();
|
||||
let sr = SampleRate::HZ_48000;
|
||||
m.set_tempo(TICKS_PER_QUARTER * 4, 60.0).unwrap();
|
||||
// Bar 1 at 120 = 96000 samples; bar 2 at 60 = 192000 samples.
|
||||
assert_eq!(m.tick_to_samples(TICKS_PER_QUARTER * 8, sr), 96_000 + 192_000);
|
||||
assert_eq!(m.samples_to_ticks(96_000 + 192_000, sr), TICKS_PER_QUARTER * 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meter_change_counts_bars() {
|
||||
let mut m = TempoMap::default();
|
||||
// 2 bars of 4/4 then 3/4.
|
||||
m.set_meter(TICKS_PER_QUARTER * 8, 3, 4).unwrap();
|
||||
assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 8), BarBeat { bar: 3, beat: 1, tick: 0 });
|
||||
assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 11), BarBeat { bar: 4, beat: 1, tick: 0 });
|
||||
assert_eq!(m.bar_start_tick(4), TICKS_PER_QUARTER * 11);
|
||||
let bb = BarBeat { bar: 4, beat: 2, tick: 10 };
|
||||
assert_eq!(m.bar_beat_at_tick(m.tick_at_bar_beat(bb)), bb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eighth_note_meters() {
|
||||
let m = TempoMap::new(120.0, 6, 8).unwrap();
|
||||
assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER * 3), BarBeat { bar: 2, beat: 1, tick: 0 });
|
||||
assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER / 2), BarBeat { bar: 1, beat: 2, tick: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_values() {
|
||||
let mut m = TempoMap::default();
|
||||
assert!(m.set_tempo(0, f64::NAN).is_err());
|
||||
assert!(m.set_tempo(0, 0.0).is_err());
|
||||
assert!(m.set_meter(0, 0, 4).is_err());
|
||||
assert!(m.set_meter(0, 4, 3).is_err());
|
||||
assert!(!m.remove_tempo(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_positions() {
|
||||
let m = TempoMap::default();
|
||||
assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER * 4), BarBeat { bar: 0, beat: 1, tick: 0 });
|
||||
assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER), BarBeat { bar: 0, beat: 4, tick: 0 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! SMPTE timecode and Feet+Frames.
|
||||
|
||||
use crate::{SampleRate, Samples, TimeError};
|
||||
|
||||
/// Session timecode rates.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FrameRate {
|
||||
Fps23_976,
|
||||
#[default]
|
||||
Fps24,
|
||||
Fps25,
|
||||
Fps29_97,
|
||||
Fps29_97Drop,
|
||||
Fps30,
|
||||
Fps47_952,
|
||||
Fps48,
|
||||
Fps50,
|
||||
Fps59_94,
|
||||
Fps59_94Drop,
|
||||
Fps60,
|
||||
Fps100,
|
||||
Fps119_88,
|
||||
Fps120,
|
||||
}
|
||||
|
||||
impl FrameRate {
|
||||
pub const ALL: [FrameRate; 15] = [
|
||||
FrameRate::Fps23_976,
|
||||
FrameRate::Fps24,
|
||||
FrameRate::Fps25,
|
||||
FrameRate::Fps29_97,
|
||||
FrameRate::Fps29_97Drop,
|
||||
FrameRate::Fps30,
|
||||
FrameRate::Fps47_952,
|
||||
FrameRate::Fps48,
|
||||
FrameRate::Fps50,
|
||||
FrameRate::Fps59_94,
|
||||
FrameRate::Fps59_94Drop,
|
||||
FrameRate::Fps60,
|
||||
FrameRate::Fps100,
|
||||
FrameRate::Fps119_88,
|
||||
FrameRate::Fps120,
|
||||
];
|
||||
|
||||
/// Real frames per second as a rational (num, den).
|
||||
pub fn rational(self) -> (i64, i64) {
|
||||
match self {
|
||||
FrameRate::Fps23_976 => (24_000, 1001),
|
||||
FrameRate::Fps24 => (24, 1),
|
||||
FrameRate::Fps25 => (25, 1),
|
||||
FrameRate::Fps29_97 | FrameRate::Fps29_97Drop => (30_000, 1001),
|
||||
FrameRate::Fps30 => (30, 1),
|
||||
FrameRate::Fps47_952 => (48_000, 1001),
|
||||
FrameRate::Fps48 => (48, 1),
|
||||
FrameRate::Fps50 => (50, 1),
|
||||
FrameRate::Fps59_94 | FrameRate::Fps59_94Drop => (60_000, 1001),
|
||||
FrameRate::Fps60 => (60, 1),
|
||||
FrameRate::Fps100 => (100, 1),
|
||||
FrameRate::Fps119_88 => (120_000, 1001),
|
||||
FrameRate::Fps120 => (120, 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames per labelled second (the counting base).
|
||||
pub fn nominal(self) -> i64 {
|
||||
let (n, d) = self.rational();
|
||||
(n + d / 2) / d
|
||||
}
|
||||
|
||||
pub fn is_drop(self) -> bool {
|
||||
matches!(self, FrameRate::Fps29_97Drop | FrameRate::Fps59_94Drop)
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FrameRate::Fps23_976 => "23.976",
|
||||
FrameRate::Fps24 => "24",
|
||||
FrameRate::Fps25 => "25",
|
||||
FrameRate::Fps29_97 => "29.97",
|
||||
FrameRate::Fps29_97Drop => "29.97 Drop",
|
||||
FrameRate::Fps30 => "30",
|
||||
FrameRate::Fps47_952 => "47.952",
|
||||
FrameRate::Fps48 => "48",
|
||||
FrameRate::Fps50 => "50",
|
||||
FrameRate::Fps59_94 => "59.94",
|
||||
FrameRate::Fps59_94Drop => "59.94 Drop",
|
||||
FrameRate::Fps60 => "60",
|
||||
FrameRate::Fps100 => "100",
|
||||
FrameRate::Fps119_88 => "119.88",
|
||||
FrameRate::Fps120 => "120",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whole frame index at a sample position (floored).
|
||||
pub fn frames_at(self, s: Samples, sr: SampleRate) -> i64 {
|
||||
let (n, d) = self.rational();
|
||||
let num = i128::from(s) * i128::from(n);
|
||||
let den = i128::from(sr.hz()) * i128::from(d);
|
||||
if den == 0 {
|
||||
return 0;
|
||||
}
|
||||
i64::try_from(num.div_euclid(den)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Sample position of the start of frame `f`.
|
||||
pub fn samples_at_frame(self, f: i64, sr: SampleRate) -> Samples {
|
||||
let (n, d) = self.rational();
|
||||
if n == 0 {
|
||||
return 0;
|
||||
}
|
||||
let v = (i128::from(f) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n);
|
||||
i64::try_from(v).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Samples per frame (fractional).
|
||||
pub fn samples_per_frame(self, sr: SampleRate) -> f64 {
|
||||
let (n, d) = self.rational();
|
||||
sr.as_f64() * d as f64 / n as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// A timecode label.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Timecode {
|
||||
pub negative: bool,
|
||||
pub hours: i64,
|
||||
pub minutes: i64,
|
||||
pub seconds: i64,
|
||||
pub frames: i64,
|
||||
}
|
||||
|
||||
impl Timecode {
|
||||
/// Label for an absolute frame count.
|
||||
pub fn from_frames(frames: i64, rate: FrameRate) -> Timecode {
|
||||
let negative = frames < 0;
|
||||
let mut f = frames.saturating_abs();
|
||||
let base = rate.nominal().max(1);
|
||||
if rate.is_drop() {
|
||||
// Drop 2 (or 4) labels each minute except every tenth.
|
||||
let drop = if base >= 60 { 4 } else { 2 };
|
||||
let per_10min = base * 600 - drop * 9;
|
||||
let per_min = base * 60 - drop;
|
||||
let tens = f / per_10min;
|
||||
let rem = f % per_10min;
|
||||
let extra = if rem > drop { drop * 9 * tens + drop * ((rem - drop) / per_min) } else { drop * 9 * tens };
|
||||
f += extra;
|
||||
}
|
||||
let frames_l = f % base;
|
||||
let total_secs = f / base;
|
||||
Timecode { negative, hours: total_secs / 3600, minutes: (total_secs / 60) % 60, seconds: total_secs % 60, frames: frames_l }
|
||||
}
|
||||
|
||||
/// Absolute frame count of this label.
|
||||
pub fn to_frames(&self, rate: FrameRate) -> i64 {
|
||||
let base = rate.nominal().max(1);
|
||||
let total_min = self.hours * 60 + self.minutes;
|
||||
let mut f = (total_min * 60 + self.seconds) * base + self.frames;
|
||||
if rate.is_drop() {
|
||||
let drop = if base >= 60 { 4 } else { 2 };
|
||||
f -= drop * (total_min - total_min / 10);
|
||||
}
|
||||
if self.negative { -f } else { f }
|
||||
}
|
||||
|
||||
pub fn from_samples(s: Samples, rate: FrameRate, sr: SampleRate) -> Timecode {
|
||||
Timecode::from_frames(rate.frames_at(s, sr), rate)
|
||||
}
|
||||
|
||||
pub fn to_samples(&self, rate: FrameRate, sr: SampleRate) -> Samples {
|
||||
rate.samples_at_frame(self.to_frames(rate), sr)
|
||||
}
|
||||
|
||||
pub fn format(&self, rate: FrameRate) -> String {
|
||||
let sep = if rate.is_drop() { ';' } else { ':' };
|
||||
let sign = if self.negative { "-" } else { "" };
|
||||
format!("{sign}{:02}:{:02}:{:02}{sep}{:02}", self.hours, self.minutes, self.seconds, self.frames)
|
||||
}
|
||||
|
||||
/// Parse `HH:MM:SS:FF` (also `;` / `.` separators and fewer fields, right-aligned).
|
||||
pub fn parse(text: &str) -> Result<Timecode, TimeError> {
|
||||
let t = text.trim();
|
||||
let (negative, t) = match t.strip_prefix('-') {
|
||||
Some(r) => (true, r),
|
||||
None => (false, t),
|
||||
};
|
||||
let parts: Vec<&str> = t.split([':', ';', '.']).collect();
|
||||
if parts.is_empty() || parts.len() > 4 {
|
||||
return Err(TimeError::Parse(text.to_string(), "timecode"));
|
||||
}
|
||||
let mut nums = [0i64; 4];
|
||||
let offset = 4 - parts.len();
|
||||
for (i, p) in parts.iter().enumerate() {
|
||||
let v: i64 = p.trim().parse().map_err(|_| TimeError::Parse(text.to_string(), "timecode"))?;
|
||||
if v < 0 {
|
||||
return Err(TimeError::Parse(text.to_string(), "timecode"));
|
||||
}
|
||||
if let Some(slot) = nums.get_mut(offset + i) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
Ok(Timecode { negative, hours: nums[0], minutes: nums[1], seconds: nums[2], frames: nums[3] })
|
||||
}
|
||||
}
|
||||
|
||||
/// Feet+Frames for 35 mm film (16 frames per foot).
|
||||
pub fn feet_frames(s: Samples, rate: FrameRate, sr: SampleRate) -> (i64, i64) {
|
||||
let f = rate.frames_at(s, sr);
|
||||
(f.div_euclid(16), f.rem_euclid(16))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn non_drop_round_trip() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let tc = Timecode::from_samples(48_000 * 3661, FrameRate::Fps25, sr);
|
||||
assert_eq!(tc.format(FrameRate::Fps25), "01:01:01:00");
|
||||
assert_eq!(tc.to_samples(FrameRate::Fps25, sr), 48_000 * 3661);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_frame_labels() {
|
||||
let r = FrameRate::Fps29_97Drop;
|
||||
// Frame 1800 is the first frame of minute 1 → labelled 00:01:00;02.
|
||||
assert_eq!(Timecode::from_frames(1800, r).format(r), "00:01:00;02");
|
||||
assert_eq!(Timecode::from_frames(1799, r).format(r), "00:00:59;29");
|
||||
// Minute 10 keeps its labels.
|
||||
assert_eq!(Timecode::from_frames(17_982, r).format(r), "00:10:00;00");
|
||||
for f in [0, 1, 1799, 1800, 1801, 17_981, 17_982, 107_892, 500_000] {
|
||||
assert_eq!(Timecode::from_frames(f, r).to_frames(r), f, "frame {f}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_variants() {
|
||||
let tc = Timecode::parse("01:02:03:04").unwrap();
|
||||
assert_eq!((tc.hours, tc.minutes, tc.seconds, tc.frames), (1, 2, 3, 4));
|
||||
let tc = Timecode::parse("3:04").unwrap();
|
||||
assert_eq!((tc.seconds, tc.frames), (3, 4));
|
||||
assert!(Timecode::parse("a:b").is_err());
|
||||
assert!(Timecode::parse("1:2:3:4:5").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feet_and_frames() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
assert_eq!(feet_frames(48_000, FrameRate::Fps24, sr), (1, 8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
ArtCraft brand assets: license
|
||||
|
||||
The ArtCraft name, wordmark and mark (the "ArtCraft Marks"), including every file in this
|
||||
directory in every color, size and format, are owned by the ArtCraft Team. They are trademarks
|
||||
and are NOT open source. They are not covered by SoundCraft's MIT OR Apache-2.0 license, and nothing in
|
||||
that license, in NOTICE or in any other file of this repository grants any right to them.
|
||||
|
||||
Permission
|
||||
|
||||
The ArtCraft Team grants a limited, non-exclusive, non-transferable, revocable, royalty-free
|
||||
permission to use the ArtCraft Marks only:
|
||||
|
||||
1. as part of copies of this repository; and
|
||||
2. within SoundCraft itself, its documentation and its official builds, to identify SoundCraft as made
|
||||
by the ArtCraft team and community and to link to getartcraft.com and its community.
|
||||
|
||||
Conditions
|
||||
|
||||
The permission holds only while every one of these is true:
|
||||
|
||||
- The marks are used exactly as supplied in this directory. Do not redraw, recolor, crop,
|
||||
stretch, rotate, animate, outline or add effects to them, extract parts of them, or combine
|
||||
them with any other name, mark or artwork.
|
||||
- They are used only in the context of SoundCraft, and only to say that the ArtCraft team makes it.
|
||||
They must never suggest that the ArtCraft Team made, sponsors, endorses, certifies or supports
|
||||
any other product, service, fork, modified version, plug-in, organization or person.
|
||||
- Neither the marks nor the ArtCraft name appear in the name, logo, icon, domain, social media
|
||||
handle, app-store listing, advertising or marketing of anything other than SoundCraft.
|
||||
|
||||
Forks and modified versions
|
||||
|
||||
If you distribute or publish a modified version of SoundCraft, or any work derived from it, you must
|
||||
remove the ArtCraft Marks from it (or replace them with your own) and must not call it ArtCraft or
|
||||
present it as an ArtCraft product. You may say, in plain text, that your work is based on SoundCraft by
|
||||
the ArtCraft team. A fork kept only to propose changes back to this repository may keep the marks
|
||||
unchanged.
|
||||
|
||||
Everything else
|
||||
|
||||
Any use not expressly permitted here needs prior written permission from the ArtCraft Team
|
||||
(https://getartcraft.com/). The permission ends automatically for anyone who breaks these terms.
|
||||
All other rights are reserved.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user