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:
Brandon Thomas
2026-10-05 22:27:29 -04:00
parent c8d5a953e2
commit 755f02fc85
106 changed files with 22835 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
[alias]
xtask = "run --package xtask --"
+8
View File
@@ -1,3 +1,11 @@
*~
plan/
/target
/dist
/corpus
/log
.DS_Store
/.claude/
/target-*/
/craft-fonts/
plans/
+68
View File
@@ -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.
+38
View File
@@ -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 |
+1
View File
@@ -0,0 +1 @@
Read `AGENTS.md`: it is the entry point for agents in this repo.
Generated
+1562
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+17
View File
@@ -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).
+4
View File
@@ -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
+21
View File
@@ -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
+232
View File
@@ -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)
}
+71
View File
@@ -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()))
}
}
+379
View File
@@ -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]);
}
}
+263
View File
@@ -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)
}
+176
View File
@@ -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(())
}
+197
View File
@@ -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);
}
}
+223
View File
@@ -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(&params, &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))
}
+326
View File
@@ -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)
}
+298
View File
@@ -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);
}
}
+250
View File
@@ -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);
}
+20
View File
@@ -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
+208
View File
@@ -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);
}
}
+237
View File
@@ -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)
}
+381
View File
@@ -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)
}
}
+563
View File
@@ -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);
}
}
+91
View File
@@ -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
}
}
+61
View File
@@ -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);
}
}
}
+95
View File
@@ -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;
+400
View File
@@ -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);
}
}
}
}
}
+634
View File
@@ -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
}
}
+313
View File
@@ -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)
}
+309
View File
@@ -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;
}
}
}
}
+77
View File
@@ -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,
})
}
+453
View File
@@ -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
}
}
+120
View File
@@ -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
}
}
+342
View File
@@ -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
}
}
+202
View File
@@ -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
}
}
+473
View File
@@ -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);
}
}
+512
View File
@@ -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);
}
}
}
}
+52
View File
@@ -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());
}
}
+311
View File
@@ -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)
}
+56
View File
@@ -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);
}
}
+252
View File
@@ -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()));
}
+443
View File
@@ -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:?}");
}
+26
View File
@@ -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
+512
View File
@@ -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
+133
View File
@@ -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}%");
}
}
+249
View File
@@ -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()}))
}
+726
View File
@@ -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}))
}
+361
View File
@@ -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}))
}
+158
View File
@@ -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}))
}
+575
View File
@@ -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(&param).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, &param);
tr.lane_mut(&param).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, &param);
tr.lane_mut(&param).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!({}))
}
+305
View File
@@ -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}");
}
}
}
}
}
+119
View File
@@ -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()}))
}
+40
View File
@@ -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)}))
}),
]
}
+581
View File
@@ -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}))
}
+57
View File
@@ -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}))
}
+160
View File
@@ -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);
}
}
+347
View File
@@ -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}");
}
}
+505
View File
@@ -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);
}
}
+144
View File
@@ -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
}
+378
View File
@@ -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)
}
+281
View File
@@ -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);
}));
});
}
+19
View File
@@ -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
+47
View File
@@ -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),
}
+221
View File
@@ -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");
}
}
+258
View File
@@ -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))
}
+28
View File
@@ -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
}
}
+125
View File
@@ -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())
}
}
+541
View File
@@ -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]);
}
}
+217
View File
@@ -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(&notes), 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(&notes), 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(&notes), 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(&notes), 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(&notes), 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(&notes), 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(&notes, 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);
}
+316
View File
@@ -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);
}
}
+19
View File
@@ -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
+809
View File
@@ -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)));
}
}
+22
View File
@@ -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
+298
View File
@@ -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);
}
}
+265
View File
@@ -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);
}
}
+21
View File
@@ -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);
+26
View File
@@ -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;
+58
View File
@@ -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>,
}
+279
View File
@@ -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));
}
}
+563
View File
@@ -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);
}
}
+81
View File
@@ -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))
}
}
+327
View File
@@ -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));
}
}
}
+24
View File
@@ -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
+404
View File
@@ -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));
}
}
+19
View File
@@ -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
+171
View File
@@ -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");
}
}
+202
View File
@@ -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);
}
}
+151
View File
@@ -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));
}
}
+336
View File
@@ -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 });
}
}
+251
View File
@@ -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));
}
}
+42
View File
@@ -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