diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 6cd5edd..5d7dab2 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - plugin: [rivet, patina, anvil, crucible] + plugin: [rivet, patina, anvil, crucible, lathe, temper] steps: - uses: https://github.com/actions/checkout@v4 with: @@ -58,6 +58,8 @@ jobs: 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 + cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/lathe.png --plugin lathe + cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/temper.png --plugin temper - uses: https://github.com/actions/upload-artifact@v3-node20 with: name: editor-previews @@ -85,6 +87,8 @@ jobs: 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/build-plugin.py --plugin lathe --target x86_64-pc-windows-gnu + python3 scripts/build-plugin.py --plugin temper --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 a753917..a146bdc 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*", "anvil-v*", "crucible-v*"] + tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*", "lathe-v*", "temper-v*"] permissions: contents: read diff --git a/Cargo.lock b/Cargo.lock index d191581..1b78060 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,6 +170,25 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "lathe-core" +version = "0.1.0" +dependencies = [ + "common-dsp", + "serde", + "serde_json", +] + +[[package]] +name = "lathe-ui" +version = "0.1.0" +dependencies = [ + "common-dsp", + "common-ui", + "egui", + "lathe-core", +] + [[package]] name = "libc" version = "0.2.191" @@ -460,6 +479,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "temper-core" +version = "0.1.0" +dependencies = [ + "common-dsp", + "serde", + "serde_json", +] + +[[package]] +name = "temper-ui" +version = "0.1.0" +dependencies = [ + "common-dsp", + "common-ui", + "egui", + "temper-core", +] + [[package]] name = "transpose" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index 9e255a1..1c04f28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" -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"] +members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/lathe/core", "plugins/lathe/ui", "plugins/temper/core", "plugins/temper/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", "plugins/lathe/plugin", "plugins/temper/plugin"] [workspace.package] edition = "2021" @@ -16,3 +16,11 @@ rivet-midi = { path = "plugins/rivet/midi" } [profile.release] lto = "thin" strip = "symbols" + +# Signal-level tests render seconds of audio; unoptimised DSP makes them crawl. +[profile.dev.package.common-dsp] +opt-level = 2 +[profile.dev.package.lathe-core] +opt-level = 2 +[profile.dev.package.temper-core] +opt-level = 2 diff --git a/README.md b/README.md index cd35551..129d185 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Rivet & Patina -Four Rust CLAP plugins for writing, designing and mixing bass music. +Six Rust CLAP plugins for writing, designing and mixing bass music. | Plugin | Purpose | Guide | | --- | --- | --- | @@ -8,17 +8,22 @@ Four Rust CLAP plugins for writing, designing and mixing bass music. | **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) | +| **Lathe** | EQ the harmonics of the played note: a cut on harmonic 3 stays on harmonic 3 | [Lathe](docs/lathe/README.md) | +| **Temper** | Distort every note the same: level and tone filters follow the note | [Temper](docs/temper/README.md) | The names come from metalwork: a **rivet** joins pieces; **patina** changes a 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. +struck on; a **crucible** melts material down to be cast again; a **lathe** turns +a piece so each cut lands the same all the way round; **tempering** sets how hard +and how tough a metal ends up. They share a Rust foundation and keep their own +musical purpose, code, documentation, and version. Ideas for further plugins are +collected in [docs/ideas.md](docs/ideas.md). ## Install Download the desired plugin's Windows ZIP from its Gitea release. Extract it -and copy its `.clap` file (`Rivet.clap`, `Patina.clap`, `Anvil.clap` or -`Crucible.clap`) into: +and copy its `.clap` file (`Rivet.clap`, `Patina.clap`, `Anvil.clap`, +`Crucible.clap`, `Lathe.clap` or `Temper.clap`) into: ```text C:\Program Files\Common Files\CLAP\ @@ -26,11 +31,12 @@ 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, Anvil and Crucible in mixer effect slots. +load the others in mixer effect slots. Normal branch/PR pipelines also provide a combined Windows preview ZIP in the **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). +`patina-v0.2.0`, `anvil-v0.1.0`, `crucible-v0.1.0`, `lathe-v0.1.0` and +`temper-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. @@ -59,9 +65,17 @@ plugins/ core/ Input history, capture, slicing, cleanup and export ui/ Crucible's native editor plugin/ CLAP audio-effect wrapper + lathe/ + core/ Harmonic band bank tuned to the tracked note + ui/ Lathe's native editor + plugin/ CLAP audio-effect wrapper with MIDI input + temper/ + core/ Note-tracked filters, level normalising, oversampled shaper + ui/ Temper's native editor + plugin/ CLAP audio-effect wrapper with MIDI input common/ ui/ Shared look: palette, embedded fonts, knobs and panels - dsp/ Shared filters, YIN pitch detection and WAV files + dsp/ Shared filters, note tracking, oversampling, meters, WAV files tools/editor-check/ Headless visual QA for all editors scripts/ Shared build, packaging, release, and analysis tools docs// Guide, changelog and editor preview per plugin @@ -78,6 +92,8 @@ 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/build-plugin.py --plugin lathe --target x86_64-pc-windows-gnu +python scripts/build-plugin.py --plugin temper --target x86_64-pc-windows-gnu python scripts/package-windows.py ``` diff --git a/docs/development.md b/docs/development.md index dc58494..7508d2a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,6 +27,8 @@ 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 scripts/check-plugin.py --plugin lathe +python scripts/check-plugin.py --plugin temper python -m unittest discover -s scripts/tests -v ``` @@ -49,6 +51,8 @@ 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/build-plugin.py --plugin lathe --target x86_64-pc-windows-gnu +python scripts/build-plugin.py --plugin temper --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 @@ -77,6 +81,8 @@ cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/patina.png --plu 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 +cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/lathe.png --plugin lathe +cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/temper.png --plugin temper ``` Patina also accepts `--silent`, `--delta`, and `--bypass` as render fixtures. diff --git a/docs/ideas.md b/docs/ideas.md new file mode 100644 index 0000000..39a565b --- /dev/null +++ b/docs/ideas.md @@ -0,0 +1,101 @@ +# Plugin ideas + +A running list of plugin ideas for riddim, dubstep and bass-music sound design. +Each entry names the problem first. Entries are grouped as released or idea. Names follow the metalwork theme of Rivet, Patina, Anvil and Crucible. + +## Problems seen in bass-music production + +1. Every bass patch brings its own sub level and phase, so the weight of a drop + jumps between switch-ups, and layered patches cancel in the sub. +2. Patches at the same peak level still differ in perceived loudness and + brightness. The usual fix is hand-set gain or clipping per patch. +3. Resampling is repetitive: bounce, chop, trim, declick, normalise, find the + root key, name, save, load into a sampler. +4. Writing wobbles: LFO shapes are drawn, synced, copied and edited for every + variation. Triplet and dotted patterns plus per-note changes take long. +5. Unison, OTT and stereo distortion widen the mids. Parts disappear in mono or + on club systems. +6. Kick and bass clash. Sidechain helps, but the sub phase at each note start + changes how the kick hits. +7. Distortion chains build up harsh resonances. (Patina covers this.) +8. Comparing a drop with a reference: the difference is usually sub/mid balance + over time, which generic meters do not show clearly. +9. Fixed-frequency tools act differently on every note. An EQ boost at 300 Hz + lifts the 4th harmonic of one note and the 7th of another; a distortion with + a fixed tone filter sounds brighter on low notes than on high notes. +10. Layered bass patches partly cancel in the mids, not only in the sub. + +## Released + +### Anvil — sub anchor (problems 1, 6) + +Replaces the band below a split frequency with a clean mono sine that follows +the bass pitch, at one level and one start phase for every note. Pitch comes +from detection with 80 ms lookahead or from MIDI. See [Anvil](anvil/README.md). + +### Crucible — resampler (problem 3) + +Records the input continuously. One click captures the last bars on the host +bar grid, slices on a grid or on hits, trims, declicks, normalises, detects the +root key and exports labelled WAVs, a full loop and an optional Serum/Vital +wavetable. See [Crucible](crucible/README.md). + +### Lathe — harmonic EQ (problem 9) + +EQ bands placed on harmonics 1–32 of the detected note instead of fixed +frequencies. "+4 dB on harmonic 3, −6 dB on harmonic 7" sounds the same on F1 +and on C2. Odd/even balance, tilt over harmonic number, and a Focus control +that lifts or cuts everything between the harmonics. Uses Anvil's pitch +tracker, or MIDI. See [Lathe](lathe/README.md). + +### Temper — note-tracked distortion (problem 9) + +A waveshaper whose tone filters before and after the shaper follow the note, so +every note distorts with the same character and the same harmonic content. +Input level is normalised before the shaper, so drive does not depend on how +loud the patch is, and the output is gain-matched. Oversampled to keep aliasing +(which is never harmonic) out. See [Temper](temper/README.md). + +## Ideas + +### Weld — layer phase alignment (problem 10) + +Takes a second bass layer through the sidechain. Per band, it rotates phase +with allpass filters (no added delay) and flips polarity so the layers sum +instead of cancel. A display shows how much level the cancellation cost. Anvil +solves the sub band; Weld solves the mids. Hardest of the list: phase matching +against a moving sidechain. + +### Ballast — switch-up loudness leveller (problem 2) + +Measures short-term perceived loudness per note or per switch-up against a +target or a reference patch and adjusts gain only, so each switch-up lands at +the same loudness without compression changing its character. Earlier working +name: Hit Leveler. Pairs with Anvil: Anvil levels the sub, Ballast the whole +patch. + +### Bellows — pitch-tracked resonator + +A comb or Karplus-style resonator tuned to the detected note. It turns noise, +distortion or drums into pitched metallic tone that is in key on every note. +Damping, harmonic spread, single or multiple combs. + +### Gauge — low-end mono check (problem 5) + +Shows phase correlation and mono-sum loss per band and per note in the bass +range, and marks the notes where widening breaks the mono sum. Optional fixes: +mono below a chosen frequency and phase rotation on the mids. Display-first, so +it is a small build. Earlier working name: Bass Mono Check. + +### Wobble sequencer (problem 4) + +Step patterns for volume, filter, formant and pitch with triplet and dotted +grids built in. Each MIDI note selects a different pattern, so a switch-up is a +different note in the piano roll. ShaperBox overlaps, but it has no note-keyed +patterns. No name yet. + +### Drop compare (problem 8) + +Loudness-matched A/B of a drop against a reference, with sub, low-mid, mid and +high energy over time: for example "your sub is 3 dB under the reference in +bars 2–4". No name yet. diff --git a/docs/lathe/CHANGELOG.md b/docs/lathe/CHANGELOG.md new file mode 100644 index 0000000..45b1f3c --- /dev/null +++ b/docs/lathe/CHANGELOG.md @@ -0,0 +1,13 @@ +# Lathe changelog + +## 0.1.0 + +- Introduce Lathe, a harmonic EQ for bass sound design. +- Bands sit on harmonics 1–32 of the played note instead of fixed frequencies, + so a cut on harmonic 3 stays on harmonic 3 on every note. +- Draw a gain per harmonic; Tilt, Odd/Even and Between shape the whole series. +- Detect pitch and note starts from the audio with 67 ms lookahead, or follow + MIDI notes sent to the effect. Filters retune at each note's attack. +- Controls: Tilt, Odd/Even, Between, Width, Harmonics, Smooth, Gate, Mix, + Output, Snap to semitone, Bypass. Shapes: Flat, Odd only, Even only, Hollow. +- Harmonic editor shows the measured level of each harmonic before and after. diff --git a/docs/lathe/README.md b/docs/lathe/README.md new file mode 100644 index 0000000..4f9cb7c --- /dev/null +++ b/docs/lathe/README.md @@ -0,0 +1,66 @@ +# Lathe + +Lathe is an EQ whose bands sit on the harmonics of the note being played. +Current version **0.1.0**. + +A normal EQ works in fixed frequencies. A cut at 130 Hz takes out the 3rd +harmonic of an F1 bass, the 2nd of a C2, and the fundamental of a C3, so the +same bass sounds different on every note of a riddim line. Lathe follows the +pitch and places one band on each harmonic: "−12 dB on harmonic 3" stays on +harmonic 3 whatever note plays. + +![Lathe](assets/editor.png) + +## Install on Windows + +Copy `Lathe.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 Lathe in a mixer slot of the bass, after the synth and before bus +processing. Keep the font license with redistributed packages. + +## Work with it + +1. Play the bass. The note readout shows what Lathe tracks; the lower strip of the + editor shows each harmonic's level going in (grey) and coming out (pink). +2. Draw gains on the harmonic bars. Drag across bars to paint a shape; + Shift-drag for fine steps; double-click a bar to reset it. +3. Use the macros for the whole series: + - **Tilt** brightens or darkens in dB per octave of harmonic number. The + fundamental is the pivot. + - **Odd/Even** lifts odd harmonics and lowers even ones (positive, more + square-like) or the reverse (negative, more octave-like). + - **Between** sets the gain halfway between harmonics. Cut it to clean up noise, + beating and inharmonic grit; lift it for dirt. +4. **Width** sets every band's bandwidth as a share of the harmonic spacing. Narrow + bands touch only the harmonic; wide bands also move its surroundings. + +Other controls: + +- **Harmonics** is how many harmonics are shaped (8–32). Higher harmonics pass + unchanged. +- **Smooth** is the retune time for slides and pitch bends. A new note always + retunes at once, on its attack. +- **Gate** is the input level that counts as a note. Raise it if tails or reverb + start new notes. +- **Mix** blends with the delayed dry signal; **Output** trims the level. +- **Snap to semitone** rounds the detected pitch. Turn it off for slides. +- **Shapes** set the drawn gains: Flat, Odd only, Even only, or Hollow (harmonics + 2–4 cut). + +The drawn gains are saved with the project but are not automation parameters; +the macros are. + +### Pitch from MIDI + +For basses whose fundamental is unclear (heavy FM, formant or noise patches), +select **MIDI** and route the bass's MIDI notes to Lathe. In FL Studio, set the +bass channel's MIDI output port and Lathe's input port to the same number. + +## Timing and limits + +Detection uses about 67 ms of lookahead, reported to the host for delay +compensation, so the bands are tuned before each note's attack leaves the +plugin. Pitch range is 30–500 Hz, one note at a time; chords are not separated. +Before the first note Lathe passes audio unchanged; between notes it holds the +last tuning so tails keep their shape. Bands above 0.45 × the sample rate are +skipped. diff --git a/docs/lathe/assets/editor.png b/docs/lathe/assets/editor.png new file mode 100644 index 0000000..fb39800 Binary files /dev/null and b/docs/lathe/assets/editor.png differ diff --git a/docs/temper/CHANGELOG.md b/docs/temper/CHANGELOG.md new file mode 100644 index 0000000..79d6221 --- /dev/null +++ b/docs/temper/CHANGELOG.md @@ -0,0 +1,14 @@ +# Temper changelog + +## 0.1.0 + +- Introduce Temper, a note-tracked distortion for bass sound design. +- Normalises each note's level before the shaper, so quiet and loud patches are + driven the same. +- Floor (highpass), Push (one harmonic boosted or cut) and Bright (lowpass) sit + at multiples of the played note, so every note keeps the same character. +- Curves: Soft, Hard, Fold and Tube, with Bias for even harmonics. The shaper runs + at 4× oversampling. +- Auto gain keeps the output at the input loudness while Drive changes. +- Detect pitch and note starts from the audio with 67 ms lookahead, or follow + MIDI notes sent to the effect. diff --git a/docs/temper/README.md b/docs/temper/README.md new file mode 100644 index 0000000..8e7d3f6 --- /dev/null +++ b/docs/temper/README.md @@ -0,0 +1,66 @@ +# Temper + +Temper is a distortion that drives every note the same. Current version **0.1.0**. + +A distortion with a fixed tone filter acts differently on each note: a lowpass +at 2 kHz lets through 45 harmonics of an F1 but only 22 of an F2, and +a patch that is 6 dB quieter is driven 6 dB less. Across a riddim line that makes +some notes fizzy and others dull. Temper evens out each note's level before the +shaper and moves its tone filters with the pitch, so the character stays the same +on every note. + +![Temper](assets/editor.png) + +## Install on Windows + +Copy `Temper.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 Temper in a mixer slot of the bass. Keep the font license +with redistributed packages. + +## Work with it + +1. Choose a **Curve**: + - **Soft**: tanh, smooth and rounded. + - **Hard**: clipping with a narrow knee, dense and square-like. + - **Fold**: the wave folds back on itself; Drive moves the upper harmonics. + - **Tube**: the negative half clips earlier, adding even harmonics. +2. Set **Drive**. The transfer plot shows the curve over the range a normalised + note covers; the **Into shaper** meter shows how far past the knee it goes. +3. Add **Bias** for asymmetry and even harmonics. +4. Shape the tone around the shaper, all in multiples of the note: + - **Floor** is a highpass before the shaper (0.25–2 × the fundamental; 0 is + off). It keeps sub mud out of the distortion. Put Anvil after Temper to add a + clean sub back. + - **Push** boosts or cuts one harmonic (**Push at**, 1–16) before the shaper, + so the distortion bites in the same place on every note. + - **Bright** is a lowpass after the shaper at a harmonic number (4–64; 0 is + off), so the top end stops at the same harmonic on every note. + +Other controls: + +- **Normalize** is how far each note's level is evened out before Drive. At 100 % + a quiet and a loud patch distort the same; at 0 % Temper acts like a normal + distortion. +- **Auto gain** keeps the output at the input's loudness, so turning up Drive + changes the character, not the level. +- **Gate** is the input level that counts as a note. +- **Mix** blends with the delayed dry signal; **Output** trims the level. + +### Pitch from MIDI + +Select **MIDI** and route the bass's MIDI notes to Temper when detection +struggles. In FL Studio, set the bass channel's MIDI output port and Temper's +input port to the same number. + +## Timing and limits + +Detection uses about 67 ms of lookahead; the 4× oversampling filters add under +1 ms. Both are reported to the host for delay compensation. Pitch range is +30–500 Hz, one note at a time. Before the first note the tracked filters are off; +between notes they hold the last tuning. + +Aliasing stays below −60 dB for bass material (tested with a 2 kHz tone at 24 dB +drive and a 440 Hz tone at full drive, on every curve). High-pitched content at +full drive aliases more: a 5 kHz tone at 36 dB on the Hard curve reaches about +−31 dB. diff --git a/docs/temper/assets/editor.png b/docs/temper/assets/editor.png new file mode 100644 index 0000000..f17ddbb Binary files /dev/null and b/docs/temper/assets/editor.png differ diff --git a/plugins/anvil/core/src/lib.rs b/plugins/anvil/core/src/lib.rs index cf9b204..74718b5 100644 --- a/plugins/anvil/core/src/lib.rs +++ b/plugins/anvil/core/src/lib.rs @@ -5,27 +5,18 @@ //! 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, -}; +use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig}; +use common_dsp::{coefficient, db_to_gain, hz_to_midi, midi_to_hz, Crossover}; 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.; +/// Detectable fundamental range. The lowest sets the lookahead (two periods). +pub const MIN_HZ: f32 = TrackerConfig::SUB.min_hz; +pub const MAX_HZ: f32 = TrackerConfig::SUB.max_hz; 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 { @@ -65,19 +56,6 @@ impl Default for AnalyzerFrame { } } -#[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, @@ -87,6 +65,55 @@ struct Voice { releasing: bool, } +/// Sine voices and per-note state, driven by tracker events at output time. +#[derive(Clone, Copy, Debug, Default)] +struct Synth { + voice: Voice, + old: Voice, + old_gain: f32, + note_peak: f32, +} +impl Synth { + fn event(&mut self, s: &Settings, e: NoteEvent) { + match e { + NoteEvent::Start { pitch, glide } => self.start(s, pitch, glide), + NoteEvent::Move { pitch } => { + if !s.snap { + self.voice.target = midi_to_hz(pitch + 12. * s.octave as f32); + } + } + NoteEvent::Stop => self.voice.releasing = true, + } + } + fn start(&mut self, s: &Settings, pitch: f32, glide: bool) { + let note = if s.snap { pitch.round() } else { pitch }; + let hz = midi_to_hz(note + 12. * s.octave as f32); + 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.; + } +} + pub struct Engine { rate: f32, channels: usize, @@ -95,37 +122,10 @@ pub struct Engine { 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, + tracker: Tracker, + synth: Synth, glide: f32, - held: [u8; 16], - held_len: usize, low_env: f32, - note_peak: f32, env_coef: f32, smooth: f32, level: f32, @@ -150,12 +150,8 @@ impl Engine { 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 tracker = Tracker::new(rate, TrackerConfig::SUB); + let latency = tracker.latency(); let settings = Settings::default(); let mut e = Self { rate, @@ -165,39 +161,10 @@ impl Engine { 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., + tracker, + synth: Synth::default(), 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), @@ -233,8 +200,11 @@ impl Engine { x.retune(self.rate, s.split); } } - if s.source != old.source { - self.release_all(); + if self + .tracker + .set(s.source == Source::Midi, s.gate, s.glide > 0.) + { + self.synth.voice.releasing = true; } self.glide = if s.glide <= 0. { 1. @@ -244,34 +214,14 @@ impl Engine { 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.tracker.reset(); + self.synth = Synth::default(); self.low_env = 0.; - self.note_peak = 0.; self.low_power = 0.; self.sub_power = 0.; self.scope_low = [0.; 2 * SCOPE]; @@ -279,190 +229,10 @@ impl Engine { } /// 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))); + self.tracker.note_on(note); } 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(); - } - } + self.tracker.note_off(note); } #[inline] fn shape(&self, x: f32) -> f32 { @@ -476,7 +246,7 @@ impl Engine { 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; + let v = &mut self.synth.voice; v.amp = if v.releasing { (v.amp - release).max(0.) } else { @@ -488,31 +258,28 @@ impl Engine { 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; + let s = &mut self.synth; + if s.old_gain > 0. { + let o = &mut s.old; + out += (TAU * o.phase).sin() * o.amp * s.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.); + s.old_gain = (s.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 mono = if self.channels == 2 { + 0.5 * (x[0] + x[1]) + } else { + x[0] + }; + let (synth, settings) = (&mut self.synth, &self.settings); + self.tracker.process(mono, |e| synth.event(settings, e)); 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 { @@ -523,11 +290,11 @@ impl Engine { 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); + if !self.synth.voice.releasing { + self.synth.note_peak = self.synth.note_peak.max(env); } - let follow = if self.note_peak > 1e-6 { - (env / self.note_peak).min(1.) + let follow = if self.synth.note_peak > 1e-6 { + (env / self.synth.note_peak).min(1.) } else { 1. }; @@ -570,9 +337,10 @@ impl Engine { 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); + let v = self.synth.voice; + if v.amp > 0. && !v.releasing { + f.hz = v.hz; + f.note = Some(hz_to_midi(v.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; diff --git a/plugins/catalog.json b/plugins/catalog.json index f4d99c3..d42b276 100644 --- a/plugins/catalog.json +++ b/plugins/catalog.json @@ -64,5 +64,39 @@ "crucible-core", "crucible-ui" ] + }, + "lathe": { + "name": "Lathe", + "description": "EQ the harmonics of the played note: a cut on harmonic 3 stays on harmonic 3", + "manifest": "plugins/lathe/plugin/Cargo.toml", + "library": "lathe", + "documentation": "docs/lathe", + "clap_id": "io.storyboard.harmonic-eq", + "licenses": { + "plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt" + }, + "packages": [ + "common-ui", + "common-dsp", + "lathe-core", + "lathe-ui" + ] + }, + "temper": { + "name": "Temper", + "description": "Distortion whose drive and tone filters follow the note, so every note distorts the same", + "manifest": "plugins/temper/plugin/Cargo.toml", + "library": "temper", + "documentation": "docs/temper", + "clap_id": "io.storyboard.tracked-distortion", + "licenses": { + "plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt" + }, + "packages": [ + "common-ui", + "common-dsp", + "temper-core", + "temper-ui" + ] } } diff --git a/plugins/common/dsp/src/lib.rs b/plugins/common/dsp/src/lib.rs index 9a2b878..b6c8f2a 100644 --- a/plugins/common/dsp/src/lib.rs +++ b/plugins/common/dsp/src/lib.rs @@ -1,4 +1,8 @@ //! Shared allocation-free DSP building blocks: filters, pitch detection, notes, WAV files. +pub mod meter; +pub mod oversample; +pub mod svf; +pub mod tracker; pub mod wav; use std::f32::consts::TAU; diff --git a/plugins/common/dsp/src/meter.rs b/plugins/common/dsp/src/meter.rs new file mode 100644 index 0000000..fbe7855 --- /dev/null +++ b/plugins/common/dsp/src/meter.rs @@ -0,0 +1,91 @@ +//! Levels of the harmonics of a known fundamental, measured with Hann-windowed Goertzel +//! filters. Work is spread evenly over the samples, so there is no per-window spike. +use std::f32::consts::TAU; + +pub const HARMONICS: usize = 32; + +pub struct HarmonicMeter { + rate: f32, + window: Vec, + count: usize, + hz: f32, + coef: [f32; HARMONICS], + s1: [f32; HARMONICS], + s2: [f32; HARMONICS], + levels: [f32; HARMONICS], +} +impl HarmonicMeter { + /// Window of about `ms`; 85 ms resolves harmonics of fundamentals down to 30 Hz. + pub fn new(rate: f32, ms: f32) -> Self { + let n = ((rate * ms * 0.001) as usize).max(64); + Self { + rate, + window: (0..n) + .map(|i| 0.5 - 0.5 * (TAU * i as f32 / n as f32).cos()) + .collect(), + count: 0, + hz: 0., + coef: [0.; HARMONICS], + s1: [0.; HARMONICS], + s2: [0.; HARMONICS], + levels: [-120.; HARMONICS], + } + } + /// Peak level in dBFS of each harmonic (index 0 is the fundamental) from the last + /// complete window. Harmonics above 0.45 × the sample rate read −120. + pub fn levels(&self) -> &[f32; HARMONICS] { + &self.levels + } + /// Fundamental of the last complete window, 0 if none. + pub fn hz(&self) -> f32 { + self.hz + } + /// Feeds one sample. `hz` is read at the start of each window (0 = no note: skip it). + /// Returns true when a window completed and `levels` changed. + #[inline] + pub fn push(&mut self, x: f32, hz: f32) -> bool { + if self.count == 0 { + self.hz = hz; + for k in 0..HARMONICS { + let f = hz * (k + 1) as f32; + self.coef[k] = if hz > 0. && f < 0.45 * self.rate { + 2. * (TAU * f / self.rate).cos() + } else { + 0. + }; + } + self.s1 = [0.; HARMONICS]; + self.s2 = [0.; HARMONICS]; + } + let w = x * self.window[self.count]; + for k in 0..HARMONICS { + let s = w + self.coef[k] * self.s1[k] - self.s2[k]; + self.s2[k] = self.s1[k]; + self.s1[k] = s; + } + self.count += 1; + if self.count < self.window.len() { + return false; + } + self.count = 0; + let n = self.window.len() as f32; + for k in 0..HARMONICS { + let f = self.hz * (k + 1) as f32; + self.levels[k] = if self.hz > 0. && f < 0.45 * self.rate { + let c = self.coef[k]; + let p = + self.s1[k] * self.s1[k] + self.s2[k] * self.s2[k] - c * self.s1[k] * self.s2[k]; + // Hann coherent gain is 0.5: a sine of peak A gives |X| = A·N/4. + let amp = 4. * p.max(0.).sqrt() / n; + 20. * amp.max(1e-6).log10() + } else { + -120. + }; + } + true + } + pub fn reset(&mut self) { + self.count = 0; + self.levels = [-120.; HARMONICS]; + } +} diff --git a/plugins/common/dsp/src/oversample.rs b/plugins/common/dsp/src/oversample.rs new file mode 100644 index 0000000..5d08839 --- /dev/null +++ b/plugins/common/dsp/src/oversample.rs @@ -0,0 +1,131 @@ +//! 4× oversampling with two linear-phase half-band FIR stages. The round trip (up, then +//! down) delays the signal by a whole number of base-rate samples, `latency()`. +const STAGE1_HALF: usize = 31; // 63 taps at 2×: steep, carries the audio band edge +const STAGE2_HALF: usize = 11; // 23 taps at 4×: the band edge is far from its images +const BETA: f32 = 9.; + +/// Kaiser-windowed half-band lowpass with gain `gain`, `2 * half + 1` taps. +fn halfband(half: usize, gain: f32) -> Vec { + fn i0(x: f32) -> f32 { + let (mut sum, mut term) = (1f64, 1f64); + let y = (x as f64 / 2.).powi(2); + for k in 1..50 { + term *= y / (k * k) as f64; + sum += term; + } + sum as f32 + } + let n = 2 * half + 1; + let norm = i0(BETA); + let mut h: Vec = (0..n) + .map(|i| { + let m = i as f32 - half as f32; + let sinc = if m == 0. { + 0.5 + } else { + (std::f32::consts::FRAC_PI_2 * m).sin() / (std::f32::consts::PI * m) + }; + let r = m / half as f32; + sinc * i0(BETA * (1. - r * r).max(0.).sqrt()) / norm + }) + .collect(); + let sum: f32 = h.iter().sum(); + for v in &mut h { + *v *= gain / sum; + } + h +} + +/// FIR with a doubled circular buffer so every convolution reads one contiguous slice. +struct Fir { + taps: Vec, + buf: Vec, + at: usize, +} +impl Fir { + fn new(taps: Vec) -> Self { + let n = taps.len(); + Self { + taps, + buf: vec![0.; 2 * n], + at: 0, + } + } + #[inline] + fn process(&mut self, x: f32) -> f32 { + let n = self.taps.len(); + self.at = if self.at == 0 { n - 1 } else { self.at - 1 }; + self.buf[self.at] = x; + self.buf[self.at + n] = x; + let window = &self.buf[self.at..self.at + n]; + window.iter().zip(&self.taps).map(|(a, b)| a * b).sum() + } + fn reset(&mut self) { + self.buf.fill(0.); + self.at = 0; + } +} + +/// One channel of 4× oversampling. Allocation-free after `new`. +pub struct Oversampler { + up1: Fir, + up2: Fir, + down2: Fir, + down1: Fir, + /// Two samples at 4× make the round trip a whole number of base samples. + pad: [f32; 2], +} +impl Default for Oversampler { + fn default() -> Self { + Self::new() + } +} +impl Oversampler { + pub const FACTOR: usize = 4; + pub fn new() -> Self { + Self { + up1: Fir::new(halfband(STAGE1_HALF, 2.)), + up2: Fir::new(halfband(STAGE2_HALF, 2.)), + down2: Fir::new(halfband(STAGE2_HALF, 1.)), + down1: Fir::new(halfband(STAGE1_HALF, 1.)), + pad: [0.; 2], + } + } + /// Round-trip delay in base-rate samples. + pub const fn latency() -> usize { + STAGE1_HALF + STAGE2_HALF.div_ceil(2) + } + #[inline] + pub fn up(&mut self, x: f32) -> [f32; 4] { + let a = self.up1.process(x); + let b = self.up1.process(0.); + let mut out = [ + self.up2.process(a), + self.up2.process(0.), + self.up2.process(b), + self.up2.process(0.), + ]; + // Delay by two 4× samples. + let [p0, p1] = self.pad; + self.pad = [out[2], out[3]]; + out = [p0, p1, out[0], out[1]]; + out + } + #[inline] + pub fn down(&mut self, y: [f32; 4]) -> f32 { + let a = self.down2.process(y[0]); + self.down2.process(y[1]); + let b = self.down2.process(y[2]); + self.down2.process(y[3]); + let out = self.down1.process(a); + self.down1.process(b); + out + } + pub fn reset(&mut self) { + self.up1.reset(); + self.up2.reset(); + self.down2.reset(); + self.down1.reset(); + self.pad = [0.; 2]; + } +} diff --git a/plugins/common/dsp/src/svf.rs b/plugins/common/dsp/src/svf.rs new file mode 100644 index 0000000..abe88d7 --- /dev/null +++ b/plugins/common/dsp/src/svf.rs @@ -0,0 +1,106 @@ +//! Trapezoidal state-variable filter (Simper/Cytomic). Stable and click-free under fast +//! retuning, so it can follow a gliding note. Coefficients are separate from state, so one +//! set can drive both channels. +use std::f32::consts::PI; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SvfCoef { + a1: f32, + a2: f32, + a3: f32, + m0: f32, + m1: f32, + m2: f32, +} +impl Default for SvfCoef { + /// Identity: passes the input unchanged. + fn default() -> Self { + Self::IDENTITY + } +} +impl SvfCoef { + pub const IDENTITY: Self = Self { + a1: 1., + a2: 0., + a3: 0., + m0: 1., + m1: 0., + m2: 0., + }; + fn base(rate: f32, hz: f32, k: f32, warp: f32) -> (f32, f32, f32) { + let g = (PI * (hz / rate).clamp(1e-6, 0.49)).tan() * warp; + let a1 = 1. / (1. + g * (g + k)); + let a2 = g * a1; + (a1, a2, g * a2) + } + /// Peaking filter. At 0 dB it is an exact identity. + pub fn bell(rate: f32, hz: f32, q: f32, gain_db: f32) -> Self { + let a = 10f32.powf(gain_db / 40.); + let k = 1. / (q.max(1e-3) * a); + let (a1, a2, a3) = Self::base(rate, hz, k, 1.); + Self { + a1, + a2, + a3, + m0: 1., + m1: k * (a * a - 1.), + m2: 0., + } + } + pub fn lowpass(rate: f32, hz: f32, q: f32) -> Self { + let (a1, a2, a3) = Self::base(rate, hz, 1. / q.max(1e-3), 1.); + Self { + a1, + a2, + a3, + m0: 0., + m1: 0., + m2: 1., + } + } + pub fn highpass(rate: f32, hz: f32, q: f32) -> Self { + let k = 1. / q.max(1e-3); + let (a1, a2, a3) = Self::base(rate, hz, k, 1.); + Self { + a1, + a2, + a3, + m0: 1., + m1: -k, + m2: -1., + } + } + pub fn high_shelf(rate: f32, hz: f32, q: f32, gain_db: f32) -> Self { + let a = 10f32.powf(gain_db / 40.); + let k = 1. / q.max(1e-3); + let (a1, a2, a3) = Self::base(rate, hz, k, a.sqrt()); + Self { + a1, + a2, + a3, + m0: a * a, + m1: k * (1. - a) * a, + m2: 1. - a * a, + } + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Svf { + ic1: f32, + ic2: f32, +} +impl Svf { + #[inline] + pub fn process(&mut self, c: &SvfCoef, v0: f32) -> f32 { + let v3 = v0 - self.ic2; + let v1 = c.a1 * self.ic1 + c.a2 * v3; + let v2 = self.ic2 + c.a2 * self.ic1 + c.a3 * v3; + self.ic1 = 2. * v1 - self.ic1; + self.ic2 = 2. * v2 - self.ic2; + c.m0 * v0 + c.m1 * v1 + c.m2 * v2 + } + pub fn reset(&mut self) { + *self = Self::default(); + } +} diff --git a/plugins/common/dsp/src/tracker.rs b/plugins/common/dsp/src/tracker.rs new file mode 100644 index 0000000..c2e2d59 --- /dev/null +++ b/plugins/common/dsp/src/tracker.rs @@ -0,0 +1,399 @@ +//! Note tracker with lookahead: pitch and note starts of a monophonic bass, from audio or MIDI. +//! +//! Detection runs on the undelayed input. Events are reported at *output* time, `latency()` +//! samples later, so a host that delays its audio path by the same amount sees each note start +//! exactly on its attack, with the pitch already known. +use crate::{coefficient, gain_to_db, hz_to_midi, Biquad, Yin}; + +const EVENTS: usize = 64; +const HOP_MS: f32 = 5.; +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, PartialEq)] +pub struct TrackerConfig { + /// Lowest detectable fundamental. Sets the lookahead (two periods plus a margin). + pub min_hz: f32, + pub max_hz: f32, + /// Lowpass in front of detection, removing upper harmonics that confuse YIN. + pub detect_lowpass: f32, + /// Approximate decimated analysis rate. + pub detect_rate: f32, +} +impl TrackerConfig { + /// Sub range: 25–250 Hz. + pub const SUB: Self = Self { + min_hz: 25., + max_hz: 250., + detect_lowpass: 400., + detect_rate: 3000., + }; + /// Bass range for harmonic tools: 30–500 Hz. + pub const BASS: Self = Self { + min_hz: 30., + max_hz: 500., + detect_lowpass: 1000., + detect_rate: 6000., + }; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum NoteEvent { + /// A new note. `glide` asks to slide from the previous pitch instead of restarting. + Start { + pitch: f32, + glide: bool, + }, + /// The sounding note's smoothed pitch moved within a semitone (bends, vibrato). + Move { + pitch: f32, + }, + Stop, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Kind { + Onset, + Release, + NoteOn(u8), + NoteOff(u8), +} +#[derive(Clone, Copy, Debug)] +struct Event { + time: u64, + kind: Kind, +} + +/// Allocation-free after `new`. Pitches are fractional MIDI notes. +pub struct Tracker { + rate: f32, + cfg: TrackerConfig, + latency: usize, + midi: bool, + gate_db: f32, + glide: bool, + 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)>, + held: [u8; 16], + held_len: usize, +} + +impl Tracker { + pub fn new(rate: f32, cfg: TrackerConfig) -> Self { + let decimate = (rate / cfg.detect_rate).round().max(1.) as usize; + let detect_rate = rate / decimate as f32; + let max_lag = (detect_rate / cfg.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 q = std::f32::consts::FRAC_1_SQRT_2; + Self { + rate, + cfg, + latency: analysis * decimate, + midi: false, + gate_db: -42., + glide: false, + detect: [Biquad::lowpass(rate, cfg.detect_lowpass, q); 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, + held: [0; 16], + held_len: 0, + } + } + /// Lookahead in samples. Delay the audio path by exactly this much. + pub fn latency(&self) -> usize { + self.latency + } + pub fn config(&self) -> TrackerConfig { + self.cfg + } + /// `midi` selects note input over detection; `gate_db` is the detection threshold; + /// `glide` lets legato notes slide. Returns true when the source changed, which drops + /// every pending and held note: the caller should release its sound. + pub fn set(&mut self, midi: bool, gate_db: f32, glide: bool) -> bool { + let changed = midi != self.midi; + self.midi = midi; + self.gate_db = gate_db; + self.glide = glide; + if changed { + self.release_all(); + } + changed + } + fn release_all(&mut self) { + self.sounding = false; + self.pending = false; + self.held_len = 0; + self.event_len = 0; + self.candidate = None; + } + pub fn reset(&mut self) { + 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(); + } + /// 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))); + } + /// True while a note sounds at output time. + pub fn sounding(&self) -> bool { + self.sounding + } + 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, + self.cfg.min_hz, + self.cfg.max_hz, + )?; + (clarity >= CLARITY).then(|| hz_to_midi(hz)) + } + fn start(&mut self, pitch: f32, glide: bool, emit: &mut impl FnMut(NoteEvent)) { + self.tracked = pitch; + self.candidate = None; + self.pending = false; + self.sounding = true; + emit(NoteEvent::Start { pitch, glide }); + } + fn stop(&mut self, emit: &mut impl FnMut(NoteEvent)) { + self.sounding = false; + self.pending = false; + self.candidate = None; + emit(NoteEvent::Stop); + } + fn handle(&mut self, kind: Kind, emit: &mut impl FnMut(NoteEvent)) { + let midi = self.midi; + match kind { + Kind::Onset if !midi => match self.estimate() { + Some(p) => self.start(p, false, emit), + None => { + self.pending = true; + self.sounding = true; + } + }, + Kind::Release if !midi => self.stop(emit), + 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.glide, emit); + } + 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(emit); + } else if top { + let next = self.held[self.held_len - 1]; + self.start(next as f32, self.glide, emit); + } + } + _ => {} + } + } + /// Regular analysis; its window starts at the current output time. + fn hop_analysis(&mut self, emit: &mut impl FnMut(NoteEvent)) { + if self.midi || !self.sounding { + return; + } + let Some(p) = self.estimate() else { + return; + }; + if self.pending { + self.start(p, false, emit); + 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.glide, emit); + } else { + self.candidate = Some((p, count + 1)); + } + } + _ => self.candidate = Some((p, 1)), + } + } else { + self.candidate = None; + self.tracked += 0.3 * (p - self.tracked); + emit(NoteEvent::Move { + pitch: self.tracked, + }); + } + } + /// Feeds one undelayed mono input sample. Events due at output time go to `emit`, in order. + pub fn process(&mut self, mono: f32, mut emit: impl FnMut(NoteEvent)) { + let d = self.detect[0].process(mono); + let d = self.detect[1].process(d); + if !self.midi { + 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.gate_db { + 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.gate_db - 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(&mut emit); + } + } + 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, &mut emit); + } + self.clock += 1; + } +} + +/// Fundamental that follows tracker events: jumps on new notes, slides on bends and legato. +#[derive(Clone, Copy, Debug, Default)] +pub struct PitchFollower { + target: f32, + hz: f32, + jump: bool, +} +impl PitchFollower { + pub fn event(&mut self, snap: bool, e: NoteEvent) { + match e { + NoteEvent::Start { pitch, glide } => { + let p = if snap { pitch.round() } else { pitch }; + self.target = crate::midi_to_hz(p); + self.jump |= !glide; + } + NoteEvent::Move { pitch } if !snap => self.target = crate::midi_to_hz(pitch), + // The last pitch is held so tails keep their tuning. + _ => {} + } + } + /// Advances one sample with one-pole coefficient `coef`. Returns the frequency, 0 before the + /// first note, and whether it jumped this sample (a new note, or the first one). + pub fn step(&mut self, coef: f32) -> (f32, bool) { + let jump = std::mem::take(&mut self.jump) || (self.hz == 0. && self.target > 0.); + if jump { + self.hz = self.target; + } else { + self.hz += (self.target - self.hz) * coef; + } + (self.hz, jump) + } + pub fn hz(&self) -> f32 { + self.hz + } +} diff --git a/plugins/common/dsp/tests/blocks.rs b/plugins/common/dsp/tests/blocks.rs new file mode 100644 index 0000000..56b27c9 --- /dev/null +++ b/plugins/common/dsp/tests/blocks.rs @@ -0,0 +1,225 @@ +use common_dsp::meter::HarmonicMeter; +use common_dsp::oversample::Oversampler; +use common_dsp::svf::{Svf, SvfCoef}; +use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig}; +use common_dsp::{hz_to_midi, Saw}; +use std::f32::consts::TAU; + +const RATE: f32 = 48000.; + +/// Steady-state gain in dB of a sine at `hz` through `f`. +fn sine_gain(mut f: impl FnMut(f32) -> f32, hz: f32) -> f32 { + let n = (RATE * 0.5) as usize; + let (mut pin, mut pout) = (0., 0.); + for i in 0..n { + let x = (TAU * hz * i as f32 / RATE).sin(); + let y = f(x); + if i > n / 2 { + pin += x * x; + pout += y * y; + } + } + 10. * (pout / pin).log10() +} + +#[test] +fn svf_bell_hits_its_gain_and_leaves_the_rest() { + for (hz, db) in [(130., -12.), (400., 6.), (2000., -24.)] { + let c = SvfCoef::bell(RATE, hz, 4., db); + let mut s = Svf::default(); + let g = sine_gain(|x| s.process(&c, x), hz); + assert!((g - db).abs() < 0.1, "{hz}: {g}"); + let mut s = Svf::default(); + let g = sine_gain(|x| s.process(&c, x), hz * 4.); + assert!(g.abs() < 0.3, "{hz} away: {g}"); + } +} + +#[test] +fn svf_bell_at_zero_db_is_an_exact_identity() { + let c = SvfCoef::bell(RATE, 100., 2., 0.); + let mut s = Svf::default(); + let mut saw = Saw::new(RATE, 55.); + for _ in 0..4800 { + let x = saw.sample(); + assert_eq!(s.process(&c, x), x); + } +} + +#[test] +fn svf_survives_retuning_every_sample() { + let mut s = Svf::default(); + let mut saw = Saw::new(RATE, 55.); + let mut peak: f32 = 0.; + for i in 0..48000 { + let hz = 40. + 4000. * (0.5 + 0.5 * (i as f32 * 0.003).sin()); + let c = SvfCoef::bell(RATE, hz, 8., 12.); + let y = s.process(&c, saw.sample()); + assert!(y.is_finite()); + peak = peak.max(y.abs()); + } + assert!(peak < 8., "{peak}"); + let c = SvfCoef::lowpass(RATE, 200., 0.707); + let mut s = Svf::default(); + assert!(sine_gain(|x| s.process(&c, x), 2000.) < -35.); + let c = SvfCoef::highpass(RATE, 200., 0.707); + let mut s = Svf::default(); + assert!(sine_gain(|x| s.process(&c, x), 20.) < -35.); +} + +#[test] +fn oversampler_round_trip_is_a_whole_sample_delay() { + let mut o = Oversampler::new(); + let l = Oversampler::latency(); + let mut saw = Saw::new(RATE, 97.); + let x: Vec = (0..9600).map(|_| saw.sample() * 0.5).collect(); + let y: Vec = x + .iter() + .map(|&v| { + let u = o.up(v); + o.down(u) + }) + .collect(); + // Band-limited content passes; compare after the delay, away from the start. + let mut err: f32 = 0.; + let mut sig: f32 = 0.; + for i in 2000..9600 { + let d = y[i] - x[i - l]; + err += d * d; + sig += x[i - l] * x[i - l]; + } + let null = 10. * (err / sig).log10(); + // The saw has energy above the passband edge, so the null is limited by that, not by timing. + assert!(null < -40., "{null}"); +} + +#[test] +fn oversampler_passband_is_flat_and_images_are_removed() { + for hz in [50., 1000., 10000., 18000.] { + let mut o = Oversampler::new(); + let g = sine_gain( + |x| { + let u = o.up(x); + o.down(u) + }, + hz, + ); + assert!(g.abs() < 0.1, "{hz}: {g}"); + } + // An image of a 1 kHz tone at 4× would sit at 47 kHz; check the 4× signal there. + let mut o = Oversampler::new(); + let n = 48000; + let (mut a, mut b) = (0f64, 0f64); + let w = TAU as f64 * 47000. / (4. * RATE as f64); + for i in 0..n { + let x = (TAU * 1000. * i as f32 / RATE).sin(); + for (k, v) in o.up(x).into_iter().enumerate() { + let t = (4 * i + k) as f64; + a += v as f64 * (w * t).cos(); + b += v as f64 * (w * t).sin(); + } + } + let image = 20. + * ((a * a + b * b).sqrt() / (2. * n as f64)) + .max(1e-12) + .log10(); + assert!(image < -85., "{image}"); +} + +#[test] +fn harmonic_meter_reads_saw_harmonics() { + let hz = 55.; + let mut m = HarmonicMeter::new(RATE, 85.); + let mut saw = Saw::new(RATE, hz); + let mut done = 0; + for _ in 0..48000 { + if m.push(saw.sample(), hz) { + done += 1; + } + } + assert!(done >= 5); + // A saw's k-th harmonic has peak 2/(πk). + for k in 1..=16 { + let want = 20. * (2. / (std::f32::consts::PI * k as f32)).log10(); + let got = m.levels()[k - 1]; + assert!((got - want).abs() < 0.5, "harmonic {k}: {got} vs {want}"); + } +} + +#[test] +fn tracker_reports_notes_at_output_time() { + let mut t = Tracker::new(RATE, TrackerConfig::BASS); + t.set(false, -42., false); + let l = t.latency(); + assert!(l < (0.08 * RATE) as usize, "{l}"); + let onset = 4800; + let mut events = vec![]; + let mut saw_a = Saw::new(RATE, 87.31); + let mut saw_b = Saw::new(RATE, 220.); + for i in 0..onset + 48000 { + let x = if i < onset { + 0. + } else if i < onset + 24000 { + 0.5 * saw_a.sample() + } else { + 0.5 * saw_b.sample() + }; + t.process(x, |e| events.push((i, e))); + } + let starts: Vec<_> = events + .iter() + .filter_map(|(i, e)| match e { + NoteEvent::Start { pitch, .. } => Some((*i, *pitch)), + _ => None, + }) + .collect(); + assert_eq!(starts.len(), 2, "{starts:?}"); + assert!( + (starts[0].0 as i64 - (onset + l) as i64).abs() <= 24, + "{starts:?}" + ); + assert!((starts[0].1 - hz_to_midi(87.31)).abs() < 0.2); + assert!((starts[1].1 - hz_to_midi(220.)).abs() < 0.2); +} + +#[test] +fn tracker_delays_midi_by_the_latency() { + let mut t = Tracker::new(RATE, TrackerConfig::BASS); + assert!(t.set(true, -42., true)); + let l = t.latency(); + let mut events = vec![]; + for i in 0..l + 2000 { + if i == 100 { + t.note_on(41); + } + if i == 600 { + t.note_on(43); + } + if i == 900 { + // Releasing the lower held note first leaves the top note; then the top ends it. + t.note_off(41); + t.note_off(43); + } + t.process(0., |e| events.push((i, e))); + } + assert_eq!( + events, + vec![ + ( + 100 + l, + NoteEvent::Start { + pitch: 41., + glide: false + } + ), + ( + 600 + l, + NoteEvent::Start { + pitch: 43., + glide: true + } + ), + (900 + l, NoteEvent::Stop), + ] + ); +} diff --git a/plugins/lathe/core/Cargo.toml b/plugins/lathe/core/Cargo.toml new file mode 100644 index 0000000..3a6b87b --- /dev/null +++ b/plugins/lathe/core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lathe-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/lathe/core/src/lib.rs b/plugins/lathe/core/src/lib.rs new file mode 100644 index 0000000..a21260c --- /dev/null +++ b/plugins/lathe/core/src/lib.rs @@ -0,0 +1,258 @@ +//! Harmonic EQ: bands sit on the harmonics of the played note instead of fixed frequencies, +//! so a cut on harmonic 3 stays on harmonic 3 on every note. +//! +//! The audio path is delayed by the tracker's lookahead, so the filters are already tuned to +//! a note when its attack leaves the plugin. +mod settings; +use common_dsp::meter::HarmonicMeter; +use common_dsp::svf::{Svf, SvfCoef}; +use common_dsp::tracker::{PitchFollower, Tracker, TrackerConfig}; +use common_dsp::{coefficient, db_to_gain, hz_to_midi}; +pub use settings::{Settings, Source, GAIN_MAX, GAIN_MIN, HARMONICS}; + +pub const MIN_HZ: f32 = TrackerConfig::BASS.min_hz; +pub const MAX_HZ: f32 = TrackerConfig::BASS.max_hz; +/// Harmonic bands plus the bands halfway between them. +const BANDS: usize = 2 * HARMONICS; +/// Filter coefficients are recomputed this often, in samples. +const RETUNE: usize = 16; +const GAIN_MS: f32 = 20.; + +#[derive(Clone, Copy, Debug)] +pub struct AnalyzerFrame { + /// Fundamental the filters are tuned to, 0 before the first note. + pub hz: f32, + /// Sounding note, rounded; None between notes. + pub note: Option, + /// Harmonic levels in dBFS of the delayed input and of the output (index 0 = fundamental). + pub input: [f32; HARMONICS], + pub output: [f32; HARMONICS], + pub latency: u32, + pub sample_rate: f32, +} +impl Default for AnalyzerFrame { + fn default() -> Self { + Self { + hz: 0., + note: None, + input: [-120.; HARMONICS], + output: [-120.; HARMONICS], + latency: 0, + sample_rate: 48000., + } + } +} + +pub struct Engine { + rate: f32, + channels: usize, + settings: Settings, + latency: usize, + delay: Vec<[f32; 2]>, + delay_at: usize, + tracker: Tracker, + pitch: PitchFollower, + hz: f32, + hz_coef: f32, + countdown: usize, + coefs: [SvfCoef; BANDS], + active: [bool; BANDS], + gains: [f32; BANDS], + state: [[Svf; BANDS]; 2], + gain_coef: f32, + smooth: f32, + mix: f32, + output: f32, + bypass: f32, + meter_in: HarmonicMeter, + meter_out: HarmonicMeter, + /// Smoothed controls start at the next settings instead of gliding from defaults. + fresh: bool, +} + +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 tracker = Tracker::new(rate, TrackerConfig::BASS); + let latency = tracker.latency(); + let settings = Settings::default(); + let mut e = Self { + rate, + channels, + settings, + latency, + delay: vec![[0.; 2]; latency + 1], + delay_at: 0, + tracker, + pitch: PitchFollower::default(), + hz: 0., + hz_coef: 1., + countdown: 0, + coefs: [SvfCoef::IDENTITY; BANDS], + active: [false; BANDS], + gains: [0.; BANDS], + state: [[Svf::default(); BANDS]; 2], + gain_coef: coefficient(rate / RETUNE as f32, GAIN_MS), + smooth: coefficient(rate, 10.), + mix: 1., + output: 1., + bypass: 0., + meter_in: HarmonicMeter::new(rate, 85.), + meter_out: HarmonicMeter::new(rate, 85.), + fresh: true, + }; + e.set_settings(settings)?; + e.fresh = true; + 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()?; + self.tracker + .set(s.source == Source::Midi, s.gate, s.smooth > 0.); + self.hz_coef = coefficient(self.rate, s.smooth / 3.); + self.settings = s; + if std::mem::take(&mut self.fresh) { + self.mix = s.mix * 0.01; + self.output = db_to_gain(s.output); + self.bypass = f32::from(s.bypass); + self.gains = self.targets(); + } + Ok(()) + } + pub fn reset(&mut self) { + self.delay.fill([0.; 2]); + self.tracker.reset(); + for ch in &mut self.state { + for f in ch.iter_mut() { + f.reset(); + } + } + self.meter_in.reset(); + self.meter_out.reset(); + self.countdown = 0; + self.fresh = true; + } + pub fn note_on(&mut self, note: u8) { + self.tracker.note_on(note); + } + pub fn note_off(&mut self, note: u8) { + self.tracker.note_off(note); + } + /// Gain targets in dB of every band for the current settings. + fn targets(&self) -> [f32; BANDS] { + let s = &self.settings; + let mut t = [0.; BANDS]; + for k in 1..=s.harmonics as usize { + t[k - 1] = s.effective(k); + t[HARMONICS + k - 1] = s.between; + } + t + } + fn retune(&mut self, jump: bool) { + let s = self.settings; + let targets = self.targets(); + let width = s.width * 0.01; + for b in 0..BANDS { + self.gains[b] = if jump { + targets[b] + } else { + self.gains[b] + (targets[b] - self.gains[b]) * self.gain_coef + }; + if (self.gains[b] - targets[b]).abs() < 1e-4 { + self.gains[b] = targets[b]; + } + let order = if b < HARMONICS { + (b + 1) as f32 + } else { + (b - HARMONICS + 1) as f32 + 0.5 + }; + let centre = order * self.hz; + let audible = self.hz > 0. && centre < 0.45 * self.rate; + if audible && !self.active[b] { + for ch in &mut self.state { + ch[b].reset(); + } + } + self.active[b] = audible && self.gains[b] != 0.; + if self.active[b] { + self.coefs[b] = SvfCoef::bell(self.rate, centre, order / width, self.gains[b]); + } + } + } + pub fn process_sample(&mut self, x: [f32; 2]) -> [f32; 2] { + let mono = if self.channels == 2 { + 0.5 * (x[0] + x[1]) + } else { + x[0] + }; + let (pitch, snap) = (&mut self.pitch, self.settings.snap); + self.tracker.process(mono, |e| pitch.event(snap, e)); + let len = self.delay.len(); + self.delay[self.delay_at] = x; + self.delay_at = (self.delay_at + 1) % len; + let y = self.delay[self.delay_at]; + + let first = self.hz == 0.; + let (hz, jump) = self.pitch.step(self.hz_coef); + let first = first && jump; + self.hz = hz; + if jump || self.countdown == 0 { + self.retune(first); + self.countdown = RETUNE; + } + self.countdown -= 1; + + let s = self.settings; + self.mix += (s.mix * 0.01 - self.mix) * self.smooth; + self.output += (db_to_gain(s.output) - self.output) * self.smooth; + self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth; + let mut out = [0.; 2]; + for c in 0..self.channels { + let mut v = y[c]; + for b in 0..BANDS { + if self.active[b] { + v = self.state[c][b].process(&self.coefs[b], v); + } + } + let wet = (self.mix * v + (1. - self.mix) * y[c]) * self.output; + out[c] = wet * (1. - self.bypass) + y[c] * self.bypass; + } + let hz = if self.tracker.sounding() { self.hz } else { 0. }; + let (dry, wet) = if self.channels == 2 { + (0.5 * (y[0] + y[1]), 0.5 * (out[0] + out[1])) + } else { + (y[0], out[0]) + }; + self.meter_in.push(dry, hz); + self.meter_out.push(wet, hz); + out + } + pub fn take_analyzer(&self) -> AnalyzerFrame { + let mut f = AnalyzerFrame { + hz: self.hz, + latency: self.latency as u32, + sample_rate: self.rate, + input: *self.meter_in.levels(), + output: *self.meter_out.levels(), + ..AnalyzerFrame::default() + }; + if self.tracker.sounding() && self.hz > 0. { + f.note = Some(hz_to_midi(self.hz).round().clamp(0., 127.) as u8); + } + f + } +} diff --git a/plugins/lathe/core/src/settings.rs b/plugins/lathe/core/src/settings.rs new file mode 100644 index 0000000..c738cd1 --- /dev/null +++ b/plugins/lathe/core/src/settings.rs @@ -0,0 +1,106 @@ +//! Host-independent controls. Gains in dB, times in milliseconds. +use serde::{Deserialize, Serialize}; + +/// Editable harmonics: the fundamental is harmonic 1. +pub const HARMONICS: usize = 32; +pub const GAIN_MIN: f32 = -24.; +pub const GAIN_MAX: f32 = 12.; + +#[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. + Midi, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Settings { + pub source: Source, + /// Gain per harmonic, index 0 = fundamental. + pub gains: [f32; HARMONICS], + /// dB per octave of harmonic number, added on top of `gains`. Harmonic 1 is the pivot. + pub tilt: f32, + /// Positive lifts odd harmonics and lowers even ones by half this amount each. + pub odd_even: f32, + /// Gain of the regions halfway between harmonics: noise, beating, inharmonic grit. + pub between: f32, + /// Bandwidth of every band as a percentage of the fundamental. + pub width: f32, + /// Number of harmonics that are shaped; higher ones pass unchanged. + pub harmonics: u32, + /// Retune time when the pitch slides or bends. + pub smooth: f32, + /// Detection threshold in dBFS for note starts (Audio source). + pub gate: f32, + /// Round detected pitch to the nearest semitone. + pub snap: bool, + pub mix: f32, + pub output: f32, + pub bypass: bool, +} +impl Default for Settings { + fn default() -> Self { + Self { + source: Source::Audio, + gains: [0.; HARMONICS], + tilt: 0., + odd_even: 0., + between: 0., + width: 20., + harmonics: 24, + smooth: 10., + gate: -42., + snap: true, + mix: 100., + output: 0., + bypass: false, + } + } +} +impl Settings { + pub fn validate(&self) -> Result<(), &'static str> { + let finite = [ + self.tilt, + self.odd_even, + self.between, + self.width, + self.smooth, + self.gate, + self.mix, + self.output, + ] + .iter() + .chain(self.gains.iter()) + .all(|v| v.is_finite()); + if !finite { + return Err("non-finite setting"); + } + let ok = self.gains.iter().all(|g| (GAIN_MIN..=GAIN_MAX).contains(g)) + && (-12.0..=12.).contains(&self.tilt) + && (-12.0..=12.).contains(&self.odd_even) + && (-24.0..=12.).contains(&self.between) + && (5.0..=50.).contains(&self.width) + && (8..=HARMONICS as u32).contains(&self.harmonics) + && (0.0..=100.).contains(&self.smooth) + && (-60.0..=-20.).contains(&self.gate) + && (0.0..=100.).contains(&self.mix) + && (-12.0..=12.).contains(&self.output); + if ok { + Ok(()) + } else { + Err("setting out of range") + } + } + /// Gain applied to harmonic `k` (1-based), with tilt and odd/even included. + pub fn effective(&self, k: usize) -> f32 { + if k == 0 || k > self.harmonics as usize || k > HARMONICS { + return 0.; + } + let parity = if k % 2 == 1 { 0.5 } else { -0.5 }; + (self.gains[k - 1] + self.tilt * (k as f32).log2() + self.odd_even * parity) + .clamp(-30., 18.) + } +} diff --git a/plugins/lathe/core/tests/dsp.rs b/plugins/lathe/core/tests/dsp.rs new file mode 100644 index 0000000..ad8e3a8 --- /dev/null +++ b/plugins/lathe/core/tests/dsp.rs @@ -0,0 +1,139 @@ +use common_dsp::meter::HarmonicMeter; +use common_dsp::Saw; +use lathe_core::{Engine, Settings, Source}; + +const RATE: f32 = 48000.; + +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) +} +fn saw(hz: f32, seconds: f32, amp: f32) -> Vec { + let mut s = Saw::new(RATE, hz); + (0..(seconds * RATE) as usize) + .map(|_| s.sample() * amp) + .collect() +} +fn render(e: &mut Engine, x: &[f32]) -> Vec { + x.iter().map(|&v| e.process_sample([v, v])[0]).collect() +} +/// Harmonic levels of the last complete window of `x`. +fn harmonics(x: &[f32], hz: f32) -> [f32; 32] { + let mut m = HarmonicMeter::new(RATE, 85.); + for &v in x { + m.push(v, hz); + } + *m.levels() +} + +#[test] +fn a_cut_on_harmonic_three_stays_on_harmonic_three_on_every_note() { + let mut s = Settings::default(); + s.gains[2] = -12.; + for hz in [43.65, 65.41, 130.81] { + let x = saw(hz, 1., 0.5); + let (mut e, l) = engine(s); + let y = render(&mut e, &x); + let a = harmonics(&x[..x.len() - l], hz); + let b = harmonics(&y[l..], hz); + let d: Vec = (0..6).map(|k| b[k] - a[k]).collect(); + assert!((d[2] + 12.).abs() < 0.7, "{hz}: {d:?}"); + for k in [0, 1, 3, 4] { + assert!(d[k].abs() < 1., "{hz} harmonic {}: {d:?}", k + 1); + } + } +} + +#[test] +fn a_flat_map_passes_the_delayed_input_exactly() { + let x = saw(55., 0.5, 0.5); + let (mut e, l) = engine(Settings::default()); + let y = render(&mut e, &x); + for i in l..x.len() { + assert_eq!(y[i], x[i - l]); + } +} + +#[test] +fn odd_even_balances_the_harmonic_series() { + let s = Settings { + odd_even: 6., + ..Settings::default() + }; + let hz = 55.; + let x = saw(hz, 1., 0.5); + let (mut e, l) = engine(s); + let y = render(&mut e, &x); + let a = harmonics(&x[..x.len() - l], hz); + let b = harmonics(&y[l..], hz); + for k in 1..8 { + let want = if k % 2 == 1 { 3. } else { -3. }; + let got = b[k - 1] - a[k - 1]; + assert!((got - want).abs() < 0.8, "harmonic {k}: {got}"); + } +} + +#[test] +fn note_changes_do_not_click() { + let mut s = Settings::default(); + s.gains[1] = 12.; + s.gains[3] = -24.; + s.tilt = -3.; + let mut x = saw(43.65, 0.5, 0.5); + x.extend(saw(58.27, 0.5, 0.5)); + x.extend(saw(87.31, 0.5, 0.5)); + let (mut e, _) = engine(s); + let y = render(&mut e, &x); + let step = y + .windows(2) + .map(|w| (w[1] - w[0]).abs()) + .fold(0f32, f32::max); + let in_step = x + .windows(2) + .map(|w| (w[1] - w[0]).abs()) + .fold(0f32, f32::max); + assert!(y.iter().all(|v| v.is_finite())); + // A saw has its own jumps; the filtered output must not jump much further than that. + assert!(step < 2. * in_step, "{step} vs {in_step}"); +} + +#[test] +fn midi_source_tunes_the_bands_at_the_delayed_note_time() { + let mut s = Settings { + source: Source::Midi, + ..Settings::default() + }; + s.gains[0] = -24.; + let (mut e, l) = engine(s); + // 43.65 Hz is MIDI 29. Notes arrive at sample 0; the bands must be tuned by sample l. + e.note_on(29); + let x = saw(43.65, 1., 0.5); + let y = render(&mut e, &x); + let a = harmonics(&x[..x.len() - l], 43.65); + let b = harmonics(&y[l..], 43.65); + assert!(b[0] - a[0] < -20., "{}", b[0] - a[0]); + let f = e.take_analyzer(); + assert_eq!(f.note, Some(29)); + assert!((f.hz - 43.65).abs() < 0.05); +} + +#[test] +fn settings_are_validated_and_serialise() { + let s = Settings::default(); + let json = serde_json::to_string(&s).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), s); + let mut bad = s; + bad.gains[5] = 40.; + assert!(bad.validate().is_err()); + bad = s; + bad.harmonics = 4; + assert!(bad.validate().is_err()); + bad = s; + bad.width = f32::NAN; + assert!(bad.validate().is_err()); + let mut e = Engine::new(RATE, 2).unwrap(); + assert!(e.set_settings(bad).is_err()); + assert_eq!(e.settings(), s); +} diff --git a/plugins/lathe/core/tests/realtime.rs b/plugins/lathe/core/tests/realtime.rs new file mode 100644 index 0000000..2e02911 --- /dev/null +++ b/plugins/lathe/core/tests/realtime.rs @@ -0,0 +1,69 @@ +use lathe_core::{Engine, 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.gains[(i / 4800) % 32] = -6.; + settings.tilt = ((i / 4800) % 5) as f32 - 2.; + settings.source = if i > 48000 { + Source::Midi + } else { + Source::Audio + }; + 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])); + 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/lathe/plugin/Cargo.lock b/plugins/lathe/plugin/Cargo.lock new file mode 100644 index 0000000..8a297c1 --- /dev/null +++ b/plugins/lathe/plugin/Cargo.lock @@ -0,0 +1,2381 @@ +# 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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" +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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +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.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +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 = "lathe" +version = "0.1.0" +dependencies = [ + "clap-sys", + "crossbeam-queue", + "lathe-core", + "lathe-ui", + "nih_plug", + "nih_plug_egui", + "serde_json", +] + +[[package]] +name = "lathe-core" +version = "0.1.0" +dependencies = [ + "common-dsp", + "serde", +] + +[[package]] +name = "lathe-ui" +version = "0.1.0" +dependencies = [ + "common-dsp", + "common-ui", + "egui", + "lathe-core", +] + +[[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.5", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +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.18+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec6e78bd70db9420d8d212fa9dc97f116448a1ceff5d00eabb991d407e21396" +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.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" +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/lathe/plugin/Cargo.toml b/plugins/lathe/plugin/Cargo.toml new file mode 100644 index 0000000..facc04e --- /dev/null +++ b/plugins/lathe/plugin/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "lathe" +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" } +lathe-core = { path = "../core" } +lathe-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/lathe/plugin/src/lib.rs b/plugins/lathe/plugin/src/lib.rs new file mode 100644 index 0000000..923db8c --- /dev/null +++ b/plugins/lathe/plugin/src/lib.rs @@ -0,0 +1,189 @@ +//! CLAP audio effect. All DSP state is owned by the processing thread. +mod params; +use crossbeam_queue::ArrayQueue; +use lathe_core::{AnalyzerFrame, Engine, HARMONICS}; +use nih_plug::prelude::*; +use nih_plug_egui::{create_egui_editor, egui}; +use params::LatheParams; +use std::sync::Arc; + +struct EditorData { + ui: lathe_ui::State, + gestures: [bool; 8], +} +pub struct Lathe { + params: Arc, + analyzer: Arc>, + engine: Option, + gains: [f32; HARMONICS], + publish_count: usize, + publish_interval: usize, +} +impl Default for Lathe { + fn default() -> Self { + Self { + params: Arc::new(LatheParams::default()), + analyzer: Arc::new(ArrayQueue::new(2)), + engine: None, + gains: [0.; HARMONICS], + publish_count: 0, + publish_interval: 1600, + } + } +} +impl Plugin for Lathe { + const NAME: &'static str = "Lathe"; + 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 = false; + type SysExMessage = (); + type BackgroundTask = (); + fn params(&self) -> Arc { + self.params.clone() + } + fn editor(&mut self, _: AsyncExecutor) -> Option> { + let params = self.params.clone(); + let analyzer = self.analyzer.clone(); + create_egui_editor( + params.editor.clone(), + EditorData { + ui: lathe_ui::State::default(), + gestures: [false; 8], + }, + |ctx, _| lathe_ui::style(ctx), + move |ctx, setter, data| { + while let Some(frame) = analyzer.pop() { + data.ui.accept_frame(frame); + } + let current = params.gains.read().map(|g| *g).unwrap_or([0.; HARMONICS]); + let before = params.settings(¤t); + let mut edited = before; + nih_plug_egui::resizable_window::ResizableWindow::new("lathe-editor") + .min_size(egui::vec2( + lathe_ui::WIDTH as f32 - 80., + lathe_ui::HEIGHT as f32 - 40., + )) + .show(ctx, ¶ms.editor, |ui| { + lathe_ui::show(ui, &mut edited, &mut data.ui) + }); + params.apply( + setter, + &before, + &edited, + data.ui.gesture_active, + &mut data.gestures, + ); + // Harmonic levels follow the audio. + ctx.request_repaint_after(std::time::Duration::from_millis(40)); + }, + ) + } + 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; + }; + let settings = self.params.settings(&self.gains); + self.gains = settings.gains; + if engine.set_settings(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); + 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 settings = self.params.settings(&self.gains); + self.gains = settings.gains; + let _ = engine.set_settings(settings); + 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); + for (c, value) in sample.into_iter().enumerate() { + *value = out[c]; + } + self.publish_count += 1; + if self.publish_count >= self.publish_interval { + self.analyzer.force_push(engine.take_analyzer()); + self.publish_count = 0; + } + } + // The lookahead delay and filter tails must drain after input stops. + ProcessStatus::KeepAlive + } +} +impl ClapPlugin for Lathe { + const CLAP_ID: &'static str = "io.storyboard.harmonic-eq"; + const CLAP_DESCRIPTION: Option<&'static str> = + Some("EQ the harmonics of the played note: a cut on harmonic 3 stays on harmonic 3"); + const CLAP_MANUAL_URL: Option<&'static str> = None; + const CLAP_SUPPORT_URL: Option<&'static str> = None; + const CLAP_FEATURES: &'static [ClapFeature] = &[ + ClapFeature::AudioEffect, + ClapFeature::Equalizer, + ClapFeature::Stereo, + ClapFeature::Mono, + ]; +} +nih_export_clap!(Lathe); diff --git a/plugins/lathe/plugin/src/params.rs b/plugins/lathe/plugin/src/params.rs new file mode 100644 index 0000000..db97a95 --- /dev/null +++ b/plugins/lathe/plugin/src/params.rs @@ -0,0 +1,186 @@ +use lathe_core::{Settings, Source, HARMONICS}; +use nih_plug::prelude::*; +use nih_plug_egui::EguiState; +use std::sync::{Arc, RwLock}; + +#[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) +} +/// Continuous controls, in the order of the gesture slots. +const FLOATS: usize = 8; + +#[derive(Params)] +pub struct LatheParams { + #[persist = "editor"] + pub editor: Arc, + /// Drawn per-harmonic gains. Saved with the project, not exposed as automation lanes. + #[persist = "lathe-gains"] + pub gains: RwLock<[f32; HARMONICS]>, + #[id = "source"] + pub source: EnumParam, + #[id = "tilt"] + pub tilt: FloatParam, + #[id = "odd-even"] + pub odd_even: FloatParam, + #[id = "between"] + pub between: FloatParam, + #[id = "width"] + pub width: FloatParam, + #[id = "smooth"] + pub smooth: FloatParam, + #[id = "gate"] + pub gate: FloatParam, + #[id = "mix"] + pub mix: FloatParam, + #[id = "output"] + pub output: FloatParam, + #[id = "harmonics"] + pub harmonics: IntParam, + #[id = "snap"] + pub snap: BoolParam, + #[id = "bypass"] + pub bypass: BoolParam, +} +impl Default for LatheParams { + fn default() -> Self { + let d = Settings::default(); + Self { + editor: EguiState::from_size(lathe_ui::WIDTH, lathe_ui::HEIGHT), + gains: RwLock::new(d.gains), + source: EnumParam::new("Pitch source", SourceParam::Audio), + tilt: linear("Tilt", d.tilt, -12., 12., " dB/oct"), + odd_even: linear("Odd/Even", d.odd_even, -12., 12., " dB"), + between: linear("Between", d.between, -24., 12., " dB"), + width: linear("Width", d.width, 5., 50., " %"), + smooth: linear("Smooth", d.smooth, 0., 100., " ms"), + gate: linear("Gate", d.gate, -60., -20., " dB"), + mix: linear("Mix", d.mix, 0., 100., " %"), + output: linear("Output", d.output, -12., 12., " dB"), + harmonics: IntParam::new( + "Harmonics", + d.harmonics as i32, + IntRange::Linear { + min: 8, + max: HARMONICS as i32, + }, + ), + snap: BoolParam::new("Snap to semitone", d.snap), + bypass: BoolParam::new("Bypass", false).make_bypass(), + } + } +} +impl LatheParams { + /// Current settings. `gains` falls back to `last` when the editor holds the lock, so the + /// audio thread never waits. + pub fn settings(&self, last: &[f32; HARMONICS]) -> Settings { + let gains = self.gains.try_read().map(|g| *g).unwrap_or(*last); + Settings { + source: match self.source.value() { + SourceParam::Audio => Source::Audio, + SourceParam::Midi => Source::Midi, + }, + gains: gains.map(|g| { + if g.is_finite() { + g.clamp(lathe_core::GAIN_MIN, lathe_core::GAIN_MAX) + } else { + 0. + } + }), + tilt: self.tilt.value(), + odd_even: self.odd_even.value(), + between: self.between.value(), + width: self.width.value(), + harmonics: self.harmonics.value() as u32, + smooth: self.smooth.value(), + gate: self.gate.value(), + snap: self.snap.value(), + mix: self.mix.value(), + output: self.output.value(), + bypass: self.bypass.value(), + } + } + fn floats(&self) -> [&FloatParam; FLOATS] { + [ + &self.tilt, + &self.odd_even, + &self.between, + &self.width, + &self.smooth, + &self.gate, + &self.mix, + &self.output, + ] + } + /// 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. Gains are stored + /// directly. + pub fn apply( + &self, + setter: &ParamSetter, + old: &Settings, + new: &Settings, + dragging: bool, + open: &mut [bool; FLOATS], + ) { + if new.gains != old.gains { + if let Ok(mut g) = self.gains.write() { + *g = new.gains; + } + } + let values = [ + (old.tilt, new.tilt), + (old.odd_even, new.odd_even), + (old.between, new.between), + (old.width, new.width), + (old.smooth, new.smooth), + (old.gate, new.gate), + (old.mix, new.mix), + (old.output, new.output), + ]; + for (i, (p, (a, b))) in self.floats().into_iter().zip(values).enumerate() { + if a != b { + if !open[i] { + open[i] = true; + setter.begin_set_parameter(p); + } + setter.set_parameter(p, b); + } + if !dragging && open[i] { + setter.end_set_parameter(p); + open[i] = false; + } + } + if new.harmonics != old.harmonics { + setter.begin_set_parameter(&self.harmonics); + setter.set_parameter(&self.harmonics, new.harmonics as i32); + setter.end_set_parameter(&self.harmonics); + } + for (p, a, b) in [ + (&self.snap, old.snap, new.snap), + (&self.bypass, old.bypass, new.bypass), + ] { + if a != b { + setter.begin_set_parameter(p); + setter.set_parameter(p, b); + setter.end_set_parameter(p); + } + } + if 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); + } + } +} diff --git a/plugins/lathe/plugin/tests/host.rs b/plugins/lathe/plugin/tests/host.rs new file mode 100644 index 0000000..018af67 --- /dev/null +++ b/plugins/lathe/plugin/tests/host.rs @@ -0,0 +1,219 @@ +//! 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 = &lathe::clap_entry; + assert!(entry.init.unwrap()(c"lathe.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"Lathe"); + assert_eq!( + CStr::from_ptr((*descriptor).id), + c"io.storyboard.harmonic-eq" + ); + 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("tilt".into(), ParamValue::F32(-3.)); + envelope + .params + .insert("harmonics".into(), ParamValue::I32(16)); + let mut gains = [0f32; 32]; + gains[2] = -12.; + gains[4] = 6.; + envelope + .fields + .insert("lathe-gains".into(), serde_json::to_string(&gains).unwrap()); + 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() + ); + let saved: [f32; 32] = serde_json::from_str(&restored.fields["lathe-gains"]).unwrap(); + assert_eq!(saved, gains); + for rate in [44100., 48000., 96000.] { + assert!(p.activate.unwrap()(plugin, rate, 1, 512)); + let delay = (*latency).get.unwrap()(plugin) as usize; + // About 67 ms lookahead: two periods of the lowest detected note (30 Hz). + assert!((delay as f64 / rate - 0.067).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/lathe/ui/Cargo.toml b/plugins/lathe/ui/Cargo.toml new file mode 100644 index 0000000..9208628 --- /dev/null +++ b/plugins/lathe/ui/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "lathe-ui" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +egui = "0.31.1" +lathe-core = { path = "../core" } +common-ui = { path = "../../common/ui" } +common-dsp = { path = "../../common/dsp" } diff --git a/plugins/lathe/ui/src/lib.rs b/plugins/lathe/ui/src/lib.rs new file mode 100644 index 0000000..7ac276f --- /dev/null +++ b/plugins/lathe/ui/src/lib.rs @@ -0,0 +1,431 @@ +//! Lathe editor, shared by the CLAP wrapper and the headless renderer. +use common_ui::*; +use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2}; +use lathe_core::{AnalyzerFrame, Settings, Source, GAIN_MAX, GAIN_MIN, HARMONICS}; + +pub use common_ui::style; +pub const WIDTH: u32 = 1040; +pub const HEIGHT: u32 = 650; + +pub struct State { + pub analyzer: AnalyzerFrame, + /// True while a pointer button is held, so knob drags become one host gesture. + pub gesture_active: bool, + pub help: String, + /// Last point of a draw stroke in the harmonic editor: (harmonic index, dB). + stroke: Option<(usize, f32)>, +} +impl Default for State { + fn default() -> Self { + Self { + analyzer: AnalyzerFrame::default(), + gesture_active: false, + help: DEFAULT_HELP.into(), + stroke: None, + } + } +} +const DEFAULT_HELP: &str = + "Draw gains on the harmonics. They follow the note: harmonic 3 stays harmonic 3 on every pitch."; +impl State { + pub fn accept_frame(&mut self, frame: AnalyzerFrame) { + let old = self.analyzer; + self.analyzer = frame; + // Levels fall smoothly so bars stay readable between windows. + for k in 0..HARMONICS { + self.analyzer.input[k] = frame.input[k].max(old.input[k] - 3.); + self.analyzer.output[k] = frame.output[k].max(old.output[k] - 3.); + } + } +} +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); + }); +} + +const GRID: Color32 = Color32::from_rgb(24, 26, 30); +const LEVEL_FLOOR: f32 = -72.; + +fn gain_y(rect: Rect, db: f32) -> f32 { + let t = (db - GAIN_MIN) / (GAIN_MAX - GAIN_MIN); + rect.bottom() - rect.height() * t.clamp(0., 1.) +} +fn y_gain(rect: Rect, y: f32) -> f32 { + let t = (rect.bottom() - y) / rect.height(); + // Snap near 0 dB so drawing a flat line is easy. + let db = GAIN_MIN + t * (GAIN_MAX - GAIN_MIN); + let db = if db.abs() < 0.4 { 0. } else { db }; + db.clamp(GAIN_MIN, GAIN_MAX) +} + +/// Bar editor of the per-harmonic gains, with the effective curve and measured levels. +fn harmonic_editor(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, height: f32) { + let size = Vec2::new(ui.available_width(), height); + let (outer, response) = ui.allocate_exact_size(size, Sense::click_and_drag()); + let p = ui.painter_at(outer); + p.rect_filled(outer, 2., SCREEN); + let levels_h = 64.; + let labels_h = 18.; + let left = 34.; + let gains = Rect::from_min_max( + outer.left_top() + Vec2::new(left, 12.), + Pos2::new( + outer.right() - 10., + outer.bottom() - levels_h - labels_h - 8., + ), + ); + let levels = Rect::from_min_max( + Pos2::new(gains.left(), gains.bottom() + labels_h + 4.), + Pos2::new(gains.right(), outer.bottom() - 6.), + ); + let col = gains.width() / HARMONICS as f32; + let column = |x: f32| { + (((x - gains.left()) / col).floor() as isize).clamp(0, HARMONICS as isize - 1) as usize + }; + + // Interaction: drag draws, Shift-drag nudges, double-click resets a bar. + if let Some(pos) = response.interact_pointer_pos() { + let k = column(pos.x); + if response.double_clicked() { + settings.gains[k] = 0.; + state.stroke = None; + } else if response.dragged() || response.drag_started() || response.clicked() { + if ui.input(|i| i.modifiers.shift) { + let dy = ui.input(|i| i.pointer.delta().y); + let step = dy * (GAIN_MAX - GAIN_MIN) / gains.height() * 0.15; + settings.gains[k] = (settings.gains[k] - step).clamp(GAIN_MIN, GAIN_MAX); + } else { + let db = y_gain(gains, pos.y); + // Fill every column the pointer skipped since the last frame. + let (from, from_db) = state.stroke.unwrap_or((k, db)); + let (a, b) = (from.min(k), from.max(k)); + for i in a..=b { + let t = if b == a { + 1. + } else { + (i as f32 - from as f32) / (k as f32 - from as f32) + }; + settings.gains[i] = from_db + (db - from_db) * t.clamp(0., 1.); + } + state.stroke = Some((k, db)); + } + } + } + if !response.dragged() { + state.stroke = None; + } + + // Grid: dB lines with labels. + for db in [12., 6., 0., -6., -12., -18., -24.] { + let y = gain_y(gains, db); + let zero = db == 0.; + p.line_segment( + [Pos2::new(gains.left(), y), Pos2::new(gains.right(), y)], + Stroke::new(1.0_f32, if zero { EDGE } else { GRID }), + ); + p.text( + Pos2::new(outer.left() + 6., y), + egui::Align2::LEFT_CENTER, + if zero { + "0".into() + } else { + format!("{db:+.0}") + }, + FontId::proportional(10.), + if zero { MUTED } else { DIM }, + ); + } + let zero = gain_y(gains, 0.); + let active = settings.harmonics as usize; + let hover = response.hover_pos().map(|pos| column(pos.x)); + let mut curve = Vec::with_capacity(HARMONICS); + for k in 0..HARMONICS { + let x0 = gains.left() + col * k as f32; + let bar = Rect::from_min_max( + Pos2::new(x0 + col * 0.18, zero.min(gain_y(gains, settings.gains[k]))), + Pos2::new(x0 + col * 0.82, zero.max(gain_y(gains, settings.gains[k]))), + ); + let on = k < active; + let fill = if !on { + Color32::from_rgb(40, 42, 47) + } else if hover == Some(k) { + Color32::from_rgb(255, 60, 130) + } else { + ACCENT + }; + if bar.height() >= 1. { + p.rect_filled(bar, 1., fill); + } else { + p.line_segment( + [Pos2::new(bar.left(), zero), Pos2::new(bar.right(), zero)], + Stroke::new(2.0_f32, if on { DIM } else { GRID }), + ); + } + if on { + curve.push(Pos2::new( + x0 + col * 0.5, + gain_y(gains, settings.effective(k + 1)), + )); + } + let label_color = if hover == Some(k) { + TEXT + } else if on { + MUTED + } else { + DIM + }; + p.text( + Pos2::new(x0 + col * 0.5, gains.bottom() + 4.), + egui::Align2::CENTER_TOP, + format!("{}", k + 1), + FontId::proportional(10.), + label_color, + ); + // Measured levels: input (steel) and output (accent), side by side. + let f = &state.analyzer; + for (i, (db, color)) in [(f.input[k], STEEL), (f.output[k], ACCENT)] + .into_iter() + .enumerate() + { + let t = ((db - LEVEL_FLOOR) / -LEVEL_FLOOR).clamp(0., 1.); + if t <= 0. { + continue; + } + let w = col * 0.3; + let x = x0 + col * 0.18 + i as f32 * (w + col * 0.04); + let r = Rect::from_min_max( + Pos2::new(x, levels.bottom() - levels.height() * t), + Pos2::new(x + w, levels.bottom()), + ); + p.rect_filled(r, 0., color.gamma_multiply(if on { 0.9 } else { 0.4 })); + } + } + if curve.len() > 1 { + p.add(egui::Shape::line(curve, Stroke::new(1.5_f32, FOCUS))); + } + p.line_segment( + [ + Pos2::new(levels.left(), levels.top() - 2.), + Pos2::new(levels.right(), levels.top() - 2.), + ], + Stroke::new(1.0_f32, GRID), + ); + p.text( + gains.right_top() + Vec2::new(-4., 2.), + egui::Align2::RIGHT_TOP, + "GAIN · EFFECTIVE", + FontId::proportional(11.), + MUTED, + ); + p.text( + Pos2::new(levels.right() - 4., levels.top() + 2.), + egui::Align2::RIGHT_TOP, + "LEVEL IN · OUT", + FontId::proportional(11.), + MUTED, + ); + p.rect_stroke( + outer, + 2., + Stroke::new(1.0_f32, EDGE), + egui::StrokeKind::Inside, + ); + if let Some(k) = hover { + let f = &state.analyzer; + let at = if f.hz > 0. { + format!(" at {:.0} Hz", f.hz * (k + 1) as f32) + } else { + String::new() + }; + state.help = format!( + "Harmonic {}{at}: drawn {:+.1} dB, effective {:+.1} dB. Drag to draw, Shift-drag fine, double-click resets.", + k + 1, + settings.gains[k], + settings.effective(k + 1), + ); + } +} + +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, "LATHE", "HARMONIC EQ", |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; + } + 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(210.); + ui.set_min_height(TOP); + ui.vertical(|ui| note_panel(ui, settings, state, &frame)); + }); + ui.add_space(4.); + panel(ui, |ui| { + ui.set_width(ui.available_width()); + harmonic_editor(ui, settings, state, 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 = 330.; + +/// Label, help text, and gain in dB for 1-based harmonic `k`. +type Preset = (&'static str, &'static str, fn(usize) -> f32); + +fn note_panel( + ui: &mut egui::Ui, + settings: &mut Settings, + state: &mut State, + 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)), + _ if frame.hz > 0. => ("—".into(), format!("holding {:.1} Hz", frame.hz)), + _ => ("—".into(), "no note yet".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(6.); + 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", + } + )) + .size(12.) + .color(MUTED), + ); + ui.add_space(14.); + caption(ui, "SHAPES"); + let presets: [Preset; 4] = [ + ("FLAT", "Reset every drawn gain to 0 dB.", |_| 0.), + ( + "ODD ONLY", + "Remove even harmonics: hollow, square-like.", + |k| { + if k % 2 == 0 { + -24. + } else { + 0. + } + }, + ), + ( + "EVEN ONLY", + "Keep the fundamental and even harmonics: octave-like, smooth.", + |k| { + if k > 1 && k % 2 == 1 { + -24. + } else { + 0. + } + }, + ), + ( + "HOLLOW", + "Cut harmonics 2–4: scooped, vocal growl body.", + |k| { + if (2..=4).contains(&k) { + -12. + } else { + 0. + } + }, + ), + ]; + ui.horizontal_wrapped(|ui| { + for (label, help, f) in presets { + let r = ui.button(RichText::new(label).size(12.)); + if r.clicked() { + for k in 0..HARMONICS { + settings.gains[k] = f(k + 1); + } + } + hint(&r, state, help); + } + }); + ui.add_space(10.); + let r = ui.selectable_label(settings.snap, RichText::new("SNAP TO SEMITONE").size(13.)); + if r.clicked() { + settings.snap = !settings.snap; + } + hint( + &r, + state, + "Round the detected pitch. Turn off for slides and pitch bends.", + ); +} + +fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { + let d = Settings::default(); + ui.columns(9, |c| { + knob(&mut c[0], state, "Tilt", &mut settings.tilt, -12. ..=12., d.tilt, " dB/oct", 80., + "Brighter or darker by harmonic number: dB per octave of harmonics, pivot on the fundamental."); + knob(&mut c[1], state, "Odd/Even", &mut settings.odd_even, -12. ..=12., d.odd_even, " dB", 80., + "Positive: odd harmonics up, even down (square-like). Negative: the reverse."); + knob(&mut c[2], state, "Between", &mut settings.between, -24. ..=12., d.between, " dB", 80., + "Gain between the harmonics. Cut to clean noise and beating, lift for grit."); + knob(&mut c[3], state, "Width", &mut settings.width, 5. ..=50., d.width, " %", 80., + "Bandwidth of every band as a share of the harmonic spacing."); + let mut harmonics = settings.harmonics as f32; + knob(&mut c[4], state, "Harmonics", &mut harmonics, 8. ..=32., d.harmonics as f32, "", 54., + "How many harmonics are shaped. Higher harmonics pass unchanged."); + settings.harmonics = harmonics.round() as u32; + knob(&mut c[5], state, "Smooth", &mut settings.smooth, 0. ..=100., d.smooth, " ms", 54., + "Retune time for slides and bends. New notes always retune at once."); + knob(&mut c[6], state, "Gate", &mut settings.gate, -60. ..=-20., d.gate, " dB", 54., + "Input level that counts as a note. Raise it if tails retrigger."); + knob(&mut c[7], state, "Mix", &mut settings.mix, 0. ..=100., d.mix, " %", 54., + "Blend with the delayed dry signal."); + knob(&mut c[8], state, "Output", &mut settings.output, -12. ..=12., d.output, " dB", 54., + "Output trim."); + }); +} diff --git a/plugins/temper/core/Cargo.toml b/plugins/temper/core/Cargo.toml new file mode 100644 index 0000000..e0e1d4c --- /dev/null +++ b/plugins/temper/core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "temper-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/temper/core/src/lib.rs b/plugins/temper/core/src/lib.rs new file mode 100644 index 0000000..78c19fd --- /dev/null +++ b/plugins/temper/core/src/lib.rs @@ -0,0 +1,405 @@ +//! Note-tracked distortion: the filters around the shaper follow the played note and the +//! input level is normalised per note, so every note distorts with the same character. +//! +//! The audio path is delayed by the tracker's lookahead, so filters and drive are set for a +//! note before its attack reaches the shaper. The shaper runs at 4× the sample rate. +mod settings; +use common_dsp::meter::{HarmonicMeter, HARMONICS}; +use common_dsp::oversample::Oversampler; +use common_dsp::svf::{Svf, SvfCoef}; +use common_dsp::tracker::{NoteEvent, PitchFollower, Tracker, TrackerConfig}; +use common_dsp::{coefficient, db_to_gain, gain_to_db, hz_to_midi}; +pub use settings::{Curve, Settings, Source}; + +pub const MIN_HZ: f32 = TrackerConfig::BASS.min_hz; +pub const MAX_HZ: f32 = TrackerConfig::BASS.max_hz; +/// Harmonics shown by the editor. +pub const SHOWN: usize = 16; +/// RMS level that Normalize brings every note to before Drive. +const TARGET_DB: f32 = -12.; +const RETUNE: usize = 16; +const DRY: usize = 64; + +#[derive(Clone, Copy, Debug)] +pub struct AnalyzerFrame { + pub hz: f32, + pub note: Option, + /// Harmonic levels in dBFS of the delayed input and of the output (index 0 = fundamental). + pub input: [f32; SHOWN], + pub output: [f32; SHOWN], + /// Peak level reaching the shaper, after normalisation and drive, in dB. + pub shaper_db: f32, + /// Gain applied by Normalize and by Auto gain, in dB. + pub normalize_db: f32, + pub auto_db: f32, + pub latency: u32, + pub sample_rate: f32, +} +impl Default for AnalyzerFrame { + fn default() -> Self { + Self { + hz: 0., + note: None, + input: [-120.; SHOWN], + output: [-120.; SHOWN], + shaper_db: -120., + normalize_db: 0., + auto_db: 0., + latency: 0, + sample_rate: 48000., + } + } +} + +/// Per-note input level, measured on the undelayed input so it is known at the attack. +#[derive(Clone, Copy, Debug, Default)] +struct Level { + /// RMS of the undelayed input. + ahead: f32, + /// Loudest `ahead` since the note started, slowly released. + note: f32, +} +impl Level { + fn event(&mut self, e: NoteEvent) { + if let NoteEvent::Start { glide: false, .. } = e { + // `ahead` already looks one lookahead into the new note. + self.note = self.ahead; + } + } +} + +#[derive(Clone, Copy, Debug)] +struct Filters { + floor: SvfCoef, + push: SvfCoef, + bright: SvfCoef, +} + +struct Channel { + floor: Svf, + push: Svf, + bright: Svf, + over: Oversampler, + dc_x: f32, + dc_y: f32, + dry: [f32; DRY], +} +impl Channel { + fn new() -> Self { + Self { + floor: Svf::default(), + push: Svf::default(), + bright: Svf::default(), + over: Oversampler::new(), + dc_x: 0., + dc_y: 0., + dry: [0.; DRY], + } + } + fn reset(&mut self) { + self.floor.reset(); + self.push.reset(); + self.bright.reset(); + self.over.reset(); + self.dc_x = 0.; + self.dc_y = 0.; + self.dry = [0.; DRY]; + } +} + +struct Smoothed { + value: f32, + coef: f32, +} +impl Smoothed { + fn new(value: f32, coef: f32) -> Self { + Self { value, coef } + } + #[inline] + fn next(&mut self, target: f32) -> f32 { + let d = target - self.value; + self.value = if d.abs() < 1e-6 { + target + } else { + self.value + d * self.coef + }; + self.value + } +} + +pub struct Engine { + rate: f32, + channels: usize, + settings: Settings, + latency: usize, + delay: Vec<[f32; 2]>, + delay_at: usize, + dry_at: usize, + tracker: Tracker, + pitch: PitchFollower, + hz_coef: f32, + countdown: usize, + filters: Filters, + channel: [Channel; 2], + level: Level, + rms_coef: f32, + release: f32, + norm_db: Smoothed, + drive: Smoothed, + bias: Smoothed, + mix: Smoothed, + output: Smoothed, + bypass: Smoothed, + auto_db: Smoothed, + power_in: f32, + power_out: f32, + power_coef: f32, + dc_r: f32, + shaper_peak: f32, + meter_in: HarmonicMeter, + meter_out: HarmonicMeter, + /// Smoothed controls start at the next settings instead of gliding from defaults. + fresh: bool, +} + +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 tracker = Tracker::new(rate, TrackerConfig::BASS); + let lookahead = tracker.latency(); + let smooth = coefficient(rate, 10.); + let settings = Settings::default(); + let mut e = Self { + rate, + channels, + settings, + latency: lookahead + Oversampler::latency(), + delay: vec![[0.; 2]; lookahead + 1], + delay_at: 0, + dry_at: 0, + tracker, + pitch: PitchFollower::default(), + hz_coef: coefficient(rate, 5.), + countdown: 0, + filters: Filters { + floor: SvfCoef::IDENTITY, + push: SvfCoef::IDENTITY, + bright: SvfCoef::IDENTITY, + }, + channel: [Channel::new(), Channel::new()], + level: Level::default(), + rms_coef: coefficient(rate, 10.), + release: 1. - coefficient(rate, 1500.), + norm_db: Smoothed::new(0., coefficient(rate, 5.)), + drive: Smoothed::new(db_to_gain(settings.drive), smooth), + bias: Smoothed::new(0., smooth), + mix: Smoothed::new(1., smooth), + output: Smoothed::new(1., smooth), + bypass: Smoothed::new(0., smooth), + auto_db: Smoothed::new(0., coefficient(rate, 50.)), + power_in: 0., + power_out: 0., + power_coef: coefficient(rate, 300.), + dc_r: 1. - std::f32::consts::TAU * 10. / rate, + shaper_peak: 0., + meter_in: HarmonicMeter::new(rate, 85.), + meter_out: HarmonicMeter::new(rate, 85.), + fresh: true, + }; + e.set_settings(settings)?; + e.fresh = true; + Ok(e) + } + /// Lookahead plus the oversampling filters. + 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()?; + self.tracker.set(s.source == Source::Midi, s.gate, true); + if std::mem::take(&mut self.fresh) { + self.drive.value = db_to_gain(s.drive); + self.bias.value = s.bias * 0.008; + self.mix.value = s.mix * 0.01; + self.output.value = db_to_gain(s.output); + self.bypass.value = f32::from(s.bypass); + } + self.settings = s; + Ok(()) + } + pub fn reset(&mut self) { + self.delay.fill([0.; 2]); + self.tracker.reset(); + for c in &mut self.channel { + c.reset(); + } + self.level = Level::default(); + self.power_in = 0.; + self.power_out = 0.; + self.shaper_peak = 0.; + self.meter_in.reset(); + self.meter_out.reset(); + self.countdown = 0; + self.fresh = true; + } + pub fn note_on(&mut self, note: u8) { + self.tracker.note_on(note); + } + pub fn note_off(&mut self, note: u8) { + self.tracker.note_off(note); + } + fn retune(&mut self, hz: f32) { + let s = &self.settings; + let limit = 0.45 * self.rate; + let tuned = |mult: f32| hz > 0. && mult > 0. && hz * mult < limit; + self.filters.floor = if tuned(s.floor) { + SvfCoef::highpass(self.rate, hz * s.floor, std::f32::consts::FRAC_1_SQRT_2) + } else { + SvfCoef::IDENTITY + }; + let k = s.push_harmonic as f32; + self.filters.push = if tuned(k) && s.push_gain != 0. { + SvfCoef::bell(self.rate, hz * k, k / 0.6, s.push_gain) + } else { + SvfCoef::IDENTITY + }; + self.filters.bright = if tuned(s.bright) { + SvfCoef::lowpass(self.rate, hz * s.bright, std::f32::consts::FRAC_1_SQRT_2) + } else { + SvfCoef::IDENTITY + }; + } + pub fn process_sample(&mut self, x: [f32; 2]) -> [f32; 2] { + let mono = if self.channels == 2 { + 0.5 * (x[0] + x[1]) + } else { + x[0] + }; + // Level of the input one lookahead ahead of the audio that is about to be shaped. + let a = &mut self.level; + a.ahead += (mono * mono - a.ahead) * self.rms_coef; + a.note = (a.note * self.release).max(a.ahead); + let (pitch, level) = (&mut self.pitch, &mut self.level); + self.tracker.process(mono, |e| { + pitch.event(false, e); + level.event(e); + }); + let len = self.delay.len(); + self.delay[self.delay_at] = x; + self.delay_at = (self.delay_at + 1) % len; + let y = self.delay[self.delay_at]; + + let (hz, jump) = self.pitch.step(self.hz_coef); + if jump || self.countdown == 0 { + self.retune(hz); + self.countdown = RETUNE; + } + self.countdown -= 1; + + let s = self.settings; + let rms_db = 10. * self.level.note.max(1e-12).log10(); + // Below −60 dBFS there is nothing to normalise; hold the last gain. + let norm_target = if rms_db > -60. { + (s.normalize * 0.01 * (TARGET_DB - rms_db)).clamp(-24., 36.) + } else { + self.norm_db.value + }; + let norm = db_to_gain(self.norm_db.next(norm_target)); + let drive = self.drive.next(db_to_gain(s.drive)); + let bias = self.bias.next(s.bias * 0.008); + let mix = self.mix.next(s.mix * 0.01); + let output = self.output.next(db_to_gain(s.output)); + let bypass = self.bypass.next(f32::from(s.bypass)); + let curve = s.curve; + let offset = curve.shape(bias); + let f = self.filters; + let dry_at = self.dry_at; + let back = (dry_at + DRY - Oversampler::latency()) % DRY; + let mut wet = [0.; 2]; + let mut dry = [0.; 2]; + let mut peak = self.shaper_peak; + for c in 0..self.channels { + let ch = &mut self.channel[c]; + ch.dry[dry_at] = y[c]; + dry[c] = ch.dry[back]; + let v = ch.floor.process(&f.floor, y[c]); + let v = ch.push.process(&f.push, v) * norm * drive; + let up = ch.over.up(v); + let shaped = up.map(|u| { + peak = peak.max(u.abs()); + curve.shape(u + bias) - offset + }); + let v = ch.over.down(shaped); + let v = ch.bright.process(&f.bright, v); + // DC blocker: bias and asymmetric curves leave an offset. + let d = v - ch.dc_x + self.dc_r * ch.dc_y; + ch.dc_x = v; + ch.dc_y = d; + wet[c] = d; + } + self.shaper_peak = peak; + self.dry_at = (dry_at + 1) % DRY; + + let (dry_mono, wet_mono) = if self.channels == 2 { + (0.5 * (dry[0] + dry[1]), 0.5 * (wet[0] + wet[1])) + } else { + (dry[0], wet[0]) + }; + self.power_in += (dry_mono * dry_mono - self.power_in) * self.power_coef; + self.power_out += (wet_mono * wet_mono - self.power_out) * self.power_coef; + let auto_target = if !s.auto_gain { + 0. + } else if self.power_in > 1e-7 && self.power_out > 1e-9 { + (10. * (self.power_in / self.power_out).log10()).clamp(-36., 24.) + } else { + self.auto_db.value + }; + let auto = db_to_gain(self.auto_db.next(auto_target)); + let mut out = [0.; 2]; + for c in 0..self.channels { + let v = (mix * wet[c] * auto + (1. - mix) * dry[c]) * output; + out[c] = v * (1. - bypass) + dry[c] * bypass; + } + let tone = if self.tracker.sounding() { hz } else { 0. }; + let o = if self.channels == 2 { + 0.5 * (out[0] + out[1]) + } else { + out[0] + }; + self.meter_in.push(dry_mono, tone); + self.meter_out.push(o, tone); + out + } + pub fn take_analyzer(&mut self) -> AnalyzerFrame { + let hz = self.pitch.hz(); + let mut f = AnalyzerFrame { + hz, + latency: self.latency as u32, + sample_rate: self.rate, + shaper_db: gain_to_db(self.shaper_peak), + normalize_db: self.norm_db.value, + auto_db: self.auto_db.value, + ..AnalyzerFrame::default() + }; + self.shaper_peak = 0.; + f.input + .copy_from_slice(&self.meter_in.levels()[..SHOWN.min(HARMONICS)]); + f.output + .copy_from_slice(&self.meter_out.levels()[..SHOWN.min(HARMONICS)]); + if self.tracker.sounding() && hz > 0. { + f.note = Some(hz_to_midi(hz).round().clamp(0., 127.) as u8); + } + f + } +} diff --git a/plugins/temper/core/src/settings.rs b/plugins/temper/core/src/settings.rs new file mode 100644 index 0000000..da0220c --- /dev/null +++ b/plugins/temper/core/src/settings.rs @@ -0,0 +1,138 @@ +//! Host-independent controls. Levels in dB, harmonic positions as multiples of the fundamental. +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. + Midi, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +pub enum Curve { + /// tanh: smooth, odd harmonics, rounded top. + #[default] + Soft, + /// Clipping with a narrow knee: dense, square-like. + Hard, + /// Sine wavefolder: drive folds the wave back, adding moving upper harmonics. + Fold, + /// Asymmetric: the negative half clips earlier, adding even harmonics. + Tube, +} +impl Curve { + pub const ALL: [Curve; 4] = [Curve::Soft, Curve::Hard, Curve::Fold, Curve::Tube]; + pub fn name(self) -> &'static str { + match self { + Curve::Soft => "Soft", + Curve::Hard => "Hard", + Curve::Fold => "Fold", + Curve::Tube => "Tube", + } + } + /// Transfer function; maps 0 to 0. + #[inline] + pub fn shape(self, u: f32) -> f32 { + match self { + Curve::Soft => u.tanh(), + Curve::Hard => { + let u = u.clamp(-8., 8.); + u / (1. + u.abs().powi(8)).powf(0.125) + } + // Scaled so full drive gives about five folds; more is mostly aliasing noise. + Curve::Fold => (std::f32::consts::FRAC_PI_2 * 0.4 * u.clamp(-64., 64.)).sin(), + Curve::Tube => { + if u >= 0. { + u.tanh() + } else { + 0.6 * (u / 0.6).tanh() + } + } + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Settings { + pub source: Source, + /// Gain into the shaper after level normalisation. + pub drive: f32, + pub curve: Curve, + /// Asymmetry in percent: shifts the curve's operating point, adding even harmonics. + pub bias: f32, + /// How far the input level is normalised per note before the shaper, in percent. At 100 % + /// a quiet patch and a loud patch are driven the same. + pub normalize: f32, + /// Highpass in front of the shaper at this multiple of the fundamental; 0 is off. + pub floor: f32, + /// Harmonic that `push_gain` boosts or cuts before the shaper. + pub push_harmonic: u32, + pub push_gain: f32, + /// Lowpass after the shaper at this harmonic number; 0 is off. + pub bright: f32, + /// Matches output loudness to the input, so Drive changes character, not level. + pub auto_gain: bool, + /// Detection threshold in dBFS for note starts (Audio source). + pub gate: f32, + pub mix: f32, + pub output: f32, + pub bypass: bool, +} +impl Default for Settings { + fn default() -> Self { + Self { + source: Source::Audio, + drive: 12., + curve: Curve::Soft, + bias: 0., + normalize: 100., + floor: 0., + push_harmonic: 3, + push_gain: 0., + bright: 0., + auto_gain: true, + gate: -42., + mix: 100., + output: 0., + bypass: false, + } + } +} +impl Settings { + pub fn validate(&self) -> Result<(), &'static str> { + let finite = [ + self.drive, + self.bias, + self.normalize, + self.floor, + self.push_gain, + self.bright, + self.gate, + self.mix, + self.output, + ] + .iter() + .all(|v| v.is_finite()); + if !finite { + return Err("non-finite setting"); + } + let ok = (0.0..=36.).contains(&self.drive) + && (0.0..=100.).contains(&self.bias) + && (0.0..=100.).contains(&self.normalize) + && (self.floor == 0. || (0.25..=2.).contains(&self.floor)) + && (1..=16).contains(&self.push_harmonic) + && (-12.0..=12.).contains(&self.push_gain) + && (self.bright == 0. || (4.0..=64.).contains(&self.bright)) + && (-60.0..=-20.).contains(&self.gate) + && (0.0..=100.).contains(&self.mix) + && (-12.0..=12.).contains(&self.output); + if ok { + Ok(()) + } else { + Err("setting out of range") + } + } +} diff --git a/plugins/temper/core/tests/dsp.rs b/plugins/temper/core/tests/dsp.rs new file mode 100644 index 0000000..d11e2f2 --- /dev/null +++ b/plugins/temper/core/tests/dsp.rs @@ -0,0 +1,244 @@ +use common_dsp::meter::HarmonicMeter; +use common_dsp::Saw; +use std::f32::consts::TAU; +use temper_core::{Curve, Engine, Settings, Source}; + +const RATE: f32 = 48000.; + +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) +} +fn saw(hz: f32, seconds: f32, amp: f32) -> Vec { + let mut s = Saw::new(RATE, hz); + (0..(seconds * RATE) as usize) + .map(|_| s.sample() * amp) + .collect() +} +fn render(e: &mut Engine, x: &[f32]) -> Vec { + x.iter().map(|&v| e.process_sample([v, v])[0]).collect() +} +fn harmonics(x: &[f32], hz: f32) -> [f32; 32] { + let mut m = HarmonicMeter::new(RATE, 85.); + for &v in x { + m.push(v, hz); + } + *m.levels() +} +/// Harmonics 1–n relative to the fundamental. +fn profile(x: &[f32], hz: f32, n: usize) -> Vec { + let h = harmonics(x, hz); + (0..n).map(|k| h[k] - h[0]).collect() +} +fn rms_db(x: &[f32]) -> f32 { + 10. * (x.iter().map(|v| v * v).sum::() / x.len() as f32).log10() +} + +#[test] +fn quiet_and_loud_patches_distort_the_same() { + let s = Settings { + drive: 18., + auto_gain: false, + ..Settings::default() + }; + let mut profiles = vec![]; + for amp in [0.5, 0.06] { + let (mut e, _) = engine(s); + let y = render(&mut e, &saw(55., 1.2, amp)); + profiles.push(profile(&y[y.len() / 2..], 55., 8)); + } + for k in 1..8 { + let d = profiles[0][k] - profiles[1][k]; + assert!(d.abs() < 1., "harmonic {}: {profiles:?}", k + 1); + } + // Without normalisation the quiet patch is barely distorted. + let s = Settings { normalize: 0., ..s }; + let (mut e, _) = engine(s); + let y = render(&mut e, &saw(55., 1.2, 0.06)); + let quiet = profile(&y[y.len() / 2..], 55., 8); + assert!( + (quiet[2] - profiles[0][2]).abs() > 2., + "{quiet:?} vs {profiles:?}" + ); +} + +#[test] +fn bright_follows_the_note() { + let s = Settings { + drive: 24., + curve: Curve::Hard, + bright: 8., + ..Settings::default() + }; + let mut profiles = vec![]; + for hz in [43.65, 87.31] { + let (mut e, _) = engine(s); + let y = render(&mut e, &saw(hz, 1.2, 0.4)); + profiles.push(profile(&y[y.len() / 2..], hz, 14)); + } + for k in 1..14 { + let d = profiles[0][k] - profiles[1][k]; + assert!(d.abs() < 1.5, "harmonic {}: {profiles:?}", k + 1); + } + // Above the cutoff the harmonics fall away on both notes. + assert!(profiles[0][12] < profiles[0][4] - 10.); +} + +/// Energy away from the harmonics of `hz`, relative to the harmonics, in dB. +fn alias_ratio(curve: Curve, drive: f32, hz: f32) -> f32 { + let s = Settings { + drive, + curve, + auto_gain: false, + ..Settings::default() + }; + let (mut e, _) = engine(s); + let x: Vec = (0..36000) + .map(|i| 0.3 * (TAU * hz * i as f32 / RATE).sin()) + .collect(); + let y = render(&mut e, &x); + let tail = &y[12000..]; + let n = tail.len(); + let window: Vec = (0..n) + .map(|i| 0.5 - 0.5 * (TAU * i as f32 / n as f32).cos()) + .collect(); + let (mut harmonic, mut other) = (0f64, 0f64); + for f in (20..23000).step_by(10) { + // Goertzel power at f. + let c = 2. * (TAU as f64 * f as f64 / RATE as f64).cos(); + let (mut s1, mut s2) = (0f64, 0f64); + for (v, w) in tail.iter().zip(&window) { + let s0 = (v * w) as f64 + c * s1 - s2; + s2 = s1; + s1 = s0; + } + let p = s1 * s1 + s2 * s2 - c * s1 * s2; + let near = (f as f32 / hz - (f as f32 / hz).round()).abs() * hz < 30.; + if near { + harmonic += p; + } else { + other += p; + } + } + (10. * (other / harmonic).log10()) as f32 +} + +#[test] +fn oversampling_keeps_aliasing_down_in_the_bass_range() { + // 2 kHz stands for the upper harmonics of a growl, 440 Hz for a high bass fundamental. + for curve in Curve::ALL { + let r = alias_ratio(curve, 24., 2000.); + assert!(r < -60., "{} 2 kHz: {r}", curve.name()); + let r = alias_ratio(curve, 36., 440.); + assert!(r < -55., "{} 440 Hz: {r}", curve.name()); + } +} + +#[test] +#[ignore] +fn print_alias_ratios() { + for curve in Curve::ALL { + for hz in [440., 2000., 5000.] { + for drive in [12., 24., 36.] { + println!( + "{} {hz} {drive}: {:.1}", + curve.name(), + alias_ratio(curve, drive, hz) + ); + } + } + } +} + +#[test] +fn auto_gain_matches_input_loudness() { + for drive in [6., 24., 36.] { + let s = Settings { + drive, + ..Settings::default() + }; + let (mut e, l) = engine(s); + let x = saw(55., 2., 0.3); + let y = render(&mut e, &x); + let d = rms_db(&y[y.len() / 2..]) - rms_db(&x[x.len() / 2 - l..x.len() - l]); + assert!(d.abs() < 1., "{drive}: {d}"); + } +} + +#[test] +fn zero_mix_is_the_delayed_dry_signal_and_latency_is_exact() { + let s = Settings { + mix: 0., + ..Settings::default() + }; + let (mut e, l) = engine(s); + let x = saw(55., 1., 0.5); + let y = render(&mut e, &x); + for i in RATE as usize / 2..x.len() { + assert_eq!(y[i], x[i - l], "{i}"); + } + // With the shaper nearly linear the wet path lines up with the dry one. + let s = Settings { + drive: 0., + normalize: 0., + curve: Curve::Soft, + auto_gain: false, + ..Settings::default() + }; + let (mut e, l) = engine(s); + let mut x = vec![0f32; 9600]; + x[100] = 0.01; + let y = render(&mut e, &x); + let at = y + .iter() + .enumerate() + .max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())) + .unwrap() + .0; + assert_eq!(at, 100 + l); +} + +#[test] +fn midi_source_tunes_filters_at_the_delayed_note_time() { + let s = Settings { + source: Source::Midi, + bright: 4., + drive: 18., + curve: Curve::Hard, + ..Settings::default() + }; + let (mut e, _) = engine(s); + e.note_on(29); + let y = render(&mut e, &saw(43.65, 1., 0.4)); + let p = profile(&y[y.len() / 2..], 43.65, 12); + assert!(p[10] < p[2] - 12., "{p:?}"); + let f = e.take_analyzer(); + assert_eq!(f.note, Some(29)); +} + +#[test] +fn settings_are_validated_and_serialise() { + let s = Settings::default(); + let json = serde_json::to_string(&s).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), s); + for bad in [ + Settings { floor: 0.1, ..s }, + Settings { bright: 2., ..s }, + Settings { + push_harmonic: 0, + ..s + }, + Settings { + drive: f32::NAN, + ..s + }, + ] { + assert!(bad.validate().is_err(), "{bad:?}"); + } + for c in Curve::ALL { + assert_eq!(c.shape(0.), 0.); + assert!(c.shape(100.).abs() <= 1.0001); + } +} diff --git a/plugins/temper/core/tests/realtime.rs b/plugins/temper/core/tests/realtime.rs new file mode 100644 index 0000000..3fc273d --- /dev/null +++ b/plugins/temper/core/tests/realtime.rs @@ -0,0 +1,71 @@ +use std::{ + alloc::{GlobalAlloc, Layout, System}, + cell::Cell, +}; +use temper_core::{Curve, Engine, Settings, Source}; + +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.curve = Curve::ALL[(i / 4800) % 4]; + settings.bright = if i % 9600 == 0 { 8. } else { 0. }; + settings.floor = if i % 9600 == 0 { 0. } else { 0.5 }; + settings.push_gain = ((i / 4800) % 5) as f32 - 2.; + settings.source = if i > 48000 { + Source::Midi + } else { + Source::Audio + }; + 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])); + 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/temper/plugin/Cargo.lock b/plugins/temper/plugin/Cargo.lock new file mode 100644 index 0000000..4eedc63 --- /dev/null +++ b/plugins/temper/plugin/Cargo.lock @@ -0,0 +1,2381 @@ +# 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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" +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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +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.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +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.5", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +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 = "temper" +version = "0.1.0" +dependencies = [ + "clap-sys", + "crossbeam-queue", + "nih_plug", + "nih_plug_egui", + "serde_json", + "temper-core", + "temper-ui", +] + +[[package]] +name = "temper-core" +version = "0.1.0" +dependencies = [ + "common-dsp", + "serde", +] + +[[package]] +name = "temper-ui" +version = "0.1.0" +dependencies = [ + "common-dsp", + "common-ui", + "egui", + "temper-core", +] + +[[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.18+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec6e78bd70db9420d8d212fa9dc97f116448a1ceff5d00eabb991d407e21396" +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.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" +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/temper/plugin/Cargo.toml b/plugins/temper/plugin/Cargo.toml new file mode 100644 index 0000000..067cf14 --- /dev/null +++ b/plugins/temper/plugin/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "temper" +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" } +temper-core = { path = "../core" } +temper-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/temper/plugin/src/lib.rs b/plugins/temper/plugin/src/lib.rs new file mode 100644 index 0000000..9eb35e0 --- /dev/null +++ b/plugins/temper/plugin/src/lib.rs @@ -0,0 +1,183 @@ +//! CLAP audio effect. All DSP state is owned by the processing thread. +mod params; +use crossbeam_queue::ArrayQueue; +use nih_plug::prelude::*; +use nih_plug_egui::{create_egui_editor, egui}; +use params::TemperParams; +use std::sync::Arc; +use temper_core::{AnalyzerFrame, Engine}; + +struct EditorData { + ui: temper_ui::State, + gestures: [bool; 9], +} +pub struct Temper { + params: Arc, + analyzer: Arc>, + engine: Option, + publish_count: usize, + publish_interval: usize, +} +impl Default for Temper { + fn default() -> Self { + Self { + params: Arc::new(TemperParams::default()), + analyzer: Arc::new(ArrayQueue::new(2)), + engine: None, + publish_count: 0, + publish_interval: 1600, + } + } +} +impl Plugin for Temper { + const NAME: &'static str = "Temper"; + 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 = false; + type SysExMessage = (); + type BackgroundTask = (); + fn params(&self) -> Arc { + self.params.clone() + } + fn editor(&mut self, _: AsyncExecutor) -> Option> { + let params = self.params.clone(); + let analyzer = self.analyzer.clone(); + create_egui_editor( + params.editor.clone(), + EditorData { + ui: temper_ui::State::default(), + gestures: [false; 9], + }, + |ctx, _| temper_ui::style(ctx), + move |ctx, setter, data| { + while let Some(frame) = analyzer.pop() { + data.ui.accept_frame(frame); + } + let before = params.settings(); + let mut edited = before; + nih_plug_egui::resizable_window::ResizableWindow::new("temper-editor") + .min_size(egui::vec2( + temper_ui::WIDTH as f32 - 80., + temper_ui::HEIGHT as f32 - 40., + )) + .show(ctx, ¶ms.editor, |ui| { + temper_ui::show(ui, &mut edited, &mut data.ui) + }); + params.apply( + setter, + &before, + &edited, + data.ui.gesture_active, + &mut data.gestures, + ); + // Meters follow the audio. + ctx.request_repaint_after(std::time::Duration::from_millis(40)); + }, + ) + } + 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); + 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 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); + for (c, value) in sample.into_iter().enumerate() { + *value = out[c]; + } + self.publish_count += 1; + if self.publish_count >= self.publish_interval { + self.analyzer.force_push(engine.take_analyzer()); + self.publish_count = 0; + } + } + // The lookahead delay, oversampling filters and tails must drain after input stops. + ProcessStatus::KeepAlive + } +} +impl ClapPlugin for Temper { + const CLAP_ID: &'static str = "io.storyboard.tracked-distortion"; + const CLAP_DESCRIPTION: Option<&'static str> = Some( + "Distortion whose drive and tone filters follow the note, so every note distorts the same", + ); + const CLAP_MANUAL_URL: Option<&'static str> = None; + const CLAP_SUPPORT_URL: Option<&'static str> = None; + const CLAP_FEATURES: &'static [ClapFeature] = &[ + ClapFeature::AudioEffect, + ClapFeature::Distortion, + ClapFeature::Stereo, + ClapFeature::Mono, + ]; +} +nih_export_clap!(Temper); diff --git a/plugins/temper/plugin/src/params.rs b/plugins/temper/plugin/src/params.rs new file mode 100644 index 0000000..245a955 --- /dev/null +++ b/plugins/temper/plugin/src/params.rs @@ -0,0 +1,207 @@ +use nih_plug::prelude::*; +use nih_plug_egui::EguiState; +use std::sync::Arc; +use temper_core::{Curve, Settings, Source}; + +#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)] +pub enum SourceParam { + #[id = "audio"] + Audio, + #[id = "midi"] + #[name = "MIDI"] + Midi, +} +#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)] +pub enum CurveParam { + #[id = "soft"] + Soft, + #[id = "hard"] + Hard, + #[id = "fold"] + Fold, + #[id = "tube"] + Tube, +} +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) +} +const FLOATS: usize = 9; + +#[derive(Params)] +pub struct TemperParams { + #[persist = "editor"] + pub editor: Arc, + #[id = "source"] + pub source: EnumParam, + #[id = "curve"] + pub curve: EnumParam, + #[id = "drive"] + pub drive: FloatParam, + #[id = "bias"] + pub bias: FloatParam, + #[id = "normalize"] + pub normalize: FloatParam, + #[id = "floor"] + pub floor: FloatParam, + #[id = "push"] + pub push_gain: FloatParam, + #[id = "bright"] + pub bright: FloatParam, + #[id = "gate"] + pub gate: FloatParam, + #[id = "mix"] + pub mix: FloatParam, + #[id = "output"] + pub output: FloatParam, + #[id = "push-at"] + pub push_harmonic: IntParam, + #[id = "auto-gain"] + pub auto_gain: BoolParam, + #[id = "bypass"] + pub bypass: BoolParam, +} +impl Default for TemperParams { + fn default() -> Self { + let d = Settings::default(); + Self { + editor: EguiState::from_size(temper_ui::WIDTH, temper_ui::HEIGHT), + source: EnumParam::new("Pitch source", SourceParam::Audio), + curve: EnumParam::new("Curve", CurveParam::Soft), + drive: linear("Drive", d.drive, 0., 36., " dB"), + bias: linear("Bias", d.bias, 0., 100., " %"), + normalize: linear("Normalize", d.normalize, 0., 100., " %"), + floor: linear("Floor", d.floor, 0., 2., " ×f0"), + push_gain: linear("Push", d.push_gain, -12., 12., " dB"), + bright: linear("Bright", d.bright, 0., 64., " ×f0"), + gate: linear("Gate", d.gate, -60., -20., " dB"), + mix: linear("Mix", d.mix, 0., 100., " %"), + output: linear("Output", d.output, -12., 12., " dB"), + push_harmonic: IntParam::new( + "Push at", + d.push_harmonic as i32, + IntRange::Linear { min: 1, max: 16 }, + ), + auto_gain: BoolParam::new("Auto gain", d.auto_gain), + bypass: BoolParam::new("Bypass", false).make_bypass(), + } + } +} +fn curve_to(c: CurveParam) -> Curve { + match c { + CurveParam::Soft => Curve::Soft, + CurveParam::Hard => Curve::Hard, + CurveParam::Fold => Curve::Fold, + CurveParam::Tube => Curve::Tube, + } +} +fn curve_from(c: Curve) -> CurveParam { + match c { + Curve::Soft => CurveParam::Soft, + Curve::Hard => CurveParam::Hard, + Curve::Fold => CurveParam::Fold, + Curve::Tube => CurveParam::Tube, + } +} +impl TemperParams { + pub fn settings(&self) -> Settings { + // Floor and Bright are continuous for automation; below their ranges they are off. + let floor = self.floor.value(); + let bright = self.bright.value(); + Settings { + source: match self.source.value() { + SourceParam::Audio => Source::Audio, + SourceParam::Midi => Source::Midi, + }, + curve: curve_to(self.curve.value()), + drive: self.drive.value(), + bias: self.bias.value(), + normalize: self.normalize.value(), + floor: if floor < 0.25 { 0. } else { floor }, + push_harmonic: self.push_harmonic.value() as u32, + push_gain: self.push_gain.value(), + bright: if bright < 4. { 0. } else { bright }, + auto_gain: self.auto_gain.value(), + gate: self.gate.value(), + mix: self.mix.value(), + output: self.output.value(), + bypass: self.bypass.value(), + } + } + fn floats(&self) -> [&FloatParam; FLOATS] { + [ + &self.drive, + &self.bias, + &self.normalize, + &self.floor, + &self.push_gain, + &self.bright, + &self.gate, + &self.mix, + &self.output, + ] + } + /// 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; FLOATS], + ) { + let values = [ + (old.drive, new.drive), + (old.bias, new.bias), + (old.normalize, new.normalize), + (old.floor, new.floor), + (old.push_gain, new.push_gain), + (old.bright, new.bright), + (old.gate, new.gate), + (old.mix, new.mix), + (old.output, new.output), + ]; + for (i, (p, (a, b))) in self.floats().into_iter().zip(values).enumerate() { + if a != b { + if !open[i] { + open[i] = true; + setter.begin_set_parameter(p); + } + setter.set_parameter(p, b); + } + if !dragging && open[i] { + setter.end_set_parameter(p); + open[i] = false; + } + } + if new.push_harmonic != old.push_harmonic { + setter.begin_set_parameter(&self.push_harmonic); + setter.set_parameter(&self.push_harmonic, new.push_harmonic as i32); + setter.end_set_parameter(&self.push_harmonic); + } + for (p, a, b) in [ + (&self.auto_gain, old.auto_gain, new.auto_gain), + (&self.bypass, old.bypass, new.bypass), + ] { + if a != b { + setter.begin_set_parameter(p); + setter.set_parameter(p, b); + setter.end_set_parameter(p); + } + } + if new.curve != old.curve { + setter.begin_set_parameter(&self.curve); + setter.set_parameter(&self.curve, curve_from(new.curve)); + setter.end_set_parameter(&self.curve); + } + if 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); + } + } +} diff --git a/plugins/temper/plugin/tests/host.rs b/plugins/temper/plugin/tests/host.rs new file mode 100644 index 0000000..4453ba3 --- /dev/null +++ b/plugins/temper/plugin/tests/host.rs @@ -0,0 +1,211 @@ +//! 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 = &temper::clap_entry; + assert!(entry.init.unwrap()(c"temper.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"Temper"); + assert_eq!( + CStr::from_ptr((*descriptor).id), + c"io.storyboard.tracked-distortion" + ); + 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("drive".into(), ParamValue::F32(24.)); + envelope + .params + .insert("curve".into(), ParamValue::String("fold".into())); + envelope.params.insert("push-at".into(), ParamValue::I32(5)); + envelope + .params + .insert("source".into(), ParamValue::String("midi".into())); + // Mix 0: the output must be the dry signal, delayed by the reported latency. + envelope.params.insert("mix".into(), ParamValue::F32(0.)); + 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() + ); + for rate in [44100., 48000., 96000.] { + assert!(p.activate.unwrap()(plugin, rate, 1, 512)); + let delay = (*latency).get.unwrap()(plugin) as usize; + // About 67 ms lookahead plus the oversampling filters. + assert!((delay as f64 / rate - 0.068).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/temper/ui/Cargo.toml b/plugins/temper/ui/Cargo.toml new file mode 100644 index 0000000..52f17d7 --- /dev/null +++ b/plugins/temper/ui/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "temper-ui" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +egui = "0.31.1" +temper-core = { path = "../core" } +common-ui = { path = "../../common/ui" } +common-dsp = { path = "../../common/dsp" } diff --git a/plugins/temper/ui/src/lib.rs b/plugins/temper/ui/src/lib.rs new file mode 100644 index 0000000..f13f2b0 --- /dev/null +++ b/plugins/temper/ui/src/lib.rs @@ -0,0 +1,413 @@ +//! Temper editor, shared by the CLAP wrapper and the headless renderer. +use common_dsp::db_to_gain; +use common_ui::*; +use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2}; +use temper_core::{AnalyzerFrame, Curve, Settings, Source, SHOWN}; + +pub use common_ui::style; +pub const WIDTH: u32 = 1040; +pub const HEIGHT: u32 = 650; + +pub struct State { + 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 { + analyzer: AnalyzerFrame::default(), + gesture_active: false, + help: DEFAULT_HELP.into(), + } + } +} +const DEFAULT_HELP: &str = + "Every note is driven the same: the input level is normalised per note and the tone filters follow the pitch."; +impl State { + pub fn accept_frame(&mut self, frame: AnalyzerFrame) { + let old = self.analyzer; + self.analyzer = frame; + for k in 0..SHOWN { + self.analyzer.input[k] = frame.input[k].max(old.input[k] - 3.); + self.analyzer.output[k] = frame.output[k].max(old.output[k] - 3.); + } + self.analyzer.shaper_db = frame.shaper_db.max(old.shaper_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); + }); +} + +const GRID: Color32 = Color32::from_rgb(24, 26, 30); +/// Input peak at which a normalised note reaches the shaper before Drive. +const NOMINAL: f32 = 0.35; + +fn transfer(ui: &mut egui::Ui, settings: &Settings, size: f32) { + let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + let p = ui.painter_at(rect); + p.rect_filled(rect, 2., SCREEN); + let c = rect.center(); + let half = rect.width() * 0.45; + for t in [-1., -0.5, 0.5, 1.] { + p.line_segment( + [ + Pos2::new(c.x + t * half, rect.top()), + Pos2::new(c.x + t * half, rect.bottom()), + ], + Stroke::new(1.0_f32, GRID), + ); + p.line_segment( + [ + Pos2::new(rect.left(), c.y - t * half), + Pos2::new(rect.right(), c.y - t * half), + ], + Stroke::new(1.0_f32, GRID), + ); + } + p.line_segment( + [Pos2::new(rect.left(), c.y), Pos2::new(rect.right(), c.y)], + Stroke::new(1.0_f32, EDGE), + ); + p.line_segment( + [Pos2::new(c.x, rect.top()), Pos2::new(c.x, rect.bottom())], + Stroke::new(1.0_f32, EDGE), + ); + p.line_segment( + [ + Pos2::new(c.x - half, c.y + half), + Pos2::new(c.x + half, c.y - half), + ], + Stroke::new(1.0_f32, DIM), + ); + // Input axis: a normalised note's peak at ±1; output: the shaper's ±1. + let gain = db_to_gain(settings.drive) * NOMINAL; + let bias = settings.bias * 0.008; + let offset = settings.curve.shape(bias); + let points: Vec = (0..=160) + .map(|i| { + let x = -1.1 + 2.2 * i as f32 / 160.; + let y = settings.curve.shape(x * gain + bias) - offset; + Pos2::new(c.x + x * half, c.y - y.clamp(-1.1, 1.1) * half) + }) + .collect(); + p.add(egui::Shape::line(points, Stroke::new(2.0_f32, ACCENT))); + p.text( + rect.left_top() + Vec2::new(8., 6.), + egui::Align2::LEFT_TOP, + "TRANSFER", + FontId::proportional(11.), + MUTED, + ); + p.rect_stroke( + rect, + 2., + Stroke::new(1.0_f32, EDGE), + egui::StrokeKind::Inside, + ); +} + +fn spectrum(ui: &mut egui::Ui, frame: &AnalyzerFrame, settings: &Settings, height: f32) { + let size = Vec2::new(ui.available_width(), height); + let (outer, response) = ui.allocate_exact_size(size, Sense::hover()); + let p = ui.painter_at(outer); + p.rect_filled(outer, 2., SCREEN); + let area = Rect::from_min_max( + outer.left_top() + Vec2::new(34., 24.), + outer.right_bottom() - Vec2::new(10., 24.), + ); + let floor = -72.; + let y = |db: f32| area.bottom() - area.height() * ((db - floor) / -floor).clamp(0., 1.); + for db in [0., -12., -24., -36., -48., -60.] { + let yy = y(db); + p.line_segment( + [Pos2::new(area.left(), yy), Pos2::new(area.right(), yy)], + Stroke::new(1.0_f32, GRID), + ); + p.text( + Pos2::new(outer.left() + 6., yy), + egui::Align2::LEFT_CENTER, + format!("{db:.0}"), + FontId::proportional(10.), + DIM, + ); + } + let col = area.width() / SHOWN as f32; + for k in 0..SHOWN { + let x0 = area.left() + col * k as f32; + for (i, (db, color)) in [(frame.input[k], STEEL), (frame.output[k], ACCENT)] + .into_iter() + .enumerate() + { + if db <= floor { + continue; + } + let w = col * 0.32; + let x = x0 + col * 0.16 + i as f32 * (w + col * 0.04); + p.rect_filled( + Rect::from_min_max(Pos2::new(x, y(db)), Pos2::new(x + w, area.bottom())), + 0., + color, + ); + } + let marked = (k + 1) as u32 == settings.push_harmonic && settings.push_gain != 0.; + p.text( + Pos2::new(x0 + col * 0.5, area.bottom() + 4.), + egui::Align2::CENTER_TOP, + format!("{}", k + 1), + FontId::proportional(10.), + if marked { ACCENT } else { MUTED }, + ); + } + // Tracked filter positions, in harmonic numbers. + let mark = |h: f32, label: &str| { + if h > 0. && h <= SHOWN as f32 + 0.5 { + let x = area.left() + col * (h - 0.5); + p.line_segment( + [Pos2::new(x, area.top()), Pos2::new(x, area.bottom())], + Stroke::new(1.0_f32, FOCUS), + ); + p.text( + Pos2::new(x + 4., area.top()), + egui::Align2::LEFT_TOP, + label, + FontId::proportional(10.), + FOCUS, + ); + } + }; + mark(settings.floor, "FLOOR"); + mark(settings.bright, "BRIGHT"); + p.text( + outer.left_top() + Vec2::new(34., 6.), + egui::Align2::LEFT_TOP, + "HARMONICS", + FontId::proportional(11.), + MUTED, + ); + p.text( + outer.right_top() + Vec2::new(-10., 6.), + egui::Align2::RIGHT_TOP, + "IN · OUT", + FontId::proportional(11.), + MUTED, + ); + p.rect_stroke( + outer, + 2., + Stroke::new(1.0_f32, EDGE), + egui::StrokeKind::Inside, + ); + let _ = response; +} + +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, "TEMPER", "TRACKED DISTORTION", |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(settings.auto_gain, RichText::new("AUTO GAIN").size(13.)) + .on_hover_text( + "Match output loudness to the input, so Drive changes character, not level.", + ); + if r.clicked() { + settings.auto_gain = !settings.auto_gain; + } + 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(210.); + ui.set_min_height(TOP); + ui.vertical(|ui| note_panel(ui, settings, &frame)); + }); + ui.add_space(4.); + panel(ui, |ui| { + ui.set_min_height(TOP); + ui.vertical(|ui| { + transfer(ui, settings, 236.); + ui.add_space(4.); + ui.horizontal(|ui| { + for curve in Curve::ALL { + let r = ui.selectable_label( + settings.curve == curve, + RichText::new(curve.name().to_uppercase()).size(12.), + ); + if r.clicked() { + settings.curve = curve; + } + hint(&r, state, match curve { + Curve::Soft => "Soft: tanh. Smooth, odd harmonics, rounded top.", + Curve::Hard => "Hard: clipping with a narrow knee. Dense, square-like.", + Curve::Fold => "Fold: the wave folds back on itself. Drive moves the upper harmonics.", + Curve::Tube => "Tube: the negative half clips earlier. Adds even harmonics.", + }); + } + }); + }); + }); + ui.add_space(4.); + panel(ui, |ui| { + ui.set_width(ui.available_width()); + spectrum(ui, &frame, settings, 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 = 290.; + +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)), + _ if frame.hz > 0. => ("—".into(), format!("holding {:.1} Hz", frame.hz)), + _ => ("—".into(), "no note yet".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(6.); + 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", + } + )) + .size(12.) + .color(MUTED), + ); + ui.add_space(14.); + caption(ui, "INTO SHAPER"); + let (rect, _) = ui.allocate_exact_size(Vec2::new(180., 10.), Sense::hover()); + let p = ui.painter(); + p.rect_filled(rect, 1., SCREEN); + // −24..+24 dB around the curve's knee at 0 dB. + let t = ((frame.shaper_db + 24.) / 48.).clamp(0., 1.); + let mut fill = rect; + fill.set_width(rect.width() * t); + p.rect_filled(fill, 1., ACCENT); + let knee = rect.left() + rect.width() * 0.5; + p.line_segment( + [ + Pos2::new(knee, rect.top() - 2.), + Pos2::new(knee, rect.bottom() + 2.), + ], + Stroke::new(1.0_f32, TEXT), + ); + p.rect_stroke( + rect, + 1., + Stroke::new(1.0_f32, EDGE), + egui::StrokeKind::Inside, + ); + ui.label( + RichText::new(if frame.shaper_db < -60. { + "—".to_string() + } else { + format!("{:+.1} dB over the knee", frame.shaper_db) + }) + .monospace() + .size(12.) + .color(TEXT), + ); + ui.add_space(8.); + readout( + ui, + "NORMALIZE GAIN", + format!("{:+.1} dB", frame.normalize_db), + STEEL, + ); + readout( + ui, + "AUTO GAIN", + if settings.auto_gain { + format!("{:+.1} dB", frame.auto_db) + } else { + "off".into() + }, + STEEL, + ); +} + +fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { + let d = Settings::default(); + ui.columns(10, |c| { + knob(&mut c[0], state, "Drive", &mut settings.drive, 0. ..=36., d.drive, " dB", 80., + "Gain into the curve. With Normalize at 100 % every note starts from the same level."); + knob(&mut c[1], state, "Bias", &mut settings.bias, 0. ..=100., d.bias, " %", 80., + "Shift the curve's operating point: asymmetric clipping, even harmonics."); + knob(&mut c[2], state, "Push", &mut settings.push_gain, -12. ..=12., d.push_gain, " dB", 80., + "Boost or cut one harmonic before the curve, so the distortion bites there on every note."); + knob(&mut c[3], state, "Mix", &mut settings.mix, 0. ..=100., d.mix, " %", 80., + "Blend with the delayed dry signal."); + let mut push = settings.push_harmonic as f32; + knob(&mut c[4], state, "Push at", &mut push, 1. ..=16., d.push_harmonic as f32, "", 54., + "Harmonic number that Push boosts or cuts."); + settings.push_harmonic = push.round().clamp(1., 16.) as u32; + knob(&mut c[5], state, "Floor", &mut settings.floor, 0. ..=2., d.floor, " ×f0", 54., + "Highpass before the curve at this multiple of the note. 0 is off. Keeps sub mud out of the distortion."); + if settings.floor < 0.25 { + settings.floor = 0.; + } + knob(&mut c[6], state, "Bright", &mut settings.bright, 0. ..=64., d.bright, " ×f0", 54., + "Lowpass after the curve at this harmonic. 0 is off. The top end stops at the same harmonic on every note."); + if settings.bright < 4. { + settings.bright = 0.; + } + knob(&mut c[7], state, "Normalize", &mut settings.normalize, 0. ..=100., d.normalize, " %", 54., + "How far each note's level is evened out before Drive. 100 %: quiet and loud patches distort the same."); + knob(&mut c[8], state, "Gate", &mut settings.gate, -60. ..=-20., d.gate, " dB", 54., + "Input level that counts as a note. Raise it if tails retrigger."); + knob(&mut c[9], state, "Output", &mut settings.output, -12. ..=12., d.output, " dB", 54., + "Output trim."); + }); +} diff --git a/tools/editor-check/Cargo.lock b/tools/editor-check/Cargo.lock index 059dd1b..ca570db 100644 --- a/tools/editor-check/Cargo.lock +++ b/tools/editor-check/Cargo.lock @@ -158,11 +158,15 @@ dependencies = [ "crucible-ui", "egui", "image", + "lathe-core", + "lathe-ui", "patina-core", "patina-ui", "rivet-app", "rivet-core", "rivet-midi", + "temper-core", + "temper-ui", ] [[package]] @@ -246,6 +250,24 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "lathe-core" +version = "0.1.0" +dependencies = [ + "common-dsp", + "serde", +] + +[[package]] +name = "lathe-ui" +version = "0.1.0" +dependencies = [ + "common-dsp", + "common-ui", + "egui", + "lathe-core", +] + [[package]] name = "libc" version = "0.2.191" @@ -574,6 +596,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "temper-core" +version = "0.1.0" +dependencies = [ + "common-dsp", + "serde", +] + +[[package]] +name = "temper-ui" +version = "0.1.0" +dependencies = [ + "common-dsp", + "common-ui", + "egui", + "temper-core", +] + [[package]] name = "transpose" version = "0.2.3" diff --git a/tools/editor-check/Cargo.toml b/tools/editor-check/Cargo.toml index ab0eeae..fb9cc68 100644 --- a/tools/editor-check/Cargo.toml +++ b/tools/editor-check/Cargo.toml @@ -19,3 +19,7 @@ anvil-ui = { path = "../../plugins/anvil/ui" } common-dsp = { path = "../../plugins/common/dsp" } crucible-core = { path = "../../plugins/crucible/core" } crucible-ui = { path = "../../plugins/crucible/ui" } +lathe-core = { path = "../../plugins/lathe/core" } +lathe-ui = { path = "../../plugins/lathe/ui" } +temper-core = { path = "../../plugins/temper/core" } +temper-ui = { path = "../../plugins/temper/ui" } diff --git a/tools/editor-check/src/main.rs b/tools/editor-check/src/main.rs index 94065dc..7a41eb6 100644 --- a/tools/editor-check/src/main.rs +++ b/tools/editor-check/src/main.rs @@ -10,15 +10,21 @@ fn main() { .find(|a| a[0] == "--plugin") .map(|a| a[1].as_str()) .unwrap_or("rivet"); - if !["rivet", "patina", "anvil", "crucible"].contains(&plugin) { - eprintln!("Use --plugin rivet, patina, anvil or crucible"); + if !["rivet", "patina", "anvil", "crucible", "lathe", "temper"].contains(&plugin) { + eprintln!("Use --plugin rivet, patina, anvil, crucible, lathe or temper"); std::process::exit(2); } let patina = plugin == "patina" || args.iter().any(|arg| arg == "--resonance"); let compact = std::env::args().any(|arg| arg == "--compact"); let anvil = plugin == "anvil"; let crucible = plugin == "crucible"; - let (width, height) = if crucible { + let lathe = plugin == "lathe"; + let temper = plugin == "temper"; + let (width, height) = if temper { + (temper_ui::WIDTH, temper_ui::HEIGHT) + } else if lathe { + (lathe_ui::WIDTH, lathe_ui::HEIGHT) + } else if crucible { (crucible_ui::WIDTH, crucible_ui::HEIGHT) } else if anvil { (anvil_ui::WIDTH, anvil_ui::HEIGHT) @@ -85,6 +91,54 @@ fn main() { } anvil_state.accept_frame(engine.take_analyzer()); } + let mut lathe_settings = lathe_core::Settings::default(); + let mut lathe_state = lathe_ui::State::default(); + if lathe { + // A growl-like map: cut the hollow region, lift the 5th, darker top. + lathe_settings.gains[1] = -4.; + lathe_settings.gains[2] = -12.; + lathe_settings.gains[4] = 6.; + lathe_settings.gains[6] = 3.; + lathe_settings.gains[10] = -8.; + lathe_settings.gains[11] = -8.; + lathe_settings.tilt = -1.5; + lathe_settings.between = -6.; + let mut engine = lathe_core::Engine::new(48000., 2).unwrap(); + engine.set_settings(lathe_settings).unwrap(); + let mut saw = common_dsp::Saw::new(48000., 43.65); + for i in 0..36000 { + let x = (saw.sample() * 1.6).tanh() * 0.4; + engine.process_sample([x, x]); + if i % 1600 == 0 { + lathe_state.accept_frame(engine.take_analyzer()); + } + } + lathe_state.accept_frame(engine.take_analyzer()); + } + let mut temper_settings = temper_core::Settings { + drive: 20., + curve: temper_core::Curve::Hard, + bias: 20., + floor: 0.5, + push_harmonic: 5, + push_gain: 6., + bright: 12., + ..Default::default() + }; + let mut temper_state = temper_ui::State::default(); + if temper { + let mut engine = temper_core::Engine::new(48000., 2).unwrap(); + engine.set_settings(temper_settings).unwrap(); + let mut saw = common_dsp::Saw::new(48000., 43.65); + for i in 0..36000 { + let x = saw.sample() * 0.3; + engine.process_sample([x, x]); + if i % 1600 == 0 { + temper_state.accept_frame(engine.take_analyzer()); + } + } + temper_state.accept_frame(engine.take_analyzer()); + } let mut crucible_settings = crucible_core::Settings { folder: "C:\\Users\\you\\Documents\\Crucible".into(), ..Default::default() @@ -173,7 +227,11 @@ fn main() { let session_motif = session.motifs[0].id; shared.replace(session).unwrap(); let ctx = egui::Context::default(); - if crucible { + if temper { + temper_ui::style(&ctx); + } else if lathe { + lathe_ui::style(&ctx); + } else if crucible { crucible_ui::style(&ctx); } else if anvil { anvil_ui::style(&ctx); @@ -202,7 +260,11 @@ fn main() { }, |ui| { egui::CentralPanel::default().show(ui, |ui| { - if crucible { + if temper { + temper_ui::show(ui, &mut temper_settings, &mut temper_state); + } else if lathe { + lathe_ui::show(ui, &mut lathe_settings, &mut lathe_state); + } else if crucible { crucible_ui::show( ui, &mut crucible_settings,