Compare commits

...
Author SHA1 Message Date
nvrl b3ebe4d952 feat: add Lathe harmonic EQ and Temper tracked distortion
Check and Windows preview / check (anvil) (push) Successful in 2m8s
Check and Windows preview / check (crucible) (push) Successful in 1m32s
Check and Windows preview / check (lathe) (push) Successful in 2m13s
Check and Windows preview / check (patina) (push) Successful in 2m5s
Check and Windows preview / check (rivet) (push) Successful in 1m34s
Check and Windows preview / check (temper) (push) Successful in 2m12s
Check and Windows preview / previews (push) Successful in 1m19s
Check and Windows preview / windows (push) Successful in 2m13s
Independent plugin release / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 6s
Independent plugin release / build (push) Successful in 2m57s
Lathe places EQ bands on harmonics 1-32 of the played note, so a cut on
harmonic 3 stays on harmonic 3 on every note. Temper normalises each
note's level before an oversampled shaper and tunes its floor, push and
bright filters to the note, so every note distorts the same.

The note tracker moves from Anvil into common-dsp, together with a
state-variable filter, 4x oversampler and harmonic meter. Anvil's
behaviour and editor render are unchanged.

docs/ideas.md collects all plugin ideas and their status.
2026-11-06 06:08:51 +01:00
48 changed files with 9862 additions and 339 deletions

No files matched your search

