Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f36ff0780c |
No files matched your search
@@ -22,7 +22,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
plugin: [rivet, patina]
|
||||
plugin: [rivet, patina, anvil, crucible]
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
@@ -56,6 +56,8 @@ jobs:
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/rivet.png --plugin rivet
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/patina.png --plugin patina
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/patina-compact.png --plugin patina --compact
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/anvil.png --plugin anvil
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/crucible.png --plugin crucible
|
||||
- uses: https://github.com/actions/upload-artifact@v3-node20
|
||||
with:
|
||||
name: editor-previews
|
||||
@@ -81,6 +83,8 @@ jobs:
|
||||
- run: |
|
||||
python3 scripts/build-plugin.py --plugin rivet --target x86_64-pc-windows-gnu
|
||||
python3 scripts/build-plugin.py --plugin patina --target x86_64-pc-windows-gnu
|
||||
python3 scripts/build-plugin.py --plugin anvil --target x86_64-pc-windows-gnu
|
||||
python3 scripts/build-plugin.py --plugin crucible --target x86_64-pc-windows-gnu
|
||||
python3 scripts/package-windows.py
|
||||
- uses: https://github.com/actions/upload-artifact@v3-node20
|
||||
with:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Independent plugin release
|
||||
on:
|
||||
push:
|
||||
tags: ["rivet-v*", "patina-v*"]
|
||||
tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
Generated
+51
@@ -30,6 +30,25 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anvil-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"common-dsp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anvil-ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anvil-core",
|
||||
"common-dsp",
|
||||
"common-ui",
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
@@ -48,6 +67,17 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "common-dsp"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "common-ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.14"
|
||||
@@ -63,6 +93,26 @@ version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "crucible-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"common-dsp",
|
||||
"crossbeam-queue",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crucible-ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"common-dsp",
|
||||
"common-ui",
|
||||
"crucible-core",
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.31.1"
|
||||
@@ -233,6 +283,7 @@ dependencies = [
|
||||
name = "patina-ui"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"common-ui",
|
||||
"egui",
|
||||
"patina-core",
|
||||
]
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"]
|
||||
exclude = ["plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin"]
|
||||
members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/crucible/core", "plugins/crucible/ui", "plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"]
|
||||
exclude = ["plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin", "plugins/anvil/plugin", "plugins/crucible/plugin"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
# Rivet & Patina
|
||||
|
||||
Two Rust CLAP plugins for writing and refining bass music.
|
||||
Four Rust CLAP plugins for writing, designing and mixing bass music.
|
||||
|
||||
| Plugin | Purpose | Guide |
|
||||
| --- | --- | --- |
|
||||
| **Rivet** | Tell a MIDI motif as a story on a storyboard of roles, with joined seams | [Rivet](docs/rivet/README.md) |
|
||||
| **Patina** | Smooth harsh resonances while retaining tone and transient character | [Patina](docs/patina/README.md) |
|
||||
| **Anvil** | Give every bass note the same clean sub: one level, one start phase | [Anvil](docs/anvil/README.md) |
|
||||
| **Crucible** | Capture the last bars, slice them and export clean, key-labelled samples | [Crucible](docs/crucible/README.md) |
|
||||
|
||||
The names come from metalwork: a **rivet** joins pieces; **patina** changes a
|
||||
surface while leaving its character. They share a Rust foundation and keep
|
||||
surface while leaving its character; an **anvil** is the solid base everything is
|
||||
struck on; a **crucible** melts material down to be cast again. They share a Rust foundation and keep
|
||||
their own musical purpose, code, documentation, and version.
|
||||
|
||||
## Install
|
||||
|
||||
Download the desired plugin's Windows ZIP from its Gitea release. Extract it
|
||||
and copy `Rivet.clap` or `Patina.clap` into:
|
||||
and copy its `.clap` file (`Rivet.clap`, `Patina.clap`, `Anvil.clap` or
|
||||
`Crucible.clap`) into:
|
||||
|
||||
```text
|
||||
C:\Program Files\Common Files\CLAP\
|
||||
@@ -22,11 +26,11 @@ C:\Program Files\Common Files\CLAP\
|
||||
|
||||
In FL Studio 2024.1 or newer, open **Options → Manage plugins**, enable
|
||||
**Verify plugins**, and **Find installed plugins**. Load Rivet in the Channel Rack;
|
||||
load Patina in a mixer effect slot.
|
||||
load Patina, Anvil and Crucible in mixer effect slots.
|
||||
|
||||
Normal branch/PR pipelines also provide a combined Windows preview ZIP in the
|
||||
**windows** job. Releases are separate: `rivet-v0.3.0` and `patina-v0.2.0`
|
||||
publish independently. See [release workflow](docs/releases.md).
|
||||
**windows** job. Releases are separate: tags such as `rivet-v0.4.0`,
|
||||
`patina-v0.2.0`, `anvil-v0.1.0` and `crucible-v0.1.0` publish independently. See [release workflow](docs/releases.md).
|
||||
|
||||
These names replace Motif Storyboard and Resonance Compressor. Their CLAP IDs
|
||||
and saved-state identifiers are preserved; replace old scanned copies and rescan.
|
||||
@@ -44,13 +48,23 @@ plugins/
|
||||
plugin/ CLAP wrapper, editor, optional standalone
|
||||
patina/
|
||||
core/ Spectral detector and attenuation engine
|
||||
ui/ Shared native editor and embedded fonts
|
||||
ui/ Patina's native editor
|
||||
render/ Offline WAV evaluation
|
||||
plugin/ CLAP audio-effect wrapper
|
||||
tools/editor-check/ Headless visual QA for both editors
|
||||
anvil/
|
||||
core/ Pitch tracking, crossover and sub oscillator
|
||||
ui/ Anvil's native editor
|
||||
plugin/ CLAP audio-effect wrapper with MIDI input
|
||||
crucible/
|
||||
core/ Input history, capture, slicing, cleanup and export
|
||||
ui/ Crucible's native editor
|
||||
plugin/ CLAP audio-effect wrapper
|
||||
common/
|
||||
ui/ Shared look: palette, embedded fonts, knobs and panels
|
||||
dsp/ Shared filters, YIN pitch detection and WAV files
|
||||
tools/editor-check/ Headless visual QA for all editors
|
||||
scripts/ Shared build, packaging, release, and analysis tools
|
||||
docs/rivet/ Rivet guide, theory, research, and editor previews
|
||||
docs/patina/ Patina guide, research, and editor preview
|
||||
docs/<plugin>/ Guide, changelog and editor preview per plugin
|
||||
references/ Local reference audio; excluded from distribution
|
||||
```
|
||||
|
||||
@@ -62,6 +76,8 @@ separate. [Development and testing](docs/development.md) covers the commands.
|
||||
cargo test --workspace --locked
|
||||
python scripts/build-plugin.py --plugin rivet --target x86_64-pc-windows-gnu
|
||||
python scripts/build-plugin.py --plugin patina --target x86_64-pc-windows-gnu
|
||||
python scripts/build-plugin.py --plugin anvil --target x86_64-pc-windows-gnu
|
||||
python scripts/build-plugin.py --plugin crucible --target x86_64-pc-windows-gnu
|
||||
python scripts/package-windows.py
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Anvil changelog
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Introduce Anvil, a sub anchor for bass music.
|
||||
- Replace the band below Split with a clean mono sine. Every note gets the same
|
||||
peak level and starts at the same phase, so switch-ups and kick hits line up.
|
||||
- Detect pitch and note starts from the audio with 80 ms lookahead, or follow
|
||||
MIDI notes sent to the effect.
|
||||
- Controls: Sub level, Split, Dynamics, Drive, Octave, Glide, Phase, Gate, Keep,
|
||||
Output, Retrigger, Snap to semitone, Bypass. Solo sub monitors only the new sub.
|
||||
- Note readout, level meters and an oscilloscope of the original low band against
|
||||
the new sub.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Anvil
|
||||
|
||||
Anvil gives every bass note the same solid sub. Current version **0.1.0**.
|
||||
|
||||
In riddim and dubstep, each switch-up patch brings its own sub: a different level,
|
||||
a different phase, sometimes two layers that cancel. The drop's weight jumps from
|
||||
note to note and the kick lands differently on each one. Anvil removes the
|
||||
original band below **Split** and puts a clean mono sine in its place. The sine
|
||||
follows the bass pitch, has the same peak level on every note, and starts at the
|
||||
same phase on every note.
|
||||
|
||||

|
||||
|
||||
## Install on Windows
|
||||
|
||||
Copy `Anvil.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio 2024.1
|
||||
or newer, open Options → Manage plugins, enable Verify plugins, and Find installed
|
||||
plugins. Load Anvil in the mixer slot of the bass bus. Keep the font license with
|
||||
redistributed packages.
|
||||
|
||||
## Work with it
|
||||
|
||||
1. Put Anvil on the bass bus, after the sound design and before the bus
|
||||
compression or clipping.
|
||||
2. Set **Split** just above the sub. 80–100 Hz suits most riddim basses.
|
||||
3. Set **Sub level** once. Every patch on the bus now gets this level.
|
||||
4. Use **Phase** to line the start of each note up with your kick. With
|
||||
**Retrigger** on, every note starts at this phase.
|
||||
5. Turn on **Solo sub** to hear only the new sub; the scope shows the original
|
||||
low band (grey) against the new sub (accent).
|
||||
|
||||
Other controls:
|
||||
|
||||
- **Dynamics** at 0 % holds the level for the whole note. Raise it to let the sub
|
||||
follow the original's volume shape, for example a wobble.
|
||||
- **Drive** adds upper harmonics so the sub reads on phones and laptops.
|
||||
- **Octave** moves the sub against the detected note. Use −1 for a bass played
|
||||
one octave up.
|
||||
- **Glide** slides between overlapping notes; 0 jumps.
|
||||
- **Gate** is the input level that counts as a note. Raise it if tails or reverb
|
||||
start new notes.
|
||||
- **Keep** blends some of the original low band back under the new sub.
|
||||
- **Snap to semitone** rounds detected pitch. Turn it off for pitch bends and slides.
|
||||
|
||||
### Pitch from MIDI
|
||||
|
||||
Detection follows the audio by default. For heavy formant or FM basses where the
|
||||
fundamental is unclear, select **MIDI** and route the bass's MIDI notes to Anvil.
|
||||
In FL Studio, set the bass channel's MIDI output port and Anvil's input port to the
|
||||
same number, or duplicate the notes into a pattern on a channel that feeds Anvil.
|
||||
|
||||
## Timing and limits
|
||||
|
||||
Detection uses 80 ms of lookahead, reported to the host for delay compensation,
|
||||
so each note's pitch is known before its attack leaves the plugin. Pitch range is
|
||||
25–250 Hz, one voice: chords and layered notes in the sub band are not separated.
|
||||
Notes shorter than about 15 ms may not be recognised. A pitch move of more than
|
||||
0.7 semitone that holds for two analysis hops (10 ms) starts a new note.
|
||||
|
||||
The crossover is a 4th-order Linkwitz–Riley pair, so with Keep at 100 % and no
|
||||
note playing, the signal passes with flat magnitude. Stereo above the split is
|
||||
kept; the new sub is mono.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 243 KiB |
@@ -0,0 +1,13 @@
|
||||
# Crucible changelog
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Introduce Crucible, a resampler that always records its input.
|
||||
- Capture the newest 1–16 complete bars on the host's bar grid, or the newest
|
||||
audio when the transport is stopped. Tempo changes inside a take are followed.
|
||||
- Slice on a straight or triplet grid, or at transients. Add and remove cuts by
|
||||
hand, include or exclude slices, and audition them in place.
|
||||
- Clean every slice: trim leading silence, fade both ends, normalise to −0.3 dBFS.
|
||||
The root note of each pitched slice is detected and written into the file.
|
||||
- Export the loop, every slice, and optionally a 2048-sample wavetable for Serum
|
||||
and Vital into a dated folder.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Crucible
|
||||
|
||||
Crucible melts the bars you just played into ready-to-use samples. Current version
|
||||
**0.1.0**.
|
||||
|
||||
Resampling is the core loop of riddim sound design: bounce, open in Edison, chop,
|
||||
trim, declick, normalise, find the key, name, save, load into a sampler. Crucible
|
||||
does all of it from one button. It always records its input, so the take you
|
||||
liked is already there when you press **Capture**.
|
||||
|
||||

|
||||
|
||||
## Install on Windows
|
||||
|
||||
Copy `Crucible.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio
|
||||
2024.1 or newer, open Options → Manage plugins, enable Verify plugins, and Find
|
||||
installed plugins. Load Crucible in a mixer slot at the end of the chain you want
|
||||
to resample. Keep the font license with redistributed packages.
|
||||
|
||||
## Work with it
|
||||
|
||||
1. Play the pattern or song section.
|
||||
2. Choose **Bars** (1–16) and press **Capture**. Crucible takes the newest complete
|
||||
bars on FL's bar grid. With the transport stopped, it takes the newest audio at
|
||||
the last known tempo instead.
|
||||
3. Choose how to slice: **Grid** (1/4, 1/8, 1/16, 1/8T, 1/16T) or **Transients**
|
||||
with a Sensitivity. Double-click the waveform to add a cut, right-click to remove
|
||||
the nearest one.
|
||||
4. Click a slice to hear it. Untick **Export** to leave it out.
|
||||
5. Press **Export**.
|
||||
|
||||
Each export creates a folder named after the date, time and tempo, holding:
|
||||
|
||||
- `loop.wav`: the whole take, for Slicex or Edison;
|
||||
- `s01_F1.wav`, `s02_G#1.wav`, …: one file per slice, named by its detected root
|
||||
note. The root is also stored in the file's sampler chunk;
|
||||
- `wavetable.wav` when **Wavetable** is on: up to 256 single-cycle frames of 2048
|
||||
samples from the selected slice. Each frame starts on a rising zero crossing, so
|
||||
frames morph without phase jumps. Serum reads the frame size from the file; in
|
||||
Vital, import it as a wavetable with 2048-sample frames.
|
||||
|
||||
Every slice is cleaned on export: **Trim silence** removes leading silence below
|
||||
−60 dBFS, **Declick** fades both ends (default 2 ms), **Normalize** peaks it at
|
||||
−0.3 dBFS. Files are 32-bit float WAV at the session rate.
|
||||
|
||||
Add the export folder to FL's browser once (Options → File settings → Browser extra
|
||||
search folders). New exports then appear there and can be dragged into channels,
|
||||
the playlist or Edison. **Open folder** shows the latest export in Explorer.
|
||||
|
||||
## Timing and limits
|
||||
|
||||
Crucible adds no latency and passes audio through untouched. Auditioned slices are
|
||||
mixed into its output only while they play. The history holds 72 s, enough for
|
||||
16 bars at 60 BPM. The newest eight takes stay in memory until the plugin is
|
||||
removed; they are not saved with the project, so export what you want to keep.
|
||||
|
||||
Bar alignment follows the host's song position and time signature. Root notes are
|
||||
detected between 35 Hz and 1 kHz. Unpitched slices, such as drums or noise, get
|
||||
no note in their name. Transient slicing finds hits that rise out of quieter
|
||||
audio; for legato basses, slice on the grid.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 220 KiB |
+12
-2
@@ -12,7 +12,9 @@ licenses. Shared build/check/release tools consume it. Each wrapper's Cargo
|
||||
package version is the authoritative release version.
|
||||
|
||||
The root workspace contains Rivet's core, MIDI, application, and offline preview
|
||||
crates, plus Patina's core, editor, and renderer. Wrappers under
|
||||
crates; Patina's core, editor, and renderer; Anvil's and Crucible's core and
|
||||
editor crates; and the shared `common-ui` (palette, fonts, knobs, panels) and
|
||||
`common-dsp` (filters, YIN pitch detection, WAV files) crates. Wrappers under
|
||||
`plugins/<product>/plugin/` remain independent workspaces because they depend on
|
||||
a pinned NIH-plug Git revision. Their host identities and persistence stay separate.
|
||||
|
||||
@@ -23,6 +25,8 @@ cargo test --workspace --locked
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
python scripts/check-plugin.py --plugin rivet
|
||||
python scripts/check-plugin.py --plugin patina
|
||||
python scripts/check-plugin.py --plugin anvil
|
||||
python scripts/check-plugin.py --plugin crucible
|
||||
python -m unittest discover -s scripts/tests -v
|
||||
```
|
||||
|
||||
@@ -43,12 +47,14 @@ install the Rust `x86_64-pc-windows-gnu` target and MinGW's
|
||||
```sh
|
||||
python scripts/build-plugin.py --plugin rivet --target x86_64-pc-windows-gnu
|
||||
python scripts/build-plugin.py --plugin patina --target x86_64-pc-windows-gnu
|
||||
python scripts/build-plugin.py --plugin anvil --target x86_64-pc-windows-gnu
|
||||
python scripts/build-plugin.py --plugin crucible --target x86_64-pc-windows-gnu
|
||||
python scripts/package-windows.py
|
||||
python scripts/package-windows.py --plugin rivet
|
||||
python scripts/package-windows.py --plugin patina
|
||||
```
|
||||
|
||||
Build outputs use `dist/<target>/Rivet.clap` and `Patina.clap`, with docs and
|
||||
Build outputs use `dist/<target>/<Name>.clap`, with docs and
|
||||
licenses retaining their directory structure. `CARGO_TARGET_DIR` is supported
|
||||
for sharing compilation between wrappers. MSVC packaging accepts
|
||||
`--target x86_64-pc-windows-msvc`.
|
||||
@@ -69,6 +75,8 @@ cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/rivet.png --plug
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/rivet-creative.png --plugin rivet --creative
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/patina.png --plugin patina
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/patina-compact.png --plugin patina --compact
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/anvil.png --plugin anvil
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/crucible.png --plugin crucible
|
||||
```
|
||||
|
||||
Patina also accepts `--silent`, `--delta`, and `--bypass` as render fixtures.
|
||||
@@ -82,6 +90,8 @@ artistic quality. Use the product guides for FL Studio acceptance checks.
|
||||
| --- | --- | --- |
|
||||
| Rivet | Motif Storyboard | `io.storyboard.motif` |
|
||||
| Patina | Resonance Compressor | `io.storyboard.resonance-compressor` |
|
||||
| Anvil | (new) | `io.storyboard.sub-anchor` |
|
||||
| Crucible | (new) | `io.storyboard.resampler` |
|
||||
|
||||
Rivet retains `story-session-v1` and its session migration support; Patina retains
|
||||
all parameter IDs. No JSON/state migration is introduced by the rename. These
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "anvil-core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common-dsp = { path = "../../common/dsp" }
|
||||
serde = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,596 @@
|
||||
//! Sub anchor: replaces the band below the split with a clean mono sine that follows the
|
||||
//! bass pitch, holds one level for every note and restarts at the same phase on each note.
|
||||
//!
|
||||
//! The audio path is delayed by a fixed lookahead. Detection runs on the undelayed input, so
|
||||
//! a note's pitch is known before its delayed attack leaves the plugin, and the sine starts
|
||||
//! exactly on that attack.
|
||||
mod settings;
|
||||
use common_dsp::{
|
||||
coefficient, db_to_gain, gain_to_db, hz_to_midi, midi_to_hz, Biquad, Crossover, Yin,
|
||||
};
|
||||
pub use settings::{Settings, Source};
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
/// Lowest detectable fundamental. Sets the lookahead (two periods).
|
||||
pub const MIN_HZ: f32 = 25.;
|
||||
pub const MAX_HZ: f32 = 250.;
|
||||
pub const SCOPE: usize = 256;
|
||||
const EVENTS: usize = 64;
|
||||
const DETECT_RATE: f32 = 3000.;
|
||||
const HOP_MS: f32 = 5.;
|
||||
const ATTACK_MS: f32 = 2.;
|
||||
const RELEASE_MS: f32 = 15.;
|
||||
const CROSSFADE_MS: f32 = 3.;
|
||||
const GATE_HOLD_MS: f32 = 20.;
|
||||
const GATE_HYSTERESIS_DB: f32 = 6.;
|
||||
/// A pitch move this large (semitones) counts as a new note.
|
||||
const JUMP: f32 = 0.7;
|
||||
const CLARITY: f32 = 0.5;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Monitor {
|
||||
/// Hear only the generated sub.
|
||||
pub solo: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct AnalyzerFrame {
|
||||
/// Current sub frequency in Hz, 0 when silent.
|
||||
pub hz: f32,
|
||||
/// MIDI note of the current sub, rounded.
|
||||
pub note: Option<u8>,
|
||||
pub low_db: f32,
|
||||
pub sub_db: f32,
|
||||
/// Original low band and new sub, triggered on a rising zero crossing of the sub.
|
||||
pub scope_low: [f32; SCOPE],
|
||||
pub scope_sub: [f32; SCOPE],
|
||||
/// Duration of the scope window in milliseconds.
|
||||
pub scope_ms: f32,
|
||||
pub latency: u32,
|
||||
pub sample_rate: f32,
|
||||
}
|
||||
impl Default for AnalyzerFrame {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hz: 0.,
|
||||
note: None,
|
||||
low_db: -120.,
|
||||
sub_db: -120.,
|
||||
scope_low: [0.; SCOPE],
|
||||
scope_sub: [0.; SCOPE],
|
||||
scope_ms: 60.,
|
||||
latency: 0,
|
||||
sample_rate: 48000.,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Kind {
|
||||
Onset,
|
||||
Release,
|
||||
NoteOn(u8),
|
||||
NoteOff(u8),
|
||||
}
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Event {
|
||||
time: u64,
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct Voice {
|
||||
phase: f32,
|
||||
hz: f32,
|
||||
target: f32,
|
||||
amp: f32,
|
||||
releasing: bool,
|
||||
}
|
||||
|
||||
pub struct Engine {
|
||||
rate: f32,
|
||||
channels: usize,
|
||||
settings: Settings,
|
||||
latency: usize,
|
||||
delay: Vec<[f32; 2]>,
|
||||
delay_at: usize,
|
||||
crossover: [Crossover; 2],
|
||||
detect: [Biquad; 2],
|
||||
decimate: usize,
|
||||
decimate_count: usize,
|
||||
detect_rate: f32,
|
||||
ring: Vec<f32>,
|
||||
ring_at: usize,
|
||||
scratch: Vec<f32>,
|
||||
yin: Yin,
|
||||
hop: usize,
|
||||
hop_count: usize,
|
||||
gate_env: f32,
|
||||
gate_release: f32,
|
||||
gate_open: bool,
|
||||
gate_hold: usize,
|
||||
clock: u64,
|
||||
events: [Event; EVENTS],
|
||||
event_head: usize,
|
||||
event_len: usize,
|
||||
/// Output-time note state.
|
||||
sounding: bool,
|
||||
pending: bool,
|
||||
tracked: f32,
|
||||
candidate: Option<(f32, u8)>,
|
||||
voice: Voice,
|
||||
old: Voice,
|
||||
old_gain: f32,
|
||||
glide: f32,
|
||||
held: [u8; 16],
|
||||
held_len: usize,
|
||||
low_env: f32,
|
||||
note_peak: f32,
|
||||
env_coef: f32,
|
||||
smooth: f32,
|
||||
level: f32,
|
||||
keep: f32,
|
||||
output: f32,
|
||||
bypass: f32,
|
||||
meter_coef: f32,
|
||||
low_power: f32,
|
||||
sub_power: f32,
|
||||
scope_low: [f32; 2 * SCOPE],
|
||||
scope_sub: [f32; 2 * SCOPE],
|
||||
scope_at: usize,
|
||||
scope_stride: usize,
|
||||
scope_count: usize,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
|
||||
if !(8000.0..=768000.).contains(&rate) {
|
||||
return Err("unsupported sample rate");
|
||||
}
|
||||
if !(1..=2).contains(&channels) {
|
||||
return Err("unsupported channel count");
|
||||
}
|
||||
let decimate = (rate / DETECT_RATE).round().max(1.) as usize;
|
||||
let detect_rate = rate / decimate as f32;
|
||||
let max_lag = (detect_rate / MIN_HZ).ceil() as usize;
|
||||
// YIN needs one comparison window of at least the longest period after the lags.
|
||||
let analysis = 2 * max_lag + 3;
|
||||
let latency = analysis * decimate;
|
||||
let settings = Settings::default();
|
||||
let mut e = Self {
|
||||
rate,
|
||||
channels,
|
||||
settings,
|
||||
latency,
|
||||
delay: vec![[0.; 2]; latency + 1],
|
||||
delay_at: 0,
|
||||
crossover: [Crossover::new(rate, settings.split); 2],
|
||||
detect: [Biquad::lowpass(rate, 400., std::f32::consts::FRAC_1_SQRT_2); 2],
|
||||
decimate,
|
||||
decimate_count: 0,
|
||||
detect_rate,
|
||||
ring: vec![0.; analysis],
|
||||
ring_at: 0,
|
||||
scratch: vec![0.; analysis],
|
||||
yin: Yin::new(max_lag + 2),
|
||||
hop: ((detect_rate * HOP_MS * 0.001) as usize).max(1),
|
||||
hop_count: 0,
|
||||
gate_env: 0.,
|
||||
gate_release: 1. - coefficient(rate, 30.),
|
||||
gate_open: false,
|
||||
gate_hold: 0,
|
||||
clock: 0,
|
||||
events: [Event {
|
||||
time: 0,
|
||||
kind: Kind::Release,
|
||||
}; EVENTS],
|
||||
event_head: 0,
|
||||
event_len: 0,
|
||||
sounding: false,
|
||||
pending: false,
|
||||
tracked: 0.,
|
||||
candidate: None,
|
||||
voice: Voice::default(),
|
||||
old: Voice::default(),
|
||||
old_gain: 0.,
|
||||
glide: 1.,
|
||||
held: [0; 16],
|
||||
held_len: 0,
|
||||
low_env: 0.,
|
||||
note_peak: 0.,
|
||||
env_coef: coefficient(rate, 10.),
|
||||
smooth: coefficient(rate, 10.),
|
||||
level: db_to_gain(settings.level),
|
||||
keep: 0.,
|
||||
output: 1.,
|
||||
bypass: 0.,
|
||||
meter_coef: coefficient(rate, 150.),
|
||||
low_power: 0.,
|
||||
sub_power: 0.,
|
||||
scope_low: [0.; 2 * SCOPE],
|
||||
scope_sub: [0.; 2 * SCOPE],
|
||||
scope_at: 0,
|
||||
scope_stride: ((rate * 0.06) / SCOPE as f32).round().max(1.) as usize,
|
||||
scope_count: 0,
|
||||
};
|
||||
e.set_settings(settings)?;
|
||||
Ok(e)
|
||||
}
|
||||
pub fn latency_samples(&self) -> u32 {
|
||||
self.latency as u32
|
||||
}
|
||||
pub fn channels(&self) -> usize {
|
||||
self.channels
|
||||
}
|
||||
pub fn settings(&self) -> Settings {
|
||||
self.settings
|
||||
}
|
||||
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
|
||||
s.validate()?;
|
||||
let old = self.settings;
|
||||
if (s.split - old.split).abs() > 1e-3 {
|
||||
for x in &mut self.crossover {
|
||||
x.retune(self.rate, s.split);
|
||||
}
|
||||
}
|
||||
if s.source != old.source {
|
||||
self.release_all();
|
||||
}
|
||||
self.glide = if s.glide <= 0. {
|
||||
1.
|
||||
} else {
|
||||
coefficient(self.rate, s.glide / 3.)
|
||||
};
|
||||
self.settings = s;
|
||||
Ok(())
|
||||
}
|
||||
fn release_all(&mut self) {
|
||||
self.voice.releasing = true;
|
||||
self.sounding = false;
|
||||
self.pending = false;
|
||||
self.held_len = 0;
|
||||
self.event_len = 0;
|
||||
self.candidate = None;
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.delay.fill([0.; 2]);
|
||||
for x in &mut self.crossover {
|
||||
x.reset();
|
||||
}
|
||||
for f in &mut self.detect {
|
||||
f.reset();
|
||||
}
|
||||
self.ring.fill(0.);
|
||||
self.decimate_count = 0;
|
||||
self.hop_count = 0;
|
||||
self.gate_env = 0.;
|
||||
self.gate_open = false;
|
||||
self.gate_hold = 0;
|
||||
self.release_all();
|
||||
self.voice = Voice::default();
|
||||
self.old = Voice::default();
|
||||
self.old_gain = 0.;
|
||||
self.low_env = 0.;
|
||||
self.note_peak = 0.;
|
||||
self.low_power = 0.;
|
||||
self.sub_power = 0.;
|
||||
self.scope_low = [0.; 2 * SCOPE];
|
||||
self.scope_sub = [0.; 2 * SCOPE];
|
||||
}
|
||||
/// MIDI note start at the current input sample. Used when the source is MIDI.
|
||||
pub fn note_on(&mut self, note: u8) {
|
||||
self.push(Kind::NoteOn(note.min(127)));
|
||||
}
|
||||
pub fn note_off(&mut self, note: u8) {
|
||||
self.push(Kind::NoteOff(note.min(127)));
|
||||
}
|
||||
fn push(&mut self, kind: Kind) {
|
||||
if self.event_len == EVENTS {
|
||||
// Dropping the oldest keeps the newest note state; overflow only happens on floods.
|
||||
self.event_head = (self.event_head + 1) % EVENTS;
|
||||
self.event_len -= 1;
|
||||
}
|
||||
let at = (self.event_head + self.event_len) % EVENTS;
|
||||
self.events[at] = Event {
|
||||
time: self.clock,
|
||||
kind,
|
||||
};
|
||||
self.event_len += 1;
|
||||
}
|
||||
/// Detected pitch of the most recent analysis window, in fractional MIDI notes.
|
||||
fn estimate(&mut self) -> Option<f32> {
|
||||
let n = self.ring.len();
|
||||
for i in 0..n {
|
||||
self.scratch[i] = self.ring[(self.ring_at + i) % n];
|
||||
}
|
||||
let (hz, clarity) = self
|
||||
.yin
|
||||
.estimate(&self.scratch, self.detect_rate, MIN_HZ, MAX_HZ)?;
|
||||
(clarity >= CLARITY).then(|| hz_to_midi(hz))
|
||||
}
|
||||
fn start(&mut self, pitch: f32, glide: bool) {
|
||||
let s = self.settings;
|
||||
let note = if s.snap { pitch.round() } else { pitch };
|
||||
let hz = midi_to_hz(note + 12. * s.octave as f32);
|
||||
self.tracked = pitch;
|
||||
self.candidate = None;
|
||||
self.pending = false;
|
||||
self.sounding = true;
|
||||
let audible = self.voice.amp > 1e-4 && !self.voice.releasing;
|
||||
if glide && audible {
|
||||
self.voice.target = hz;
|
||||
return;
|
||||
}
|
||||
if s.retrigger || !audible {
|
||||
if self.voice.amp > 1e-4 {
|
||||
// Restarting the phase mid-cycle would click; the old cycle fades out instead.
|
||||
self.old = self.voice;
|
||||
self.old_gain = 1.;
|
||||
}
|
||||
self.voice = Voice {
|
||||
phase: s.phase / 360.,
|
||||
hz,
|
||||
target: hz,
|
||||
amp: 0.,
|
||||
releasing: false,
|
||||
};
|
||||
} else {
|
||||
self.voice.target = hz;
|
||||
self.voice.hz = hz;
|
||||
}
|
||||
self.note_peak = 0.;
|
||||
}
|
||||
fn stop(&mut self) {
|
||||
self.voice.releasing = true;
|
||||
self.sounding = false;
|
||||
self.pending = false;
|
||||
self.candidate = None;
|
||||
}
|
||||
fn handle(&mut self, kind: Kind) {
|
||||
let midi = self.settings.source == Source::Midi;
|
||||
match kind {
|
||||
Kind::Onset if !midi => match self.estimate() {
|
||||
Some(p) => self.start(p, false),
|
||||
None => {
|
||||
self.pending = true;
|
||||
self.sounding = true;
|
||||
}
|
||||
},
|
||||
Kind::Release if !midi => self.stop(),
|
||||
Kind::NoteOn(n) if midi => {
|
||||
let len = self.held_len;
|
||||
if let Some(i) = self.held[..len].iter().position(|h| *h == n) {
|
||||
self.held.copy_within(i + 1..len, i);
|
||||
self.held_len -= 1;
|
||||
}
|
||||
if self.held_len == self.held.len() {
|
||||
self.held.copy_within(1.., 0);
|
||||
self.held_len -= 1;
|
||||
}
|
||||
let legato = self.held_len > 0;
|
||||
self.held[self.held_len] = n;
|
||||
self.held_len += 1;
|
||||
self.start(n as f32, legato && self.settings.glide > 0.);
|
||||
}
|
||||
Kind::NoteOff(n) if midi => {
|
||||
let len = self.held_len;
|
||||
let Some(i) = self.held[..len].iter().position(|h| *h == n) else {
|
||||
return;
|
||||
};
|
||||
let top = i + 1 == len;
|
||||
self.held.copy_within(i + 1..len, i);
|
||||
self.held_len -= 1;
|
||||
if self.held_len == 0 {
|
||||
self.stop();
|
||||
} else if top {
|
||||
let next = self.held[self.held_len - 1];
|
||||
self.start(next as f32, self.settings.glide > 0.);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Regular analysis; its window starts at the current output time.
|
||||
fn hop_analysis(&mut self) {
|
||||
if self.settings.source != Source::Audio || !self.sounding {
|
||||
return;
|
||||
}
|
||||
let Some(p) = self.estimate() else {
|
||||
return;
|
||||
};
|
||||
if self.pending {
|
||||
self.start(p, false);
|
||||
return;
|
||||
}
|
||||
if (p - self.tracked).abs() >= JUMP {
|
||||
match self.candidate {
|
||||
Some((c, count)) if (c - p).abs() < 0.5 => {
|
||||
if count + 1 >= 2 {
|
||||
self.start(p, self.settings.glide > 0.);
|
||||
} else {
|
||||
self.candidate = Some((p, count + 1));
|
||||
}
|
||||
}
|
||||
_ => self.candidate = Some((p, 1)),
|
||||
}
|
||||
} else {
|
||||
self.candidate = None;
|
||||
self.tracked += 0.3 * (p - self.tracked);
|
||||
if !self.settings.snap {
|
||||
self.voice.target = midi_to_hz(self.tracked + 12. * self.settings.octave as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn detect(&mut self, x: [f32; 2]) {
|
||||
let mono = if self.channels == 2 {
|
||||
0.5 * (x[0] + x[1])
|
||||
} else {
|
||||
x[0]
|
||||
};
|
||||
let d = self.detect[0].process(mono);
|
||||
let d = self.detect[1].process(d);
|
||||
if self.settings.source == Source::Audio {
|
||||
let a = d.abs();
|
||||
self.gate_env = if a > self.gate_env {
|
||||
a
|
||||
} else {
|
||||
self.gate_env * self.gate_release
|
||||
};
|
||||
let level = gain_to_db(self.gate_env);
|
||||
if level > self.settings.gate {
|
||||
self.gate_hold = (self.rate * GATE_HOLD_MS * 0.001) as usize;
|
||||
if !self.gate_open {
|
||||
self.gate_open = true;
|
||||
self.push(Kind::Onset);
|
||||
}
|
||||
} else if self.gate_open && level < self.settings.gate - GATE_HYSTERESIS_DB {
|
||||
if self.gate_hold == 0 {
|
||||
self.gate_open = false;
|
||||
self.push(Kind::Release);
|
||||
} else {
|
||||
self.gate_hold -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.decimate_count += 1;
|
||||
if self.decimate_count >= self.decimate {
|
||||
self.decimate_count = 0;
|
||||
self.ring[self.ring_at] = d;
|
||||
self.ring_at = (self.ring_at + 1) % self.ring.len();
|
||||
self.hop_count += 1;
|
||||
if self.hop_count >= self.hop {
|
||||
self.hop_count = 0;
|
||||
self.hop_analysis();
|
||||
}
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn shape(&self, x: f32) -> f32 {
|
||||
let drive = self.settings.drive * 0.01;
|
||||
if drive <= 0. {
|
||||
return x;
|
||||
}
|
||||
let k = 1. + 6. * drive;
|
||||
(k * x).tanh() / k.tanh()
|
||||
}
|
||||
fn oscillator(&mut self) -> f32 {
|
||||
let attack = 1. / (self.rate * ATTACK_MS * 0.001);
|
||||
let release = 1. / (self.rate * RELEASE_MS * 0.001);
|
||||
let v = &mut self.voice;
|
||||
v.amp = if v.releasing {
|
||||
(v.amp - release).max(0.)
|
||||
} else {
|
||||
(v.amp + attack).min(1.)
|
||||
};
|
||||
v.hz += (v.target - v.hz) * self.glide;
|
||||
let mut out = 0.;
|
||||
if v.amp > 0. {
|
||||
out += (TAU * v.phase).sin() * v.amp;
|
||||
v.phase = (v.phase + v.hz / self.rate).fract();
|
||||
}
|
||||
if self.old_gain > 0. {
|
||||
let o = &mut self.old;
|
||||
out += (TAU * o.phase).sin() * o.amp * self.old_gain;
|
||||
o.phase = (o.phase + o.hz / self.rate).fract();
|
||||
self.old_gain = (self.old_gain - 1. / (self.rate * CROSSFADE_MS * 0.001)).max(0.);
|
||||
}
|
||||
self.shape(out)
|
||||
}
|
||||
pub fn process_sample(&mut self, x: [f32; 2], monitor: Monitor) -> [f32; 2] {
|
||||
self.detect(x);
|
||||
let len = self.delay.len();
|
||||
self.delay[self.delay_at] = x;
|
||||
self.delay_at = (self.delay_at + 1) % len;
|
||||
// The slot after the newest write is the oldest: exactly `latency` samples ago.
|
||||
let y = self.delay[self.delay_at];
|
||||
while self.event_len > 0 {
|
||||
let e = self.events[self.event_head];
|
||||
if e.time + self.latency as u64 > self.clock {
|
||||
break;
|
||||
}
|
||||
self.event_head = (self.event_head + 1) % EVENTS;
|
||||
self.event_len -= 1;
|
||||
self.handle(e.kind);
|
||||
}
|
||||
self.clock += 1;
|
||||
let s = self.settings;
|
||||
let (l0, h0) = self.crossover[0].process(y[0]);
|
||||
let (l1, h1) = if self.channels == 2 {
|
||||
self.crossover[1].process(y[1])
|
||||
} else {
|
||||
(l0, h0)
|
||||
};
|
||||
let low = 0.5 * (l0 + l1);
|
||||
self.low_env += (low * low - self.low_env) * self.env_coef;
|
||||
let env = self.low_env.sqrt();
|
||||
if !self.voice.releasing {
|
||||
self.note_peak = self.note_peak.max(env);
|
||||
}
|
||||
let follow = if self.note_peak > 1e-6 {
|
||||
(env / self.note_peak).min(1.)
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
let dynamics = s.dynamics * 0.01;
|
||||
let shape = 1. - dynamics + dynamics * follow;
|
||||
self.level += (db_to_gain(s.level) - self.level) * self.smooth;
|
||||
self.keep += (s.keep * 0.01 - self.keep) * self.smooth;
|
||||
self.output += (db_to_gain(s.output) - self.output) * self.smooth;
|
||||
self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth;
|
||||
let sub = self.oscillator() * self.level * shape;
|
||||
self.low_power += (low * low - self.low_power) * self.meter_coef;
|
||||
self.sub_power += (sub * sub - self.sub_power) * self.meter_coef;
|
||||
self.scope_count += 1;
|
||||
if self.scope_count >= self.scope_stride {
|
||||
self.scope_count = 0;
|
||||
self.scope_low[self.scope_at] = low;
|
||||
self.scope_sub[self.scope_at] = sub;
|
||||
self.scope_at = (self.scope_at + 1) % (2 * SCOPE);
|
||||
}
|
||||
let wet = |l: f32, h: f32| {
|
||||
if monitor.solo {
|
||||
sub
|
||||
} else {
|
||||
h + self.keep * l + sub
|
||||
}
|
||||
};
|
||||
let w = [wet(l0, h0), wet(l1, h1)];
|
||||
let mut out = [0.; 2];
|
||||
for c in 0..self.channels {
|
||||
out[c] = (w[c] * self.output) * (1. - self.bypass) + y[c] * self.bypass;
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn take_analyzer(&self) -> AnalyzerFrame {
|
||||
let mut f = AnalyzerFrame {
|
||||
latency: self.latency as u32,
|
||||
sample_rate: self.rate,
|
||||
low_db: 10. * self.low_power.max(1e-12).log10() + 3.01,
|
||||
sub_db: 10. * self.sub_power.max(1e-12).log10() + 3.01,
|
||||
scope_ms: (self.scope_stride * SCOPE) as f32 / self.rate * 1000.,
|
||||
..AnalyzerFrame::default()
|
||||
};
|
||||
if self.voice.amp > 0. && !self.voice.releasing {
|
||||
f.hz = self.voice.hz;
|
||||
f.note = Some(hz_to_midi(self.voice.hz).round().clamp(0., 127.) as u8);
|
||||
}
|
||||
// Trigger on the latest rising zero crossing that still leaves a full window behind it.
|
||||
let n = 2 * SCOPE;
|
||||
let oldest = self.scope_at;
|
||||
let mut start = oldest;
|
||||
for back in 0..SCOPE {
|
||||
let i = (oldest + SCOPE - back) % n;
|
||||
let prev = (i + n - 1) % n;
|
||||
if self.scope_sub[prev] < 0. && self.scope_sub[i] >= 0. {
|
||||
start = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for k in 0..SCOPE {
|
||||
let i = (start + k) % n;
|
||||
f.scope_low[k] = self.scope_low[i];
|
||||
f.scope_sub[k] = self.scope_sub[i];
|
||||
}
|
||||
f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Host-independent controls. Levels in dB, times in milliseconds, frequencies in Hz.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub enum Source {
|
||||
/// Pitch and note starts are detected from the incoming bass.
|
||||
#[default]
|
||||
Audio,
|
||||
/// Notes come from MIDI note input; the audio only supplies the band above the split.
|
||||
Midi,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub source: Source,
|
||||
/// Peak level of the new sub in dBFS, the same for every note.
|
||||
pub level: f32,
|
||||
/// Crossover frequency: below it the original sub is replaced.
|
||||
pub split: f32,
|
||||
/// 0 % holds a constant level per note; 100 % follows the original low band's shape.
|
||||
pub dynamics: f32,
|
||||
/// Soft saturation of the sine, adding upper harmonics for small speakers.
|
||||
pub drive: f32,
|
||||
pub octave: i32,
|
||||
/// Pitch slide time between overlapping notes.
|
||||
pub glide: f32,
|
||||
/// Start phase in degrees on every note start.
|
||||
pub phase: f32,
|
||||
/// Detection threshold in dBFS for note starts (Audio source).
|
||||
pub gate: f32,
|
||||
/// Amount of the original low band that is kept under the new sub.
|
||||
pub keep: f32,
|
||||
pub output: f32,
|
||||
/// Restart the sine at `phase` on every note, so each note and kick hit lines up the same.
|
||||
pub retrigger: bool,
|
||||
/// Round detected pitch to the nearest semitone.
|
||||
pub snap: bool,
|
||||
pub bypass: bool,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
source: Source::Audio,
|
||||
level: -9.,
|
||||
split: 90.,
|
||||
dynamics: 0.,
|
||||
drive: 0.,
|
||||
octave: 0,
|
||||
glide: 0.,
|
||||
phase: 0.,
|
||||
gate: -42.,
|
||||
keep: 0.,
|
||||
output: 0.,
|
||||
retrigger: true,
|
||||
snap: true,
|
||||
bypass: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Settings {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
let finite = [
|
||||
self.level,
|
||||
self.split,
|
||||
self.dynamics,
|
||||
self.drive,
|
||||
self.glide,
|
||||
self.phase,
|
||||
self.gate,
|
||||
self.keep,
|
||||
self.output,
|
||||
]
|
||||
.iter()
|
||||
.all(|v| v.is_finite());
|
||||
if !finite {
|
||||
return Err("non-finite setting");
|
||||
}
|
||||
let ok = (-30.0..=0.).contains(&self.level)
|
||||
&& (40.0..=200.).contains(&self.split)
|
||||
&& (0.0..=100.).contains(&self.dynamics)
|
||||
&& (0.0..=100.).contains(&self.drive)
|
||||
&& (-2..=1).contains(&self.octave)
|
||||
&& (0.0..=200.).contains(&self.glide)
|
||||
&& (0.0..=360.).contains(&self.phase)
|
||||
&& (-60.0..=-20.).contains(&self.gate)
|
||||
&& (0.0..=100.).contains(&self.keep)
|
||||
&& (-12.0..=12.).contains(&self.output);
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("setting out of range")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
use anvil_core::{Engine, Monitor, Settings, Source};
|
||||
use common_dsp::Saw;
|
||||
|
||||
const RATE: f32 = 48000.;
|
||||
|
||||
/// Saw notes (start s, end s, Hz, peak) with 1 ms fades.
|
||||
fn notes(list: &[(f32, f32, f32, f32)], total: f32) -> Vec<f32> {
|
||||
let mut x = vec![0.; (total * RATE) as usize];
|
||||
for &(a, b, hz, amp) in list {
|
||||
let (a, b) = ((a * RATE) as usize, (b * RATE) as usize);
|
||||
let mut saw = Saw::new(RATE, hz);
|
||||
let fade = (0.001 * RATE) as usize;
|
||||
for i in a..b.min(x.len()) {
|
||||
let edge = ((i - a).min(b - 1 - i) as f32 / fade as f32).min(1.);
|
||||
x[i] += saw.sample() * amp * edge;
|
||||
}
|
||||
}
|
||||
x
|
||||
}
|
||||
fn render(e: &mut Engine, x: &[f32], solo: bool) -> Vec<f32> {
|
||||
x.iter()
|
||||
.map(|&s| e.process_sample([s, s], Monitor { solo })[0])
|
||||
.collect()
|
||||
}
|
||||
fn rms(x: &[f32]) -> f32 {
|
||||
(x.iter().map(|v| v * v).sum::<f32>() / x.len() as f32).sqrt()
|
||||
}
|
||||
fn db(x: f32) -> f32 {
|
||||
20. * x.max(1e-9).log10()
|
||||
}
|
||||
/// Frequency from interpolated rising zero crossings.
|
||||
fn frequency(x: &[f32]) -> f32 {
|
||||
let mut first = None;
|
||||
let mut last = 0.;
|
||||
let mut count = 0;
|
||||
for i in 1..x.len() {
|
||||
if x[i - 1] < 0. && x[i] >= 0. {
|
||||
let t = (i - 1) as f32 + x[i - 1] / (x[i - 1] - x[i]);
|
||||
if first.is_none() {
|
||||
first = Some(t);
|
||||
} else {
|
||||
count += 1;
|
||||
}
|
||||
last = t;
|
||||
}
|
||||
}
|
||||
count as f32 * RATE / (last - first.unwrap())
|
||||
}
|
||||
fn seconds(t: f32) -> usize {
|
||||
(t * RATE) as usize
|
||||
}
|
||||
fn engine(s: Settings) -> (Engine, usize) {
|
||||
let mut e = Engine::new(RATE, 2).unwrap();
|
||||
e.set_settings(s).unwrap();
|
||||
let l = e.latency_samples() as usize;
|
||||
(e, l)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_note_gets_the_same_sub_level() {
|
||||
let (mut e, l) = engine(Settings::default());
|
||||
let x = notes(&[(0.1, 0.6, 55., 0.5), (0.8, 1.3, 55., 0.1)], 1.6);
|
||||
let y = render(&mut e, &x, true);
|
||||
let loud = rms(&y[seconds(0.3) + l..seconds(0.5) + l]);
|
||||
let quiet = rms(&y[seconds(1.0) + l..seconds(1.2) + l]);
|
||||
assert!(
|
||||
(db(loud) - db(quiet)).abs() < 0.5,
|
||||
"{} vs {}",
|
||||
db(loud),
|
||||
db(quiet)
|
||||
);
|
||||
// −9 dBFS peak sine.
|
||||
assert!((db(loud * 2f32.sqrt()) + 9.).abs() < 0.5, "{}", db(loud));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_follows_the_detected_pitch_and_octave() {
|
||||
for (octave, expect) in [(0, 43.654), (-1, 21.827)] {
|
||||
let (mut e, l) = engine(Settings {
|
||||
octave,
|
||||
..Settings::default()
|
||||
});
|
||||
let x = notes(&[(0.1, 1.1, 43.65, 0.5)], 1.3);
|
||||
let y = render(&mut e, &x, true);
|
||||
let f = frequency(&y[seconds(0.3) + l..seconds(1.0) + l]);
|
||||
assert!((f / expect - 1.).abs() < 0.005, "octave {octave}: {f}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pitch_change_without_a_gap_is_a_new_note() {
|
||||
let (mut e, l) = engine(Settings::default());
|
||||
let x = notes(&[(0.1, 0.6, 55., 0.5), (0.6, 1.1, 82.41, 0.5)], 1.3);
|
||||
let y = render(&mut e, &x, true);
|
||||
let f = frequency(&y[seconds(0.75) + l..seconds(1.05) + l]);
|
||||
assert!((f / 82.41 - 1.).abs() < 0.005, "{f}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_note_restarts_at_the_start_phase() {
|
||||
let (mut e, l) = engine(Settings {
|
||||
phase: 90.,
|
||||
..Settings::default()
|
||||
});
|
||||
let x = notes(&[(0.1, 0.4, 55., 0.5), (0.6, 0.9, 55., 0.3)], 1.1);
|
||||
let y = render(&mut e, &x, true);
|
||||
let period = (RATE / 55.) as usize;
|
||||
let a = &y[seconds(0.1) + l..seconds(0.1) + l + 2 * period];
|
||||
let b = &y[seconds(0.6) + l..seconds(0.6) + l + 2 * period];
|
||||
let peak = y.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
let worst = a.iter().zip(b).fold(0f32, |m, (p, q)| m.max((p - q).abs()));
|
||||
assert!(worst < 0.1 * peak, "{worst} of {peak}");
|
||||
// Fit the phase over the second period, after the 2 ms attack.
|
||||
let w = std::f32::consts::TAU * 55. / RATE;
|
||||
let (mut s, mut c) = (0., 0.);
|
||||
for (k, v) in a.iter().enumerate().skip(period) {
|
||||
s += v * (w * k as f32).sin();
|
||||
c += v * (w * k as f32).cos();
|
||||
}
|
||||
let phase = c.atan2(s).to_degrees();
|
||||
assert!((phase - 90.).abs() < 20., "{phase}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midi_notes_set_pitch_and_are_delayed_by_the_latency() {
|
||||
let (mut e, l) = engine(Settings {
|
||||
source: Source::Midi,
|
||||
..Settings::default()
|
||||
});
|
||||
let mut y = Vec::new();
|
||||
for i in 0..seconds(0.8) {
|
||||
if i == 1000 {
|
||||
e.note_on(33);
|
||||
}
|
||||
if i == seconds(0.6) {
|
||||
e.note_off(33);
|
||||
}
|
||||
y.push(e.process_sample([0.; 2], Monitor { solo: true })[0]);
|
||||
}
|
||||
assert!(y[..1000 + l].iter().all(|v| *v == 0.));
|
||||
assert!(y[1000 + l + 50].abs() > 0.);
|
||||
let f = frequency(&y[seconds(0.2) + l..seconds(0.5)]);
|
||||
assert!((f / 55. - 1.).abs() < 0.001, "{f}");
|
||||
assert!(y[seconds(0.6) + l + seconds(0.05)..]
|
||||
.iter()
|
||||
.all(|v| v.abs() < 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_changes_do_not_click() {
|
||||
let (mut e, _) = engine(Settings::default());
|
||||
let x = notes(
|
||||
&[
|
||||
(0.1, 0.4, 55., 0.5),
|
||||
(0.4, 0.7, 73.42, 0.5),
|
||||
(0.7, 1.0, 41.2, 0.5),
|
||||
],
|
||||
1.3,
|
||||
);
|
||||
let y = render(&mut e, &x, true);
|
||||
let step = y.windows(2).fold(0f32, |m, w| m.max((w[1] - w[0]).abs()));
|
||||
// A −9 dBFS sine at 73 Hz moves at most 0.0034 per sample.
|
||||
assert!(step < 0.012, "{step}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_without_notes_is_an_allpass() {
|
||||
let (mut e, _) = engine(Settings {
|
||||
source: Source::Midi,
|
||||
keep: 100.,
|
||||
..Settings::default()
|
||||
});
|
||||
for hz in [40., 90., 300., 3000.] {
|
||||
e.reset();
|
||||
let x: Vec<f32> = (0..seconds(1.))
|
||||
.map(|i| 0.3 * (std::f32::consts::TAU * hz * i as f32 / RATE).sin())
|
||||
.collect();
|
||||
let y = render(&mut e, &x, false);
|
||||
let d = db(rms(&y[seconds(0.5)..])) - db(rms(&x[seconds(0.5)..]));
|
||||
assert!(d.abs() < 0.1, "{hz}: {d}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_removes_original_sub_and_keeps_mids() {
|
||||
let (mut e, l) = engine(Settings {
|
||||
source: Source::Midi,
|
||||
..Settings::default()
|
||||
});
|
||||
let x: Vec<f32> = (0..seconds(1.))
|
||||
.map(|i| 0.3 * (std::f32::consts::TAU * 40. * i as f32 / RATE).sin())
|
||||
.collect();
|
||||
let y = render(&mut e, &x, false);
|
||||
assert!(db(rms(&y[seconds(0.5) + l..])) < db(0.3) - 18.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_are_validated_and_serialise() {
|
||||
let mut e = Engine::new(RATE, 1).unwrap();
|
||||
assert!(e
|
||||
.set_settings(Settings {
|
||||
level: 3.,
|
||||
..Settings::default()
|
||||
})
|
||||
.is_err());
|
||||
let json = serde_json::to_string(&Settings::default()).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Settings>(&json).unwrap(),
|
||||
Settings::default()
|
||||
);
|
||||
assert!(Engine::new(1000., 2).is_err());
|
||||
assert!(Engine::new(48000., 3).is_err());
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use anvil_core::{Engine, Monitor, Settings, Source};
|
||||
use std::{
|
||||
alloc::{GlobalAlloc, Layout, System},
|
||||
cell::Cell,
|
||||
};
|
||||
|
||||
struct CheckedAllocator;
|
||||
thread_local! {
|
||||
static CHECK:Cell<bool>=const {Cell::new(false)};
|
||||
static OPERATIONS:Cell<usize>=const {Cell::new(0)};
|
||||
}
|
||||
fn record() {
|
||||
if CHECK.try_with(Cell::get).unwrap_or(false) {
|
||||
let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1));
|
||||
}
|
||||
}
|
||||
unsafe impl GlobalAlloc for CheckedAllocator {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
record();
|
||||
unsafe { System.alloc(layout) }
|
||||
}
|
||||
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
|
||||
record();
|
||||
unsafe { System.dealloc(pointer, layout) }
|
||||
}
|
||||
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
|
||||
record();
|
||||
unsafe { System.realloc(pointer, layout, size) }
|
||||
}
|
||||
}
|
||||
#[global_allocator]
|
||||
static ALLOCATOR: CheckedAllocator = CheckedAllocator;
|
||||
|
||||
#[test]
|
||||
fn processing_notes_settings_and_analysis_do_not_allocate_or_free() {
|
||||
let mut engine = Engine::new(48000., 2).unwrap();
|
||||
let mut settings = Settings::default();
|
||||
engine.set_settings(settings).unwrap();
|
||||
CHECK.with(|v| v.set(true));
|
||||
for i in 0..96000 {
|
||||
if i % 4800 == 0 {
|
||||
settings.split = 60. + (i % 100) as f32;
|
||||
settings.source = if i > 48000 {
|
||||
Source::Midi
|
||||
} else {
|
||||
Source::Audio
|
||||
};
|
||||
settings.octave = -((i / 4800) % 2);
|
||||
engine.set_settings(settings).unwrap();
|
||||
engine.note_on(30 + (i / 4800 % 12) as u8);
|
||||
}
|
||||
if i % 4800 == 2400 {
|
||||
engine.note_off(30 + (i / 4800 % 12) as u8);
|
||||
}
|
||||
let t = i as f32 / 48000.;
|
||||
let x = if (i / 2400) % 2 == 0 {
|
||||
(std::f32::consts::TAU * 55. * t).sin() * 0.5
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
std::hint::black_box(engine.process_sample([x, x], Monitor { solo: i % 3 == 0 }));
|
||||
if i % 1600 == 0 {
|
||||
std::hint::black_box(engine.take_analyzer());
|
||||
}
|
||||
}
|
||||
engine.reset();
|
||||
CHECK.with(|v| v.set(false));
|
||||
assert_eq!(OPERATIONS.with(Cell::get), 0);
|
||||
}
|
||||
Generated
+2382
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "anvil"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "ISC"
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
|
||||
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
|
||||
anvil-core = { path = "../core" }
|
||||
anvil-ui = { path = "../ui" }
|
||||
crossbeam-queue = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = "symbols"
|
||||
@@ -0,0 +1,199 @@
|
||||
//! CLAP audio effect. All DSP state is owned by the processing thread.
|
||||
mod params;
|
||||
use anvil_core::{AnalyzerFrame, Engine, Monitor};
|
||||
use crossbeam_queue::ArrayQueue;
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug_egui::{create_egui_editor, egui};
|
||||
use params::AnvilParams;
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
struct Shared {
|
||||
analyzer: ArrayQueue<AnalyzerFrame>,
|
||||
solo: AtomicBool,
|
||||
}
|
||||
struct EditorData {
|
||||
ui: anvil_ui::State,
|
||||
gestures: [bool; 9],
|
||||
}
|
||||
pub struct Anvil {
|
||||
params: Arc<AnvilParams>,
|
||||
shared: Arc<Shared>,
|
||||
engine: Option<Engine>,
|
||||
publish_count: usize,
|
||||
publish_interval: usize,
|
||||
}
|
||||
impl Default for Anvil {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
params: Arc::new(AnvilParams::default()),
|
||||
shared: Arc::new(Shared {
|
||||
analyzer: ArrayQueue::new(2),
|
||||
solo: AtomicBool::new(false),
|
||||
}),
|
||||
engine: None,
|
||||
publish_count: 0,
|
||||
publish_interval: 1600,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Plugin for Anvil {
|
||||
const NAME: &'static str = "Anvil";
|
||||
const VENDOR: &'static str = "Rivet & Patina";
|
||||
const URL: &'static str = "";
|
||||
const EMAIL: &'static str = "";
|
||||
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
|
||||
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
|
||||
AudioIOLayout {
|
||||
main_input_channels: NonZeroU32::new(2),
|
||||
main_output_channels: NonZeroU32::new(2),
|
||||
..AudioIOLayout::const_default()
|
||||
},
|
||||
AudioIOLayout {
|
||||
main_input_channels: NonZeroU32::new(1),
|
||||
main_output_channels: NonZeroU32::new(1),
|
||||
..AudioIOLayout::const_default()
|
||||
},
|
||||
];
|
||||
const MIDI_INPUT: MidiConfig = MidiConfig::Basic;
|
||||
const SAMPLE_ACCURATE_AUTOMATION: bool = true;
|
||||
type SysExMessage = ();
|
||||
type BackgroundTask = ();
|
||||
fn params(&self) -> Arc<dyn Params> {
|
||||
self.params.clone()
|
||||
}
|
||||
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
|
||||
let params = self.params.clone();
|
||||
let shared = self.shared.clone();
|
||||
create_egui_editor(
|
||||
params.editor.clone(),
|
||||
EditorData {
|
||||
ui: anvil_ui::State::default(),
|
||||
gestures: [false; 9],
|
||||
},
|
||||
|ctx, data| {
|
||||
data.ui.solo = false;
|
||||
anvil_ui::style(ctx);
|
||||
},
|
||||
move |ctx, setter, data| {
|
||||
while let Some(frame) = shared.analyzer.pop() {
|
||||
data.ui.accept_frame(frame);
|
||||
}
|
||||
let before = params.settings();
|
||||
let mut edited = before;
|
||||
nih_plug_egui::resizable_window::ResizableWindow::new("anvil-editor")
|
||||
.min_size(egui::vec2(
|
||||
anvil_ui::WIDTH as f32 - 80.,
|
||||
anvil_ui::HEIGHT as f32 - 40.,
|
||||
))
|
||||
.show(ctx, ¶ms.editor, |ui| {
|
||||
anvil_ui::show(ui, &mut edited, &mut data.ui)
|
||||
});
|
||||
params.apply(
|
||||
setter,
|
||||
&before,
|
||||
&edited,
|
||||
data.ui.gesture_active,
|
||||
&mut data.gestures,
|
||||
);
|
||||
// Solo is a monitor only: never saved and never left on after the editor closes.
|
||||
shared.solo.store(data.ui.solo, Ordering::Release);
|
||||
if data.ui.analyzer.note.is_some() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn initialize(
|
||||
&mut self,
|
||||
layout: &AudioIOLayout,
|
||||
config: &BufferConfig,
|
||||
context: &mut impl InitContext<Self>,
|
||||
) -> bool {
|
||||
let Ok(mut engine) = Engine::new(
|
||||
config.sample_rate,
|
||||
layout.main_input_channels.map_or(0, |v| v.get() as usize),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
if engine.set_settings(self.params.settings()).is_err() {
|
||||
return false;
|
||||
}
|
||||
context.set_latency_samples(engine.latency_samples());
|
||||
self.publish_interval = (config.sample_rate / 30.) as usize;
|
||||
self.engine = Some(engine);
|
||||
self.shared.solo.store(false, Ordering::Release);
|
||||
true
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
if let Some(e) = self.engine.as_mut() {
|
||||
e.reset();
|
||||
}
|
||||
self.publish_count = 0;
|
||||
}
|
||||
fn process(
|
||||
&mut self,
|
||||
buffer: &mut Buffer,
|
||||
_: &mut AuxiliaryBuffers,
|
||||
context: &mut impl ProcessContext<Self>,
|
||||
) -> ProcessStatus {
|
||||
let Some(engine) = self.engine.as_mut() else {
|
||||
return ProcessStatus::Normal;
|
||||
};
|
||||
let _ = engine.set_settings(self.params.settings());
|
||||
let solo = self.shared.solo.load(Ordering::Acquire) && self.params.editor.is_open();
|
||||
let mut next = context.next_event();
|
||||
for (index, mut sample) in buffer.iter_samples().enumerate() {
|
||||
while let Some(event) = next {
|
||||
if event.timing() as usize > index {
|
||||
break;
|
||||
}
|
||||
match event {
|
||||
NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => {
|
||||
engine.note_on(note)
|
||||
}
|
||||
NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => {
|
||||
engine.note_off(note)
|
||||
}
|
||||
NoteEvent::Choke { note, .. } => engine.note_off(note),
|
||||
_ => {}
|
||||
}
|
||||
next = context.next_event();
|
||||
}
|
||||
let x = [
|
||||
*sample.get_mut(0).unwrap(),
|
||||
if engine.channels() == 2 {
|
||||
*sample.get_mut(1).unwrap()
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
];
|
||||
let out = engine.process_sample(x, Monitor { solo });
|
||||
for (c, value) in sample.into_iter().enumerate() {
|
||||
*value = out[c];
|
||||
}
|
||||
self.publish_count += 1;
|
||||
if self.publish_count >= self.publish_interval {
|
||||
self.shared.analyzer.force_push(engine.take_analyzer());
|
||||
self.publish_count = 0;
|
||||
}
|
||||
}
|
||||
// The lookahead delay and the sub release must drain after input stops.
|
||||
ProcessStatus::KeepAlive
|
||||
}
|
||||
}
|
||||
impl ClapPlugin for Anvil {
|
||||
const CLAP_ID: &'static str = "io.storyboard.sub-anchor";
|
||||
const CLAP_DESCRIPTION: Option<&'static str> =
|
||||
Some("Replace the sub with a clean sine at one level and phase for every note");
|
||||
const CLAP_MANUAL_URL: Option<&'static str> = None;
|
||||
const CLAP_SUPPORT_URL: Option<&'static str> = None;
|
||||
const CLAP_FEATURES: &'static [ClapFeature] = &[
|
||||
ClapFeature::AudioEffect,
|
||||
ClapFeature::Stereo,
|
||||
ClapFeature::Mono,
|
||||
];
|
||||
}
|
||||
nih_export_clap!(Anvil);
|
||||
@@ -0,0 +1,192 @@
|
||||
use anvil_core::{Settings, Source};
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug_egui::EguiState;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum SourceParam {
|
||||
#[id = "audio"]
|
||||
Audio,
|
||||
#[id = "midi"]
|
||||
#[name = "MIDI"]
|
||||
Midi,
|
||||
}
|
||||
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
|
||||
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
|
||||
}
|
||||
#[derive(Params)]
|
||||
pub struct AnvilParams {
|
||||
#[persist = "editor"]
|
||||
pub editor: Arc<EguiState>,
|
||||
#[id = "source"]
|
||||
pub source: EnumParam<SourceParam>,
|
||||
#[id = "level"]
|
||||
pub level: FloatParam,
|
||||
#[id = "split"]
|
||||
pub split: FloatParam,
|
||||
#[id = "dynamics"]
|
||||
pub dynamics: FloatParam,
|
||||
#[id = "drive"]
|
||||
pub drive: FloatParam,
|
||||
#[id = "octave"]
|
||||
pub octave: IntParam,
|
||||
#[id = "glide"]
|
||||
pub glide: FloatParam,
|
||||
#[id = "phase"]
|
||||
pub phase: FloatParam,
|
||||
#[id = "gate"]
|
||||
pub gate: FloatParam,
|
||||
#[id = "keep"]
|
||||
pub keep: FloatParam,
|
||||
#[id = "output"]
|
||||
pub output: FloatParam,
|
||||
#[id = "retrigger"]
|
||||
pub retrigger: BoolParam,
|
||||
#[id = "snap"]
|
||||
pub snap: BoolParam,
|
||||
#[id = "bypass"]
|
||||
pub bypass: BoolParam,
|
||||
}
|
||||
impl Default for AnvilParams {
|
||||
fn default() -> Self {
|
||||
let d = Settings::default();
|
||||
Self {
|
||||
editor: EguiState::from_size(anvil_ui::WIDTH, anvil_ui::HEIGHT),
|
||||
source: EnumParam::new("Pitch source", SourceParam::Audio),
|
||||
level: linear("Sub level", d.level, -30., 0., " dBFS"),
|
||||
split: FloatParam::new(
|
||||
"Split",
|
||||
d.split,
|
||||
FloatRange::Skewed {
|
||||
min: 40.,
|
||||
max: 200.,
|
||||
factor: FloatRange::skew_factor(-1.),
|
||||
},
|
||||
)
|
||||
.with_unit(" Hz"),
|
||||
dynamics: linear("Dynamics", d.dynamics, 0., 100., " %"),
|
||||
drive: linear("Drive", d.drive, 0., 100., " %"),
|
||||
octave: IntParam::new("Octave", d.octave, IntRange::Linear { min: -2, max: 1 }),
|
||||
glide: linear("Glide", d.glide, 0., 200., " ms"),
|
||||
phase: linear("Phase", d.phase, 0., 360., "°"),
|
||||
gate: linear("Gate", d.gate, -60., -20., " dB"),
|
||||
keep: linear("Keep", d.keep, 0., 100., " %"),
|
||||
output: linear("Output", d.output, -12., 12., " dB"),
|
||||
retrigger: BoolParam::new("Retrigger", d.retrigger),
|
||||
snap: BoolParam::new("Snap to semitone", d.snap),
|
||||
bypass: BoolParam::new("Bypass", false).make_bypass(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl AnvilParams {
|
||||
pub fn settings(&self) -> Settings {
|
||||
Settings {
|
||||
source: match self.source.value() {
|
||||
SourceParam::Audio => Source::Audio,
|
||||
SourceParam::Midi => Source::Midi,
|
||||
},
|
||||
level: self.level.value(),
|
||||
split: self.split.value(),
|
||||
dynamics: self.dynamics.value(),
|
||||
drive: self.drive.value(),
|
||||
octave: self.octave.value(),
|
||||
glide: self.glide.value(),
|
||||
phase: self.phase.value(),
|
||||
gate: self.gate.value(),
|
||||
keep: self.keep.value(),
|
||||
output: self.output.value(),
|
||||
retrigger: self.retrigger.value(),
|
||||
snap: self.snap.value(),
|
||||
bypass: self.bypass.value(),
|
||||
}
|
||||
}
|
||||
/// Pushes every changed editor value to the host. Continuous controls hold one
|
||||
/// gesture while the pointer is down; switches are set in a single gesture.
|
||||
pub fn apply(
|
||||
&self,
|
||||
setter: &ParamSetter,
|
||||
old: &Settings,
|
||||
new: &Settings,
|
||||
dragging: bool,
|
||||
open: &mut [bool; 9],
|
||||
) {
|
||||
let floats: [(&FloatParam, f32, f32); 8] = [
|
||||
(&self.level, old.level, new.level),
|
||||
(&self.split, old.split, new.split),
|
||||
(&self.dynamics, old.dynamics, new.dynamics),
|
||||
(&self.drive, old.drive, new.drive),
|
||||
(&self.glide, old.glide, new.glide),
|
||||
(&self.phase, old.phase, new.phase),
|
||||
(&self.gate, old.gate, new.gate),
|
||||
(&self.keep, old.keep, new.keep),
|
||||
];
|
||||
let mut continuous = |i: usize, changed: bool, set: &dyn Fn()| {
|
||||
if changed {
|
||||
if !open[i] {
|
||||
open[i] = true;
|
||||
set_begin(setter, self, i);
|
||||
}
|
||||
set();
|
||||
}
|
||||
if !dragging && open[i] {
|
||||
set_end(setter, self, i);
|
||||
open[i] = false;
|
||||
}
|
||||
};
|
||||
for (i, (p, a, b)) in floats.into_iter().enumerate() {
|
||||
continuous(i, a != b, &|| setter.set_parameter(p, b));
|
||||
}
|
||||
continuous(8, old.output != new.output, &|| {
|
||||
setter.set_parameter(&self.output, new.output)
|
||||
});
|
||||
let once = |changed: bool, f: &dyn Fn()| {
|
||||
if changed {
|
||||
f();
|
||||
}
|
||||
};
|
||||
once(new.octave != old.octave, &|| {
|
||||
setter.begin_set_parameter(&self.octave);
|
||||
setter.set_parameter(&self.octave, new.octave);
|
||||
setter.end_set_parameter(&self.octave);
|
||||
});
|
||||
for (p, a, b) in [
|
||||
(&self.retrigger, old.retrigger, new.retrigger),
|
||||
(&self.snap, old.snap, new.snap),
|
||||
(&self.bypass, old.bypass, new.bypass),
|
||||
] {
|
||||
once(a != b, &|| {
|
||||
setter.begin_set_parameter(p);
|
||||
setter.set_parameter(p, b);
|
||||
setter.end_set_parameter(p);
|
||||
});
|
||||
}
|
||||
once(new.source != old.source, &|| {
|
||||
let v = match new.source {
|
||||
Source::Audio => SourceParam::Audio,
|
||||
Source::Midi => SourceParam::Midi,
|
||||
};
|
||||
setter.begin_set_parameter(&self.source);
|
||||
setter.set_parameter(&self.source, v);
|
||||
setter.end_set_parameter(&self.source);
|
||||
});
|
||||
}
|
||||
fn continuous(&self, i: usize) -> &FloatParam {
|
||||
[
|
||||
&self.level,
|
||||
&self.split,
|
||||
&self.dynamics,
|
||||
&self.drive,
|
||||
&self.glide,
|
||||
&self.phase,
|
||||
&self.gate,
|
||||
&self.keep,
|
||||
&self.output,
|
||||
][i]
|
||||
}
|
||||
}
|
||||
fn set_begin(setter: &ParamSetter, p: &AnvilParams, i: usize) {
|
||||
setter.begin_set_parameter(p.continuous(i));
|
||||
}
|
||||
fn set_end(setter: &ParamSetter, p: &AnvilParams, i: usize) {
|
||||
setter.end_set_parameter(p.continuous(i));
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW.
|
||||
use clap_sys::{
|
||||
audio_buffer::*,
|
||||
events::*,
|
||||
ext::{audio_ports::*, latency::*, state::*},
|
||||
factory::plugin_factory::*,
|
||||
host::*,
|
||||
process::*,
|
||||
stream::*,
|
||||
version::*,
|
||||
};
|
||||
use std::{
|
||||
ffi::{c_char, c_void, CStr},
|
||||
io::{Cursor, Read},
|
||||
ptr,
|
||||
};
|
||||
|
||||
unsafe extern "C" fn extension(_: *const clap_host, _: *const c_char) -> *const c_void {
|
||||
ptr::null()
|
||||
}
|
||||
unsafe extern "C" fn request(_: *const clap_host) {}
|
||||
unsafe extern "C" fn count(_: *const clap_input_events) -> u32 {
|
||||
0
|
||||
}
|
||||
unsafe extern "C" fn event(_: *const clap_input_events, _: u32) -> *const clap_event_header {
|
||||
ptr::null()
|
||||
}
|
||||
unsafe extern "C" fn push(_: *const clap_output_events, _: *const clap_event_header) -> bool {
|
||||
true
|
||||
}
|
||||
unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 {
|
||||
// SAFETY: The test owns the Vec for the entire synchronous save call.
|
||||
let bytes = unsafe { &mut *((*stream).ctx as *mut Vec<u8>) };
|
||||
bytes.extend_from_slice(unsafe {
|
||||
std::slice::from_raw_parts(buffer as *const u8, size as usize)
|
||||
});
|
||||
size as i64
|
||||
}
|
||||
unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
|
||||
// SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage.
|
||||
let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor<Vec<u8>>) };
|
||||
cursor
|
||||
.read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) })
|
||||
.unwrap() as i64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_ports_state_latency_and_audio_lifecycle() {
|
||||
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
|
||||
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
|
||||
unsafe {
|
||||
let entry = &anvil::clap_entry;
|
||||
assert!(entry.init.unwrap()(c"anvil.clap".as_ptr()));
|
||||
let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr())
|
||||
as *const clap_plugin_factory;
|
||||
assert!(!factory.is_null());
|
||||
let factory = &*factory;
|
||||
assert_eq!(factory.get_plugin_count.unwrap()(factory), 1);
|
||||
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
|
||||
assert!(!descriptor.is_null());
|
||||
assert_eq!(CStr::from_ptr((*descriptor).name), c"Anvil");
|
||||
assert_eq!(
|
||||
CStr::from_ptr((*descriptor).id),
|
||||
c"io.storyboard.sub-anchor"
|
||||
);
|
||||
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
|
||||
let host = clap_host {
|
||||
clap_version: CLAP_VERSION,
|
||||
host_data: ptr::null_mut(),
|
||||
name: c"Storyboard test host".as_ptr(),
|
||||
vendor: c"Tests".as_ptr(),
|
||||
url: c"".as_ptr(),
|
||||
version: c"1.0".as_ptr(),
|
||||
get_extension: Some(extension),
|
||||
request_restart: Some(request),
|
||||
request_process: Some(request),
|
||||
request_callback: Some(request),
|
||||
};
|
||||
let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id);
|
||||
assert!(!plugin.is_null());
|
||||
let p = &*plugin;
|
||||
assert!(p.init.unwrap()(plugin));
|
||||
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
|
||||
as *const clap_plugin_audio_ports;
|
||||
assert!(!ports.is_null());
|
||||
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
|
||||
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
|
||||
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
|
||||
as *const clap_plugin_latency;
|
||||
assert!(!latency.is_null());
|
||||
let state =
|
||||
p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state;
|
||||
assert!(!state.is_null());
|
||||
let state = &*state;
|
||||
let save = || {
|
||||
let mut bytes = Vec::new();
|
||||
let stream = clap_ostream {
|
||||
ctx: &mut bytes as *mut Vec<u8> as *mut c_void,
|
||||
write: Some(write),
|
||||
};
|
||||
assert!(state.save.unwrap()(plugin, &stream));
|
||||
bytes
|
||||
};
|
||||
let mut envelope: nih_plug::prelude::PluginState =
|
||||
serde_json::from_slice(&save()[8..]).unwrap();
|
||||
use nih_plug::wrapper::state::ParamValue;
|
||||
envelope
|
||||
.params
|
||||
.insert("level".into(), ParamValue::F32(-14.));
|
||||
envelope.params.insert("octave".into(), ParamValue::I32(-1));
|
||||
envelope
|
||||
.params
|
||||
.insert("snap".into(), ParamValue::Bool(false));
|
||||
envelope
|
||||
.params
|
||||
.insert("source".into(), ParamValue::String("midi".into()));
|
||||
let encoded = serde_json::to_vec(&envelope).unwrap();
|
||||
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
|
||||
framed.extend_from_slice(&encoded);
|
||||
let mut bytes = Cursor::new(framed);
|
||||
let stream = clap_istream {
|
||||
ctx: &mut bytes as *mut Cursor<Vec<u8>> as *mut c_void,
|
||||
read: Some(read),
|
||||
};
|
||||
assert!(state.load.unwrap()(plugin, &stream));
|
||||
let restored: nih_plug::prelude::PluginState =
|
||||
serde_json::from_slice(&save()[8..]).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&restored.params).unwrap(),
|
||||
serde_json::to_value(&envelope.params).unwrap()
|
||||
);
|
||||
assert!(!restored.fields.contains_key("solo"));
|
||||
for rate in [44100., 48000., 96000.] {
|
||||
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
|
||||
let delay = (*latency).get.unwrap()(plugin) as usize;
|
||||
// About 80 ms of lookahead: two periods of the lowest detected note.
|
||||
assert!((delay as f64 / rate - 0.08).abs() < 0.01, "{delay}");
|
||||
assert!(p.start_processing.unwrap()(plugin));
|
||||
let total = delay + 9600;
|
||||
let mut position = 0;
|
||||
let (mut energy_in, mut energy_out) = (0f64, 0f64);
|
||||
while position < total {
|
||||
let frames = [1, 127, 512, 31][position % 4].min(total - position);
|
||||
// A 1 kHz tone sits above the split and passes through unchanged.
|
||||
let tone = |n: usize| {
|
||||
0.25 * (std::f64::consts::TAU * 1000. * n as f64 / rate).sin() as f32
|
||||
};
|
||||
let mut input_left = [0f32; 512];
|
||||
let mut input_right = [0f32; 512];
|
||||
for i in 0..frames {
|
||||
input_left[i] = tone(position + i);
|
||||
input_right[i] = -tone(position + i);
|
||||
}
|
||||
let mut left = [0f32; 512];
|
||||
let mut right = [0f32; 512];
|
||||
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
|
||||
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
|
||||
let incoming = clap_audio_buffer {
|
||||
data32: in_channels.as_mut_ptr(),
|
||||
data64: ptr::null_mut(),
|
||||
channel_count: 2,
|
||||
latency: 0,
|
||||
constant_mask: 0,
|
||||
};
|
||||
let mut outgoing_audio = clap_audio_buffer {
|
||||
data32: out_channels.as_mut_ptr(),
|
||||
data64: ptr::null_mut(),
|
||||
channel_count: 2,
|
||||
latency: 0,
|
||||
constant_mask: 0,
|
||||
};
|
||||
let input = clap_input_events {
|
||||
ctx: ptr::null_mut(),
|
||||
size: Some(count),
|
||||
get: Some(event),
|
||||
};
|
||||
let outgoing = clap_output_events {
|
||||
ctx: ptr::null_mut(),
|
||||
try_push: Some(push),
|
||||
};
|
||||
let process = clap_process {
|
||||
steady_time: position as i64,
|
||||
frames_count: frames as u32,
|
||||
transport: ptr::null(),
|
||||
audio_inputs: &incoming,
|
||||
audio_outputs: &mut outgoing_audio,
|
||||
audio_inputs_count: 1,
|
||||
audio_outputs_count: 1,
|
||||
in_events: &input,
|
||||
out_events: &outgoing,
|
||||
};
|
||||
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
|
||||
for i in 0..frames {
|
||||
let n = position + i;
|
||||
if n < delay {
|
||||
assert!(left[i].abs() < 1e-6, "output before the lookahead");
|
||||
} else if n > delay + 4800 {
|
||||
energy_in += (tone(n - delay) as f64).powi(2);
|
||||
energy_out += (left[i] as f64).powi(2) + (right[i] as f64).powi(2);
|
||||
}
|
||||
}
|
||||
position += frames;
|
||||
}
|
||||
let ratio_db = 10. * (energy_out / (2. * energy_in)).log10();
|
||||
assert!(ratio_db.abs() < 0.2, "{ratio_db}");
|
||||
p.stop_processing.unwrap()(plugin);
|
||||
p.deactivate.unwrap()(plugin);
|
||||
}
|
||||
p.destroy.unwrap()(plugin);
|
||||
entry.deinit.unwrap()();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "anvil-ui"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
egui = "0.31.1"
|
||||
anvil-core = { path = "../core" }
|
||||
common-ui = { path = "../../common/ui" }
|
||||
common-dsp = { path = "../../common/dsp" }
|
||||
@@ -0,0 +1,350 @@
|
||||
//! Anvil editor, shared by the CLAP wrapper and the headless renderer.
|
||||
use anvil_core::{AnalyzerFrame, Settings, Source, SCOPE};
|
||||
use common_ui::*;
|
||||
use egui::{self, Color32, FontId, Pos2, RichText, Sense, Stroke, Vec2};
|
||||
|
||||
pub use common_ui::style;
|
||||
pub const WIDTH: u32 = 980;
|
||||
pub const HEIGHT: u32 = 704;
|
||||
|
||||
pub struct State {
|
||||
pub solo: bool,
|
||||
pub analyzer: AnalyzerFrame,
|
||||
/// True while a pointer button is held, so knob drags become one host gesture.
|
||||
pub gesture_active: bool,
|
||||
pub help: String,
|
||||
}
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
solo: false,
|
||||
analyzer: AnalyzerFrame::default(),
|
||||
gesture_active: false,
|
||||
help: DEFAULT_HELP.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
const DEFAULT_HELP: &str =
|
||||
"Put Anvil on the bass, set Split just above the sub, then set Sub level once for every patch.";
|
||||
impl State {
|
||||
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
|
||||
let old = self.analyzer;
|
||||
self.analyzer = frame;
|
||||
// Meters fall smoothly; the readout and scope stay exact.
|
||||
self.analyzer.low_db = frame.low_db.max(old.low_db - 1.5);
|
||||
self.analyzer.sub_db = frame.sub_db.max(old.sub_db - 1.5);
|
||||
}
|
||||
}
|
||||
fn hint(r: &egui::Response, state: &mut State, text: &str) {
|
||||
if r.hovered() || r.has_focus() {
|
||||
state.help = text.into();
|
||||
}
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn knob(
|
||||
ui: &mut egui::Ui,
|
||||
state: &mut State,
|
||||
name: &str,
|
||||
value: &mut f32,
|
||||
range: std::ops::RangeInclusive<f32>,
|
||||
default: f32,
|
||||
unit: &str,
|
||||
size: f32,
|
||||
help: &str,
|
||||
) {
|
||||
ui.vertical_centered(|ui| {
|
||||
let r = rotary(ui, name, value, range, default, unit, size, ACCENT);
|
||||
hint(&r, state, help);
|
||||
});
|
||||
}
|
||||
fn toggle(ui: &mut egui::Ui, state: &mut State, on: &mut bool, label: &str, help: &str) {
|
||||
let r = ui.selectable_label(*on, RichText::new(label).size(13.));
|
||||
if r.clicked() {
|
||||
*on = !*on;
|
||||
}
|
||||
hint(&r, state, help);
|
||||
}
|
||||
fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32) {
|
||||
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
|
||||
let p = ui.painter_at(rect);
|
||||
p.rect_filled(rect, 2., SCREEN);
|
||||
let mid = rect.center().y;
|
||||
for k in 1..6 {
|
||||
let x = rect.left() + rect.width() * k as f32 / 6.;
|
||||
p.line_segment(
|
||||
[Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())],
|
||||
Stroke::new(1.0_f32, Color32::from_rgb(24, 26, 30)),
|
||||
);
|
||||
}
|
||||
p.line_segment(
|
||||
[Pos2::new(rect.left(), mid), Pos2::new(rect.right(), mid)],
|
||||
Stroke::new(1.0_f32, EDGE),
|
||||
);
|
||||
let peak = frame
|
||||
.scope_low
|
||||
.iter()
|
||||
.chain(frame.scope_sub.iter())
|
||||
.fold(0.05f32, |m, v| m.max(v.abs()));
|
||||
let scale = rect.height() * 0.42 / peak;
|
||||
for (data, color, width) in [
|
||||
(&frame.scope_low, STEEL, 1.5_f32),
|
||||
(&frame.scope_sub, ACCENT, 2.0_f32),
|
||||
] {
|
||||
let points: Vec<Pos2> = data
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
Pos2::new(
|
||||
rect.left() + rect.width() * i as f32 / (SCOPE - 1) as f32,
|
||||
mid - v * scale,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
p.add(egui::Shape::line(points, Stroke::new(width, color)));
|
||||
}
|
||||
p.text(
|
||||
rect.left_top() + Vec2::new(10., 8.),
|
||||
egui::Align2::LEFT_TOP,
|
||||
"ORIGINAL LOW BAND",
|
||||
FontId::proportional(11.),
|
||||
STEEL,
|
||||
);
|
||||
p.text(
|
||||
rect.left_top() + Vec2::new(130., 8.),
|
||||
egui::Align2::LEFT_TOP,
|
||||
"NEW SUB",
|
||||
FontId::proportional(11.),
|
||||
ACCENT,
|
||||
);
|
||||
p.text(
|
||||
rect.right_top() + Vec2::new(-10., 8.),
|
||||
egui::Align2::RIGHT_TOP,
|
||||
format!("{:.0} ms", frame.scope_ms),
|
||||
FontId::proportional(11.),
|
||||
MUTED,
|
||||
);
|
||||
p.rect_stroke(
|
||||
rect,
|
||||
2.,
|
||||
Stroke::new(1.0_f32, EDGE),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
backdrop(ui);
|
||||
state.help = DEFAULT_HELP.into();
|
||||
state.gesture_active = ui.input(|i| i.pointer.any_down());
|
||||
ui.add_space(2.);
|
||||
header(ui, "ANVIL", "SUB ANCHOR", |ui| {
|
||||
let r = ui
|
||||
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
|
||||
.on_hover_text("Crossfade to the delayed dry signal.");
|
||||
if r.clicked() {
|
||||
settings.bypass = !settings.bypass;
|
||||
}
|
||||
let r = ui
|
||||
.selectable_label(state.solo, RichText::new("SOLO SUB").size(13.))
|
||||
.on_hover_text("Hear only the generated sub.");
|
||||
if r.clicked() {
|
||||
state.solo = !state.solo;
|
||||
}
|
||||
ui.separator();
|
||||
for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] {
|
||||
let r = ui.selectable_label(settings.source == source, RichText::new(label).size(13.));
|
||||
if r.clicked() {
|
||||
settings.source = source;
|
||||
}
|
||||
}
|
||||
caption(ui, "PITCH FROM");
|
||||
});
|
||||
ui.add_space(6.);
|
||||
let frame = state.analyzer;
|
||||
ui.horizontal_top(|ui| {
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(260.);
|
||||
ui.set_min_height(TOP);
|
||||
ui.vertical(|ui| note_panel(ui, settings, &frame));
|
||||
});
|
||||
ui.add_space(4.);
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
scope(ui, &frame, TOP);
|
||||
});
|
||||
});
|
||||
ui.add_space(6.);
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.vertical(|ui| controls(ui, settings, state));
|
||||
});
|
||||
ui.separator();
|
||||
ui.label(RichText::new(&state.help).color(MUTED));
|
||||
}
|
||||
const TOP: f32 = 196.;
|
||||
fn note_panel(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame) {
|
||||
caption(ui, "NOTE");
|
||||
let (name, hz) = match frame.note {
|
||||
Some(n) if frame.hz > 0. => (common_dsp::note_name(n), format!("{:.1} Hz", frame.hz)),
|
||||
_ => ("—".into(), "no note".into()),
|
||||
};
|
||||
ui.label(
|
||||
RichText::new(name)
|
||||
.font(FontId::new(54., egui::FontFamily::Name("medium".into())))
|
||||
.color(if frame.note.is_some() { TEXT } else { DIM }),
|
||||
);
|
||||
ui.label(RichText::new(hz).monospace().size(14.).color(MUTED));
|
||||
ui.add_space(10.);
|
||||
meter(ui, "ORIGINAL", frame.low_db, STEEL, 100.);
|
||||
meter(ui, "NEW SUB", frame.sub_db, ACCENT, 100.);
|
||||
ui.add_space(10.);
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{:.0} ms lookahead · {}",
|
||||
frame.latency as f32 / frame.sample_rate * 1000.,
|
||||
match settings.source {
|
||||
Source::Audio => "detecting pitch",
|
||||
Source::Midi => "following MIDI notes",
|
||||
}
|
||||
))
|
||||
.size(12.)
|
||||
.color(MUTED),
|
||||
);
|
||||
}
|
||||
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
let defaults = Settings::default();
|
||||
ui.columns(4, |c| {
|
||||
knob(
|
||||
&mut c[0],
|
||||
state,
|
||||
"Sub level",
|
||||
&mut settings.level,
|
||||
-30. ..=0.,
|
||||
defaults.level,
|
||||
" dBFS",
|
||||
88.,
|
||||
"Peak level of the new sub. Every note gets exactly this level.",
|
||||
);
|
||||
knob(
|
||||
&mut c[1],
|
||||
state,
|
||||
"Split",
|
||||
&mut settings.split,
|
||||
40. ..=200.,
|
||||
defaults.split,
|
||||
" Hz",
|
||||
88.,
|
||||
"Below this frequency the original sub is replaced.",
|
||||
);
|
||||
knob(
|
||||
&mut c[2],
|
||||
state,
|
||||
"Dynamics",
|
||||
&mut settings.dynamics,
|
||||
0. ..=100.,
|
||||
defaults.dynamics,
|
||||
" %",
|
||||
88.,
|
||||
"0 %: constant level per note. Higher: follow the original's wobble shape.",
|
||||
);
|
||||
knob(
|
||||
&mut c[3],
|
||||
state,
|
||||
"Drive",
|
||||
&mut settings.drive,
|
||||
0. ..=100.,
|
||||
defaults.drive,
|
||||
" %",
|
||||
88.,
|
||||
"Adds upper harmonics so the sub reads on small speakers.",
|
||||
);
|
||||
});
|
||||
ui.add_space(6.);
|
||||
ui.separator();
|
||||
ui.columns(6, |c| {
|
||||
let mut octave = settings.octave as f32;
|
||||
knob(
|
||||
&mut c[0],
|
||||
state,
|
||||
"Octave",
|
||||
&mut octave,
|
||||
-2. ..=1.,
|
||||
0.,
|
||||
"",
|
||||
54.,
|
||||
"Shift the sub against the detected note. −1 for basses played high.",
|
||||
);
|
||||
settings.octave = octave.round() as i32;
|
||||
knob(
|
||||
&mut c[1],
|
||||
state,
|
||||
"Glide",
|
||||
&mut settings.glide,
|
||||
0. ..=200.,
|
||||
defaults.glide,
|
||||
" ms",
|
||||
54.,
|
||||
"Slide time when notes overlap. 0 jumps.",
|
||||
);
|
||||
knob(
|
||||
&mut c[2],
|
||||
state,
|
||||
"Phase",
|
||||
&mut settings.phase,
|
||||
0. ..=360.,
|
||||
defaults.phase,
|
||||
"°",
|
||||
54.,
|
||||
"Start phase of every note. Line it up with your kick.",
|
||||
);
|
||||
knob(
|
||||
&mut c[3],
|
||||
state,
|
||||
"Gate",
|
||||
&mut settings.gate,
|
||||
-60. ..=-20.,
|
||||
defaults.gate,
|
||||
" dB",
|
||||
54.,
|
||||
"Input level that counts as a note. Raise it if tails retrigger.",
|
||||
);
|
||||
knob(
|
||||
&mut c[4],
|
||||
state,
|
||||
"Keep",
|
||||
&mut settings.keep,
|
||||
0. ..=100.,
|
||||
defaults.keep,
|
||||
" %",
|
||||
54.,
|
||||
"Blend some original low band back under the new sub.",
|
||||
);
|
||||
knob(
|
||||
&mut c[5],
|
||||
state,
|
||||
"Output",
|
||||
&mut settings.output,
|
||||
-12. ..=12.,
|
||||
defaults.output,
|
||||
" dB",
|
||||
54.,
|
||||
"Output trim.",
|
||||
);
|
||||
});
|
||||
ui.add_space(4.);
|
||||
ui.horizontal(|ui| {
|
||||
toggle(
|
||||
ui,
|
||||
state,
|
||||
&mut settings.retrigger,
|
||||
"RETRIGGER",
|
||||
"Restart the sine at Phase on every note, so each note and kick hit lines up.",
|
||||
);
|
||||
toggle(
|
||||
ui,
|
||||
state,
|
||||
&mut settings.snap,
|
||||
"SNAP TO SEMITONE",
|
||||
"Round the detected pitch. Turn off for slides and pitch bends.",
|
||||
);
|
||||
});
|
||||
}
|
||||
+36
-1
@@ -22,12 +22,47 @@
|
||||
"documentation": "docs/patina",
|
||||
"clap_id": "io.storyboard.resonance-compressor",
|
||||
"licenses": {
|
||||
"plugins/patina/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
|
||||
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
|
||||
},
|
||||
"packages": [
|
||||
"common-ui",
|
||||
"patina-core",
|
||||
"patina-ui",
|
||||
"patina-render"
|
||||
]
|
||||
},
|
||||
"anvil": {
|
||||
"name": "Anvil",
|
||||
"description": "Replace the sub with a clean sine at one level and phase for every note",
|
||||
"manifest": "plugins/anvil/plugin/Cargo.toml",
|
||||
"library": "anvil",
|
||||
"documentation": "docs/anvil",
|
||||
"clap_id": "io.storyboard.sub-anchor",
|
||||
"licenses": {
|
||||
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
|
||||
},
|
||||
"packages": [
|
||||
"common-ui",
|
||||
"common-dsp",
|
||||
"anvil-core",
|
||||
"anvil-ui"
|
||||
]
|
||||
},
|
||||
"crucible": {
|
||||
"name": "Crucible",
|
||||
"description": "Capture the last bars, slice them and export clean, key-labelled samples",
|
||||
"manifest": "plugins/crucible/plugin/Cargo.toml",
|
||||
"library": "crucible",
|
||||
"documentation": "docs/crucible",
|
||||
"clap_id": "io.storyboard.resampler",
|
||||
"licenses": {
|
||||
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
|
||||
},
|
||||
"packages": [
|
||||
"common-ui",
|
||||
"common-dsp",
|
||||
"crucible-core",
|
||||
"crucible-ui"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "common-dsp"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Shared allocation-free DSP building blocks: filters, pitch detection, notes, WAV files.
|
||||
pub mod wav;
|
||||
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
/// Transposed direct form II biquad (RBJ cookbook coefficients).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Biquad {
|
||||
b0: f32,
|
||||
b1: f32,
|
||||
b2: f32,
|
||||
a1: f32,
|
||||
a2: f32,
|
||||
z1: f32,
|
||||
z2: f32,
|
||||
}
|
||||
impl Biquad {
|
||||
fn from(b: [f32; 3], a: [f32; 3]) -> Self {
|
||||
Self {
|
||||
b0: b[0] / a[0],
|
||||
b1: b[1] / a[0],
|
||||
b2: b[2] / a[0],
|
||||
a1: a[1] / a[0],
|
||||
a2: a[2] / a[0],
|
||||
z1: 0.,
|
||||
z2: 0.,
|
||||
}
|
||||
}
|
||||
pub fn lowpass(rate: f32, hz: f32, q: f32) -> Self {
|
||||
let w = TAU * (hz / rate).clamp(1e-5, 0.49);
|
||||
let (s, c) = w.sin_cos();
|
||||
let alpha = s / (2. * q);
|
||||
Self::from(
|
||||
[(1. - c) / 2., 1. - c, (1. - c) / 2.],
|
||||
[1. + alpha, -2. * c, 1. - alpha],
|
||||
)
|
||||
}
|
||||
pub fn highpass(rate: f32, hz: f32, q: f32) -> Self {
|
||||
let w = TAU * (hz / rate).clamp(1e-5, 0.49);
|
||||
let (s, c) = w.sin_cos();
|
||||
let alpha = s / (2. * q);
|
||||
Self::from(
|
||||
[(1. + c) / 2., -(1. + c), (1. + c) / 2.],
|
||||
[1. + alpha, -2. * c, 1. - alpha],
|
||||
)
|
||||
}
|
||||
/// Peaking EQ; used by tests to model resonant material.
|
||||
pub fn peak(rate: f32, hz: f32, q: f32, gain_db: f32) -> Self {
|
||||
let w = TAU * (hz / rate).clamp(1e-5, 0.49);
|
||||
let (s, c) = w.sin_cos();
|
||||
let a = 10f32.powf(gain_db / 40.);
|
||||
let alpha = s / (2. * q);
|
||||
Self::from(
|
||||
[1. + alpha * a, -2. * c, 1. - alpha * a],
|
||||
[1. + alpha / a, -2. * c, 1. - alpha / a],
|
||||
)
|
||||
}
|
||||
/// Replace the coefficients and keep the filter state, for smooth parameter changes.
|
||||
pub fn retune(&mut self, other: Self) {
|
||||
let (z1, z2) = (self.z1, self.z2);
|
||||
*self = other;
|
||||
self.z1 = z1;
|
||||
self.z2 = z2;
|
||||
}
|
||||
#[inline]
|
||||
pub fn process(&mut self, x: f32) -> f32 {
|
||||
let y = self.b0 * x + self.z1;
|
||||
self.z1 = self.b1 * x - self.a1 * y + self.z2;
|
||||
self.z2 = self.b2 * x - self.a2 * y;
|
||||
y
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.z1 = 0.;
|
||||
self.z2 = 0.;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fourth-order Linkwitz–Riley crossover. Low plus high is an allpass: flat magnitude.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Crossover {
|
||||
low: [Biquad; 2],
|
||||
high: [Biquad; 2],
|
||||
}
|
||||
impl Crossover {
|
||||
pub fn new(rate: f32, hz: f32) -> Self {
|
||||
let q = std::f32::consts::FRAC_1_SQRT_2;
|
||||
Self {
|
||||
low: [Biquad::lowpass(rate, hz, q); 2],
|
||||
high: [Biquad::highpass(rate, hz, q); 2],
|
||||
}
|
||||
}
|
||||
pub fn retune(&mut self, rate: f32, hz: f32) {
|
||||
let fresh = Self::new(rate, hz);
|
||||
for i in 0..2 {
|
||||
self.low[i].retune(fresh.low[i]);
|
||||
self.high[i].retune(fresh.high[i]);
|
||||
}
|
||||
}
|
||||
/// Returns (low, high).
|
||||
#[inline]
|
||||
pub fn process(&mut self, x: f32) -> (f32, f32) {
|
||||
let low = self.low[0].process(x);
|
||||
let low = self.low[1].process(low);
|
||||
let high = self.high[0].process(x);
|
||||
let high = self.high[1].process(high);
|
||||
// LR4 bands are in phase at the crossover point, so they sum to an allpass.
|
||||
(low, high)
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
for f in self.low.iter_mut().chain(self.high.iter_mut()) {
|
||||
f.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// YIN fundamental-frequency estimator with preallocated scratch.
|
||||
pub struct Yin {
|
||||
diff: Vec<f32>,
|
||||
pub threshold: f32,
|
||||
}
|
||||
impl Yin {
|
||||
/// `max_lag` is the longest period in samples that will ever be requested.
|
||||
pub fn new(max_lag: usize) -> Self {
|
||||
Self {
|
||||
diff: vec![0.; max_lag + 2],
|
||||
threshold: 0.15,
|
||||
}
|
||||
}
|
||||
pub fn max_lag(&self) -> usize {
|
||||
self.diff.len() - 2
|
||||
}
|
||||
/// Estimates the fundamental of `x` in Hz with its clarity (1 = perfectly periodic).
|
||||
/// `x` must hold at least twice the longest period. Never allocates.
|
||||
pub fn estimate(
|
||||
&mut self,
|
||||
x: &[f32],
|
||||
rate: f32,
|
||||
min_hz: f32,
|
||||
max_hz: f32,
|
||||
) -> Option<(f32, f32)> {
|
||||
let hi = ((rate / min_hz).ceil() as usize).min(self.max_lag());
|
||||
let lo = ((rate / max_hz).floor() as usize).max(2);
|
||||
if hi <= lo + 2 || x.len() < hi + 2 {
|
||||
return None;
|
||||
}
|
||||
let window = x.len() - hi - 1;
|
||||
if window < hi {
|
||||
return None;
|
||||
}
|
||||
let energy: f32 = x[..window].iter().map(|v| v * v).sum();
|
||||
if energy < 1e-9 * window as f32 {
|
||||
return None;
|
||||
}
|
||||
let d = &mut self.diff;
|
||||
d[0] = 1.;
|
||||
let mut running = 0.;
|
||||
for tau in 1..=hi + 1 {
|
||||
let mut s = 0.;
|
||||
for j in 0..window {
|
||||
let e = x[j] - x[j + tau];
|
||||
s += e * e;
|
||||
}
|
||||
running += s;
|
||||
// Cumulative-mean normalisation: suppresses the zero-lag dip.
|
||||
d[tau] = if running > 0. {
|
||||
s * tau as f32 / running
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
}
|
||||
let mut best = None;
|
||||
let mut tau = lo;
|
||||
while tau < hi {
|
||||
if d[tau] < self.threshold {
|
||||
while tau + 1 < hi && d[tau + 1] < d[tau] {
|
||||
tau += 1;
|
||||
}
|
||||
best = Some(tau);
|
||||
break;
|
||||
}
|
||||
tau += 1;
|
||||
}
|
||||
let tau = match best {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
let (t, v) = (lo..hi)
|
||||
.map(|t| (t, d[t]))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
if v > 0.35 {
|
||||
return None;
|
||||
}
|
||||
t
|
||||
}
|
||||
};
|
||||
let (a, b, c) = (d[tau - 1], d[tau], d[tau + 1]);
|
||||
let denom = a - 2. * b + c;
|
||||
let shift = if denom.abs() > 1e-12 {
|
||||
(0.5 * (a - c) / denom).clamp(-1., 1.)
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
Some((rate / (tau as f32 + shift), (1. - b).clamp(0., 1.)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn midi_to_hz(note: f32) -> f32 {
|
||||
440. * 2f32.powf((note - 69.) / 12.)
|
||||
}
|
||||
pub fn hz_to_midi(hz: f32) -> f32 {
|
||||
69. + 12. * (hz.max(1e-3) / 440.).log2()
|
||||
}
|
||||
/// Scientific note name: MIDI 60 = C4, 43.65 Hz = F1. FL Studio labels MIDI 60 as C5.
|
||||
pub fn note_name(note: u8) -> String {
|
||||
const NAMES: [&str; 12] = [
|
||||
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
|
||||
];
|
||||
format!("{}{}", NAMES[note as usize % 12], note as i32 / 12 - 1)
|
||||
}
|
||||
pub fn db_to_gain(db: f32) -> f32 {
|
||||
10f32.powf(db / 20.)
|
||||
}
|
||||
pub fn gain_to_db(g: f32) -> f32 {
|
||||
20. * g.abs().max(1e-9).log10()
|
||||
}
|
||||
/// One-pole smoothing coefficient for a time constant in milliseconds.
|
||||
pub fn coefficient(rate: f32, ms: f32) -> f32 {
|
||||
if ms <= 0. {
|
||||
1.
|
||||
} else {
|
||||
1. - (-1. / (rate * ms * 0.001)).exp()
|
||||
}
|
||||
}
|
||||
/// PolyBLEP sawtooth oscillator: cheap and close to band-limited, for tests and previews.
|
||||
pub struct Saw {
|
||||
phase: f32,
|
||||
inc: f32,
|
||||
}
|
||||
impl Saw {
|
||||
pub fn new(rate: f32, hz: f32) -> Self {
|
||||
Self {
|
||||
phase: 0.,
|
||||
inc: hz / rate,
|
||||
}
|
||||
}
|
||||
pub fn sample(&mut self) -> f32 {
|
||||
let t = self.phase;
|
||||
let dt = self.inc;
|
||||
let blep = if t < dt {
|
||||
let x = t / dt;
|
||||
x + x - x * x - 1.
|
||||
} else if t > 1. - dt {
|
||||
let x = (t - 1.) / dt;
|
||||
x * x + x + x + 1.
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
self.phase += dt;
|
||||
if self.phase >= 1. {
|
||||
self.phase -= 1.;
|
||||
}
|
||||
2. * t - 1. - blep
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Minimal RIFF/WAVE writer and reader: 32-bit float data, sampler root key, Serum wavetable tag.
|
||||
use std::{io, path::Path};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Wave {
|
||||
pub channels: u16,
|
||||
pub rate: u32,
|
||||
/// Interleaved samples.
|
||||
pub samples: Vec<f32>,
|
||||
/// MIDI root key, written as a `smpl` chunk that samplers (FL included) read.
|
||||
pub root: Option<u8>,
|
||||
/// Wavetable frame size, written as Serum's `clm ` chunk.
|
||||
pub frame: Option<u32>,
|
||||
}
|
||||
|
||||
fn chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
|
||||
out.extend_from_slice(id);
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(body);
|
||||
if body.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode(w: &Wave) -> Vec<u8> {
|
||||
let channels = w.channels.max(1);
|
||||
let frames = w.samples.len() / channels as usize;
|
||||
let mut body = Vec::with_capacity(w.samples.len() * 4 + 256);
|
||||
body.extend_from_slice(b"WAVE");
|
||||
let mut fmt = Vec::new();
|
||||
fmt.extend_from_slice(&3u16.to_le_bytes()); // IEEE float
|
||||
fmt.extend_from_slice(&channels.to_le_bytes());
|
||||
fmt.extend_from_slice(&w.rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&(w.rate * 4 * channels as u32).to_le_bytes());
|
||||
fmt.extend_from_slice(&(4 * channels).to_le_bytes());
|
||||
fmt.extend_from_slice(&32u16.to_le_bytes());
|
||||
fmt.extend_from_slice(&0u16.to_le_bytes());
|
||||
chunk(&mut body, b"fmt ", &fmt);
|
||||
chunk(&mut body, b"fact", &(frames as u32).to_le_bytes());
|
||||
if let Some(frame) = w.frame {
|
||||
// Serum reads the frame size from the "<!>NNNN" prefix.
|
||||
chunk(
|
||||
&mut body,
|
||||
b"clm ",
|
||||
format!("<!>{frame} 00000000 wavetable (Crucible)").as_bytes(),
|
||||
);
|
||||
}
|
||||
if let Some(root) = w.root {
|
||||
let mut smpl = Vec::new();
|
||||
let period = (1e9 / w.rate.max(1) as f64).round() as u32;
|
||||
for v in [0, 0, period, root as u32, 0, 0, 0, 0, 0] {
|
||||
smpl.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
chunk(&mut body, b"smpl", &smpl);
|
||||
}
|
||||
let mut data = Vec::with_capacity(w.samples.len() * 4);
|
||||
for s in &w.samples[..frames * channels as usize] {
|
||||
data.extend_from_slice(&s.to_le_bytes());
|
||||
}
|
||||
chunk(&mut body, b"data", &data);
|
||||
let mut out = Vec::with_capacity(body.len() + 8);
|
||||
out.extend_from_slice(b"RIFF");
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn write(path: &Path, w: &Wave) -> io::Result<()> {
|
||||
std::fs::write(path, encode(w))
|
||||
}
|
||||
|
||||
/// Reads float32 or PCM16/24 files written by this module or common tools.
|
||||
pub fn decode(b: &[u8]) -> Result<Wave, &'static str> {
|
||||
if b.len() < 12 || &b[..4] != b"RIFF" || &b[8..12] != b"WAVE" {
|
||||
return Err("expected RIFF/WAVE");
|
||||
}
|
||||
let mut w = Wave::default();
|
||||
let (mut format, mut bits) = (0u16, 0u16);
|
||||
let mut at = 12;
|
||||
let mut data = false;
|
||||
let u16_at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
|
||||
let u32_at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
|
||||
while at + 8 <= b.len() {
|
||||
let id = &b[at..at + 4];
|
||||
let len = u32_at(at + 4) as usize;
|
||||
let start = at + 8;
|
||||
let end = start
|
||||
.checked_add(len)
|
||||
.filter(|e| *e <= b.len())
|
||||
.ok_or("truncated chunk")?;
|
||||
match id {
|
||||
b"fmt " if len >= 16 => {
|
||||
format = u16_at(start);
|
||||
w.channels = u16_at(start + 2);
|
||||
w.rate = u32_at(start + 4);
|
||||
bits = u16_at(start + 14);
|
||||
}
|
||||
b"smpl" if len >= 16 => w.root = Some(u32_at(start + 12) as u8),
|
||||
b"clm " => {
|
||||
let text = std::str::from_utf8(&b[start..end]).map_err(|_| "bad clm chunk")?;
|
||||
w.frame = text
|
||||
.strip_prefix("<!>")
|
||||
.and_then(|t| t.split_whitespace().next())
|
||||
.and_then(|n| n.parse().ok());
|
||||
}
|
||||
b"data" => {
|
||||
data = true;
|
||||
let d = &b[start..end];
|
||||
w.samples = match (format, bits) {
|
||||
(3, 32) => d
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| f32::from_le_bytes(*c))
|
||||
.collect(),
|
||||
(1, 16) => d
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| i16::from_le_bytes(*c) as f32 / 32768.)
|
||||
.collect(),
|
||||
(1, 24) => d
|
||||
.as_chunks::<3>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| (i32::from_le_bytes([0, c[0], c[1], c[2]]) >> 8) as f32 / 8388608.)
|
||||
.collect(),
|
||||
_ => return Err("unsupported sample format"),
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
at = end + (len % 2);
|
||||
}
|
||||
if w.channels == 0 || w.rate == 0 {
|
||||
return Err("missing fmt chunk");
|
||||
}
|
||||
if !data {
|
||||
return Err("missing data chunk");
|
||||
}
|
||||
Ok(w)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use common_dsp::{hz_to_midi, midi_to_hz, note_name, wav, Crossover, Saw, Yin};
|
||||
|
||||
fn cents(a: f32, b: f32) -> f32 {
|
||||
1200. * (a / b).log2()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yin_tracks_bass_fundamentals_within_two_cents() {
|
||||
let rate = 48000.;
|
||||
let mut yin = Yin::new(2400);
|
||||
for hz in [43.65, 55., 110., 220.] {
|
||||
let mut saw = Saw::new(rate, hz);
|
||||
let x: Vec<f32> = (0..6000).map(|_| saw.sample()).collect();
|
||||
let (got, clarity) = yin.estimate(&x, rate, 25., 1000.).unwrap();
|
||||
assert!(cents(got, hz).abs() < 2., "{hz}: {got}");
|
||||
assert!(clarity > 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yin_rejects_silence_and_noise() {
|
||||
let mut yin = Yin::new(2400);
|
||||
assert!(yin.estimate(&[0.; 6000], 48000., 25., 1000.).is_none());
|
||||
let mut seed = 1u32;
|
||||
let noise: Vec<f32> = (0..6000)
|
||||
.map(|_| {
|
||||
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
(seed >> 8) as f32 / (1u32 << 24) as f32 - 0.5
|
||||
})
|
||||
.collect();
|
||||
assert!(yin.estimate(&noise, 48000., 25., 1000.).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossover_bands_sum_to_flat_magnitude() {
|
||||
let rate = 48000.;
|
||||
for hz in [20., 45., 90., 180., 1000., 8000., 18000.] {
|
||||
let mut x = Crossover::new(rate, 90.);
|
||||
let (mut a, mut b) = (0f64, 0f64);
|
||||
let n = 96000;
|
||||
for i in 0..n {
|
||||
let s = (std::f32::consts::TAU * hz * i as f32 / rate).sin();
|
||||
let (l, h) = x.process(s);
|
||||
if i > n / 2 {
|
||||
a += (s as f64).powi(2);
|
||||
b += ((l + h) as f64).powi(2);
|
||||
}
|
||||
}
|
||||
let db = 10. * (b / a).log10();
|
||||
assert!(db.abs() < 0.1, "{hz} Hz: {db} dB");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_round_trip() {
|
||||
assert!((midi_to_hz(29.) - 43.65).abs() < 0.01);
|
||||
assert!((hz_to_midi(440.) - 69.).abs() < 1e-4);
|
||||
assert_eq!(note_name(29), "F1");
|
||||
assert_eq!(note_name(61), "C#4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wave_round_trip_keeps_root_and_wavetable_chunks() {
|
||||
let w = wav::Wave {
|
||||
channels: 2,
|
||||
rate: 44100,
|
||||
samples: (0..999)
|
||||
.map(|i| (i as f32 * 0.01).sin())
|
||||
.collect::<Vec<_>>()[..998]
|
||||
.to_vec(),
|
||||
root: Some(29),
|
||||
frame: Some(2048),
|
||||
};
|
||||
let bytes = wav::encode(&w);
|
||||
assert_eq!(&bytes[..4], b"RIFF");
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize,
|
||||
bytes.len() - 8
|
||||
);
|
||||
assert_eq!(wav::decode(&bytes).unwrap(), w);
|
||||
assert!(wav::decode(&bytes[..40]).is_err());
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "common-ui"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
egui = "0.31.1"
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,320 @@
|
||||
//! Shared hardware look for every plugin editor: palette, fonts, knobs, panels.
|
||||
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
|
||||
|
||||
// Dark anodized hardware: graphite surfaces, one signal colour, steel data traces.
|
||||
pub const BG: Color32 = Color32::from_rgb(14, 15, 17);
|
||||
pub const PANEL: Color32 = Color32::from_rgb(25, 27, 31);
|
||||
pub const PANEL_TOP: Color32 = Color32::from_rgb(34, 36, 41);
|
||||
pub const EDGE: Color32 = Color32::from_rgb(44, 47, 53);
|
||||
pub const SCREEN: Color32 = Color32::from_rgb(9, 10, 11);
|
||||
pub const TEXT: Color32 = Color32::from_rgb(232, 233, 236);
|
||||
pub const MUTED: Color32 = Color32::from_rgb(140, 146, 156);
|
||||
pub const DIM: Color32 = Color32::from_rgb(70, 74, 82);
|
||||
/// Signal colour: cuts, active states, value arcs.
|
||||
pub const ACCENT: Color32 = Color32::from_rgb(255, 1, 96);
|
||||
/// Measured input and neutral data.
|
||||
pub const STEEL: Color32 = Color32::from_rgb(184, 192, 204);
|
||||
/// Focus / protect curve, distinct from cuts without competing with them.
|
||||
pub const FOCUS: Color32 = Color32::from_rgb(126, 167, 216);
|
||||
pub fn style(ctx: &egui::Context) {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
fonts.font_data.insert(
|
||||
"regular".into(),
|
||||
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Regular.ttf")).into(),
|
||||
);
|
||||
fonts.font_data.insert(
|
||||
"medium".into(),
|
||||
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Medium.ttf")).into(),
|
||||
);
|
||||
fonts
|
||||
.families
|
||||
.get_mut(&egui::FontFamily::Proportional)
|
||||
.unwrap()
|
||||
.insert(0, "regular".into());
|
||||
fonts.families.insert(
|
||||
egui::FontFamily::Name("medium".into()),
|
||||
vec![
|
||||
"medium".into(),
|
||||
"Ubuntu-Light".into(),
|
||||
"NotoEmoji-Regular".into(),
|
||||
"emoji-icon-font".into(),
|
||||
],
|
||||
);
|
||||
ctx.set_fonts(fonts);
|
||||
let mut s = (*ctx.style()).clone();
|
||||
let v = &mut s.visuals;
|
||||
*v = egui::Visuals::dark();
|
||||
v.panel_fill = BG;
|
||||
v.window_fill = PANEL;
|
||||
v.extreme_bg_color = SCREEN;
|
||||
v.faint_bg_color = PANEL;
|
||||
v.override_text_color = Some(TEXT);
|
||||
v.selection.bg_fill = Color32::from_rgb(110, 6, 46);
|
||||
v.selection.stroke = Stroke::new(1.0_f32, ACCENT);
|
||||
v.hyperlink_color = ACCENT;
|
||||
v.window_stroke = Stroke::new(1.0_f32, EDGE);
|
||||
for (w, fill, stroke) in [
|
||||
(&mut v.widgets.noninteractive, PANEL, EDGE),
|
||||
(&mut v.widgets.inactive, Color32::from_rgb(35, 38, 43), EDGE),
|
||||
(
|
||||
&mut v.widgets.hovered,
|
||||
Color32::from_rgb(45, 48, 54),
|
||||
Color32::from_rgb(78, 82, 90),
|
||||
),
|
||||
(&mut v.widgets.active, Color32::from_rgb(58, 18, 34), ACCENT),
|
||||
(&mut v.widgets.open, Color32::from_rgb(35, 38, 43), EDGE),
|
||||
] {
|
||||
w.bg_fill = fill;
|
||||
w.weak_bg_fill = fill;
|
||||
w.bg_stroke = Stroke::new(1.0_f32, stroke);
|
||||
w.corner_radius = 3.into();
|
||||
}
|
||||
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||
v.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||
v.slider_trailing_fill = true;
|
||||
s.spacing.item_spacing = Vec2::new(10., 8.);
|
||||
s.spacing.button_padding = Vec2::new(12., 6.);
|
||||
s.text_styles
|
||||
.insert(egui::TextStyle::Body, FontId::proportional(16.));
|
||||
s.text_styles
|
||||
.insert(egui::TextStyle::Button, FontId::proportional(16.));
|
||||
ctx.set_style(s);
|
||||
}
|
||||
/// Disc with a vertical light-to-dark gradient, like brushed metal lit from above.
|
||||
pub fn metal_disc(p: &egui::Painter, center: Pos2, radius: f32, top: Color32, bottom: Color32) {
|
||||
let mut mesh = egui::Mesh::default();
|
||||
let mix = |t: f32| {
|
||||
let l = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8;
|
||||
Color32::from_rgb(
|
||||
l(top.r(), bottom.r()),
|
||||
l(top.g(), bottom.g()),
|
||||
l(top.b(), bottom.b()),
|
||||
)
|
||||
};
|
||||
mesh.colored_vertex(center, mix(0.5));
|
||||
let n = 48;
|
||||
for i in 0..=n {
|
||||
let a = std::f32::consts::TAU * i as f32 / n as f32;
|
||||
let point = center + Vec2::angled(a) * radius;
|
||||
mesh.colored_vertex(point, mix((point.y - (center.y - radius)) / (2. * radius)));
|
||||
if i > 0 {
|
||||
mesh.add_triangle(0, i, i + 1);
|
||||
}
|
||||
}
|
||||
p.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn rotary(
|
||||
ui: &mut egui::Ui,
|
||||
name: &str,
|
||||
value: &mut f32,
|
||||
range: std::ops::RangeInclusive<f32>,
|
||||
default: f32,
|
||||
unit: &str,
|
||||
size: f32,
|
||||
color: Color32,
|
||||
) -> egui::Response {
|
||||
let low = *range.start();
|
||||
let high = *range.end();
|
||||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click_and_drag());
|
||||
if response.drag_started() {
|
||||
response.request_focus();
|
||||
}
|
||||
if response.dragged() {
|
||||
let delta = ui.input(|i| i.pointer.delta().y);
|
||||
let fine = if ui.input(|i| i.modifiers.shift) {
|
||||
0.1
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
*value = (*value - delta * (high - low) / 180. * fine).clamp(low, high);
|
||||
}
|
||||
if response.double_clicked() {
|
||||
*value = default;
|
||||
}
|
||||
if response.has_focus() {
|
||||
let step = if ui.input(|i| i.modifiers.shift) {
|
||||
0.1
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
if ui.input(|i| i.key_pressed(egui::Key::ArrowUp) || i.key_pressed(egui::Key::ArrowRight)) {
|
||||
*value = (*value + step).min(high);
|
||||
}
|
||||
if ui.input(|i| i.key_pressed(egui::Key::ArrowDown) || i.key_pressed(egui::Key::ArrowLeft))
|
||||
{
|
||||
*value = (*value - step).max(low);
|
||||
}
|
||||
}
|
||||
let center = rect.center();
|
||||
let radius = size * 0.46;
|
||||
let p = ui.painter();
|
||||
let start = std::f32::consts::PI * 0.75;
|
||||
let span = std::f32::consts::PI * 1.5;
|
||||
let normalized = ((*value - low) / (high - low)).clamp(0., 1.);
|
||||
// Scale track and value arc around the knob.
|
||||
for (fraction, stroke) in [
|
||||
(1., Stroke::new(2.5_f32, DIM)),
|
||||
(normalized, Stroke::new(2.5_f32, color)),
|
||||
] {
|
||||
// A zero-length polyline tessellates to NaN vertices.
|
||||
if fraction < 1e-3 {
|
||||
continue;
|
||||
}
|
||||
let points: Vec<_> = (0..=64)
|
||||
.map(|i| {
|
||||
let a = start + span * fraction * i as f32 / 64.;
|
||||
center + Vec2::angled(a) * (radius - 1.5)
|
||||
})
|
||||
.collect();
|
||||
p.add(egui::Shape::line(points, stroke));
|
||||
}
|
||||
// Machined body: drop shadow, brushed gradient skirt, flat cap.
|
||||
let body = radius - 7.;
|
||||
p.circle_filled(
|
||||
center + Vec2::new(0., 2.),
|
||||
body + 1.,
|
||||
Color32::from_black_alpha(140),
|
||||
);
|
||||
metal_disc(
|
||||
p,
|
||||
center,
|
||||
body,
|
||||
Color32::from_rgb(78, 82, 90),
|
||||
Color32::from_rgb(28, 30, 34),
|
||||
);
|
||||
p.circle_stroke(
|
||||
center,
|
||||
body,
|
||||
Stroke::new(1.0_f32, Color32::from_rgb(10, 11, 12)),
|
||||
);
|
||||
let cap = body * 0.74;
|
||||
metal_disc(
|
||||
p,
|
||||
center,
|
||||
cap,
|
||||
Color32::from_rgb(44, 47, 52),
|
||||
Color32::from_rgb(33, 35, 40),
|
||||
);
|
||||
p.circle_stroke(
|
||||
center,
|
||||
cap,
|
||||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 22)),
|
||||
);
|
||||
let angle = start + span * normalized;
|
||||
p.line_segment(
|
||||
[
|
||||
center + Vec2::angled(angle) * (cap * 0.35),
|
||||
center + Vec2::angled(angle) * (cap - 2.),
|
||||
],
|
||||
Stroke::new(2.0_f32, TEXT),
|
||||
);
|
||||
if response.has_focus() {
|
||||
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, color));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(name.to_uppercase())
|
||||
.size(if size > 100. { 14. } else { 12. })
|
||||
.color(MUTED),
|
||||
);
|
||||
ui.add(
|
||||
egui::DragValue::new(value)
|
||||
.range(range)
|
||||
.speed(0.1)
|
||||
.suffix(unit),
|
||||
);
|
||||
response
|
||||
}
|
||||
/// Vertical gradient rectangle, used for panel faces.
|
||||
pub fn gradient(rect: Rect, top: Color32, bottom: Color32) -> egui::Shape {
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for (pos, color) in [
|
||||
(rect.left_top(), top),
|
||||
(rect.right_top(), top),
|
||||
(rect.right_bottom(), bottom),
|
||||
(rect.left_bottom(), bottom),
|
||||
] {
|
||||
mesh.colored_vertex(pos, color);
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
egui::Shape::mesh(mesh)
|
||||
}
|
||||
/// Raised hardware panel: graphite face, hairline edge, faint top highlight.
|
||||
pub fn panel<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
let face = ui.painter().add(egui::Shape::Noop);
|
||||
let inner = egui::Frame::new()
|
||||
.inner_margin(14.)
|
||||
.stroke(Stroke::new(1.0_f32, EDGE))
|
||||
.show(ui, add);
|
||||
let r = inner.response.rect;
|
||||
ui.painter().set(face, gradient(r, PANEL_TOP, PANEL));
|
||||
ui.painter().line_segment(
|
||||
[
|
||||
r.left_top() + Vec2::new(1., 1.),
|
||||
r.right_top() + Vec2::new(-1., 1.),
|
||||
],
|
||||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 16)),
|
||||
);
|
||||
inner.inner
|
||||
}
|
||||
pub fn caption(ui: &mut egui::Ui, text: &str) {
|
||||
ui.label(RichText::new(text).size(11.).color(MUTED));
|
||||
}
|
||||
/// Small labelled hardware-style readout.
|
||||
pub fn readout(ui: &mut egui::Ui, label: &str, value: String, color: Color32) {
|
||||
ui.label(RichText::new(label).size(11.).color(MUTED));
|
||||
ui.label(RichText::new(value).monospace().size(13.).color(color));
|
||||
ui.add_space(10.);
|
||||
}
|
||||
/// Product header: accent mark, product name, subtitle, then right-aligned controls.
|
||||
pub fn header(ui: &mut egui::Ui, name: &str, subtitle: &str, right: impl FnOnce(&mut egui::Ui)) {
|
||||
ui.horizontal(|ui| {
|
||||
let (mark, _) = ui.allocate_exact_size(Vec2::new(4., 26.), Sense::hover());
|
||||
ui.painter().rect_filled(mark, 1., ACCENT);
|
||||
ui.label(
|
||||
RichText::new(name)
|
||||
.font(FontId::new(26., egui::FontFamily::Name("medium".into())))
|
||||
.color(TEXT),
|
||||
);
|
||||
ui.label(RichText::new(subtitle).size(12.).color(MUTED));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), right);
|
||||
});
|
||||
}
|
||||
/// Full-window backdrop gradient used behind every editor.
|
||||
pub fn backdrop(ui: &egui::Ui) {
|
||||
ui.painter()
|
||||
.add(gradient(ui.max_rect(), Color32::from_rgb(20, 21, 24), BG));
|
||||
}
|
||||
/// Horizontal level bar in dBFS (−60..0) with a hairline frame.
|
||||
pub fn meter(ui: &mut egui::Ui, label: &str, db: f32, color: Color32, width: f32) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(
|
||||
Vec2::new(70., 16.),
|
||||
egui::Label::new(RichText::new(label).size(11.).color(MUTED)),
|
||||
);
|
||||
let (rect, _) = ui.allocate_exact_size(Vec2::new(width, 10.), Sense::hover());
|
||||
let p = ui.painter();
|
||||
p.rect_filled(rect, 1., SCREEN);
|
||||
let t = ((db + 60.) / 60.).clamp(0., 1.);
|
||||
let mut fill = rect;
|
||||
fill.set_width(rect.width() * t);
|
||||
p.rect_filled(fill, 1., color);
|
||||
p.rect_stroke(
|
||||
rect,
|
||||
1.,
|
||||
Stroke::new(1.0_f32, EDGE),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(if db <= -60. {
|
||||
"−∞".to_string()
|
||||
} else {
|
||||
format!("{db:.1} dB")
|
||||
})
|
||||
.monospace()
|
||||
.size(12.)
|
||||
.color(TEXT),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "crucible-core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common-dsp = { path = "../../common/dsp" }
|
||||
crossbeam-queue = "0.3"
|
||||
serde = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Writes a take as ready-to-use files: the full loop, one file per slice and a wavetable.
|
||||
use crate::settings::Settings;
|
||||
use crate::take::{render, wavetable, Take, TABLE};
|
||||
use common_dsp::{note_name, wav};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Exported {
|
||||
pub folder: PathBuf,
|
||||
pub files: usize,
|
||||
/// Why no wavetable was written although one was requested.
|
||||
pub wavetable_skipped: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// UTC date and time for folder names, from the Unix time in seconds.
|
||||
pub fn stamp(unix: u64) -> String {
|
||||
let days = (unix / 86400) as i64;
|
||||
let secs = unix % 86400;
|
||||
// Civil-from-days (H. Hinnant), proleptic Gregorian calendar.
|
||||
let z = days + 719468;
|
||||
let era = z.div_euclid(146097);
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
format!(
|
||||
"{year:04}-{month:02}-{day:02}_{:02}{:02}{:02}",
|
||||
secs / 3600,
|
||||
secs / 60 % 60,
|
||||
secs % 60
|
||||
)
|
||||
}
|
||||
|
||||
pub fn export(
|
||||
take: &Take,
|
||||
settings: &Settings,
|
||||
selected: Option<usize>,
|
||||
root: &Path,
|
||||
unix: u64,
|
||||
) -> std::io::Result<Exported> {
|
||||
let folder = root.join(format!("{}_{:.0}bpm", stamp(unix), take.tempo()));
|
||||
std::fs::create_dir_all(&folder)?;
|
||||
let rate = take.rate.round() as u32;
|
||||
let stereo = |samples: Vec<f32>, root: Option<u8>| wav::Wave {
|
||||
channels: 2,
|
||||
rate,
|
||||
samples,
|
||||
root,
|
||||
frame: None,
|
||||
};
|
||||
wav::write(&folder.join("loop.wav"), &stereo(take.audio.clone(), None))?;
|
||||
let mut files = 1;
|
||||
for (i, s) in take.slices.iter().enumerate().filter(|(_, s)| s.enabled) {
|
||||
let name = match s.root {
|
||||
Some(n) => format!("s{:02}_{}.wav", i + 1, note_name(n)),
|
||||
None => format!("s{:02}.wav", i + 1),
|
||||
};
|
||||
wav::write(
|
||||
&folder.join(name),
|
||||
&stereo(render(take, s, settings), s.root),
|
||||
)?;
|
||||
files += 1;
|
||||
}
|
||||
let mut wavetable_skipped = None;
|
||||
if settings.wavetable {
|
||||
let source = selected
|
||||
.and_then(|i| take.slices.get(i))
|
||||
.or_else(|| take.slices.iter().find(|s| s.enabled && s.root.is_some()));
|
||||
match source.map(|s| wavetable(&take.mono(s.start, s.end), take.rate)) {
|
||||
Some(Some(table)) => {
|
||||
let w = wav::Wave {
|
||||
channels: 1,
|
||||
rate,
|
||||
samples: table,
|
||||
root: None,
|
||||
frame: Some(TABLE as u32),
|
||||
};
|
||||
wav::write(&folder.join("wavetable.wav"), &w)?;
|
||||
files += 1;
|
||||
}
|
||||
Some(None) => wavetable_skipped = Some("the slice has no clear pitch"),
|
||||
None => wavetable_skipped = Some("no slice selected"),
|
||||
}
|
||||
}
|
||||
Ok(Exported {
|
||||
folder,
|
||||
files,
|
||||
wavetable_skipped,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Resampler: always records the input, captures the newest bars on request, slices them,
|
||||
//! cleans each slice and writes ready-to-use files.
|
||||
mod export;
|
||||
mod player;
|
||||
mod ring;
|
||||
mod settings;
|
||||
mod take;
|
||||
pub use export::{export, stamp, Exported};
|
||||
pub use player::{Clip, Player, Queues};
|
||||
pub use ring::{Marker, Recorder, Ring, Transport, MARKERS};
|
||||
pub use settings::{default_folder, Division, Mode, Settings};
|
||||
pub use take::{
|
||||
capture, render, root_key, transients, wavetable, Segment, Slice, Take, FALLBACK_TEMPO,
|
||||
OVERVIEW, TABLE, TABLE_FRAMES,
|
||||
};
|
||||
|
||||
/// Seconds of history: 16 bars at 60 BPM in 4/4, plus slack for the capture copy.
|
||||
pub const HISTORY: f32 = 72.;
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Audition of slices on the audio thread without allocating or freeing there.
|
||||
use crossbeam_queue::ArrayQueue;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub type Clip = Arc<[f32]>;
|
||||
|
||||
/// Shared queues. The GUI sends clips and drains finished ones, so all frees happen there.
|
||||
pub struct Queues {
|
||||
incoming: ArrayQueue<Option<Clip>>,
|
||||
finished: ArrayQueue<Clip>,
|
||||
}
|
||||
impl Default for Queues {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
incoming: ArrayQueue::new(4),
|
||||
finished: ArrayQueue::new(16),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Queues {
|
||||
/// Plays an interleaved stereo clip, replacing the current one.
|
||||
pub fn play(&self, clip: Clip) {
|
||||
self.drain();
|
||||
let _ = self.incoming.force_push(Some(clip));
|
||||
}
|
||||
pub fn stop(&self) {
|
||||
let _ = self.incoming.force_push(None);
|
||||
}
|
||||
/// Frees finished clips. Call regularly from the GUI thread.
|
||||
pub fn drain(&self) {
|
||||
while self.finished.pop().is_some() {}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Player {
|
||||
clip: Option<Clip>,
|
||||
at: usize,
|
||||
}
|
||||
impl Player {
|
||||
fn retire(&mut self, queues: &Queues) {
|
||||
if let Some(old) = self.clip.take() {
|
||||
// The GUI drains this queue every frame; a full queue only happens with no editor.
|
||||
if let Err(old) = queues.finished.push(old) {
|
||||
std::mem::forget(old);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Call once per block.
|
||||
pub fn block(&mut self, queues: &Queues) {
|
||||
while let Some(next) = queues.incoming.pop() {
|
||||
self.retire(queues);
|
||||
self.clip = next;
|
||||
self.at = 0;
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub fn next(&mut self, queues: &Queues) -> [f32; 2] {
|
||||
let Some(clip) = &self.clip else {
|
||||
return [0.; 2];
|
||||
};
|
||||
if self.at + 1 >= clip.len() {
|
||||
self.retire(queues);
|
||||
return [0.; 2];
|
||||
}
|
||||
let out = [clip[self.at], clip[self.at + 1]];
|
||||
self.at += 2;
|
||||
out
|
||||
}
|
||||
pub fn playing(&self) -> bool {
|
||||
self.clip.is_some()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Lock-free history of the input plus the host transport, shared by the audio and GUI threads.
|
||||
//!
|
||||
//! The audio thread writes samples with relaxed stores and publishes the frame count with a
|
||||
//! release store. The GUI thread reads the count with acquire, copies, then checks the count
|
||||
//! again: anything the writer may have overwritten during the copy is reported, never returned.
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
|
||||
/// Transport markers kept for alignment; one is written at least every half second.
|
||||
pub const MARKERS: usize = 512;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Transport {
|
||||
pub playing: bool,
|
||||
pub tempo: f64,
|
||||
/// Song position in quarter notes at the first sample of the block.
|
||||
pub beats: f64,
|
||||
pub numerator: u32,
|
||||
pub denominator: u32,
|
||||
}
|
||||
impl Transport {
|
||||
/// Bar length in quarter notes.
|
||||
pub fn bar(&self) -> f64 {
|
||||
self.numerator.max(1) as f64 * 4. / self.denominator.max(1) as f64
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Marker {
|
||||
/// Absolute recorded frame index.
|
||||
pub frame: u64,
|
||||
pub transport: Transport,
|
||||
}
|
||||
impl Marker {
|
||||
/// Song position at `frame`, extrapolated at this marker's tempo.
|
||||
pub fn beats_at(&self, frame: f64, rate: f64) -> f64 {
|
||||
self.transport.beats + (frame - self.frame as f64) / rate * self.transport.tempo / 60.
|
||||
}
|
||||
pub fn frame_at(&self, beats: f64, rate: f64) -> f64 {
|
||||
self.frame as f64 + (beats - self.transport.beats) * 60. / self.transport.tempo * rate
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Ring {
|
||||
rate: f32,
|
||||
frames: usize,
|
||||
data: Box<[AtomicU32]>,
|
||||
written: AtomicU64,
|
||||
markers: Box<[[AtomicU64; 4]]>,
|
||||
marker_count: AtomicU64,
|
||||
peak: AtomicU32,
|
||||
}
|
||||
impl Ring {
|
||||
/// Stereo history of `seconds` at `rate`.
|
||||
pub fn new(rate: f32, seconds: f32) -> Self {
|
||||
let frames = (rate * seconds).ceil().max(1.) as usize;
|
||||
Self {
|
||||
rate,
|
||||
frames,
|
||||
data: (0..frames * 2).map(|_| AtomicU32::new(0)).collect(),
|
||||
written: AtomicU64::new(0),
|
||||
markers: (0..MARKERS)
|
||||
.map(|_| std::array::from_fn(|_| AtomicU64::new(0)))
|
||||
.collect(),
|
||||
marker_count: AtomicU64::new(0),
|
||||
peak: AtomicU32::new(0),
|
||||
}
|
||||
}
|
||||
pub fn rate(&self) -> f32 {
|
||||
self.rate
|
||||
}
|
||||
pub fn capacity(&self) -> usize {
|
||||
self.frames
|
||||
}
|
||||
pub fn written(&self) -> u64 {
|
||||
self.written.load(Ordering::Acquire)
|
||||
}
|
||||
/// Input peak since the last call, for the editor meter.
|
||||
pub fn take_peak(&self) -> f32 {
|
||||
f32::from_bits(self.peak.swap(0, Ordering::AcqRel))
|
||||
}
|
||||
/// Markers that are certainly intact, oldest first.
|
||||
pub fn markers(&self) -> Vec<Marker> {
|
||||
let count = self.marker_count.load(Ordering::Acquire);
|
||||
// A margin keeps slots the writer may be refilling during this read out of the result.
|
||||
let first = count.saturating_sub(MARKERS as u64 - 8);
|
||||
let mut out: Vec<Marker> = (first..count)
|
||||
.map(|i| {
|
||||
let s = &self.markers[(i % MARKERS as u64) as usize];
|
||||
let meta = s[3].load(Ordering::Relaxed);
|
||||
Marker {
|
||||
frame: s[0].load(Ordering::Relaxed),
|
||||
transport: Transport {
|
||||
beats: f64::from_bits(s[1].load(Ordering::Relaxed)),
|
||||
tempo: f64::from_bits(s[2].load(Ordering::Relaxed)),
|
||||
numerator: (meta & 0xffff) as u32,
|
||||
denominator: ((meta >> 16) & 0xffff) as u32,
|
||||
playing: meta >> 32 & 1 == 1,
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let after = self.marker_count.load(Ordering::Acquire);
|
||||
let safe = after.saturating_sub(MARKERS as u64 - 8);
|
||||
out.drain(..(safe.saturating_sub(first) as usize).min(out.len()));
|
||||
out
|
||||
}
|
||||
/// Copies frames `[start, end)` as interleaved stereo. Fails if any were overwritten.
|
||||
pub fn copy(&self, start: u64, end: u64) -> Result<Vec<f32>, &'static str> {
|
||||
let written = self.written();
|
||||
if end > written || start > end {
|
||||
return Err("Capture range is not recorded yet.");
|
||||
}
|
||||
if written - start > self.frames as u64 {
|
||||
return Err("Capture is longer than the recording buffer.");
|
||||
}
|
||||
let mut out = Vec::with_capacity(((end - start) * 2) as usize);
|
||||
for f in start..end {
|
||||
let at = (f % self.frames as u64) as usize * 2;
|
||||
out.push(f32::from_bits(self.data[at].load(Ordering::Relaxed)));
|
||||
out.push(f32::from_bits(self.data[at + 1].load(Ordering::Relaxed)));
|
||||
}
|
||||
if self.written() - start > self.frames as u64 {
|
||||
return Err("Capture was overwritten while copying. Try again.");
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio-thread writer. Never allocates.
|
||||
pub struct Recorder {
|
||||
last: Option<Marker>,
|
||||
frame: u64,
|
||||
since_marker: u64,
|
||||
peak: f32,
|
||||
}
|
||||
impl Default for Recorder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
impl Recorder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
last: None,
|
||||
frame: 0,
|
||||
since_marker: 0,
|
||||
peak: 0.,
|
||||
}
|
||||
}
|
||||
/// Call at the start of every block with the host transport.
|
||||
pub fn block(&mut self, ring: &Ring, transport: Option<Transport>) {
|
||||
let Some(t) =
|
||||
transport.filter(|t| t.tempo.is_finite() && t.tempo > 1. && t.beats.is_finite())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let rate = ring.rate as f64;
|
||||
let changed = match self.last {
|
||||
None => true,
|
||||
Some(m) => {
|
||||
let expected = m.beats_at(self.frame as f64, rate);
|
||||
m.transport.playing != t.playing
|
||||
|| (m.transport.tempo - t.tempo).abs() > 1e-6
|
||||
|| m.transport.numerator != t.numerator
|
||||
|| m.transport.denominator != t.denominator
|
||||
|| (t.playing && (expected - t.beats).abs() > 0.01)
|
||||
}
|
||||
};
|
||||
if changed || self.since_marker as f64 >= rate * 0.5 {
|
||||
let m = Marker {
|
||||
frame: self.frame,
|
||||
transport: t,
|
||||
};
|
||||
let count = ring.marker_count.load(Ordering::Relaxed);
|
||||
let s = &ring.markers[(count % MARKERS as u64) as usize];
|
||||
s[0].store(m.frame, Ordering::Relaxed);
|
||||
s[1].store(t.beats.to_bits(), Ordering::Relaxed);
|
||||
s[2].store(t.tempo.to_bits(), Ordering::Relaxed);
|
||||
let meta = (t.numerator as u64 & 0xffff)
|
||||
| (t.denominator as u64 & 0xffff) << 16
|
||||
| (t.playing as u64) << 32;
|
||||
s[3].store(meta, Ordering::Relaxed);
|
||||
ring.marker_count.store(count + 1, Ordering::Release);
|
||||
self.last = Some(m);
|
||||
self.since_marker = 0;
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub fn push(&mut self, ring: &Ring, frame: [f32; 2]) {
|
||||
let at = (self.frame % ring.frames as u64) as usize * 2;
|
||||
ring.data[at].store(frame[0].to_bits(), Ordering::Relaxed);
|
||||
ring.data[at + 1].store(frame[1].to_bits(), Ordering::Relaxed);
|
||||
self.frame += 1;
|
||||
self.since_marker += 1;
|
||||
self.peak = self.peak.max(frame[0].abs()).max(frame[1].abs());
|
||||
}
|
||||
/// Makes the frames pushed so far visible to the GUI thread. Call at the end of a block.
|
||||
pub fn publish(&mut self, ring: &Ring) {
|
||||
ring.written.store(self.frame, Ordering::Release);
|
||||
let old = f32::from_bits(ring.peak.load(Ordering::Relaxed));
|
||||
if self.peak > old {
|
||||
ring.peak.store(self.peak.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
self.peak = 0.;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Persisted editor choices. Takes are not part of plugin state; exported files are.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub enum Mode {
|
||||
/// Cut on the bar/beat grid.
|
||||
#[default]
|
||||
Grid,
|
||||
/// Cut where a new hit starts.
|
||||
Transient,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub enum Division {
|
||||
Quarter,
|
||||
Eighth,
|
||||
#[default]
|
||||
Sixteenth,
|
||||
EighthTriplet,
|
||||
SixteenthTriplet,
|
||||
}
|
||||
impl Division {
|
||||
pub const ALL: [Division; 5] = [
|
||||
Division::Quarter,
|
||||
Division::Eighth,
|
||||
Division::Sixteenth,
|
||||
Division::EighthTriplet,
|
||||
Division::SixteenthTriplet,
|
||||
];
|
||||
/// Slices per quarter note.
|
||||
pub fn per_quarter(self) -> u32 {
|
||||
match self {
|
||||
Division::Quarter => 1,
|
||||
Division::Eighth => 2,
|
||||
Division::Sixteenth => 4,
|
||||
Division::EighthTriplet => 3,
|
||||
Division::SixteenthTriplet => 6,
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Division::Quarter => "1/4",
|
||||
Division::Eighth => "1/8",
|
||||
Division::Sixteenth => "1/16",
|
||||
Division::EighthTriplet => "1/8T",
|
||||
Division::SixteenthTriplet => "1/16T",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
pub bars: u32,
|
||||
pub mode: Mode,
|
||||
pub division: Division,
|
||||
/// Transient sensitivity, 0–100 %.
|
||||
pub sensitivity: f32,
|
||||
/// Fade length at both slice edges in milliseconds.
|
||||
pub declick: f32,
|
||||
/// Peak-normalise each slice to −0.3 dBFS.
|
||||
pub normalize: bool,
|
||||
/// Remove leading silence below −60 dBFS.
|
||||
pub trim: bool,
|
||||
/// Also write a wavetable from the selected slice.
|
||||
pub wavetable: bool,
|
||||
/// Export folder; empty selects the default.
|
||||
pub folder: String,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bars: 4,
|
||||
mode: Mode::Grid,
|
||||
division: Division::Sixteenth,
|
||||
sensitivity: 50.,
|
||||
declick: 2.,
|
||||
normalize: true,
|
||||
trim: true,
|
||||
wavetable: false,
|
||||
folder: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Settings {
|
||||
pub const BARS: [u32; 5] = [1, 2, 4, 8, 16];
|
||||
/// Clamps values from old or hand-edited state into their ranges.
|
||||
pub fn sanitize(&mut self) {
|
||||
if !Self::BARS.contains(&self.bars) {
|
||||
self.bars = 4;
|
||||
}
|
||||
self.sensitivity = if self.sensitivity.is_finite() {
|
||||
self.sensitivity.clamp(0., 100.)
|
||||
} else {
|
||||
50.
|
||||
};
|
||||
self.declick = if self.declick.is_finite() {
|
||||
self.declick.clamp(0., 20.)
|
||||
} else {
|
||||
2.
|
||||
};
|
||||
}
|
||||
pub fn folder(&self) -> PathBuf {
|
||||
if !self.folder.trim().is_empty() {
|
||||
return PathBuf::from(self.folder.trim());
|
||||
}
|
||||
default_folder()
|
||||
}
|
||||
}
|
||||
pub fn default_folder() -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
if let Some(home) = std::env::var_os("USERPROFILE") {
|
||||
return PathBuf::from(home).join("Documents").join("Crucible");
|
||||
}
|
||||
}
|
||||
match std::env::var_os("HOME") {
|
||||
Some(home) => PathBuf::from(home).join("Crucible"),
|
||||
None => PathBuf::from("Crucible"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
//! Captured audio with its tempo map, slice boundaries and per-slice cleanup.
|
||||
use crate::ring::{Marker, Ring, Transport};
|
||||
use crate::settings::{Division, Mode, Settings};
|
||||
use common_dsp::{hz_to_midi, Yin};
|
||||
|
||||
/// Used when the host reports no tempo.
|
||||
pub const FALLBACK_TEMPO: f64 = 140.;
|
||||
/// Waveform overview resolution.
|
||||
pub const OVERVIEW: usize = 2048;
|
||||
|
||||
/// One constant-tempo stretch of the take, relative to the take start.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Segment {
|
||||
pub frame: f64,
|
||||
pub quarters: f64,
|
||||
pub tempo: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Slice {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub enabled: bool,
|
||||
pub root: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Take {
|
||||
pub rate: f32,
|
||||
/// Interleaved stereo.
|
||||
pub audio: Vec<f32>,
|
||||
pub bars: u32,
|
||||
pub numerator: u32,
|
||||
pub denominator: u32,
|
||||
/// True when aligned to the host's bar lines; false when the transport was stopped.
|
||||
pub synced: bool,
|
||||
pub segments: Vec<Segment>,
|
||||
/// Slice boundaries in frames, sorted, starting at 0 and ending at the take length.
|
||||
pub cuts: Vec<usize>,
|
||||
pub slices: Vec<Slice>,
|
||||
/// Min/max per overview bucket of the mono sum.
|
||||
pub overview: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
impl Take {
|
||||
pub fn frames(&self) -> usize {
|
||||
self.audio.len() / 2
|
||||
}
|
||||
pub fn tempo(&self) -> f64 {
|
||||
self.segments.first().map_or(FALLBACK_TEMPO, |s| s.tempo)
|
||||
}
|
||||
pub fn bar_quarters(&self) -> f64 {
|
||||
self.numerator.max(1) as f64 * 4. / self.denominator.max(1) as f64
|
||||
}
|
||||
pub fn quarters(&self) -> f64 {
|
||||
self.bars as f64 * self.bar_quarters()
|
||||
}
|
||||
/// Frame at a position in quarter notes from the take start.
|
||||
pub fn frame_at(&self, quarters: f64) -> f64 {
|
||||
let s = self
|
||||
.segments
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|s| s.quarters <= quarters + 1e-9)
|
||||
.or(self.segments.first());
|
||||
match s {
|
||||
Some(s) => s.frame + (quarters - s.quarters) * 60. / s.tempo * self.rate as f64,
|
||||
None => quarters * 60. / FALLBACK_TEMPO * self.rate as f64,
|
||||
}
|
||||
}
|
||||
pub fn mono(&self, start: usize, end: usize) -> Vec<f32> {
|
||||
self.audio[start * 2..end * 2]
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|f| 0.5 * (f[0] + f[1]))
|
||||
.collect()
|
||||
}
|
||||
fn build_overview(&mut self) {
|
||||
let n = self.frames();
|
||||
self.overview = (0..OVERVIEW)
|
||||
.map(|b| {
|
||||
let (a, z) = (
|
||||
b * n / OVERVIEW,
|
||||
((b + 1) * n / OVERVIEW).max(b * n / OVERVIEW + 1),
|
||||
);
|
||||
let mut mm = [0f32, 0f32];
|
||||
for f in a..z.min(n) {
|
||||
let v = 0.5 * (self.audio[2 * f] + self.audio[2 * f + 1]);
|
||||
mm[0] = mm[0].min(v);
|
||||
mm[1] = mm[1].max(v);
|
||||
}
|
||||
mm
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
pub fn from_audio(
|
||||
rate: f32,
|
||||
audio: Vec<f32>,
|
||||
bars: u32,
|
||||
transport: Transport,
|
||||
segments: Vec<Segment>,
|
||||
synced: bool,
|
||||
) -> Self {
|
||||
let mut t = Take {
|
||||
rate,
|
||||
audio,
|
||||
bars,
|
||||
numerator: transport.numerator.max(1),
|
||||
denominator: transport.denominator.max(1),
|
||||
synced,
|
||||
segments,
|
||||
cuts: Vec::new(),
|
||||
slices: Vec::new(),
|
||||
overview: Vec::new(),
|
||||
};
|
||||
t.build_overview();
|
||||
let n = t.frames();
|
||||
t.cuts = vec![0, n];
|
||||
t.rebuild_slices();
|
||||
t
|
||||
}
|
||||
/// Re-cuts the take from the settings, replacing manual edits.
|
||||
pub fn slice(&mut self, settings: &Settings) {
|
||||
let n = self.frames();
|
||||
let mut cuts = match settings.mode {
|
||||
Mode::Grid => grid(self, settings.division),
|
||||
Mode::Transient => transients(&self.mono(0, n), self.rate, settings.sensitivity),
|
||||
};
|
||||
cuts.push(0);
|
||||
cuts.push(n);
|
||||
cuts.sort_unstable();
|
||||
cuts.dedup();
|
||||
cuts.retain(|c| *c <= n);
|
||||
self.cuts = cuts;
|
||||
self.rebuild_slices();
|
||||
}
|
||||
/// Adds a cut; returns false if it is too close to an existing one.
|
||||
pub fn add_cut(&mut self, frame: usize) -> bool {
|
||||
let min = (self.rate * 0.005) as usize;
|
||||
if frame == 0 || frame >= self.frames() || self.cuts.iter().any(|c| c.abs_diff(frame) < min)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let at = self.cuts.partition_point(|c| *c < frame);
|
||||
self.cuts.insert(at, frame);
|
||||
self.rebuild_slices();
|
||||
true
|
||||
}
|
||||
/// Removes the inner cut nearest to `frame`.
|
||||
pub fn remove_cut(&mut self, frame: usize) -> bool {
|
||||
let inner = 1..self.cuts.len().saturating_sub(1);
|
||||
let Some(i) = inner.min_by_key(|i| self.cuts[*i].abs_diff(frame)) else {
|
||||
return false;
|
||||
};
|
||||
self.cuts.remove(i);
|
||||
self.rebuild_slices();
|
||||
true
|
||||
}
|
||||
fn rebuild_slices(&mut self) {
|
||||
let old = std::mem::take(&mut self.slices);
|
||||
let mut yin = Yin::new((self.rate / 35.) as usize + 4);
|
||||
self.slices = self
|
||||
.cuts
|
||||
.windows(2)
|
||||
.map(|w| {
|
||||
// Manual include/exclude choices survive a re-cut when the slice keeps its start.
|
||||
let enabled = old
|
||||
.iter()
|
||||
.find(|s| s.start == w[0])
|
||||
.is_none_or(|s| s.enabled);
|
||||
let mono = self.mono(w[0], w[1]);
|
||||
Slice {
|
||||
start: w[0],
|
||||
end: w[1],
|
||||
enabled,
|
||||
root: root_key(&mut yin, &mono, self.rate),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
pub fn slice_at(&self, frame: usize) -> Option<usize> {
|
||||
self.slices
|
||||
.iter()
|
||||
.position(|s| frame >= s.start && frame < s.end)
|
||||
}
|
||||
}
|
||||
|
||||
fn grid(take: &Take, division: Division) -> Vec<usize> {
|
||||
let per = division.per_quarter() as f64;
|
||||
let steps = (take.quarters() * per).round() as usize;
|
||||
(1..steps)
|
||||
.map(|k| take.frame_at(k as f64 / per).round() as usize)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Onsets where a window's high-frequency weighted energy jumps above the window before it.
|
||||
/// Windows span 40 ms, longer than one cycle of the lowest bass note, so the edges inside a
|
||||
/// distorted bass cycle never count as hits. Each onset is refined to its first loud sample.
|
||||
pub fn transients(x: &[f32], rate: f32, sensitivity: f32) -> Vec<usize> {
|
||||
let hop = ((rate / 375.) as usize).max(32);
|
||||
let window = ((rate * 0.04) as usize / hop).max(1) * hop;
|
||||
if x.len() < 2 * window + hop {
|
||||
return Vec::new();
|
||||
}
|
||||
// Prefix sums of weighted energy, in f64 so long takes keep their precision.
|
||||
let mut sums = Vec::with_capacity(x.len() + 1);
|
||||
sums.push(0f64);
|
||||
let mut prev = 0f32;
|
||||
for v in x {
|
||||
let d = v - prev;
|
||||
prev = *v;
|
||||
let e = (v * v + 4. * d * d) as f64;
|
||||
sums.push(sums.last().unwrap() + e);
|
||||
}
|
||||
let level = |end: usize| {
|
||||
let e = (sums[end] - sums[end - window]) / window as f64;
|
||||
10. * (e + 1e-10).log10() as f32
|
||||
};
|
||||
let rise_db = 14. - 0.11 * sensitivity.clamp(0., 100.);
|
||||
let floor = -55.;
|
||||
let gap = (0.04 * rate) as usize;
|
||||
let mut cuts: Vec<usize> = Vec::new();
|
||||
let mut end = 2 * window;
|
||||
while end <= x.len() {
|
||||
let now = level(end);
|
||||
if now > floor && now - level(end - window) >= rise_db {
|
||||
let from = end - window;
|
||||
let to = (end + hop).min(x.len());
|
||||
let peak = x[from..to].iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
let first = x[from..to]
|
||||
.iter()
|
||||
.position(|v| v.abs() >= 0.3 * peak)
|
||||
.unwrap_or(0);
|
||||
// Start slightly before the first loud sample so the attack stays whole.
|
||||
let onset = (from + first).saturating_sub((rate * 0.0005) as usize);
|
||||
if cuts.last().is_none_or(|c| onset >= c + gap) {
|
||||
cuts.push(onset);
|
||||
// Skip past this attack so it is not found again as the window slides on.
|
||||
end = onset + window + gap;
|
||||
}
|
||||
}
|
||||
end += hop;
|
||||
}
|
||||
cuts
|
||||
}
|
||||
|
||||
/// Root note of a slice, if it is clearly pitched.
|
||||
pub fn root_key(yin: &mut Yin, mono: &[f32], rate: f32) -> Option<u8> {
|
||||
let window = (2 * yin.max_lag() + 2).min(mono.len());
|
||||
if window < (rate / 1000.) as usize * 4 || mono.len() < window {
|
||||
return None;
|
||||
}
|
||||
// Skip the attack: the body of a hit carries its pitch.
|
||||
let mut notes: Vec<f32> = Vec::new();
|
||||
let start = (mono.len() / 5).min(mono.len() - window);
|
||||
let step = (window / 2).max(1);
|
||||
let mut at = start;
|
||||
while at + window <= mono.len() && notes.len() < 6 {
|
||||
if let Some((hz, clarity)) = yin.estimate(&mono[at..at + window], rate, 35., 1000.) {
|
||||
if clarity > 0.7 {
|
||||
notes.push(hz_to_midi(hz));
|
||||
}
|
||||
}
|
||||
at += step;
|
||||
}
|
||||
if notes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
notes.sort_by(f32::total_cmp);
|
||||
let n = notes[notes.len() / 2].round();
|
||||
(0. ..=127.).contains(&n).then_some(n as u8)
|
||||
}
|
||||
|
||||
/// Builds a take from the newest `bars` complete bars, or from the newest audio when stopped.
|
||||
pub fn capture(ring: &Ring, bars: u32) -> Result<Take, &'static str> {
|
||||
let rate = ring.rate() as f64;
|
||||
let written = ring.written();
|
||||
let markers = ring.markers();
|
||||
let last = markers.iter().rev().find(|m| m.frame <= written).copied();
|
||||
let fallback = Transport {
|
||||
playing: false,
|
||||
tempo: FALLBACK_TEMPO,
|
||||
beats: 0.,
|
||||
numerator: 4,
|
||||
denominator: 4,
|
||||
};
|
||||
let t = last.map_or(fallback, |m| m.transport);
|
||||
let bar = t.bar();
|
||||
let quarters = bars as f64 * bar;
|
||||
let (start, end, segments, synced) = match last {
|
||||
Some(m) if m.transport.playing => {
|
||||
let now = m.beats_at(written as f64, rate);
|
||||
let end_beats = (now / bar + 1e-9).floor() * bar;
|
||||
let start_beats = end_beats - quarters;
|
||||
let end = frame_for(&markers, end_beats, rate);
|
||||
let start = frame_for(&markers, start_beats, rate);
|
||||
let segments = segments(&markers, start_beats, end_beats, start, rate);
|
||||
(start, end, segments, true)
|
||||
}
|
||||
_ => {
|
||||
let length = quarters * 60. / t.tempo * rate;
|
||||
let segments = vec![Segment {
|
||||
frame: 0.,
|
||||
quarters: 0.,
|
||||
tempo: t.tempo,
|
||||
}];
|
||||
(written as f64 - length, written as f64, segments, false)
|
||||
}
|
||||
};
|
||||
if start < 0. {
|
||||
return Err("Not enough audio recorded yet. Play the section first.");
|
||||
}
|
||||
let (start, end) = (start.round() as u64, end.round() as u64);
|
||||
if end - start > ring.capacity() as u64 {
|
||||
return Err("Too long for the recording buffer. Capture fewer bars.");
|
||||
}
|
||||
let audio = ring.copy(start, end)?;
|
||||
Ok(Take::from_audio(
|
||||
ring.rate(),
|
||||
audio,
|
||||
bars,
|
||||
t,
|
||||
segments,
|
||||
synced,
|
||||
))
|
||||
}
|
||||
|
||||
/// Frame of a song position, from the marker whose stretch contains it.
|
||||
fn frame_for(markers: &[Marker], beats: f64, rate: f64) -> f64 {
|
||||
let m = markers
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.transport.playing && m.transport.beats <= beats + 1e-9)
|
||||
.or_else(|| markers.iter().find(|m| m.transport.playing));
|
||||
m.map_or(0., |m| m.frame_at(beats, rate))
|
||||
}
|
||||
|
||||
fn segments(markers: &[Marker], from: f64, to: f64, origin: f64, rate: f64) -> Vec<Segment> {
|
||||
let mut out: Vec<Segment> = Vec::new();
|
||||
for m in markers.iter().filter(|m| m.transport.playing) {
|
||||
let q = m.transport.beats - from;
|
||||
if m.transport.beats >= to {
|
||||
break;
|
||||
}
|
||||
let tempo = m.transport.tempo;
|
||||
if q <= 0. {
|
||||
out.clear();
|
||||
out.push(Segment {
|
||||
frame: m.frame_at(from, rate) - origin,
|
||||
quarters: 0.,
|
||||
tempo,
|
||||
});
|
||||
} else if out.last().is_none_or(|s| (s.tempo - tempo).abs() > 1e-6) {
|
||||
out.push(Segment {
|
||||
frame: m.frame as f64 - origin,
|
||||
quarters: q,
|
||||
tempo,
|
||||
});
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push(Segment {
|
||||
frame: 0.,
|
||||
quarters: 0.,
|
||||
tempo: FALLBACK_TEMPO,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A cleaned-up slice ready to save: trimmed, faded and optionally normalised.
|
||||
pub fn render(take: &Take, slice: &Slice, settings: &Settings) -> Vec<f32> {
|
||||
let mut start = slice.start;
|
||||
let end = slice.end;
|
||||
if settings.trim {
|
||||
let threshold = 10f32.powf(-60. / 20.);
|
||||
if let Some(i) = take.audio[start * 2..end * 2]
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.position(|f| f[0].abs().max(f[1].abs()) > threshold)
|
||||
{
|
||||
// Keep a short pre-roll so the attack is not cut into.
|
||||
start += i.saturating_sub((take.rate * 0.001) as usize);
|
||||
}
|
||||
}
|
||||
let mut out = take.audio[start * 2..end * 2].to_vec();
|
||||
let frames = out.len() / 2;
|
||||
let fade = ((settings.declick * 0.001 * take.rate) as usize).min(frames / 2);
|
||||
for i in 0..fade {
|
||||
let g = i as f32 / fade as f32;
|
||||
for c in 0..2 {
|
||||
out[2 * i + c] *= g;
|
||||
out[2 * (frames - 1 - i) + c] *= g;
|
||||
}
|
||||
}
|
||||
if settings.normalize {
|
||||
let peak = out.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
if peak > 1e-6 {
|
||||
let g = 10f32.powf(-0.3 / 20.) / peak;
|
||||
out.iter_mut().for_each(|v| *v *= g);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub const TABLE: usize = 2048;
|
||||
pub const TABLE_FRAMES: usize = 256;
|
||||
|
||||
/// Single-cycle frames from a pitched slice. Each frame starts on a rising zero crossing,
|
||||
/// so neighbouring frames stay in phase and morph smoothly in a wavetable synth.
|
||||
pub fn wavetable(mono: &[f32], rate: f32) -> Option<Vec<f32>> {
|
||||
let mut yin = Yin::new((rate / 30.) as usize + 4);
|
||||
let window = 2 * yin.max_lag() + 2;
|
||||
let probe = &mono[(mono.len() / 5).min(mono.len().saturating_sub(window))..];
|
||||
let (hz, _) = yin.estimate(&probe[..window.min(probe.len())], rate, 30., 2000.)?;
|
||||
let period = rate as f64 / hz as f64;
|
||||
let rising = |i: usize| mono[i - 1] < 0. && mono[i] >= 0.;
|
||||
let crossing = |i: usize| (i - 1) as f64 + mono[i - 1] as f64 / (mono[i - 1] - mono[i]) as f64;
|
||||
let mut starts = Vec::new();
|
||||
let mut at = (1..mono.len()).find(|i| rising(*i))?;
|
||||
starts.push(crossing(at));
|
||||
loop {
|
||||
let expect = starts.last().unwrap() + period;
|
||||
let tolerance = period * 0.2;
|
||||
let lo = (expect - tolerance).max(1.) as usize;
|
||||
let hi = ((expect + tolerance) as usize).min(mono.len() - 1);
|
||||
if lo >= hi {
|
||||
break;
|
||||
}
|
||||
// The rising crossing nearest one period on; slight drift and detune are followed.
|
||||
let Some(next) = (lo..=hi).filter(|i| rising(*i)).min_by(|a, b| {
|
||||
(crossing(*a) - expect)
|
||||
.abs()
|
||||
.total_cmp(&(crossing(*b) - expect).abs())
|
||||
}) else {
|
||||
// No crossing: skip one period and look again.
|
||||
at = (expect as usize).min(mono.len() - 1);
|
||||
match (at..mono.len()).find(|i| rising(*i)) {
|
||||
Some(i) => {
|
||||
starts.push(crossing(i));
|
||||
continue;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
};
|
||||
starts.push(crossing(next));
|
||||
}
|
||||
let cycles: Vec<(f64, f64)> = starts
|
||||
.windows(2)
|
||||
.map(|w| (w[0], w[1]))
|
||||
.filter(|(a, b)| (b - a - period).abs() < period * 0.25)
|
||||
.collect();
|
||||
if cycles.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let count = cycles.len().min(TABLE_FRAMES);
|
||||
let mut table = Vec::with_capacity(count * TABLE);
|
||||
for k in 0..count {
|
||||
let (a, b) = cycles[k * cycles.len() / count];
|
||||
for j in 0..TABLE {
|
||||
let pos = a + (b - a) * j as f64 / TABLE as f64;
|
||||
let i = pos.floor() as usize;
|
||||
let frac = (pos - i as f64) as f32;
|
||||
let x0 = mono[i.min(mono.len() - 1)];
|
||||
let x1 = mono[(i + 1).min(mono.len() - 1)];
|
||||
table.push(x0 + (x1 - x0) * frac);
|
||||
}
|
||||
}
|
||||
let peak = table.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
if peak < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let g = 10f32.powf(-0.3 / 20.) / peak;
|
||||
table.iter_mut().for_each(|v| *v *= g);
|
||||
Some(table)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
use common_dsp::{wav, Saw};
|
||||
use crucible_core::*;
|
||||
|
||||
const RATE: f32 = 48000.;
|
||||
|
||||
fn playing(tempo: f64, beats: f64) -> Transport {
|
||||
Transport {
|
||||
playing: true,
|
||||
tempo,
|
||||
beats,
|
||||
numerator: 4,
|
||||
denominator: 4,
|
||||
}
|
||||
}
|
||||
/// Records `frames` of a ramp (sample value = frame index) in blocks, with transport.
|
||||
fn record(ring: &Ring, rec: &mut Recorder, from: u64, frames: u64, tempo: f64, beats0: f64) {
|
||||
let block = 480u64;
|
||||
let mut f = 0;
|
||||
while f < frames {
|
||||
let beats = beats0 + f as f64 / RATE as f64 * tempo / 60.;
|
||||
rec.block(ring, Some(playing(tempo, beats)));
|
||||
for i in 0..block.min(frames - f) {
|
||||
let v = (from + f + i) as f32;
|
||||
rec.push(ring, [v, -v]);
|
||||
}
|
||||
rec.publish(ring);
|
||||
f += block;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_copy_is_exact_across_the_wrap_and_detects_overwrites() {
|
||||
let ring = Ring::new(RATE, 1.);
|
||||
let mut rec = Recorder::new();
|
||||
record(&ring, &mut rec, 0, 70000, 120., 0.);
|
||||
let got = ring.copy(60000, 70000).unwrap();
|
||||
for (k, f) in got.chunks(2).enumerate() {
|
||||
assert_eq!(f[0], (60000 + k) as f32);
|
||||
assert_eq!(f[1], -((60000 + k) as f32));
|
||||
}
|
||||
assert!(ring.copy(10000, 20000).is_err());
|
||||
assert!(ring.copy(69000, 71000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_ends_on_the_last_bar_line() {
|
||||
let ring = Ring::new(RATE, HISTORY);
|
||||
let mut rec = Recorder::new();
|
||||
// 120 BPM: one beat = 24000 frames, one 4/4 bar = 96000 frames. Stop mid bar 4.
|
||||
record(&ring, &mut rec, 0, 96000 * 3 + 30000, 120., 0.);
|
||||
let take = capture(&ring, 2).unwrap();
|
||||
assert!(take.synced);
|
||||
assert_eq!(take.frames(), 2 * 96000);
|
||||
assert_eq!(take.audio[0], 96000.);
|
||||
assert_eq!(take.audio[take.audio.len() - 2], (3 * 96000 - 1) as f32);
|
||||
assert!(capture(&ring, 4).is_err(), "only three full bars exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_follows_tempo_changes() {
|
||||
let ring = Ring::new(RATE, HISTORY);
|
||||
let mut rec = Recorder::new();
|
||||
// 4 beats at 120 BPM (96000 frames), then 60 BPM (48000 frames per beat).
|
||||
record(&ring, &mut rec, 0, 96000, 120., 0.);
|
||||
record(&ring, &mut rec, 96000, 5 * 48000 + 1000, 60., 4.);
|
||||
let take = capture(&ring, 2).unwrap();
|
||||
// Bars 1–2 end at beat 8 = 96000 + 4 × 48000.
|
||||
assert_eq!(take.frames(), 96000 + 4 * 48000);
|
||||
assert_eq!(take.audio[0], 0.);
|
||||
assert_eq!(take.segments.len(), 2);
|
||||
assert!((take.frame_at(4.) - 96000.).abs() < 1.);
|
||||
assert!((take.frame_at(6.) - (96000. + 2. * 48000.)).abs() < 1.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_transport_captures_the_newest_audio() {
|
||||
let ring = Ring::new(RATE, HISTORY);
|
||||
let mut rec = Recorder::new();
|
||||
for _ in 0..500 {
|
||||
rec.block(
|
||||
&ring,
|
||||
Some(Transport {
|
||||
playing: false,
|
||||
..playing(150., 0.)
|
||||
}),
|
||||
);
|
||||
for _ in 0..480 {
|
||||
rec.push(&ring, [0.1, 0.1]);
|
||||
}
|
||||
rec.publish(&ring);
|
||||
}
|
||||
let take = capture(&ring, 1).unwrap();
|
||||
assert!(!take.synced);
|
||||
// One bar at 150 BPM = 1.6 s.
|
||||
assert_eq!(take.frames(), 76800);
|
||||
}
|
||||
|
||||
fn take_of(mono: Vec<f32>, tempo: f64, bars: u32) -> Take {
|
||||
let audio = mono.iter().flat_map(|v| [*v, *v]).collect();
|
||||
Take::from_audio(
|
||||
RATE,
|
||||
audio,
|
||||
bars,
|
||||
playing(tempo, 0.),
|
||||
vec![Segment {
|
||||
frame: 0.,
|
||||
quarters: 0.,
|
||||
tempo,
|
||||
}],
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_slices_straight_and_triplet() {
|
||||
let mut take = take_of(vec![0.; 96000], 120., 1);
|
||||
let mut s = Settings {
|
||||
division: Division::Sixteenth,
|
||||
..Settings::default()
|
||||
};
|
||||
take.slice(&s);
|
||||
assert_eq!(take.slices.len(), 16);
|
||||
assert_eq!(take.cuts[1], 6000);
|
||||
s.division = Division::EighthTriplet;
|
||||
take.slice(&s);
|
||||
assert_eq!(take.slices.len(), 12);
|
||||
assert_eq!(take.cuts[1], 8000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transients_find_hits_within_two_milliseconds() {
|
||||
let mut x = vec![0.; 96000];
|
||||
let hits = [4800usize, 20000, 31111, 60000, 80000];
|
||||
for &h in &hits {
|
||||
for i in 0..2000 {
|
||||
x[h + i] = (i as f32 * 0.3).sin() * (-(i as f32) / 400.).exp() * 0.8;
|
||||
}
|
||||
}
|
||||
let found = transients(&x, RATE, 50.);
|
||||
assert_eq!(found.len(), hits.len(), "{found:?}");
|
||||
for (f, h) in found.iter().zip(hits) {
|
||||
assert!(f.abs_diff(h) < 96, "{f} vs {h}");
|
||||
}
|
||||
let mut take = take_of(x, 120., 1);
|
||||
take.slice(&Settings {
|
||||
mode: Mode::Transient,
|
||||
..Settings::default()
|
||||
});
|
||||
assert_eq!(take.slices.len(), hits.len() + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_cuts_add_and_remove() {
|
||||
let mut take = take_of(vec![0.; 48000], 120., 1);
|
||||
assert!(take.add_cut(12000));
|
||||
assert!(!take.add_cut(12010), "too close");
|
||||
assert_eq!(take.slices.len(), 2);
|
||||
take.slices[1].enabled = false;
|
||||
assert!(take.add_cut(30000));
|
||||
assert!(!take.slices[1].enabled, "choice survives a re-cut");
|
||||
assert!(take.remove_cut(29000));
|
||||
assert_eq!(take.cuts, vec![0, 12000, 48000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_trims_fades_and_normalises() {
|
||||
let mut x = vec![0.; 4800];
|
||||
x[1000..]
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.for_each(|(i, v)| *v = 0.25 * (i as f32 * 0.05).sin().signum());
|
||||
let take = take_of(x, 120., 1);
|
||||
let out = render(&take, &take.slices[0], &Settings::default());
|
||||
let frames = out.len() / 2;
|
||||
// Leading silence gone except a 1 ms pre-roll.
|
||||
assert_eq!(frames, 3800 + 48);
|
||||
assert_eq!(out[0], 0.);
|
||||
assert_eq!(out[out.len() - 1], 0.);
|
||||
let peak = out.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
assert!((20. * peak.log10() + 0.3).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_key_is_detected_per_slice() {
|
||||
let mut saw = Saw::new(RATE, 43.65);
|
||||
let take = take_of((0..48000).map(|_| saw.sample() * 0.5).collect(), 120., 1);
|
||||
assert_eq!(take.slices[0].root, Some(29));
|
||||
let silent = take_of(vec![0.; 48000], 120., 1);
|
||||
assert_eq!(silent.slices[0].root, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wavetable_frames_start_on_rising_zero_crossings_and_match() {
|
||||
let mut saw = Saw::new(RATE, 110.);
|
||||
let x: Vec<f32> = (0..192000).map(|_| saw.sample() * 0.5).collect();
|
||||
let table = wavetable(&x, RATE).unwrap();
|
||||
let frames = table.len() / TABLE;
|
||||
assert_eq!(frames, TABLE_FRAMES);
|
||||
for f in table.chunks(TABLE) {
|
||||
assert!(f[0].abs() < 0.02 && f[1] > f[0], "{} {}", f[0], f[1]);
|
||||
let a = &table[..TABLE];
|
||||
let dot: f32 = a.iter().zip(f).map(|(p, q)| p * q).sum();
|
||||
let na: f32 = a.iter().map(|v| v * v).sum();
|
||||
let nb: f32 = f.iter().map(|v| v * v).sum();
|
||||
assert!(dot / (na * nb).sqrt() > 0.99);
|
||||
}
|
||||
assert!(wavetable(&vec![0.; 48000], RATE).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_writes_loop_slices_and_wavetable() {
|
||||
let dir = std::env::temp_dir().join(format!("crucible-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut saw = Saw::new(RATE, 55.);
|
||||
let mut take = take_of((0..96000).map(|_| saw.sample() * 0.5).collect(), 120., 1);
|
||||
let settings = Settings {
|
||||
division: Division::Quarter,
|
||||
wavetable: true,
|
||||
..Settings::default()
|
||||
};
|
||||
take.slice(&settings);
|
||||
take.slices[3].enabled = false;
|
||||
let done = export(&take, &settings, Some(0), &dir, 1_700_000_000).unwrap();
|
||||
assert_eq!(done.files, 1 + 3 + 1);
|
||||
assert!(done.folder.ends_with("2023-11-14_221320_120bpm"));
|
||||
let read = |name: &str| wav::decode(&std::fs::read(done.folder.join(name)).unwrap()).unwrap();
|
||||
assert_eq!(read("loop.wav").samples.len(), 192000);
|
||||
assert_eq!(read("s01_A1.wav").root, Some(33));
|
||||
assert_eq!(read("wavetable.wav").frame, Some(2048));
|
||||
assert!(!done.folder.join("s04_A1.wav").exists());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audition_plays_and_returns_clips_for_freeing() {
|
||||
let q = Queues::default();
|
||||
let mut p = Player::default();
|
||||
q.play(std::sync::Arc::from(vec![0.5f32, -0.5, 0.25, -0.25]));
|
||||
p.block(&q);
|
||||
assert_eq!(p.next(&q), [0.5, -0.5]);
|
||||
assert_eq!(p.next(&q), [0.25, -0.25]);
|
||||
assert_eq!(p.next(&q), [0., 0.]);
|
||||
assert!(!p.playing());
|
||||
q.drain();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_survive_json_and_are_sanitised() {
|
||||
let mut s: Settings =
|
||||
serde_json::from_str(r#"{"bars":3,"declick":99,"sensitivity":-5}"#).unwrap();
|
||||
s.sanitize();
|
||||
assert_eq!((s.bars, s.declick, s.sensitivity), (4, 20., 0.));
|
||||
let json = serde_json::to_string(&Settings::default()).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Settings>(&json).unwrap(),
|
||||
Settings::default()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use crucible_core::{Player, Queues, Recorder, Ring, Transport};
|
||||
use std::sync::Arc;
|
||||
use std::{
|
||||
alloc::{GlobalAlloc, Layout, System},
|
||||
cell::Cell,
|
||||
};
|
||||
|
||||
struct CheckedAllocator;
|
||||
thread_local! {
|
||||
static CHECK:Cell<bool>=const {Cell::new(false)};
|
||||
static OPERATIONS:Cell<usize>=const {Cell::new(0)};
|
||||
}
|
||||
fn record() {
|
||||
if CHECK.try_with(Cell::get).unwrap_or(false) {
|
||||
let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1));
|
||||
}
|
||||
}
|
||||
unsafe impl GlobalAlloc for CheckedAllocator {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
record();
|
||||
unsafe { System.alloc(layout) }
|
||||
}
|
||||
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
|
||||
record();
|
||||
unsafe { System.dealloc(pointer, layout) }
|
||||
}
|
||||
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
|
||||
record();
|
||||
unsafe { System.realloc(pointer, layout, size) }
|
||||
}
|
||||
}
|
||||
#[global_allocator]
|
||||
static ALLOCATOR: CheckedAllocator = CheckedAllocator;
|
||||
|
||||
#[test]
|
||||
fn recording_transport_and_audition_do_not_allocate_or_free() {
|
||||
let ring = Ring::new(48000., 4.);
|
||||
let queues = Queues::default();
|
||||
let mut recorder = Recorder::new();
|
||||
let mut player = Player::default();
|
||||
let clips: Vec<Arc<[f32]>> = (0..4).map(|i| Arc::from(vec![0.1; 2000 + i])).collect();
|
||||
CHECK.with(|v| v.set(true));
|
||||
for block in 0..2000u32 {
|
||||
if block % 300 == 0 {
|
||||
// Sending only moves the Arc; freeing happens on drain, outside the check.
|
||||
queues.play(clips[(block / 300) as usize % 4].clone());
|
||||
}
|
||||
recorder.block(
|
||||
&ring,
|
||||
Some(Transport {
|
||||
playing: block % 700 < 600,
|
||||
tempo: if block < 1000 { 140. } else { 150. },
|
||||
beats: block as f64 * 0.1,
|
||||
numerator: 4,
|
||||
denominator: 4,
|
||||
}),
|
||||
);
|
||||
player.block(&queues);
|
||||
for i in 0..256 {
|
||||
let a = player.next(&queues);
|
||||
recorder.push(&ring, [i as f32 * 1e-3 + a[0], a[1]]);
|
||||
}
|
||||
recorder.publish(&ring);
|
||||
}
|
||||
CHECK.with(|v| v.set(false));
|
||||
assert_eq!(OPERATIONS.with(Cell::get), 0);
|
||||
queues.drain();
|
||||
}
|
||||
Generated
+2382
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "crucible"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "ISC"
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
|
||||
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
|
||||
crucible-core = { path = "../core" }
|
||||
crucible-ui = { path = "../ui" }
|
||||
|
||||
[dev-dependencies]
|
||||
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = "symbols"
|
||||
@@ -0,0 +1,200 @@
|
||||
//! CLAP audio effect. Audio passes through unchanged; the input is recorded continuously.
|
||||
use crucible_core::{Player, Queues, Recorder, Ring, Settings, Transport, HISTORY};
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug_egui::{create_egui_editor, egui, EguiState};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
#[derive(Params)]
|
||||
pub struct CrucibleParams {
|
||||
#[persist = "editor"]
|
||||
pub editor: Arc<EguiState>,
|
||||
#[persist = "crucible-settings"]
|
||||
pub settings: RwLock<Settings>,
|
||||
#[id = "bypass"]
|
||||
pub bypass: BoolParam,
|
||||
}
|
||||
impl Default for CrucibleParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
editor: EguiState::from_size(crucible_ui::WIDTH, crucible_ui::HEIGHT),
|
||||
settings: RwLock::new(Settings::default()),
|
||||
bypass: BoolParam::new("Bypass", false).make_bypass(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
/// Replaced on activation; the editor clones the Arc briefly, never the audio thread.
|
||||
ring: Mutex<Option<Arc<Ring>>>,
|
||||
queues: Arc<Queues>,
|
||||
}
|
||||
pub struct Crucible {
|
||||
params: Arc<CrucibleParams>,
|
||||
shared: Arc<Shared>,
|
||||
ring: Option<Arc<Ring>>,
|
||||
recorder: Recorder,
|
||||
player: Player,
|
||||
}
|
||||
impl Default for Crucible {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
params: Arc::new(CrucibleParams::default()),
|
||||
shared: Arc::new(Shared {
|
||||
ring: Mutex::new(None),
|
||||
queues: Arc::new(Queues::default()),
|
||||
}),
|
||||
ring: None,
|
||||
recorder: Recorder::new(),
|
||||
player: Player::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Plugin for Crucible {
|
||||
const NAME: &'static str = "Crucible";
|
||||
const VENDOR: &'static str = "Rivet & Patina";
|
||||
const URL: &'static str = "";
|
||||
const EMAIL: &'static str = "";
|
||||
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
|
||||
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
|
||||
AudioIOLayout {
|
||||
main_input_channels: NonZeroU32::new(2),
|
||||
main_output_channels: NonZeroU32::new(2),
|
||||
..AudioIOLayout::const_default()
|
||||
},
|
||||
AudioIOLayout {
|
||||
main_input_channels: NonZeroU32::new(1),
|
||||
main_output_channels: NonZeroU32::new(1),
|
||||
..AudioIOLayout::const_default()
|
||||
},
|
||||
];
|
||||
const SAMPLE_ACCURATE_AUTOMATION: bool = false;
|
||||
type SysExMessage = ();
|
||||
type BackgroundTask = ();
|
||||
fn params(&self) -> Arc<dyn Params> {
|
||||
self.params.clone()
|
||||
}
|
||||
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
|
||||
let params = self.params.clone();
|
||||
let shared = self.shared.clone();
|
||||
create_egui_editor(
|
||||
params.editor.clone(),
|
||||
crucible_ui::State::default(),
|
||||
|ctx, _| crucible_ui::style(ctx),
|
||||
move |ctx, _setter, state| {
|
||||
let link = crucible_ui::Link {
|
||||
ring: shared.ring.lock().ok().and_then(|r| r.clone()),
|
||||
queues: shared.queues.clone(),
|
||||
};
|
||||
let mut settings = params
|
||||
.settings
|
||||
.read()
|
||||
.map(|s| s.clone())
|
||||
.unwrap_or_default();
|
||||
let before = settings.clone();
|
||||
nih_plug_egui::resizable_window::ResizableWindow::new("crucible-editor")
|
||||
.min_size(egui::vec2(
|
||||
crucible_ui::WIDTH as f32 - 80.,
|
||||
crucible_ui::HEIGHT as f32 - 40.,
|
||||
))
|
||||
.show(ctx, ¶ms.editor, |ui| {
|
||||
crucible_ui::show(ui, &mut settings, state, &link)
|
||||
});
|
||||
if settings != before {
|
||||
if let Ok(mut s) = params.settings.write() {
|
||||
*s = settings;
|
||||
}
|
||||
}
|
||||
// Meters and the transport readout follow the audio.
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
},
|
||||
)
|
||||
}
|
||||
fn initialize(
|
||||
&mut self,
|
||||
_: &AudioIOLayout,
|
||||
config: &BufferConfig,
|
||||
_: &mut impl InitContext<Self>,
|
||||
) -> bool {
|
||||
if let Ok(mut s) = self.params.settings.write() {
|
||||
s.sanitize();
|
||||
}
|
||||
let ring = Arc::new(Ring::new(config.sample_rate, HISTORY));
|
||||
self.ring = Some(ring.clone());
|
||||
self.recorder = Recorder::new();
|
||||
self.player = Player::default();
|
||||
if let Ok(mut slot) = self.shared.ring.lock() {
|
||||
*slot = Some(ring);
|
||||
}
|
||||
true
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.player = Player::default();
|
||||
}
|
||||
fn process(
|
||||
&mut self,
|
||||
buffer: &mut Buffer,
|
||||
_: &mut AuxiliaryBuffers,
|
||||
context: &mut impl ProcessContext<Self>,
|
||||
) -> ProcessStatus {
|
||||
let Some(ring) = self.ring.as_deref() else {
|
||||
return ProcessStatus::Normal;
|
||||
};
|
||||
let bypass = self.params.bypass.value();
|
||||
let t = context.transport();
|
||||
let transport = match (t.tempo, t.pos_beats()) {
|
||||
(Some(tempo), Some(beats)) => Some(Transport {
|
||||
playing: t.playing,
|
||||
tempo,
|
||||
beats,
|
||||
numerator: t.time_sig_numerator.unwrap_or(4).clamp(1, 64) as u32,
|
||||
denominator: t.time_sig_denominator.unwrap_or(4).clamp(1, 64) as u32,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
if !bypass {
|
||||
self.recorder.block(ring, transport);
|
||||
}
|
||||
self.player.block(&self.shared.queues);
|
||||
let channels = buffer.channels();
|
||||
for mut sample in buffer.iter_samples() {
|
||||
let left = *sample.get_mut(0).unwrap();
|
||||
let right = if channels > 1 {
|
||||
*sample.get_mut(1).unwrap()
|
||||
} else {
|
||||
left
|
||||
};
|
||||
if bypass {
|
||||
continue;
|
||||
}
|
||||
self.recorder.push(ring, [left, right]);
|
||||
// Auditioned slices are added to the output, never recorded.
|
||||
let a = self.player.next(&self.shared.queues);
|
||||
if self.player.playing() || a != [0.; 2] {
|
||||
*sample.get_mut(0).unwrap() += if channels > 1 {
|
||||
a[0]
|
||||
} else {
|
||||
0.5 * (a[0] + a[1])
|
||||
};
|
||||
if channels > 1 {
|
||||
*sample.get_mut(1).unwrap() += a[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
self.recorder.publish(ring);
|
||||
ProcessStatus::Normal
|
||||
}
|
||||
}
|
||||
impl ClapPlugin for Crucible {
|
||||
const CLAP_ID: &'static str = "io.storyboard.resampler";
|
||||
const CLAP_DESCRIPTION: Option<&'static str> =
|
||||
Some("Capture the last bars, slice them and export clean, key-labelled samples");
|
||||
const CLAP_MANUAL_URL: Option<&'static str> = None;
|
||||
const CLAP_SUPPORT_URL: Option<&'static str> = None;
|
||||
const CLAP_FEATURES: &'static [ClapFeature] = &[
|
||||
ClapFeature::AudioEffect,
|
||||
ClapFeature::Utility,
|
||||
ClapFeature::Stereo,
|
||||
ClapFeature::Mono,
|
||||
];
|
||||
}
|
||||
nih_export_clap!(Crucible);
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW.
|
||||
use clap_sys::{
|
||||
audio_buffer::*,
|
||||
events::*,
|
||||
ext::{audio_ports::*, latency::*, state::*},
|
||||
factory::plugin_factory::*,
|
||||
host::*,
|
||||
process::*,
|
||||
stream::*,
|
||||
version::*,
|
||||
};
|
||||
use std::{
|
||||
ffi::{c_char, c_void, CStr},
|
||||
io::{Cursor, Read},
|
||||
ptr,
|
||||
};
|
||||
|
||||
unsafe extern "C" fn extension(_: *const clap_host, _: *const c_char) -> *const c_void {
|
||||
ptr::null()
|
||||
}
|
||||
unsafe extern "C" fn request(_: *const clap_host) {}
|
||||
unsafe extern "C" fn count(_: *const clap_input_events) -> u32 {
|
||||
0
|
||||
}
|
||||
unsafe extern "C" fn event(_: *const clap_input_events, _: u32) -> *const clap_event_header {
|
||||
ptr::null()
|
||||
}
|
||||
unsafe extern "C" fn push(_: *const clap_output_events, _: *const clap_event_header) -> bool {
|
||||
true
|
||||
}
|
||||
unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 {
|
||||
// SAFETY: The test owns the Vec for the entire synchronous save call.
|
||||
let bytes = unsafe { &mut *((*stream).ctx as *mut Vec<u8>) };
|
||||
bytes.extend_from_slice(unsafe {
|
||||
std::slice::from_raw_parts(buffer as *const u8, size as usize)
|
||||
});
|
||||
size as i64
|
||||
}
|
||||
unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
|
||||
// SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage.
|
||||
let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor<Vec<u8>>) };
|
||||
cursor
|
||||
.read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) })
|
||||
.unwrap() as i64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_ports_state_latency_and_audio_lifecycle() {
|
||||
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
|
||||
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
|
||||
unsafe {
|
||||
let entry = &crucible::clap_entry;
|
||||
assert!(entry.init.unwrap()(c"crucible.clap".as_ptr()));
|
||||
let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr())
|
||||
as *const clap_plugin_factory;
|
||||
assert!(!factory.is_null());
|
||||
let factory = &*factory;
|
||||
assert_eq!(factory.get_plugin_count.unwrap()(factory), 1);
|
||||
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
|
||||
assert!(!descriptor.is_null());
|
||||
assert_eq!(CStr::from_ptr((*descriptor).name), c"Crucible");
|
||||
assert_eq!(CStr::from_ptr((*descriptor).id), c"io.storyboard.resampler");
|
||||
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
|
||||
let host = clap_host {
|
||||
clap_version: CLAP_VERSION,
|
||||
host_data: ptr::null_mut(),
|
||||
name: c"Storyboard test host".as_ptr(),
|
||||
vendor: c"Tests".as_ptr(),
|
||||
url: c"".as_ptr(),
|
||||
version: c"1.0".as_ptr(),
|
||||
get_extension: Some(extension),
|
||||
request_restart: Some(request),
|
||||
request_process: Some(request),
|
||||
request_callback: Some(request),
|
||||
};
|
||||
let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id);
|
||||
assert!(!plugin.is_null());
|
||||
let p = &*plugin;
|
||||
assert!(p.init.unwrap()(plugin));
|
||||
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
|
||||
as *const clap_plugin_audio_ports;
|
||||
assert!(!ports.is_null());
|
||||
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
|
||||
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
|
||||
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
|
||||
as *const clap_plugin_latency;
|
||||
assert!(!latency.is_null());
|
||||
let state =
|
||||
p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state;
|
||||
assert!(!state.is_null());
|
||||
let state = &*state;
|
||||
let save = || {
|
||||
let mut bytes = Vec::new();
|
||||
let stream = clap_ostream {
|
||||
ctx: &mut bytes as *mut Vec<u8> as *mut c_void,
|
||||
write: Some(write),
|
||||
};
|
||||
assert!(state.save.unwrap()(plugin, &stream));
|
||||
bytes
|
||||
};
|
||||
let mut envelope: nih_plug::prelude::PluginState =
|
||||
serde_json::from_slice(&save()[8..]).unwrap();
|
||||
use nih_plug::wrapper::state::ParamValue;
|
||||
assert!(envelope.fields.contains_key("crucible-settings"));
|
||||
envelope
|
||||
.params
|
||||
.insert("bypass".into(), ParamValue::Bool(false));
|
||||
let settings = crucible_core::Settings {
|
||||
bars: 8,
|
||||
declick: 5.,
|
||||
wavetable: true,
|
||||
folder: "D:/Samples/Crucible".into(),
|
||||
..Default::default()
|
||||
};
|
||||
envelope.fields.insert(
|
||||
"crucible-settings".into(),
|
||||
serde_json::to_string(&settings).unwrap(),
|
||||
);
|
||||
let encoded = serde_json::to_vec(&envelope).unwrap();
|
||||
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
|
||||
framed.extend_from_slice(&encoded);
|
||||
let mut bytes = Cursor::new(framed);
|
||||
let stream = clap_istream {
|
||||
ctx: &mut bytes as *mut Cursor<Vec<u8>> as *mut c_void,
|
||||
read: Some(read),
|
||||
};
|
||||
assert!(state.load.unwrap()(plugin, &stream));
|
||||
let restored: nih_plug::prelude::PluginState =
|
||||
serde_json::from_slice(&save()[8..]).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&restored.params).unwrap(),
|
||||
serde_json::to_value(&envelope.params).unwrap()
|
||||
);
|
||||
let restored_settings: crucible_core::Settings =
|
||||
serde_json::from_str(&restored.fields["crucible-settings"]).unwrap();
|
||||
assert_eq!(restored_settings, settings);
|
||||
for rate in [44100., 48000., 96000.] {
|
||||
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
|
||||
let delay = (*latency).get.unwrap()(plugin) as usize;
|
||||
assert_eq!(delay, 0);
|
||||
assert!(p.start_processing.unwrap()(plugin));
|
||||
let mut position = 0;
|
||||
while position < delay + 1024 {
|
||||
let frames = [1, 127, 512, 31][position % 4].min(delay + 1024 - position);
|
||||
let mut input_left = [0f32; 512];
|
||||
let mut input_right = [0f32; 512];
|
||||
for i in 0..frames {
|
||||
let x = if position + i == 0 { 0.5 } else { 0. };
|
||||
input_left[i] = x;
|
||||
input_right[i] = -x;
|
||||
}
|
||||
let mut left = [0f32; 512];
|
||||
let mut right = [0f32; 512];
|
||||
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
|
||||
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
|
||||
let incoming = clap_audio_buffer {
|
||||
data32: in_channels.as_mut_ptr(),
|
||||
data64: ptr::null_mut(),
|
||||
channel_count: 2,
|
||||
latency: 0,
|
||||
constant_mask: 0,
|
||||
};
|
||||
let mut outgoing_audio = clap_audio_buffer {
|
||||
data32: out_channels.as_mut_ptr(),
|
||||
data64: ptr::null_mut(),
|
||||
channel_count: 2,
|
||||
latency: 0,
|
||||
constant_mask: 0,
|
||||
};
|
||||
let input = clap_input_events {
|
||||
ctx: ptr::null_mut(),
|
||||
size: Some(count),
|
||||
get: Some(event),
|
||||
};
|
||||
let outgoing = clap_output_events {
|
||||
ctx: ptr::null_mut(),
|
||||
try_push: Some(push),
|
||||
};
|
||||
let process = clap_process {
|
||||
steady_time: position as i64,
|
||||
frames_count: frames as u32,
|
||||
transport: ptr::null(),
|
||||
audio_inputs: &incoming,
|
||||
audio_outputs: &mut outgoing_audio,
|
||||
audio_inputs_count: 1,
|
||||
audio_outputs_count: 1,
|
||||
in_events: &input,
|
||||
out_events: &outgoing,
|
||||
};
|
||||
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
|
||||
for i in 0..frames {
|
||||
// Audio passes through untouched.
|
||||
let expected = if position + i == 0 { 0.5 } else { 0. };
|
||||
assert!((left[i] - expected).abs() < 1e-6);
|
||||
assert!((right[i] + expected).abs() < 1e-6);
|
||||
}
|
||||
position += frames;
|
||||
}
|
||||
p.stop_processing.unwrap()(plugin);
|
||||
p.deactivate.unwrap()(plugin);
|
||||
}
|
||||
p.destroy.unwrap()(plugin);
|
||||
entry.deinit.unwrap()();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "crucible-ui"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
egui = "0.31.1"
|
||||
crucible-core = { path = "../core" }
|
||||
common-ui = { path = "../../common/ui" }
|
||||
common-dsp = { path = "../../common/dsp" }
|
||||
@@ -0,0 +1,567 @@
|
||||
//! Crucible editor, shared by the CLAP wrapper and the headless renderer.
|
||||
use common_ui::*;
|
||||
use crucible_core::{
|
||||
capture, export, render, Division, Exported, Mode, Queues, Ring, Settings, Take, Transport,
|
||||
OVERVIEW,
|
||||
};
|
||||
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub use common_ui::style;
|
||||
pub const WIDTH: u32 = 1040;
|
||||
pub const HEIGHT: u32 = 680;
|
||||
const MAX_TAKES: usize = 8;
|
||||
const DEFAULT_HELP: &str =
|
||||
"Play your bass, press Capture, then drag the exported files from FL's browser.";
|
||||
|
||||
/// Connection to the audio thread.
|
||||
pub struct Link {
|
||||
pub ring: Option<Arc<Ring>>,
|
||||
pub queues: Arc<Queues>,
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
pub takes: Vec<Take>,
|
||||
pub take: Option<usize>,
|
||||
pub slice: Option<usize>,
|
||||
pub status: String,
|
||||
pub help: String,
|
||||
pub peak: f32,
|
||||
pub transport: Option<Transport>,
|
||||
pub folder: String,
|
||||
counter: usize,
|
||||
names: Vec<String>,
|
||||
/// Filled by the export thread when it finishes.
|
||||
job: Option<Arc<Mutex<Option<std::io::Result<Exported>>>>>,
|
||||
last_export: Option<std::path::PathBuf>,
|
||||
}
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
takes: Vec::new(),
|
||||
take: None,
|
||||
slice: None,
|
||||
status: String::new(),
|
||||
help: DEFAULT_HELP.into(),
|
||||
peak: 0.,
|
||||
transport: None,
|
||||
folder: String::new(),
|
||||
counter: 0,
|
||||
names: Vec::new(),
|
||||
job: None,
|
||||
last_export: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
/// Adds a take (newest first) and slices it with the current settings.
|
||||
pub fn add(&mut self, mut take: Take, settings: &Settings) {
|
||||
take.slice(settings);
|
||||
self.counter += 1;
|
||||
self.names.insert(
|
||||
0,
|
||||
format!(
|
||||
"Take {} · {} bar{} · {:.0} BPM",
|
||||
self.counter,
|
||||
take.bars,
|
||||
if take.bars == 1 { "" } else { "s" },
|
||||
take.tempo()
|
||||
),
|
||||
);
|
||||
self.takes.insert(0, take);
|
||||
self.takes.truncate(MAX_TAKES);
|
||||
self.names.truncate(MAX_TAKES);
|
||||
self.take = Some(0);
|
||||
self.slice = None;
|
||||
}
|
||||
fn current(&mut self) -> Option<&mut Take> {
|
||||
self.take.and_then(|i| self.takes.get_mut(i))
|
||||
}
|
||||
}
|
||||
|
||||
fn hint(r: &egui::Response, state: &mut State, text: &str) {
|
||||
if r.hovered() {
|
||||
state.help = text.into();
|
||||
}
|
||||
}
|
||||
fn chip(ui: &mut egui::Ui, on: bool, label: &str) -> egui::Response {
|
||||
ui.selectable_label(on, RichText::new(label).size(13.))
|
||||
}
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs())
|
||||
}
|
||||
fn open_folder(path: &std::path::Path) {
|
||||
let program = if cfg!(windows) {
|
||||
"explorer"
|
||||
} else {
|
||||
"xdg-open"
|
||||
};
|
||||
let _ = std::process::Command::new(program).arg(path).spawn();
|
||||
}
|
||||
|
||||
fn audition(link: &Link, take: &Take, index: usize, settings: &Settings) {
|
||||
if let Some(slice) = take.slices.get(index) {
|
||||
link.queues.play(Arc::from(render(take, slice, settings)));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link: &Link) {
|
||||
backdrop(ui);
|
||||
link.queues.drain();
|
||||
state.help = DEFAULT_HELP.into();
|
||||
if let Some(ring) = &link.ring {
|
||||
let p = ring.take_peak();
|
||||
state.peak = p.max(state.peak * 0.9);
|
||||
if let Some(m) = ring.markers().last() {
|
||||
state.transport = Some(m.transport);
|
||||
}
|
||||
}
|
||||
let finished = state
|
||||
.job
|
||||
.as_ref()
|
||||
.and_then(|job| job.lock().ok().and_then(|mut slot| slot.take()));
|
||||
if let Some(result) = finished {
|
||||
state.job = None;
|
||||
state.status = match result {
|
||||
Ok(done) => {
|
||||
let mut s = format!("Exported {} files to {}", done.files, done.folder.display());
|
||||
if let Some(why) = done.wavetable_skipped {
|
||||
s += &format!(" (no wavetable: {why})");
|
||||
}
|
||||
state.last_export = Some(done.folder);
|
||||
s
|
||||
}
|
||||
Err(e) => format!("Export failed: {e}"),
|
||||
};
|
||||
}
|
||||
ui.add_space(2.);
|
||||
header(ui, "CRUCIBLE", "RESAMPLER", |ui| {
|
||||
let db = 20. * state.peak.max(1e-6).log10();
|
||||
ui.label(
|
||||
RichText::new(if db < -90. {
|
||||
"—".into()
|
||||
} else {
|
||||
format!("{db:.1} dB")
|
||||
})
|
||||
.monospace()
|
||||
.size(12.)
|
||||
.color(if db > -0.1 { ACCENT } else { MUTED }),
|
||||
);
|
||||
caption(ui, "INPUT");
|
||||
});
|
||||
ui.add_space(6.);
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| capture_row(ui, settings, state, link));
|
||||
});
|
||||
ui.add_space(6.);
|
||||
let body = 300.;
|
||||
ui.horizontal_top(|ui| {
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(190.);
|
||||
ui.set_min_height(body);
|
||||
ui.vertical(|ui| takes(ui, state));
|
||||
});
|
||||
ui.add_space(4.);
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
waveform(ui, settings, state, link, body);
|
||||
});
|
||||
});
|
||||
ui.add_space(6.);
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.vertical(|ui| controls(ui, settings, state, link));
|
||||
});
|
||||
ui.separator();
|
||||
let line = if state.status.is_empty() || state.help != DEFAULT_HELP {
|
||||
state.help.clone()
|
||||
} else {
|
||||
state.status.clone()
|
||||
};
|
||||
ui.label(RichText::new(line).color(MUTED));
|
||||
}
|
||||
|
||||
fn capture_row(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link: &Link) {
|
||||
let button = egui::Button::new(
|
||||
RichText::new("CAPTURE")
|
||||
.font(FontId::new(17., egui::FontFamily::Name("medium".into())))
|
||||
.color(Color32::WHITE),
|
||||
)
|
||||
.fill(Color32::from_rgb(150, 0, 56))
|
||||
.stroke(Stroke::new(1.0_f32, ACCENT))
|
||||
.min_size(Vec2::new(130., 34.));
|
||||
let r = ui.add_enabled(link.ring.is_some(), button);
|
||||
hint(
|
||||
&r,
|
||||
state,
|
||||
"Grab the newest complete bars. While stopped, grabs the newest audio instead.",
|
||||
);
|
||||
if r.clicked() {
|
||||
if let Some(ring) = &link.ring {
|
||||
match capture(ring, settings.bars) {
|
||||
Ok(take) => {
|
||||
let synced = take.synced;
|
||||
state.add(take, settings);
|
||||
state.status = if synced {
|
||||
format!("Captured {} bars on the bar grid.", settings.bars)
|
||||
} else {
|
||||
"Transport stopped: captured the newest audio at the last tempo.".into()
|
||||
};
|
||||
}
|
||||
Err(e) => state.status = e.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.add_space(10.);
|
||||
caption(ui, "BARS");
|
||||
for bars in Settings::BARS {
|
||||
if chip(ui, settings.bars == bars, &bars.to_string()).clicked() {
|
||||
settings.bars = bars;
|
||||
}
|
||||
}
|
||||
ui.add_space(10.);
|
||||
ui.separator();
|
||||
let text = match state.transport {
|
||||
Some(t) => format!(
|
||||
"{:.1} BPM · {}/{} · {}",
|
||||
t.tempo,
|
||||
t.numerator,
|
||||
t.denominator,
|
||||
if t.playing { "playing" } else { "stopped" }
|
||||
),
|
||||
None if link.ring.is_some() => "No host tempo yet · 140 BPM assumed".into(),
|
||||
None => "Audio engine inactive".into(),
|
||||
};
|
||||
ui.label(RichText::new(text).monospace().size(13.).color(STEEL));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if let Some(ring) = &link.ring {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{:.0} s history",
|
||||
ring.capacity() as f32 / ring.rate()
|
||||
))
|
||||
.size(12.)
|
||||
.color(MUTED),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn takes(ui: &mut egui::Ui, state: &mut State) {
|
||||
caption(ui, "TAKES");
|
||||
if state.takes.is_empty() {
|
||||
ui.label(RichText::new("Nothing captured yet.").color(DIM));
|
||||
}
|
||||
let mut pick = None;
|
||||
for (i, name) in state.names.iter().enumerate() {
|
||||
let r = ui.add_sized(
|
||||
Vec2::new(ui.available_width(), 26.),
|
||||
egui::SelectableLabel::new(state.take == Some(i), RichText::new(name).size(13.)),
|
||||
);
|
||||
if r.clicked() {
|
||||
pick = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(i) = pick {
|
||||
state.take = Some(i);
|
||||
state.slice = None;
|
||||
}
|
||||
if let Some(i) = state.take {
|
||||
ui.add_space(8.);
|
||||
if ui.button(RichText::new("REMOVE TAKE").size(12.)).clicked() {
|
||||
state.takes.remove(i);
|
||||
state.names.remove(i);
|
||||
state.take = (!state.takes.is_empty()).then_some(0);
|
||||
state.slice = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn waveform(ui: &mut egui::Ui, settings: &Settings, state: &mut State, link: &Link, height: f32) {
|
||||
let (rect, response) =
|
||||
ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::click());
|
||||
let p = ui.painter_at(rect);
|
||||
p.rect_filled(rect, 2., SCREEN);
|
||||
let Some(ti) = state.take.filter(|i| *i < state.takes.len()) else {
|
||||
p.text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"Play the part you want to resample, then press CAPTURE",
|
||||
FontId::proportional(15.),
|
||||
DIM,
|
||||
);
|
||||
return;
|
||||
};
|
||||
let take = &state.takes[ti];
|
||||
let frames = take.frames().max(1) as f32;
|
||||
let label_band = 18.;
|
||||
let wave = Rect::from_min_max(rect.min + Vec2::new(0., label_band), rect.max);
|
||||
let x_of = |frame: usize| wave.left() + wave.width() * frame as f32 / frames;
|
||||
// Slices: tint, disabled dimming, selection.
|
||||
for (i, s) in take.slices.iter().enumerate() {
|
||||
let r = Rect::from_min_max(
|
||||
Pos2::new(x_of(s.start), wave.top()),
|
||||
Pos2::new(x_of(s.end), wave.bottom()),
|
||||
);
|
||||
let fill = if state.slice == Some(i) {
|
||||
Color32::from_rgba_unmultiplied(255, 1, 96, 28)
|
||||
} else if !s.enabled {
|
||||
Color32::from_rgba_unmultiplied(0, 0, 0, 120)
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
p.rect_filled(r, 0., fill);
|
||||
if r.width() > 26. {
|
||||
let text = s.root.map_or("·".into(), common_dsp::note_name);
|
||||
p.text(
|
||||
Pos2::new(r.center().x, rect.top() + 3.),
|
||||
egui::Align2::CENTER_TOP,
|
||||
text,
|
||||
FontId::proportional(11.),
|
||||
if s.enabled { STEEL } else { DIM },
|
||||
);
|
||||
}
|
||||
}
|
||||
// Bar and beat grid.
|
||||
let quarters = take.quarters();
|
||||
let bar = take.bar_quarters();
|
||||
let mut q = 0.;
|
||||
while q <= quarters + 1e-6 {
|
||||
let x = x_of(take.frame_at(q).round() as usize);
|
||||
let is_bar = ((q / bar).round() * bar - q).abs() < 1e-6;
|
||||
p.line_segment(
|
||||
[Pos2::new(x, wave.top()), Pos2::new(x, wave.bottom())],
|
||||
Stroke::new(
|
||||
1.0_f32,
|
||||
if is_bar {
|
||||
Color32::from_rgb(60, 64, 72)
|
||||
} else {
|
||||
Color32::from_rgb(28, 30, 34)
|
||||
},
|
||||
),
|
||||
);
|
||||
q += 1.;
|
||||
}
|
||||
// Overview waveform.
|
||||
let mid = wave.center().y;
|
||||
let half = wave.height() * 0.46;
|
||||
let peak = take
|
||||
.overview
|
||||
.iter()
|
||||
.fold(0.01f32, |m, v| m.max(v[0].abs()).max(v[1].abs()));
|
||||
let columns = wave.width().max(1.) as usize;
|
||||
for c in 0..columns {
|
||||
let a = c * OVERVIEW / columns;
|
||||
let z = ((c + 1) * OVERVIEW / columns).max(a + 1).min(OVERVIEW);
|
||||
let (mut lo, mut hi) = (0f32, 0f32);
|
||||
for v in &take.overview[a..z] {
|
||||
lo = lo.min(v[0]);
|
||||
hi = hi.max(v[1]);
|
||||
}
|
||||
let x = wave.left() + c as f32 + 0.5;
|
||||
p.line_segment(
|
||||
[
|
||||
Pos2::new(x, mid - hi / peak * half),
|
||||
Pos2::new(x, mid - lo / peak * half + 0.5),
|
||||
],
|
||||
Stroke::new(1.0_f32, STEEL),
|
||||
);
|
||||
}
|
||||
// Cuts.
|
||||
for c in &take.cuts[1..take.cuts.len().saturating_sub(1)] {
|
||||
let x = x_of(*c);
|
||||
p.line_segment(
|
||||
[Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())],
|
||||
Stroke::new(1.0_f32, ACCENT),
|
||||
);
|
||||
}
|
||||
p.rect_stroke(
|
||||
rect,
|
||||
2.,
|
||||
Stroke::new(1.0_f32, EDGE),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
let frame_at =
|
||||
|pos: Pos2| (((pos.x - wave.left()) / wave.width()).clamp(0., 1.) * frames) as usize;
|
||||
if response.hovered() {
|
||||
state.help =
|
||||
"Click a slice to hear it. Double-click to add a cut, right-click to remove the nearest."
|
||||
.into();
|
||||
}
|
||||
let pointer = response.interact_pointer_pos();
|
||||
if response.double_clicked() {
|
||||
if let Some(pos) = pointer {
|
||||
let f = frame_at(pos);
|
||||
if let Some(t) = state.current() {
|
||||
if t.add_cut(f) {
|
||||
state.slice = state.takes[ti].slice_at(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if response.clicked() {
|
||||
if let Some(pos) = pointer {
|
||||
let f = frame_at(pos);
|
||||
state.slice = state.takes[ti].slice_at(f);
|
||||
if let Some(i) = state.slice {
|
||||
audition(link, &state.takes[ti], i, settings);
|
||||
}
|
||||
}
|
||||
} else if response.secondary_clicked() {
|
||||
if let Some(pos) = pointer {
|
||||
let f = frame_at(pos);
|
||||
if let Some(t) = state.current() {
|
||||
t.remove_cut(f);
|
||||
}
|
||||
state.slice = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link: &Link) {
|
||||
let before = settings.clone();
|
||||
ui.horizontal(|ui| {
|
||||
caption(ui, "SLICE BY");
|
||||
for (mode, label) in [(Mode::Grid, "GRID"), (Mode::Transient, "TRANSIENTS")] {
|
||||
if chip(ui, settings.mode == mode, label).clicked() {
|
||||
settings.mode = mode;
|
||||
}
|
||||
}
|
||||
ui.add_space(8.);
|
||||
match settings.mode {
|
||||
Mode::Grid => {
|
||||
for d in Division::ALL {
|
||||
if chip(ui, settings.division == d, d.label()).clicked() {
|
||||
settings.division = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
Mode::Transient => {
|
||||
caption(ui, "SENSITIVITY");
|
||||
let r = ui.add(
|
||||
egui::Slider::new(&mut settings.sensitivity, 0. ..=100.)
|
||||
.suffix(" %")
|
||||
.clamping(egui::SliderClamping::Always),
|
||||
);
|
||||
hint(&r, state, "Higher finds quieter hits.");
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
caption(ui, "DECLICK");
|
||||
let r = ui.add(
|
||||
egui::Slider::new(&mut settings.declick, 0. ..=20.)
|
||||
.suffix(" ms")
|
||||
.clamping(egui::SliderClamping::Always),
|
||||
);
|
||||
hint(
|
||||
&r,
|
||||
state,
|
||||
"Fade at both ends of every slice, so cuts never click.",
|
||||
);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let r = chip(ui, settings.trim, "TRIM SILENCE");
|
||||
if r.clicked() {
|
||||
settings.trim = !settings.trim;
|
||||
}
|
||||
hint(&r, state, "Remove silence before the first sound of a slice.");
|
||||
let r = chip(ui, settings.normalize, "NORMALIZE");
|
||||
if r.clicked() {
|
||||
settings.normalize = !settings.normalize;
|
||||
}
|
||||
hint(&r, state, "Peak every slice at −0.3 dBFS.");
|
||||
let r = chip(ui, settings.wavetable, "WAVETABLE");
|
||||
if r.clicked() {
|
||||
settings.wavetable = !settings.wavetable;
|
||||
}
|
||||
hint(
|
||||
&r,
|
||||
state,
|
||||
"Also write wavetable.wav from the selected slice: 2048-sample frames for Serum or Vital.",
|
||||
);
|
||||
ui.separator();
|
||||
let take = state.take;
|
||||
let slice = state.slice;
|
||||
match (take.and_then(|t| state.takes.get_mut(t)), slice) {
|
||||
(Some(t), Some(i)) if i < t.slices.len() => {
|
||||
let s = &mut t.slices[i];
|
||||
let seconds = (s.end - s.start) as f32 / t.rate;
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"SLICE {} · {} · {:.2} s",
|
||||
i + 1,
|
||||
s.root.map_or("no pitch".into(), common_dsp::note_name),
|
||||
seconds
|
||||
))
|
||||
.size(13.)
|
||||
.color(TEXT),
|
||||
);
|
||||
ui.checkbox(&mut s.enabled, "Export");
|
||||
if ui.small_button("▶").clicked() {
|
||||
audition(link, t, i, settings);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
ui.label(RichText::new("No slice selected").size(13.).color(DIM));
|
||||
}
|
||||
}
|
||||
});
|
||||
let recut = settings.mode != before.mode
|
||||
|| settings.division != before.division
|
||||
|| (settings.mode == Mode::Transient && settings.sensitivity != before.sensitivity);
|
||||
if recut {
|
||||
if let Some(t) = state.current() {
|
||||
t.slice(settings);
|
||||
}
|
||||
state.slice = None;
|
||||
}
|
||||
ui.add_space(4.);
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
caption(ui, "FOLDER");
|
||||
if state.folder.is_empty() {
|
||||
state.folder = settings.folder().display().to_string();
|
||||
}
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut state.folder).desired_width(420.));
|
||||
if r.changed() {
|
||||
settings.folder = state.folder.clone();
|
||||
}
|
||||
hint(
|
||||
&r,
|
||||
state,
|
||||
"Add this folder to FL's browser once; exports then appear there for dragging.",
|
||||
);
|
||||
let can = state.take.is_some() && state.job.is_none();
|
||||
let export_button =
|
||||
egui::Button::new(RichText::new("EXPORT").size(14.).color(Color32::WHITE))
|
||||
.fill(Color32::from_rgb(150, 0, 56))
|
||||
.stroke(Stroke::new(1.0_f32, ACCENT));
|
||||
if ui.add_enabled(can, export_button).clicked() {
|
||||
if let Some(take) = state.take.and_then(|i| state.takes.get(i)).cloned() {
|
||||
let slot = Arc::new(Mutex::new(None));
|
||||
let done = slot.clone();
|
||||
let settings = settings.clone();
|
||||
let selected = state.slice;
|
||||
std::thread::spawn(move || {
|
||||
let folder = settings.folder();
|
||||
let result = export(&take, &settings, selected, &folder, unix_now());
|
||||
if let Ok(mut slot) = done.lock() {
|
||||
*slot = Some(result);
|
||||
}
|
||||
});
|
||||
state.job = Some(slot);
|
||||
state.status = "Exporting…".into();
|
||||
}
|
||||
}
|
||||
if ui.button(RichText::new("OPEN FOLDER").size(13.)).clicked() {
|
||||
let path = state
|
||||
.last_export
|
||||
.clone()
|
||||
.unwrap_or_else(|| settings.folder());
|
||||
let _ = std::fs::create_dir_all(&path);
|
||||
open_folder(&path);
|
||||
}
|
||||
});
|
||||
}
|
||||
Generated
+8
@@ -306,6 +306,13 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "common-ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -1435,6 +1442,7 @@ dependencies = [
|
||||
name = "patina-ui"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"common-ui",
|
||||
"egui",
|
||||
"patina-core",
|
||||
]
|
||||
|
||||
@@ -7,3 +7,4 @@ license = "ISC"
|
||||
[dependencies]
|
||||
egui = "0.31.1"
|
||||
patina-core = { path = "../core" }
|
||||
common-ui = { path = "../../common/ui" }
|
||||
@@ -1,87 +1,10 @@
|
||||
//! Shared native editor, also exercised by the headless renderer.
|
||||
pub use common_ui::style;
|
||||
use common_ui::*;
|
||||
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
|
||||
use patina_core::{focus_offset, frequency, AnalyzerFrame, Focus, Settings, GRID};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// Dark anodized hardware: graphite surfaces, one signal colour, steel data traces.
|
||||
const BG: Color32 = Color32::from_rgb(14, 15, 17);
|
||||
const PANEL: Color32 = Color32::from_rgb(25, 27, 31);
|
||||
const PANEL_TOP: Color32 = Color32::from_rgb(34, 36, 41);
|
||||
const EDGE: Color32 = Color32::from_rgb(44, 47, 53);
|
||||
const SCREEN: Color32 = Color32::from_rgb(9, 10, 11);
|
||||
const TEXT: Color32 = Color32::from_rgb(232, 233, 236);
|
||||
const MUTED: Color32 = Color32::from_rgb(140, 146, 156);
|
||||
const DIM: Color32 = Color32::from_rgb(70, 74, 82);
|
||||
/// Signal colour: cuts, active states, value arcs.
|
||||
const ACCENT: Color32 = Color32::from_rgb(255, 1, 96);
|
||||
/// Measured input and neutral data.
|
||||
const STEEL: Color32 = Color32::from_rgb(184, 192, 204);
|
||||
/// Focus / protect curve, distinct from cuts without competing with them.
|
||||
const FOCUS: Color32 = Color32::from_rgb(126, 167, 216);
|
||||
pub fn style(ctx: &egui::Context) {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
fonts.font_data.insert(
|
||||
"regular".into(),
|
||||
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Regular.ttf")).into(),
|
||||
);
|
||||
fonts.font_data.insert(
|
||||
"medium".into(),
|
||||
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Medium.ttf")).into(),
|
||||
);
|
||||
fonts
|
||||
.families
|
||||
.get_mut(&egui::FontFamily::Proportional)
|
||||
.unwrap()
|
||||
.insert(0, "regular".into());
|
||||
fonts.families.insert(
|
||||
egui::FontFamily::Name("medium".into()),
|
||||
vec![
|
||||
"medium".into(),
|
||||
"Ubuntu-Light".into(),
|
||||
"NotoEmoji-Regular".into(),
|
||||
"emoji-icon-font".into(),
|
||||
],
|
||||
);
|
||||
ctx.set_fonts(fonts);
|
||||
let mut s = (*ctx.style()).clone();
|
||||
let v = &mut s.visuals;
|
||||
*v = egui::Visuals::dark();
|
||||
v.panel_fill = BG;
|
||||
v.window_fill = PANEL;
|
||||
v.extreme_bg_color = SCREEN;
|
||||
v.faint_bg_color = PANEL;
|
||||
v.override_text_color = Some(TEXT);
|
||||
v.selection.bg_fill = Color32::from_rgb(110, 6, 46);
|
||||
v.selection.stroke = Stroke::new(1.0_f32, ACCENT);
|
||||
v.hyperlink_color = ACCENT;
|
||||
v.window_stroke = Stroke::new(1.0_f32, EDGE);
|
||||
for (w, fill, stroke) in [
|
||||
(&mut v.widgets.noninteractive, PANEL, EDGE),
|
||||
(&mut v.widgets.inactive, Color32::from_rgb(35, 38, 43), EDGE),
|
||||
(
|
||||
&mut v.widgets.hovered,
|
||||
Color32::from_rgb(45, 48, 54),
|
||||
Color32::from_rgb(78, 82, 90),
|
||||
),
|
||||
(&mut v.widgets.active, Color32::from_rgb(58, 18, 34), ACCENT),
|
||||
(&mut v.widgets.open, Color32::from_rgb(35, 38, 43), EDGE),
|
||||
] {
|
||||
w.bg_fill = fill;
|
||||
w.weak_bg_fill = fill;
|
||||
w.bg_stroke = Stroke::new(1.0_f32, stroke);
|
||||
w.corner_radius = 3.into();
|
||||
}
|
||||
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||
v.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||
v.slider_trailing_fill = true;
|
||||
s.spacing.item_spacing = Vec2::new(10., 8.);
|
||||
s.spacing.button_padding = Vec2::new(12., 6.);
|
||||
s.text_styles
|
||||
.insert(egui::TextStyle::Body, FontId::proportional(16.));
|
||||
s.text_styles
|
||||
.insert(egui::TextStyle::Button, FontId::proportional(16.));
|
||||
ctx.set_style(s);
|
||||
}
|
||||
pub struct State {
|
||||
pub delta: bool,
|
||||
pub selected: Option<usize>,
|
||||
@@ -139,29 +62,6 @@ impl State {
|
||||
fn db(x: f32) -> f32 {
|
||||
20. * x.max(1e-6).log10()
|
||||
}
|
||||
/// Disc with a vertical light-to-dark gradient, like brushed metal lit from above.
|
||||
fn metal_disc(p: &egui::Painter, center: Pos2, radius: f32, top: Color32, bottom: Color32) {
|
||||
let mut mesh = egui::Mesh::default();
|
||||
let mix = |t: f32| {
|
||||
let l = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8;
|
||||
Color32::from_rgb(
|
||||
l(top.r(), bottom.r()),
|
||||
l(top.g(), bottom.g()),
|
||||
l(top.b(), bottom.b()),
|
||||
)
|
||||
};
|
||||
mesh.colored_vertex(center, mix(0.5));
|
||||
let n = 48;
|
||||
for i in 0..=n {
|
||||
let a = std::f32::consts::TAU * i as f32 / n as f32;
|
||||
let point = center + Vec2::angled(a) * radius;
|
||||
mesh.colored_vertex(point, mix((point.y - (center.y - radius)) / (2. * radius)));
|
||||
if i > 0 {
|
||||
mesh.add_triangle(0, i, i + 1);
|
||||
}
|
||||
}
|
||||
p.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
/// Shows a help line in the status bar while the knob is hovered or focused.
|
||||
fn hint(response: egui::Response, state: &mut State, text: &str) {
|
||||
if response.hovered() || response.has_focus() {
|
||||
@@ -212,166 +112,6 @@ fn control(
|
||||
}
|
||||
});
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rotary(
|
||||
ui: &mut egui::Ui,
|
||||
name: &str,
|
||||
value: &mut f32,
|
||||
range: std::ops::RangeInclusive<f32>,
|
||||
default: f32,
|
||||
unit: &str,
|
||||
size: f32,
|
||||
color: Color32,
|
||||
) -> egui::Response {
|
||||
let low = *range.start();
|
||||
let high = *range.end();
|
||||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click_and_drag());
|
||||
if response.drag_started() {
|
||||
response.request_focus();
|
||||
}
|
||||
if response.dragged() {
|
||||
let delta = ui.input(|i| i.pointer.delta().y);
|
||||
let fine = if ui.input(|i| i.modifiers.shift) {
|
||||
0.1
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
*value = (*value - delta * (high - low) / 180. * fine).clamp(low, high);
|
||||
}
|
||||
if response.double_clicked() {
|
||||
*value = default;
|
||||
}
|
||||
if response.has_focus() {
|
||||
let step = if ui.input(|i| i.modifiers.shift) {
|
||||
0.1
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
if ui.input(|i| i.key_pressed(egui::Key::ArrowUp) || i.key_pressed(egui::Key::ArrowRight)) {
|
||||
*value = (*value + step).min(high);
|
||||
}
|
||||
if ui.input(|i| i.key_pressed(egui::Key::ArrowDown) || i.key_pressed(egui::Key::ArrowLeft))
|
||||
{
|
||||
*value = (*value - step).max(low);
|
||||
}
|
||||
}
|
||||
let center = rect.center();
|
||||
let radius = size * 0.46;
|
||||
let p = ui.painter();
|
||||
let start = std::f32::consts::PI * 0.75;
|
||||
let span = std::f32::consts::PI * 1.5;
|
||||
let normalized = ((*value - low) / (high - low)).clamp(0., 1.);
|
||||
// Scale track and value arc around the knob.
|
||||
for (fraction, stroke) in [
|
||||
(1., Stroke::new(2.5_f32, DIM)),
|
||||
(normalized, Stroke::new(2.5_f32, color)),
|
||||
] {
|
||||
let points: Vec<_> = (0..=64)
|
||||
.map(|i| {
|
||||
let a = start + span * fraction * i as f32 / 64.;
|
||||
center + Vec2::angled(a) * (radius - 1.5)
|
||||
})
|
||||
.collect();
|
||||
p.add(egui::Shape::line(points, stroke));
|
||||
}
|
||||
// Machined body: drop shadow, brushed gradient skirt, flat cap.
|
||||
let body = radius - 7.;
|
||||
p.circle_filled(
|
||||
center + Vec2::new(0., 2.),
|
||||
body + 1.,
|
||||
Color32::from_black_alpha(140),
|
||||
);
|
||||
metal_disc(
|
||||
p,
|
||||
center,
|
||||
body,
|
||||
Color32::from_rgb(78, 82, 90),
|
||||
Color32::from_rgb(28, 30, 34),
|
||||
);
|
||||
p.circle_stroke(
|
||||
center,
|
||||
body,
|
||||
Stroke::new(1.0_f32, Color32::from_rgb(10, 11, 12)),
|
||||
);
|
||||
let cap = body * 0.74;
|
||||
metal_disc(
|
||||
p,
|
||||
center,
|
||||
cap,
|
||||
Color32::from_rgb(44, 47, 52),
|
||||
Color32::from_rgb(33, 35, 40),
|
||||
);
|
||||
p.circle_stroke(
|
||||
center,
|
||||
cap,
|
||||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 22)),
|
||||
);
|
||||
let angle = start + span * normalized;
|
||||
p.line_segment(
|
||||
[
|
||||
center + Vec2::angled(angle) * (cap * 0.35),
|
||||
center + Vec2::angled(angle) * (cap - 2.),
|
||||
],
|
||||
Stroke::new(2.0_f32, TEXT),
|
||||
);
|
||||
if response.has_focus() {
|
||||
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, color));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(name.to_uppercase())
|
||||
.size(if size > 100. { 14. } else { 12. })
|
||||
.color(MUTED),
|
||||
);
|
||||
ui.add(
|
||||
egui::DragValue::new(value)
|
||||
.range(range)
|
||||
.speed(0.1)
|
||||
.suffix(unit),
|
||||
);
|
||||
response
|
||||
}
|
||||
/// Vertical gradient rectangle, used for panel faces.
|
||||
fn gradient(rect: Rect, top: Color32, bottom: Color32) -> egui::Shape {
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for (pos, color) in [
|
||||
(rect.left_top(), top),
|
||||
(rect.right_top(), top),
|
||||
(rect.right_bottom(), bottom),
|
||||
(rect.left_bottom(), bottom),
|
||||
] {
|
||||
mesh.colored_vertex(pos, color);
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
egui::Shape::mesh(mesh)
|
||||
}
|
||||
/// Raised hardware panel: graphite face, hairline edge, faint top highlight.
|
||||
fn panel<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
let face = ui.painter().add(egui::Shape::Noop);
|
||||
let inner = egui::Frame::new()
|
||||
.inner_margin(14.)
|
||||
.stroke(Stroke::new(1.0_f32, EDGE))
|
||||
.show(ui, add);
|
||||
let r = inner.response.rect;
|
||||
ui.painter().set(face, gradient(r, PANEL_TOP, PANEL));
|
||||
ui.painter().line_segment(
|
||||
[
|
||||
r.left_top() + Vec2::new(1., 1.),
|
||||
r.right_top() + Vec2::new(-1., 1.),
|
||||
],
|
||||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 16)),
|
||||
);
|
||||
inner.inner
|
||||
}
|
||||
fn caption(ui: &mut egui::Ui, text: &str) {
|
||||
ui.label(RichText::new(text).size(11.).color(MUTED));
|
||||
}
|
||||
/// Small labelled hardware-style readout.
|
||||
fn readout(ui: &mut egui::Ui, label: &str, value: String, color: Color32) {
|
||||
ui.label(RichText::new(label).size(11.).color(MUTED));
|
||||
ui.label(RichText::new(value).monospace().size(13.).color(color));
|
||||
ui.add_space(10.);
|
||||
}
|
||||
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
ui.spacing_mut().slider_width = ((ui.available_width() - 100.) / 4. - 85.).max(95.);
|
||||
let before = *settings;
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ def plan(tag, root=ROOT):
|
||||
tag = tag.removeprefix("refs/tags/")
|
||||
plugin = next((p for p in catalog(root) if tag.startswith(p + "-v")), None)
|
||||
if plugin is None:
|
||||
raise ValueError("Use a rivet-v<version> or patina-v<version> tag")
|
||||
raise ValueError("Use a <plugin>-v<version> tag, for example " + ", ".join(p + "-v1.0.0" for p in catalog(root)))
|
||||
release_version = tag_version(plugin, tag, root)
|
||||
release_notes(plugin, release_version, root)
|
||||
return {"plugin": plugin, "version": release_version, "tag": tag,
|
||||
|
||||
@@ -103,6 +103,25 @@ class ReleaseTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
tag_version(plugin, tag, self.root)
|
||||
|
||||
def test_unknown_tag_names_every_catalogued_plugin(self):
|
||||
with self.assertRaisesRegex(ValueError, "rivet-v1.0.0, patina-v1.0.0"):
|
||||
release.plan("refs/tags/unknown-v1.0.0", self.root)
|
||||
|
||||
def test_repository_catalog_is_complete(self):
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
products = json.loads((root / "plugins/catalog.json").read_text())
|
||||
check = (root / ".gitea/workflows/check.yml").read_text()
|
||||
triggers = (root / ".gitea/workflows/release.yml").read_text()
|
||||
ids = [p["clap_id"] for p in products.values()]
|
||||
self.assertEqual(len(ids), len(set(ids)), "CLAP IDs must be unique")
|
||||
for plugin, product in products.items():
|
||||
self.assertTrue((root / product["manifest"]).is_file(), plugin)
|
||||
self.assertTrue((root / product["documentation"] / "CHANGELOG.md").is_file(), plugin)
|
||||
self.assertIn(f'"{plugin}-v*"', triggers)
|
||||
self.assertIn(f"--plugin {plugin} --target", check)
|
||||
for source in product.get("licenses", {}):
|
||||
self.assertTrue((root / source).is_file(), source)
|
||||
|
||||
def test_individual_zip_excludes_other_plugin_and_is_reproducible(self):
|
||||
path = self.package("patina")
|
||||
first = path.read_bytes()
|
||||
|
||||
Generated
+54
@@ -36,6 +36,24 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anvil-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"common-dsp",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anvil-ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anvil-core",
|
||||
"common-dsp",
|
||||
"common-ui",
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
@@ -66,6 +84,17 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "common-dsp"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "common-ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.3"
|
||||
@@ -90,6 +119,25 @@ version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "crucible-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"common-dsp",
|
||||
"crossbeam-queue",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crucible-ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"common-dsp",
|
||||
"common-ui",
|
||||
"crucible-core",
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.31.1"
|
||||
@@ -103,6 +151,11 @@ dependencies = [
|
||||
name = "editor-check"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anvil-core",
|
||||
"anvil-ui",
|
||||
"common-dsp",
|
||||
"crucible-core",
|
||||
"crucible-ui",
|
||||
"egui",
|
||||
"image",
|
||||
"patina-core",
|
||||
@@ -327,6 +380,7 @@ dependencies = [
|
||||
name = "patina-ui"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"common-ui",
|
||||
"egui",
|
||||
"patina-core",
|
||||
]
|
||||
|
||||
@@ -14,3 +14,8 @@ rivet-core = { path = "../../plugins/rivet/core" }
|
||||
rivet-midi = { path = "../../plugins/rivet/midi" }
|
||||
patina-core = { path = "../../plugins/patina/core" }
|
||||
patina-ui = { path = "../../plugins/patina/ui" }
|
||||
anvil-core = { path = "../../plugins/anvil/core" }
|
||||
anvil-ui = { path = "../../plugins/anvil/ui" }
|
||||
common-dsp = { path = "../../plugins/common/dsp" }
|
||||
crucible-core = { path = "../../plugins/crucible/core" }
|
||||
crucible-ui = { path = "../../plugins/crucible/ui" }
|
||||
@@ -10,13 +10,19 @@ fn main() {
|
||||
.find(|a| a[0] == "--plugin")
|
||||
.map(|a| a[1].as_str())
|
||||
.unwrap_or("rivet");
|
||||
if !["rivet", "patina"].contains(&plugin) {
|
||||
eprintln!("Use --plugin rivet or --plugin patina");
|
||||
if !["rivet", "patina", "anvil", "crucible"].contains(&plugin) {
|
||||
eprintln!("Use --plugin rivet, patina, anvil or crucible");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let patina = plugin == "patina" || args.iter().any(|arg| arg == "--resonance");
|
||||
let compact = std::env::args().any(|arg| arg == "--compact");
|
||||
let (width, height) = if patina {
|
||||
let anvil = plugin == "anvil";
|
||||
let crucible = plugin == "crucible";
|
||||
let (width, height) = if crucible {
|
||||
(crucible_ui::WIDTH, crucible_ui::HEIGHT)
|
||||
} else if anvil {
|
||||
(anvil_ui::WIDTH, anvil_ui::HEIGHT)
|
||||
} else if patina {
|
||||
if compact {
|
||||
(1040, 700)
|
||||
} else {
|
||||
@@ -63,6 +69,90 @@ fn main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut anvil_settings = anvil_core::Settings::default();
|
||||
let mut anvil_state = anvil_ui::State::default();
|
||||
if anvil {
|
||||
let mut engine = anvil_core::Engine::new(48000., 2).unwrap();
|
||||
engine.set_settings(anvil_settings).unwrap();
|
||||
let mut saw = common_dsp::Saw::new(48000., 43.65);
|
||||
for i in 0..36000 {
|
||||
// A distorted F1 bass whose own sub drifts in phase and level.
|
||||
let x = (saw.sample() * 1.6 + 0.3 * (i as f32 * 0.0009).sin()).tanh() * 0.4;
|
||||
engine.process_sample([x, x], anvil_core::Monitor::default());
|
||||
if i % 1600 == 0 {
|
||||
anvil_state.accept_frame(engine.take_analyzer());
|
||||
}
|
||||
}
|
||||
anvil_state.accept_frame(engine.take_analyzer());
|
||||
}
|
||||
let mut crucible_settings = crucible_core::Settings {
|
||||
folder: "C:\\Users\\you\\Documents\\Crucible".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut crucible_state = crucible_ui::State::default();
|
||||
let crucible_link = crucible_ui::Link {
|
||||
ring: Some(Arc::new(crucible_core::Ring::new(
|
||||
48000.,
|
||||
crucible_core::HISTORY,
|
||||
))),
|
||||
queues: Arc::new(crucible_core::Queues::default()),
|
||||
};
|
||||
if crucible {
|
||||
// Four bars of a 150 BPM riddim pattern: square-ish bass hits on varied notes.
|
||||
let rate = 48000.;
|
||||
let beat = 60. / 150. * rate;
|
||||
let frames = (16. * beat) as usize;
|
||||
let mut audio = vec![0f32; frames * 2];
|
||||
let pattern = [
|
||||
(0., 29),
|
||||
(1.5, 29),
|
||||
(2.75, 32),
|
||||
(4., 27),
|
||||
(5.5, 29),
|
||||
(6.5, 34),
|
||||
(8., 29),
|
||||
(9.5, 29),
|
||||
(10.75, 36),
|
||||
(12., 27),
|
||||
(13.5, 32),
|
||||
(14.5, 29),
|
||||
];
|
||||
for (at, note) in pattern {
|
||||
let start = (at * beat) as usize;
|
||||
let hz = common_dsp::midi_to_hz(note as f32);
|
||||
let mut saw = common_dsp::Saw::new(rate, hz);
|
||||
for i in 0..(0.6 * beat) as usize {
|
||||
let f = start + i;
|
||||
if f >= frames {
|
||||
break;
|
||||
}
|
||||
let env = (1. - i as f32 / (0.6 * beat)).powf(0.4);
|
||||
let v = (saw.sample() * 2.5).tanh() * 0.6 * env;
|
||||
audio[2 * f] += v;
|
||||
audio[2 * f + 1] += v;
|
||||
}
|
||||
}
|
||||
let transport = crucible_core::Transport {
|
||||
playing: true,
|
||||
tempo: 150.,
|
||||
beats: 0.,
|
||||
numerator: 4,
|
||||
denominator: 4,
|
||||
};
|
||||
let segments = vec![crucible_core::Segment {
|
||||
frame: 0.,
|
||||
quarters: 0.,
|
||||
tempo: 150.,
|
||||
}];
|
||||
let take = crucible_core::Take::from_audio(rate, audio, 4, transport, segments, true);
|
||||
crucible_settings.mode = crucible_core::Mode::Transient;
|
||||
crucible_state.add(take.clone(), &crucible_settings);
|
||||
crucible_state.add(take, &crucible_settings);
|
||||
crucible_state.slice = Some(2);
|
||||
crucible_state.takes[0].slices[5].enabled = false;
|
||||
crucible_state.transport = Some(transport);
|
||||
crucible_state.status = "Captured 4 bars on the bar grid.".into();
|
||||
}
|
||||
let shared = Arc::new(rivet_app::Shared::default());
|
||||
let mut session = rivet_core::Session::demo();
|
||||
session.motifs[0].phrase.notes[0].locked = true;
|
||||
@@ -83,7 +173,11 @@ fn main() {
|
||||
let session_motif = session.motifs[0].id;
|
||||
shared.replace(session).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
if patina {
|
||||
if crucible {
|
||||
crucible_ui::style(&ctx);
|
||||
} else if anvil {
|
||||
anvil_ui::style(&ctx);
|
||||
} else if patina {
|
||||
patina_ui::style(&ctx);
|
||||
} else {
|
||||
editor::style(&ctx);
|
||||
@@ -108,7 +202,16 @@ fn main() {
|
||||
},
|
||||
|ui| {
|
||||
egui::CentralPanel::default().show(ui, |ui| {
|
||||
if patina {
|
||||
if crucible {
|
||||
crucible_ui::show(
|
||||
ui,
|
||||
&mut crucible_settings,
|
||||
&mut crucible_state,
|
||||
&crucible_link,
|
||||
);
|
||||
} else if anvil {
|
||||
anvil_ui::show(ui, &mut anvil_settings, &mut anvil_state);
|
||||
} else if patina {
|
||||
patina_ui::show(ui, &mut patina_settings, &mut patina_state);
|
||||
} else {
|
||||
editor::show(ui, &shared, &mut state);
|
||||
|
||||
Reference in new issue
Block a user