Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3ebe4d952 |
No files matched your search
@@ -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,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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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 |
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Generated
+2381
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
@@ -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(¤t);
|
||||
let mut edited = before;
|
||||
nih_plug_egui::resizable_window::ResizableWindow::new("lathe-editor")
|
||||
.min_size(egui::vec2(
|
||||
lathe_ui::WIDTH as f32 - 80.,
|
||||
lathe_ui::HEIGHT as f32 - 40.,
|
||||
))
|
||||
.show(ctx, ¶ms.editor, |ui| {
|
||||
lathe_ui::show(ui, &mut edited, &mut data.ui)
|
||||
});
|
||||
params.apply(
|
||||
setter,
|
||||
&before,
|
||||
&edited,
|
||||
data.ui.gesture_active,
|
||||
&mut data.gestures,
|
||||
);
|
||||
// Harmonic levels follow the audio.
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(40));
|
||||
},
|
||||
)
|
||||
}
|
||||
fn initialize(
|
||||
&mut self,
|
||||
layout: &AudioIOLayout,
|
||||
config: &BufferConfig,
|
||||
context: &mut impl InitContext<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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()();
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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.");
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Generated
+2381
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
@@ -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, ¶ms.editor, |ui| {
|
||||
temper_ui::show(ui, &mut edited, &mut data.ui)
|
||||
});
|
||||
params.apply(
|
||||
setter,
|
||||
&before,
|
||||
&edited,
|
||||
data.ui.gesture_active,
|
||||
&mut data.gestures,
|
||||
);
|
||||
// Meters follow the audio.
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(40));
|
||||
},
|
||||
)
|
||||
}
|
||||
fn initialize(
|
||||
&mut self,
|
||||
layout: &AudioIOLayout,
|
||||
config: &BufferConfig,
|
||||
context: &mut impl InitContext<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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()();
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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.");
|
||||
});
|
||||
}
|
||||
Generated
+40
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user