diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index afa031a..6cd5edd 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -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: diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 57d5903..a753917 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 26c8b4c..d191581 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index 6be5600..9e255a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index fbb7df8..cd35551 100644 --- a/README.md +++ b/README.md @@ -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// 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 ``` diff --git a/docs/anvil/CHANGELOG.md b/docs/anvil/CHANGELOG.md new file mode 100644 index 0000000..5c441c4 --- /dev/null +++ b/docs/anvil/CHANGELOG.md @@ -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. diff --git a/docs/anvil/README.md b/docs/anvil/README.md new file mode 100644 index 0000000..fea0760 --- /dev/null +++ b/docs/anvil/README.md @@ -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. + +![Anvil](assets/editor.png) + +## 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. diff --git a/docs/anvil/assets/editor.png b/docs/anvil/assets/editor.png new file mode 100644 index 0000000..6839fd7 Binary files /dev/null and b/docs/anvil/assets/editor.png differ diff --git a/docs/crucible/CHANGELOG.md b/docs/crucible/CHANGELOG.md new file mode 100644 index 0000000..03ff0b7 --- /dev/null +++ b/docs/crucible/CHANGELOG.md @@ -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. diff --git a/docs/crucible/README.md b/docs/crucible/README.md new file mode 100644 index 0000000..1876b2c --- /dev/null +++ b/docs/crucible/README.md @@ -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**. + +![Crucible](assets/editor.png) + +## 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. diff --git a/docs/crucible/assets/editor.png b/docs/crucible/assets/editor.png new file mode 100644 index 0000000..959aa01 Binary files /dev/null and b/docs/crucible/assets/editor.png differ diff --git a/docs/development.md b/docs/development.md index ac07e12..dc58494 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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//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//Rivet.clap` and `Patina.clap`, with docs and +Build outputs use `dist//.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 diff --git a/plugins/anvil/core/Cargo.toml b/plugins/anvil/core/Cargo.toml new file mode 100644 index 0000000..19aa150 --- /dev/null +++ b/plugins/anvil/core/Cargo.toml @@ -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 } diff --git a/plugins/anvil/core/src/lib.rs b/plugins/anvil/core/src/lib.rs new file mode 100644 index 0000000..cf9b204 --- /dev/null +++ b/plugins/anvil/core/src/lib.rs @@ -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, + 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, + ring_at: usize, + scratch: Vec, + 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 { + 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 { + 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 + } +} diff --git a/plugins/anvil/core/src/settings.rs b/plugins/anvil/core/src/settings.rs new file mode 100644 index 0000000..081a14a --- /dev/null +++ b/plugins/anvil/core/src/settings.rs @@ -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") + } + } +} diff --git a/plugins/anvil/core/tests/dsp.rs b/plugins/anvil/core/tests/dsp.rs new file mode 100644 index 0000000..205dc51 --- /dev/null +++ b/plugins/anvil/core/tests/dsp.rs @@ -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 { + 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 { + 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::() / 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 = (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 = (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::(&json).unwrap(), + Settings::default() + ); + assert!(Engine::new(1000., 2).is_err()); + assert!(Engine::new(48000., 3).is_err()); +} diff --git a/plugins/anvil/core/tests/realtime.rs b/plugins/anvil/core/tests/realtime.rs new file mode 100644 index 0000000..bdc20ae --- /dev/null +++ b/plugins/anvil/core/tests/realtime.rs @@ -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=const {Cell::new(false)}; + static OPERATIONS:Cell=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); +} diff --git a/plugins/anvil/plugin/Cargo.lock b/plugins/anvil/plugin/Cargo.lock new file mode 100644 index 0000000..f218c81 --- /dev/null +++ b/plugins/anvil/plugin/Cargo.lock @@ -0,0 +1,2382 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "addr2line" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e567177890eb1617b1f774005b66b26b2377afd138a2ca37aae7d8f0c81429d4" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "anvil" +version = "0.1.0" +dependencies = [ + "anvil-core", + "anvil-ui", + "clap-sys", + "crossbeam-queue", + "nih_plug", + "nih_plug_egui", + "serde_json", +] + +[[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 = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "anymap3" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atomic_float" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62af46d040ba9df09edc6528dae9d8e49f5f3e82f55b7d2ec31a733c38dbc49d" + +[[package]] +name = "atomic_refcell" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e4227379beff4205943696e6c3e0cd809bacdf3f0edd6e3dd153e2269571a4" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f4220217f3288537b83684130f42fad43320b1c3b805f1910d683d52ded24f" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "baseview" +version = "0.1.0" +source = "git+https://github.com/RustAudio/baseview.git?rev=9a0b42c09d712777b2edb4c5e0cb6baf21e988f0#9a0b42c09d712777b2edb4c5e0cb6baf21e988f0" +dependencies = [ + "cocoa", + "core-foundation", + "keyboard-types", + "nix", + "objc", + "raw-window-handle 0.5.2", + "uuid", + "winapi", + "x11", + "x11rb", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap-sys" +version = "0.5.0" +source = "git+https://github.com/micahrj/clap-sys.git?rev=25d7f53fdb6363ad63fbd80049cb7a42a97ac156#25d7f53fdb6363ad63fbd80049cb7a42a97ac156" + +[[package]] +name = "clipboard-win" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" +dependencies = [ + "error-code", +] + +[[package]] +name = "cocoa" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics 0.22.3", + "foreign-types 0.3.2", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation", + "core-graphics-types", + "libc", + "objc", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "common-dsp" +version = "0.1.0" + +[[package]] +name = "common-ui" +version = "0.1.0" +dependencies = [ + "egui", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "copypasta" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6811e17f81fe246ef2bc553f76b6ee6ab41a694845df1d37e52a92b7bbd38a" +dependencies = [ + "clipboard-win", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "x11-clipboard", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.3.2", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "crossbeam" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e71406cd8807725f7ac2f999a4cdd32e98f829fdf65f528343cebf945e41df1e" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc4feb366740ded31a004a0e4452fbf84e80ef432ecf8314c485210229672fd1" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "egui" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25dd34cec49ab55d85ebf70139cb1ccd29c977ef6b6ba4fe85489d6877ee9ef3" +dependencies = [ + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "nohash-hasher", + "profiling", +] + +[[package]] +name = "egui-baseview" +version = "0.5.0" +source = "git+https://github.com/BillyDM/egui-baseview.git?rev=ec70c3fe6b2f070dcacbc22924431edbe24bd1c0#ec70c3fe6b2f070dcacbc22924431edbe24bd1c0" +dependencies = [ + "baseview", + "copypasta", + "egui", + "egui_glow", + "keyboard-types", + "log", + "open", + "raw-window-handle 0.5.2", + "thiserror 2.0.21", +] + +[[package]] +name = "egui_glow" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "910906e3f042ea6d2378ec12a6fd07698e14ddae68aed2d819ffe944a73aab9e" +dependencies = [ + "ahash", + "bytemuck", + "egui", + "glow", + "log", + "memoffset 0.9.1", + "profiling", + "wasm-bindgen", + "web-sys", + "winit", +] + +[[package]] +name = "emath" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e4cadcff7a5353ba72b7fea76bf2122b5ebdbc68e8155aa56dfdea90083fe1b" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "epaint" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fcc0f5a7c613afd2dee5e4b30c3e6acafb8ad6f0edb06068811f708a67c562" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "nohash-hasher", + "parking_lot", + "profiling", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7e7a64c02cf7a5b51e745a9e45f60660a286f151c238b9d397b3e923f5082f" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.6", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gimli" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1033caf0b349c518623b5396bfb2cf0bddf44f0306d543a250e5743297aafd10" + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyboard-types" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7668b7cff6a51fe61cdde64cd27c8a220786f399501b57ebe36f7d8112fd68" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "midi-consts" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f2dd5c7f8aaf48a76e389068ab25ed80bdbc226b887f9013844c415698c9952" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle 0.6.2", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nih_log" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0cdb52ef79af48ae110401c883bdb9c15e0306a99ab6ecf18bc52068b668e54" +dependencies = [ + "atty", + "log", + "once_cell", + "termcolor", + "time", + "windows", +] + +[[package]] +name = "nih_plug" +version = "0.0.0" +source = "git+https://github.com/robbert-vdh/nih-plug.git?rev=de421011f41a6d10fc8c7a6084e4f4dee0143683#de421011f41a6d10fc8c7a6084e4f4dee0143683" +dependencies = [ + "anyhow", + "anymap3", + "atomic_float", + "atomic_refcell", + "backtrace", + "bitflags 1.3.2", + "cfg-if", + "clap-sys", + "core-foundation", + "crossbeam", + "libc", + "log", + "midi-consts", + "nih_log", + "nih_plug_derive", + "objc", + "parking_lot", + "raw-window-handle 0.5.2", + "serde", + "serde_json", + "widestring", + "windows", +] + +[[package]] +name = "nih_plug_derive" +version = "0.1.0" +source = "git+https://github.com/robbert-vdh/nih-plug.git?rev=de421011f41a6d10fc8c7a6084e4f4dee0143683#de421011f41a6d10fc8c7a6084e4f4dee0143683" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "nih_plug_egui" +version = "0.0.0" +source = "git+https://github.com/robbert-vdh/nih-plug.git?rev=de421011f41a6d10fc8c7a6084e4f4dee0143683#de421011f41a6d10fc8c7a6084e4f4dee0143683" +dependencies = [ + "baseview", + "crossbeam", + "egui-baseview", + "nih_plug", + "parking_lot", + "raw-window-handle 0.5.2", + "serde", +] + +[[package]] +name = "nix" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4916f159ed8e5de0082076562152a76b7a1f64a01fd9d1e0fea002c37624faf" +dependencies = [ + "bitflags 1.3.2", + "cc", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num_threads" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +dependencies = [ + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2", + "objc2-contacts", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2", + "dispatch", + "libc", + "objc2", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "object" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.3", + "pin-project-lite", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics 0.23.2", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle 0.6.2", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-clipboard" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" +dependencies = [ + "libc", + "x11rb", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.6", + "x11rb-protocol", + "xcursor", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/anvil/plugin/Cargo.toml b/plugins/anvil/plugin/Cargo.toml new file mode 100644 index 0000000..e2d8462 --- /dev/null +++ b/plugins/anvil/plugin/Cargo.toml @@ -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" diff --git a/plugins/anvil/plugin/src/lib.rs b/plugins/anvil/plugin/src/lib.rs new file mode 100644 index 0000000..4e5ddc2 --- /dev/null +++ b/plugins/anvil/plugin/src/lib.rs @@ -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, + solo: AtomicBool, +} +struct EditorData { + ui: anvil_ui::State, + gestures: [bool; 9], +} +pub struct Anvil { + params: Arc, + shared: Arc, + engine: Option, + 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 { + self.params.clone() + } + fn editor(&mut self, _: AsyncExecutor) -> Option> { + 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, + ) -> 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, + ) -> 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); diff --git a/plugins/anvil/plugin/src/params.rs b/plugins/anvil/plugin/src/params.rs new file mode 100644 index 0000000..d05c70e --- /dev/null +++ b/plugins/anvil/plugin/src/params.rs @@ -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, + #[id = "source"] + pub source: EnumParam, + #[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)); +} diff --git a/plugins/anvil/plugin/tests/host.rs b/plugins/anvil/plugin/tests/host.rs new file mode 100644 index 0000000..93fdb56 --- /dev/null +++ b/plugins/anvil/plugin/tests/host.rs @@ -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) }; + 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>) }; + 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 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> 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()(); + } +} diff --git a/plugins/anvil/ui/Cargo.toml b/plugins/anvil/ui/Cargo.toml new file mode 100644 index 0000000..e3272e9 --- /dev/null +++ b/plugins/anvil/ui/Cargo.toml @@ -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" } diff --git a/plugins/anvil/ui/src/lib.rs b/plugins/anvil/ui/src/lib.rs new file mode 100644 index 0000000..cb12748 --- /dev/null +++ b/plugins/anvil/ui/src/lib.rs @@ -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, + 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 = 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.", + ); + }); +} diff --git a/plugins/catalog.json b/plugins/catalog.json index 751b27e..f4d99c3 100644 --- a/plugins/catalog.json +++ b/plugins/catalog.json @@ -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" + ] } } diff --git a/plugins/common/dsp/Cargo.toml b/plugins/common/dsp/Cargo.toml new file mode 100644 index 0000000..80c1434 --- /dev/null +++ b/plugins/common/dsp/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "common-dsp" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] diff --git a/plugins/common/dsp/src/lib.rs b/plugins/common/dsp/src/lib.rs new file mode 100644 index 0000000..9a2b878 --- /dev/null +++ b/plugins/common/dsp/src/lib.rs @@ -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, + 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 + } +} diff --git a/plugins/common/dsp/src/wav.rs b/plugins/common/dsp/src/wav.rs new file mode 100644 index 0000000..fd24caa --- /dev/null +++ b/plugins/common/dsp/src/wav.rs @@ -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, + /// MIDI root key, written as a `smpl` chunk that samplers (FL included) read. + pub root: Option, + /// Wavetable frame size, written as Serum's `clm ` chunk. + pub frame: Option, +} + +fn chunk(out: &mut Vec, 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 { + 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 { + 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) +} diff --git a/plugins/common/dsp/tests/dsp.rs b/plugins/common/dsp/tests/dsp.rs new file mode 100644 index 0000000..94fa182 --- /dev/null +++ b/plugins/common/dsp/tests/dsp.rs @@ -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 = (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 = (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::>()[..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()); +} diff --git a/plugins/common/ui/Cargo.toml b/plugins/common/ui/Cargo.toml new file mode 100644 index 0000000..28da044 --- /dev/null +++ b/plugins/common/ui/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "common-ui" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +egui = "0.31.1" diff --git a/plugins/patina/ui/assets/OFL.txt b/plugins/common/ui/assets/OFL.txt similarity index 100% rename from plugins/patina/ui/assets/OFL.txt rename to plugins/common/ui/assets/OFL.txt diff --git a/plugins/patina/ui/assets/RobotoCondensed-Medium.ttf b/plugins/common/ui/assets/RobotoCondensed-Medium.ttf similarity index 100% rename from plugins/patina/ui/assets/RobotoCondensed-Medium.ttf rename to plugins/common/ui/assets/RobotoCondensed-Medium.ttf diff --git a/plugins/patina/ui/assets/RobotoCondensed-Regular.ttf b/plugins/common/ui/assets/RobotoCondensed-Regular.ttf similarity index 100% rename from plugins/patina/ui/assets/RobotoCondensed-Regular.ttf rename to plugins/common/ui/assets/RobotoCondensed-Regular.ttf diff --git a/plugins/common/ui/src/lib.rs b/plugins/common/ui/src/lib.rs new file mode 100644 index 0000000..84bf837 --- /dev/null +++ b/plugins/common/ui/src/lib.rs @@ -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, + 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(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), + ); + }); +} diff --git a/plugins/crucible/core/Cargo.toml b/plugins/crucible/core/Cargo.toml new file mode 100644 index 0000000..ff1d237 --- /dev/null +++ b/plugins/crucible/core/Cargo.toml @@ -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 } diff --git a/plugins/crucible/core/src/export.rs b/plugins/crucible/core/src/export.rs new file mode 100644 index 0000000..5cc83b7 --- /dev/null +++ b/plugins/crucible/core/src/export.rs @@ -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, + root: &Path, + unix: u64, +) -> std::io::Result { + 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, root: Option| 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, + }) +} diff --git a/plugins/crucible/core/src/lib.rs b/plugins/crucible/core/src/lib.rs new file mode 100644 index 0000000..028ef81 --- /dev/null +++ b/plugins/crucible/core/src/lib.rs @@ -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.; diff --git a/plugins/crucible/core/src/player.rs b/plugins/crucible/core/src/player.rs new file mode 100644 index 0000000..04fe846 --- /dev/null +++ b/plugins/crucible/core/src/player.rs @@ -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>, + finished: ArrayQueue, +} +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, + 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() + } +} diff --git a/plugins/crucible/core/src/ring.rs b/plugins/crucible/core/src/ring.rs new file mode 100644 index 0000000..f55944b --- /dev/null +++ b/plugins/crucible/core/src/ring.rs @@ -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 { + 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 = (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, &'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, + 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) { + 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.; + } +} diff --git a/plugins/crucible/core/src/settings.rs b/plugins/crucible/core/src/settings.rs new file mode 100644 index 0000000..b10448c --- /dev/null +++ b/plugins/crucible/core/src/settings.rs @@ -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"), + } +} diff --git a/plugins/crucible/core/src/take.rs b/plugins/crucible/core/src/take.rs new file mode 100644 index 0000000..052a56d --- /dev/null +++ b/plugins/crucible/core/src/take.rs @@ -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, +} + +#[derive(Clone, Debug)] +pub struct Take { + pub rate: f32, + /// Interleaved stereo. + pub audio: Vec, + 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, + /// Slice boundaries in frames, sorted, starting at 0 and ending at the take length. + pub cuts: Vec, + pub slices: Vec, + /// 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 { + 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, + bars: u32, + transport: Transport, + segments: Vec, + 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 { + self.slices + .iter() + .position(|s| frame >= s.start && frame < s.end) + } +} + +fn grid(take: &Take, division: Division) -> Vec { + 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 { + 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 = 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 { + 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 = 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 { + 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 { + let mut out: Vec = 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 { + 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> { + 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) +} diff --git a/plugins/crucible/core/tests/core.rs b/plugins/crucible/core/tests/core.rs new file mode 100644 index 0000000..6beb03d --- /dev/null +++ b/plugins/crucible/core/tests/core.rs @@ -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, 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 = (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::(&json).unwrap(), + Settings::default() + ); +} diff --git a/plugins/crucible/core/tests/realtime.rs b/plugins/crucible/core/tests/realtime.rs new file mode 100644 index 0000000..af4f445 --- /dev/null +++ b/plugins/crucible/core/tests/realtime.rs @@ -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=const {Cell::new(false)}; + static OPERATIONS:Cell=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> = (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(); +} diff --git a/plugins/crucible/plugin/Cargo.lock b/plugins/crucible/plugin/Cargo.lock new file mode 100644 index 0000000..21605c3 --- /dev/null +++ b/plugins/crucible/plugin/Cargo.lock @@ -0,0 +1,2382 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "addr2line" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e567177890eb1617b1f774005b66b26b2377afd138a2ca37aae7d8f0c81429d4" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "anymap3" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atomic_float" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62af46d040ba9df09edc6528dae9d8e49f5f3e82f55b7d2ec31a733c38dbc49d" + +[[package]] +name = "atomic_refcell" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e4227379beff4205943696e6c3e0cd809bacdf3f0edd6e3dd153e2269571a4" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f4220217f3288537b83684130f42fad43320b1c3b805f1910d683d52ded24f" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "baseview" +version = "0.1.0" +source = "git+https://github.com/RustAudio/baseview.git?rev=9a0b42c09d712777b2edb4c5e0cb6baf21e988f0#9a0b42c09d712777b2edb4c5e0cb6baf21e988f0" +dependencies = [ + "cocoa", + "core-foundation", + "keyboard-types", + "nix", + "objc", + "raw-window-handle 0.5.2", + "uuid", + "winapi", + "x11", + "x11rb", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap-sys" +version = "0.5.0" +source = "git+https://github.com/micahrj/clap-sys.git?rev=25d7f53fdb6363ad63fbd80049cb7a42a97ac156#25d7f53fdb6363ad63fbd80049cb7a42a97ac156" + +[[package]] +name = "clipboard-win" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" +dependencies = [ + "error-code", +] + +[[package]] +name = "cocoa" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics 0.22.3", + "foreign-types 0.3.2", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation", + "core-graphics-types", + "libc", + "objc", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "common-dsp" +version = "0.1.0" + +[[package]] +name = "common-ui" +version = "0.1.0" +dependencies = [ + "egui", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "copypasta" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6811e17f81fe246ef2bc553f76b6ee6ab41a694845df1d37e52a92b7bbd38a" +dependencies = [ + "clipboard-win", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "x11-clipboard", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.3.2", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "crossbeam" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e71406cd8807725f7ac2f999a4cdd32e98f829fdf65f528343cebf945e41df1e" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crucible" +version = "0.1.0" +dependencies = [ + "clap-sys", + "crucible-core", + "crucible-ui", + "nih_plug", + "nih_plug_egui", + "serde_json", +] + +[[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 = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc4feb366740ded31a004a0e4452fbf84e80ef432ecf8314c485210229672fd1" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "egui" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25dd34cec49ab55d85ebf70139cb1ccd29c977ef6b6ba4fe85489d6877ee9ef3" +dependencies = [ + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "nohash-hasher", + "profiling", +] + +[[package]] +name = "egui-baseview" +version = "0.5.0" +source = "git+https://github.com/BillyDM/egui-baseview.git?rev=ec70c3fe6b2f070dcacbc22924431edbe24bd1c0#ec70c3fe6b2f070dcacbc22924431edbe24bd1c0" +dependencies = [ + "baseview", + "copypasta", + "egui", + "egui_glow", + "keyboard-types", + "log", + "open", + "raw-window-handle 0.5.2", + "thiserror 2.0.21", +] + +[[package]] +name = "egui_glow" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "910906e3f042ea6d2378ec12a6fd07698e14ddae68aed2d819ffe944a73aab9e" +dependencies = [ + "ahash", + "bytemuck", + "egui", + "glow", + "log", + "memoffset 0.9.1", + "profiling", + "wasm-bindgen", + "web-sys", + "winit", +] + +[[package]] +name = "emath" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e4cadcff7a5353ba72b7fea76bf2122b5ebdbc68e8155aa56dfdea90083fe1b" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "epaint" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fcc0f5a7c613afd2dee5e4b30c3e6acafb8ad6f0edb06068811f708a67c562" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "nohash-hasher", + "parking_lot", + "profiling", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7e7a64c02cf7a5b51e745a9e45f60660a286f151c238b9d397b3e923f5082f" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.6", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gimli" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1033caf0b349c518623b5396bfb2cf0bddf44f0306d543a250e5743297aafd10" + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyboard-types" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7668b7cff6a51fe61cdde64cd27c8a220786f399501b57ebe36f7d8112fd68" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "midi-consts" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f2dd5c7f8aaf48a76e389068ab25ed80bdbc226b887f9013844c415698c9952" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle 0.6.2", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nih_log" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0cdb52ef79af48ae110401c883bdb9c15e0306a99ab6ecf18bc52068b668e54" +dependencies = [ + "atty", + "log", + "once_cell", + "termcolor", + "time", + "windows", +] + +[[package]] +name = "nih_plug" +version = "0.0.0" +source = "git+https://github.com/robbert-vdh/nih-plug.git?rev=de421011f41a6d10fc8c7a6084e4f4dee0143683#de421011f41a6d10fc8c7a6084e4f4dee0143683" +dependencies = [ + "anyhow", + "anymap3", + "atomic_float", + "atomic_refcell", + "backtrace", + "bitflags 1.3.2", + "cfg-if", + "clap-sys", + "core-foundation", + "crossbeam", + "libc", + "log", + "midi-consts", + "nih_log", + "nih_plug_derive", + "objc", + "parking_lot", + "raw-window-handle 0.5.2", + "serde", + "serde_json", + "widestring", + "windows", +] + +[[package]] +name = "nih_plug_derive" +version = "0.1.0" +source = "git+https://github.com/robbert-vdh/nih-plug.git?rev=de421011f41a6d10fc8c7a6084e4f4dee0143683#de421011f41a6d10fc8c7a6084e4f4dee0143683" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "nih_plug_egui" +version = "0.0.0" +source = "git+https://github.com/robbert-vdh/nih-plug.git?rev=de421011f41a6d10fc8c7a6084e4f4dee0143683#de421011f41a6d10fc8c7a6084e4f4dee0143683" +dependencies = [ + "baseview", + "crossbeam", + "egui-baseview", + "nih_plug", + "parking_lot", + "raw-window-handle 0.5.2", + "serde", +] + +[[package]] +name = "nix" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4916f159ed8e5de0082076562152a76b7a1f64a01fd9d1e0fea002c37624faf" +dependencies = [ + "bitflags 1.3.2", + "cc", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num_threads" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +dependencies = [ + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2", + "objc2-contacts", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2", + "dispatch", + "libc", + "objc2", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "object" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.3", + "pin-project-lite", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics 0.23.2", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle 0.6.2", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-clipboard" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" +dependencies = [ + "libc", + "x11rb", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.6", + "x11rb-protocol", + "xcursor", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/crucible/plugin/Cargo.toml b/plugins/crucible/plugin/Cargo.toml new file mode 100644 index 0000000..c31d8fb --- /dev/null +++ b/plugins/crucible/plugin/Cargo.toml @@ -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" diff --git a/plugins/crucible/plugin/src/lib.rs b/plugins/crucible/plugin/src/lib.rs new file mode 100644 index 0000000..09ff383 --- /dev/null +++ b/plugins/crucible/plugin/src/lib.rs @@ -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, + #[persist = "crucible-settings"] + pub settings: RwLock, + #[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>>, + queues: Arc, +} +pub struct Crucible { + params: Arc, + shared: Arc, + ring: Option>, + 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 { + self.params.clone() + } + fn editor(&mut self, _: AsyncExecutor) -> Option> { + 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, + ) -> 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, + ) -> 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); diff --git a/plugins/crucible/plugin/tests/host.rs b/plugins/crucible/plugin/tests/host.rs new file mode 100644 index 0000000..2ca3e3e --- /dev/null +++ b/plugins/crucible/plugin/tests/host.rs @@ -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) }; + 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>) }; + 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 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> 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()(); + } +} diff --git a/plugins/crucible/ui/Cargo.toml b/plugins/crucible/ui/Cargo.toml new file mode 100644 index 0000000..64de4bd --- /dev/null +++ b/plugins/crucible/ui/Cargo.toml @@ -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" } diff --git a/plugins/crucible/ui/src/lib.rs b/plugins/crucible/ui/src/lib.rs new file mode 100644 index 0000000..04e8379 --- /dev/null +++ b/plugins/crucible/ui/src/lib.rs @@ -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>, + pub queues: Arc, +} + +pub struct State { + pub takes: Vec, + pub take: Option, + pub slice: Option, + pub status: String, + pub help: String, + pub peak: f32, + pub transport: Option, + pub folder: String, + counter: usize, + names: Vec, + /// Filled by the export thread when it finishes. + job: Option>>>>, + last_export: Option, +} +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); + } + }); +} diff --git a/plugins/patina/plugin/Cargo.lock b/plugins/patina/plugin/Cargo.lock index 8b8ca14..429d283 100644 --- a/plugins/patina/plugin/Cargo.lock +++ b/plugins/patina/plugin/Cargo.lock @@ -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", ] diff --git a/plugins/patina/ui/Cargo.toml b/plugins/patina/ui/Cargo.toml index cdbc016..7df722b 100644 --- a/plugins/patina/ui/Cargo.toml +++ b/plugins/patina/ui/Cargo.toml @@ -7,3 +7,4 @@ license = "ISC" [dependencies] egui = "0.31.1" patina-core = { path = "../core" } +common-ui = { path = "../../common/ui" } diff --git a/plugins/patina/ui/src/lib.rs b/plugins/patina/ui/src/lib.rs index 6dc3ff2..554ac50 100644 --- a/plugins/patina/ui/src/lib.rs +++ b/plugins/patina/ui/src/lib.rs @@ -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, @@ -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, - 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(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; diff --git a/scripts/release.py b/scripts/release.py index 94ebf62..29c0ccc 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -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 or patina-v tag") + raise ValueError("Use a -v 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, diff --git a/scripts/tests/test_release.py b/scripts/tests/test_release.py index 542063b..55e3260 100644 --- a/scripts/tests/test_release.py +++ b/scripts/tests/test_release.py @@ -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() diff --git a/tools/editor-check/Cargo.lock b/tools/editor-check/Cargo.lock index 0ed3793..059dd1b 100644 --- a/tools/editor-check/Cargo.lock +++ b/tools/editor-check/Cargo.lock @@ -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", ] diff --git a/tools/editor-check/Cargo.toml b/tools/editor-check/Cargo.toml index 3ec629e..ab0eeae 100644 --- a/tools/editor-check/Cargo.toml +++ b/tools/editor-check/Cargo.toml @@ -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" } diff --git a/tools/editor-check/src/main.rs b/tools/editor-check/src/main.rs index 9890398..94065dc 100644 --- a/tools/editor-check/src/main.rs +++ b/tools/editor-check/src/main.rs @@ -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);