+5 -1
View File
@@ -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:
+1 -1
View File
@@ -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
Generated
+38
View File
@@ -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"
+10 -2
View File
@@ -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
+24 -8
View File
@@ -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/<plugin>/ 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
```
+6
View File
@@ -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.
+101
View File
@@ -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.
+13
View File
@@ -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.
+66
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

+14
View File
@@ -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.
+66
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

+90 -322
View File
@@ -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<f32>,
ring_at: usize,
scratch: Vec<f32>,
yin: Yin,
hop: usize,
hop_count: usize,
gate_env: f32,
gate_release: f32,
gate_open: bool,
gate_hold: usize,
clock: u64,
events: [Event; EVENTS],
event_head: usize,
event_len: usize,
/// Output-time note state.
sounding: bool,
pending: bool,
tracked: f32,
candidate: Option<(f32, u8)>,
voice: Voice,
old: Voice,
old_gain: f32,
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<f32> {
let n = self.ring.len();
for i in 0..n {
self.scratch[i] = self.ring[(self.ring_at + i) % n];
}
let (hz, clarity) = self
.yin
.estimate(&self.scratch, self.detect_rate, MIN_HZ, MAX_HZ)?;
(clarity >= CLARITY).then(|| hz_to_midi(hz))
}
fn start(&mut self, pitch: f32, glide: bool) {
let s = self.settings;
let note = if s.snap { pitch.round() } else { pitch };
let hz = midi_to_hz(note + 12. * s.octave as f32);
self.tracked = pitch;
self.candidate = None;
self.pending = false;
self.sounding = true;
let audible = self.voice.amp > 1e-4 && !self.voice.releasing;
if glide && audible {
self.voice.target = hz;
return;
}
if s.retrigger || !audible {
if self.voice.amp > 1e-4 {
// Restarting the phase mid-cycle would click; the old cycle fades out instead.
self.old = self.voice;
self.old_gain = 1.;
}
self.voice = Voice {
phase: s.phase / 360.,
hz,
target: hz,
amp: 0.,
releasing: false,
};
} else {
self.voice.target = hz;
self.voice.hz = hz;
}
self.note_peak = 0.;
}
fn stop(&mut self) {
self.voice.releasing = true;
self.sounding = false;
self.pending = false;
self.candidate = None;
}
fn handle(&mut self, kind: Kind) {
let midi = self.settings.source == Source::Midi;
match kind {
Kind::Onset if !midi => match self.estimate() {
Some(p) => self.start(p, false),
None => {
self.pending = true;
self.sounding = true;
}
},
Kind::Release if !midi => self.stop(),
Kind::NoteOn(n) if midi => {
let len = self.held_len;
if let Some(i) = self.held[..len].iter().position(|h| *h == n) {
self.held.copy_within(i + 1..len, i);
self.held_len -= 1;
}
if self.held_len == self.held.len() {
self.held.copy_within(1.., 0);
self.held_len -= 1;
}
let legato = self.held_len > 0;
self.held[self.held_len] = n;
self.held_len += 1;
self.start(n as f32, legato && self.settings.glide > 0.);
}
Kind::NoteOff(n) if midi => {
let len = self.held_len;
let Some(i) = self.held[..len].iter().position(|h| *h == n) else {
return;
};
let top = i + 1 == len;
self.held.copy_within(i + 1..len, i);
self.held_len -= 1;
if self.held_len == 0 {
self.stop();
} else if top {
let next = self.held[self.held_len - 1];
self.start(next as f32, self.settings.glide > 0.);
}
}
_ => {}
}
}
/// Regular analysis; its window starts at the current output time.
fn hop_analysis(&mut self) {
if self.settings.source != Source::Audio || !self.sounding {
return;
}
let Some(p) = self.estimate() else {
return;
};
if self.pending {
self.start(p, false);
return;
}
if (p - self.tracked).abs() >= JUMP {
match self.candidate {
Some((c, count)) if (c - p).abs() < 0.5 => {
if count + 1 >= 2 {
self.start(p, self.settings.glide > 0.);
} else {
self.candidate = Some((p, count + 1));
}
}
_ => self.candidate = Some((p, 1)),
}
} else {
self.candidate = None;
self.tracked += 0.3 * (p - self.tracked);
if !self.settings.snap {
self.voice.target = midi_to_hz(self.tracked + 12. * self.settings.octave as f32);
}
}
}
fn detect(&mut self, x: [f32; 2]) {
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
x[0]
};
let d = self.detect[0].process(mono);
let d = self.detect[1].process(d);
if self.settings.source == Source::Audio {
let a = d.abs();
self.gate_env = if a > self.gate_env {
a
} else {
self.gate_env * self.gate_release
};
let level = gain_to_db(self.gate_env);
if level > self.settings.gate {
self.gate_hold = (self.rate * GATE_HOLD_MS * 0.001) as usize;
if !self.gate_open {
self.gate_open = true;
self.push(Kind::Onset);
}
} else if self.gate_open && level < self.settings.gate - GATE_HYSTERESIS_DB {
if self.gate_hold == 0 {
self.gate_open = false;
self.push(Kind::Release);
} else {
self.gate_hold -= 1;
}
}
}
self.decimate_count += 1;
if self.decimate_count >= self.decimate {
self.decimate_count = 0;
self.ring[self.ring_at] = d;
self.ring_at = (self.ring_at + 1) % self.ring.len();
self.hop_count += 1;
if self.hop_count >= self.hop {
self.hop_count = 0;
self.hop_analysis();
}
}
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;
+34
View File
@@ -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"
]
}
}
+4
View File
@@ -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;
+91
View File
@@ -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<f32>,
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];
}
}
+131
View File
@@ -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<f32> {
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<f32> = (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<f32>,
buf: Vec<f32>,
at: usize,
}
impl Fir {
fn new(taps: Vec<f32>) -> 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];
}
}
+106
View File
@@ -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();
}
}
+399
View File
@@ -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<f32>,
ring_at: usize,
scratch: Vec<f32>,
yin: Yin,
hop: usize,
hop_count: usize,
gate_env: f32,
gate_release: f32,
gate_open: bool,
gate_hold: usize,
clock: u64,
events: [Event; EVENTS],
event_head: usize,
event_len: usize,
/// Output-time note state.
sounding: bool,
pending: bool,
tracked: f32,
candidate: Option<(f32, u8)>,
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<f32> {
let n = self.ring.len();
for i in 0..n {
self.scratch[i] = self.ring[(self.ring_at + i) % n];
}
let (hz, clarity) = self.yin.estimate(
&self.scratch,
self.detect_rate,
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
}
}
+225
View File
@@ -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<f32> = (0..9600).map(|_| saw.sample() * 0.5).collect();
let y: Vec<f32> = 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),
]
);
}
+12
View File
@@ -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 }
+258
View File
@@ -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<u8>,
/// 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<Self, &'static str> {
if !(8000.0..=768000.).contains(&rate) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let 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
}
}
+106
View File
@@ -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.)
}
}
+139
View File
@@ -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<f32> {
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<f32> {
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<f32> = (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::<Settings>(&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);
}
+69
View File
@@ -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<bool>=const {Cell::new(false)};
static OPERATIONS:Cell<usize>=const {Cell::new(0)};
}
fn record() {
if CHECK.try_with(Cell::get).unwrap_or(false) {
let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1));
}
}
unsafe impl GlobalAlloc for CheckedAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
record();
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
record();
unsafe { System.dealloc(pointer, layout) }
}
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
record();
unsafe { System.realloc(pointer, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CheckedAllocator = CheckedAllocator;
#[test]
fn processing_notes_settings_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings::default();
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..96000 {
if i % 4800 == 0 {
settings.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);
}
+2381
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -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"
+189
View File
@@ -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<LatheParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
engine: Option<Engine>,
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<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
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(&current);
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, &params.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<Self>,
) -> bool {
let Ok(mut engine) = Engine::new(
config.sample_rate,
layout.main_input_channels.map_or(0, |v| v.get() as usize),
) else {
return false;
};
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<Self>,
) -> 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);
+186
View File
@@ -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<EguiState>,
/// 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<SourceParam>,
#[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);
}
}
}
+219
View File
@@ -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<u8>) };
bytes.extend_from_slice(unsafe {
std::slice::from_raw_parts(buffer as *const u8, size as usize)
});
size as i64
}
unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
// SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage.
let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor<Vec<u8>>) };
cursor
.read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) })
.unwrap() as i64
}
#[test]
fn effect_ports_state_latency_and_audio_lifecycle() {
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
unsafe {
let entry = &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<u8> as *mut c_void,
write: Some(write),
};
assert!(state.save.unwrap()(plugin, &stream));
bytes
};
let mut envelope: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
use nih_plug::wrapper::state::ParamValue;
envelope.params.insert("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<Vec<u8>> as *mut c_void,
read: Some(read),
};
assert!(state.load.unwrap()(plugin, &stream));
let restored: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
assert_eq!(
serde_json::to_value(&restored.params).unwrap(),
serde_json::to_value(&envelope.params).unwrap()
);
let 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()();
}
}
+11
View File
@@ -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" }
+431
View File
@@ -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<f32>,
default: f32,
unit: &str,
size: f32,
help: &str,
) {
ui.vertical_centered(|ui| {
let r = rotary(ui, name, value, range, default, unit, size, ACCENT);
hint(&r, state, help);
});
}
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.");
});
}
+12
View File
@@ -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 }
+405
View File
@@ -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<u8>,
/// 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<Self, &'static str> {
if !(8000.0..=768000.).contains(&rate) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let 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
}
}
+138
View File
@@ -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")
}
}
}
+244
View File
@@ -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<f32> {
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<f32> {
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<f32> {
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::<f32>() / 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<f32> = (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<f32> = (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::<Settings>(&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);
}
}
+71
View File
@@ -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<bool>=const {Cell::new(false)};
static OPERATIONS:Cell<usize>=const {Cell::new(0)};
}
fn record() {
if CHECK.try_with(Cell::get).unwrap_or(false) {
let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1));
}
}
unsafe impl GlobalAlloc for CheckedAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
record();
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
record();
unsafe { System.dealloc(pointer, layout) }
}
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
record();
unsafe { System.realloc(pointer, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CheckedAllocator = CheckedAllocator;
#[test]
fn processing_notes_settings_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings::default();
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..96000 {
if i % 4800 == 0 {
settings.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);
}
+2381
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -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"
+183
View File
@@ -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<TemperParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
engine: Option<Engine>,
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<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
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, &params.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<Self>,
) -> bool {
let Ok(mut engine) = Engine::new(
config.sample_rate,
layout.main_input_channels.map_or(0, |v| v.get() as usize),
) else {
return false;
};
if engine.set_settings(self.params.settings()).is_err() {
return false;
}
context.set_latency_samples(engine.latency_samples());
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
true
}
fn reset(&mut self) {
if let Some(e) = self.engine.as_mut() {
e.reset();
}
self.publish_count = 0;
}
fn process(
&mut self,
buffer: &mut Buffer,
_: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let _ = engine.set_settings(self.params.settings());
let 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);
+207
View File
@@ -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<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[id = "curve"]
pub curve: EnumParam<CurveParam>,
#[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);
}
}
}
+211
View File
@@ -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<u8>) };
bytes.extend_from_slice(unsafe {
std::slice::from_raw_parts(buffer as *const u8, size as usize)
});
size as i64
}
unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
// SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage.
let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor<Vec<u8>>) };
cursor
.read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) })
.unwrap() as i64
}
#[test]
fn effect_ports_state_latency_and_audio_lifecycle() {
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
unsafe {
let entry = &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<u8> as *mut c_void,
write: Some(write),
};
assert!(state.save.unwrap()(plugin, &stream));
bytes
};
let mut envelope: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
use nih_plug::wrapper::state::ParamValue;
envelope.params.insert("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<Vec<u8>> as *mut c_void,
read: Some(read),
};
assert!(state.load.unwrap()(plugin, &stream));
let restored: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
assert_eq!(
serde_json::to_value(&restored.params).unwrap(),
serde_json::to_value(&envelope.params).unwrap()
);
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()();
}
}
+11
View File
@@ -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" }
+413
View File
@@ -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<f32>,
default: f32,
unit: &str,
size: f32,
help: &str,
) {
ui.vertical_centered(|ui| {
let r = rotary(ui, name, value, range, default, unit, size, ACCENT);
hint(&r, state, help);
});
}
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<Pos2> = (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.");
});
}
+40
View File
@@ -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"
+4
View File
@@ -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" }
+67 -5
View File
@@ -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,