Compare commits

...
7 Commits
Author SHA1 Message Date
nvrl b8d04d1936 chore: release Anvil 0.4.0, Lathe/Temper/Knurl 0.3.0, Planish/Braze/Ballast/Forge 0.2.0, Crucible/Weld 0.4.0
Check and Windows preview / check (anvil) (push) Successful in 2m37s
Check and Windows preview / check (ballast) (push) Successful in 1m49s
Check and Windows preview / check (braze) (push) Successful in 1m48s
Check and Windows preview / check (crucible) (push) Successful in 2m8s
Check and Windows preview / check (forge) (push) Successful in 2m9s
Check and Windows preview / check (knurl) (push) Successful in 1m53s
Check and Windows preview / check (lathe) (push) Successful in 1m58s
Check and Windows preview / check (patina) (push) Successful in 3m34s
Check and Windows preview / check (planish) (push) Successful in 1m59s
Check and Windows preview / check (quench) (push) Successful in 1m51s
Check and Windows preview / check (rivet) (push) Successful in 1m50s
Check and Windows preview / check (temper) (push) Successful in 1m58s
Check and Windows preview / check (weld) (push) Successful in 3m16s
Check and Windows preview / previews (push) Successful in 2m17s
Check and Windows preview / windows (push) Successful in 3m14s
Independent plugin release / publish (push) Successful in 10s
Independent plugin release / plan (push) Successful in 8s
Independent plugin release / build (push) Successful in 3m50s
The producer-feedback round: stable note tracking, the NOTE pitch source with
Key lock and Follow bends, unsure-means-neutral fallbacks, Anvil's Enhance
mode, MAIN and ADVANCED pages, Ballast on Grid by default, and Crucible's
sure-note slice names with a song key.

Adds docs/pitch.md for the shared pitch source, updates every changed
plugin's README and changelog, the release table, the editor previews, the
website bullets, and the status in docs/ideas.md.
2026-11-06 21:26:59 +01:00
nvrl 01fd3241eb feat(ui): MAIN and ADVANCED pages, Anvil main page around Mode, Split and Sub level
Producers found the panels crowded: every control had the same weight. The
header now carries a MAIN / ADVANCED switch (common-ui header_paged). The
main page keeps the controls a first result needs; the advanced page shows
everything, as before.

- Anvil: Mode (Replace or Enhance), Split and Sub level, plus Octave, Ghost,
  Keep and Output. Kick lock, Dynamics, Drive, Glide, Phase, Gate and the
  pitch switches move to Advanced. The note readout says when the original
  sub plays because tracking is unsure.
- Lathe, Temper, Knurl, Planish, Braze: tracking and fine-tuning controls
  (Gate, Smooth, Snap, Harmonics, Push at, Floor, Bright, Normalize, band
  gains, Phase) move to Advanced. Lathe labels the octaves of the note in Hz
  on the harmonic axis.
- Forge passes the page to its module pages.
- Weld: the main page shows the level won back at full height, Mode,
  Response and Output; the phase plot, Sections, Low, High and Gate move to
  Advanced.
- editor-check renders the advanced pages with --advanced.
2026-11-06 21:16:11 +01:00
nvrl d6939a753a feat(crucible): name slices only by a sure note, spelled for the song key
A slice gets a note name only when at least 60 % of up to twelve reads
across its body sit on one note; growls, pitch drops and noise get no name
rather than a wrong one. An optional song key spells names with flats in
flat keys (Eb2, not D#2).
2026-11-06 21:05:31 +01:00
nvrl adfb2c0287 feat: stable note tracking, NOTE pitch source and unsure fallback
Producers found the tracked plugins jumpy: the shared tracker switched notes
after 10 ms and retuned every 5 ms, so growls and formant basses flickered
and every tracked plugin acted on wrong notes.

Shared tracker (common-dsp):
- A new note needs a steady read for 40 ms; an octave down 80 ms, an octave
  up 200 ms (an octave-up read still fits the note's period and counts as
  agreeing). Inside a note the tuning only moves past a 0.25 semitone
  deadband. Follow bends keeps the old fast behaviour as an option.
- A trust score turns the note unsure while reads disagree; Tracker::active
  and NoteState report it, and Fallback latches "step back" across note ends.
- Notes end when the window start stays below the gate's release level for
  20 ms, so short gaps between notes are seen without waiting for the gate.
- PitchSource replaces the old (midi, gate, glide) arguments: Audio, MIDI or
  Note mode (a set note, no detection), plus a key lock (minor, major, root
  only) that snaps reads to the key.
- stems.rs: synthetic stems (growls, wobble, reese, formant, 808 slide,
  layered drop, noise riser) and a score; examples/tracker_report scores the
  corpus or real WAV stems. On the corpus the old behaviour makes up to 28
  extra note starts per second; the stable default makes none.

Plugins:
- Anvil, Lathe, Temper, Knurl, Planish, Braze and Forge gain the NOTE pitch
  source, the key lock and Follow bends (shared common-params crate and a
  shared header switch in common-ui).
- Unsure tracking: Anvil brings the original sub back in place of the sine,
  Lathe passes the audio through, Knurl fades its texture out; Temper,
  Planish and Braze hold their last sure tuning. Readouts show the note only
  when it is sure.
- Anvil gains Mode: Replace or Enhance (keeps the original sub and adds the
  sine under it).
- Planish slides its band edges over 6 ms on a new note instead of snapping.
- Ballast defaults to Grid parts, which need no tracking.
2026-11-06 21:02:52 +01:00
nvrl 7c873f2bd0 docs(ideas): record producer feedback on note tracking and usability
Producers find most plugins confusing: the note readout flickers on real
basses, and Anvil did not do what they expected. Record the causes (one
shared fast-reacting tracker behind nine plugins, wrong guesses acted on,
DSP-named controls, hidden MIDI routing), principles for the next round and
an order of work.
2026-11-06 17:59:07 +01:00
nvrl d5bdd3e2c7 fix(website): spell plugin counts above ten in the hero
Check and Windows preview / check (anvil) (push) Successful in 50s
Check and Windows preview / check (ballast) (push) Successful in 48s
Check and Windows preview / check (braze) (push) Successful in 49s
Check and Windows preview / check (crucible) (push) Successful in 50s
Check and Windows preview / check (forge) (push) Successful in 55s
Check and Windows preview / check (knurl) (push) Successful in 44s
Check and Windows preview / check (lathe) (push) Successful in 44s
Check and Windows preview / check (patina) (push) Successful in 1m17s
Check and Windows preview / check (planish) (push) Successful in 44s
Check and Windows preview / check (quench) (push) Successful in 1m32s
Check and Windows preview / check (rivet) (push) Successful in 1m40s
Check and Windows preview / check (temper) (push) Successful in 1m38s
Check and Windows preview / check (weld) (push) Successful in 3m22s
Check and Windows preview / previews (push) Successful in 2m3s
Check and Windows preview / windows (push) Successful in 2m25s
PLUGINS grew to 13, past the end of the number-word list, so the count fell
back to a number and toUpperCase failed during the static build.
2026-11-06 17:51:19 +01:00
nvrl 6295c516f2 chore: release Forge 0.1.0
Check and Windows preview / check (anvil) (push) Canceled after 0s
Check and Windows preview / check (ballast) (push) Canceled after 0s
Check and Windows preview / check (braze) (push) Canceled after 0s
Check and Windows preview / check (crucible) (push) Canceled after 0s
Check and Windows preview / check (forge) (push) Canceled after 0s
Check and Windows preview / check (knurl) (push) Canceled after 0s
Check and Windows preview / check (lathe) (push) Canceled after 0s
Check and Windows preview / check (patina) (push) Canceled after 0s
Check and Windows preview / check (planish) (push) Canceled after 0s
Check and Windows preview / check (quench) (push) Canceled after 0s
Check and Windows preview / check (rivet) (push) Canceled after 0s
Check and Windows preview / check (temper) (push) Canceled after 0s
Check and Windows preview / check (weld) (push) Canceled after 0s
Check and Windows preview / previews (push) Canceled after 0s
Check and Windows preview / windows (push) Canceled after 0s
Independent plugin release / plan (push) Successful in 8s
Independent plugin release / build (push) Successful in 3m18s
Independent plugin release / publish (push) Successful in 10s
- Forge 0.1.0: first release.
- Mark Forge released in docs/ideas.md and add it to the release table.
2026-11-06 17:48:49 +01:00
111 changed files with 3483 additions and 593 deletions

No files matched your search

Generated
+4
View File
@@ -108,11 +108,15 @@ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "common-dsp"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "common-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"egui",
]
+1 -1
View File
@@ -1,7 +1,7 @@
[workspace]
resolver = "2"
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/weld/core", "plugins/weld/ui", "plugins/knurl/core", "plugins/knurl/ui", "plugins/planish/core", "plugins/planish/ui", "plugins/braze/core", "plugins/braze/ui", "plugins/quench/core", "plugins/quench/ui", "plugins/ballast/core", "plugins/ballast/ui", "plugins/forge/core", "plugins/forge/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", "plugins/weld/plugin", "plugins/knurl/plugin", "plugins/planish/plugin"]
exclude = ["plugins/common/params", "plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin", "plugins/anvil/plugin", "plugins/crucible/plugin", "plugins/lathe/plugin", "plugins/temper/plugin", "plugins/weld/plugin", "plugins/knurl/plugin", "plugins/planish/plugin"]
[workspace.package]
edition = "2021"
+15
View File
@@ -1,5 +1,20 @@
# Anvil changelog
## 0.4.0
- Add **Mode**: **Replace** swaps the sub as before; **Enhance** keeps the
original sub and adds the sine under it.
- Add **NOTE** as a pitch source: Anvil plays a note you set, and the audio only
starts and stops it. Add Key lock and Follow bends for AUDIO detection.
- Detection holds its notes: a new note needs a steady pitch for 40 ms, octave
jumps need 80 ms down and 200 ms up. Growls, wobbles and formant basses no
longer flicker between notes. Follow bends brings back the old fast behaviour.
- When detection is unsure, Anvil plays the original sub (Enhance: adds nothing)
instead of a guessed note, and the NOTE readout shows **?**.
- The editor opens on a MAIN page with Mode, Split, Sub level, Octave, Ghost,
Keep and Output. **ADVANCED** in the header shows every control and Kick lock.
- Projects from 0.3 load with Replace mode, AUDIO detection, and Key lock off.
## 0.3.0
- Add Ghost harmonics: **Ghost** and **Ghost from** add the sub's harmonics 2–10
+32 -15
View File
@@ -1,6 +1,6 @@
# Anvil
Anvil gives every bass note the same solid sub. Current version **0.3.0**.
Anvil gives every bass note the same solid sub. Current version **0.4.0**.
In riddim and dubstep, each switch-up patch brings its own sub: a different level,
a different phase, sometimes two layers that cancel. The drop's weight jumps from
@@ -20,18 +20,33 @@ redistributed packages.
## Work with it
The editor opens on the **MAIN** page with the controls most drops need. The
**ADVANCED** switch in the header shows every control and the Kick lock panel.
1. Put Anvil on the bass bus, after the sound design and before the bus
compression or clipping.
2. Set **Split** just above the sub. 80–100 Hz suits most riddim basses.
3. Set **Sub level** once. Every patch on the bus now gets this level.
4. Use **Phase** to line the start of each note up with your kick. With
**Retrigger** on, every note starts at this phase.
5. Turn on **Solo sub** to hear only the new sub; the scope shows the original
2. Choose the **Mode**:
- **Replace** swaps the band below Split for the clean sine. Every note gets
the same level and phase. Use it when switch-ups bring different subs.
- **Enhance** keeps your sub as it is and adds the sine under it. Use it when
you like your sub and want more weight, or with **Octave** −1 for a
sub-octave.
3. Set **Split** just above the sub. 80–100 Hz suits most riddim basses.
4. Set **Sub level** once. Every patch on the bus now gets this level.
5. In the header, set **Pitch from**. For growls and FM basses, choose **NOTE**
and set the drop's root. See [Pitch source](../pitch.md).
6. On the ADVANCED page, use **Phase** to line the start of each note up with
your kick. With **Retrigger** on, every note starts at this phase.
7. Turn on **Solo sub** to hear only the new sub; the scope shows the original
low band (grey) against the new sub (accent).
6. Turn on **Phone** to check the drop the way phones and laptop speakers play
8. Turn on **Phone** to check the drop the way phones and laptop speakers play
it: mono, nothing below 400 Hz. A clean sub vanishes there. If the note
disappears with it, raise **Ghost**.
When detection is unsure of the note, Anvil does not guess. In Replace mode it
fades back to your original sub within 15 ms, and in Enhance mode it adds
nothing. The NOTE readout shows **?** meanwhile.
### Ghost harmonics
Phone speakers play almost nothing below 300–500 Hz. A clean 50 Hz sub sends all
@@ -100,27 +115,29 @@ Other controls:
- **Glide** slides between overlapping notes; 0 jumps.
- **Gate** is the input level that counts as a note. Raise it if tails or reverb
start new notes.
- **Keep** blends some of the original low band back under the new sub.
- **Keep** blends some of the original low band back under the new sub. It has
no effect in Enhance mode, where the original always stays.
- **Snap to semitone** rounds detected pitch. Turn it off for pitch bends and slides.
If Knurl is on the bass before the bus, use **MIDI**: Knurl's patterns make
detection read notes from C2 upwards an octave low. See
[Signal chain](../chain.md).
### Pitch from MIDI
### Pitch source
Detection follows the audio by default. For heavy formant or FM basses where the
fundamental is unclear, select **MIDI** and route the bass's MIDI notes to Anvil.
In FL Studio, set the bass channel's MIDI output port and Anvil's input port to the
same number, or duplicate the notes into a pattern on a channel that feeds Anvil.
**Pitch from** in the header chooses where the note comes from: **AUDIO**
detects it, **MIDI** follows MIDI notes sent to Anvil, and **NOTE** plays a note
you set. Key lock and Follow bends tune the detection. See
[Pitch source](../pitch.md).
## Timing and limits
Detection uses 80 ms of lookahead, reported to the host for delay compensation,
so each note's pitch is known before its attack leaves the plugin. Pitch range is
25–250 Hz, one voice: chords and layered notes in the sub band are not separated.
Notes shorter than about 15 ms may not be recognised. A pitch move of more than
0.7 semitone that holds for two analysis hops (10 ms) starts a new note.
Notes shorter than about 40 ms may not be recognised. A new pitch must hold
steady for 40 ms to start a new note (80 ms an octave down, 200 ms an octave
up); with Follow bends on, 10 ms. A note ends after 20 ms of silence.
The crossover is a 4th-order Linkwitz–Riley pair, so with Keep at 100 % and no
note playing, the signal passes with flat magnitude. Stereo above the split is
Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

After

Width:  |  Height:  |  Size: 248 KiB

+8
View File
@@ -1,5 +1,13 @@
# Ballast changelog
## 0.2.0
- **Parts from** defaults to **Grid**, on quarter notes. Switch-ups land on the
grid, and the grid needs no note detection. Saved projects keep their choice.
- In Notes mode, detection holds its notes: a new pitch must hold for 40 ms to
start a new part, and a note ends after 20 ms of silence. Growls and wobbles
no longer split into many parts.
## 0.1.0
- Introduce Ballast, a switch-up leveller: every part of a bassline gets one
+9 -5
View File
@@ -1,7 +1,7 @@
# Ballast
Ballast levels every note or switch-up of a bassline to one loudness, with a
single gain for each. Current version **0.1.0**.
single gain for each. Current version **0.2.0**.
Riddim and dubstep basslines change patch every few beats. Patches at the same
peak level still differ in how loud they sound, so each switch-up lands at a
@@ -21,11 +21,15 @@ design. Keep the font license with redistributed packages.
## Work with it
1. Choose where a part starts with **Parts from**:
- **Grid** (the default): every line of the host's beat grid, from 1 bar to
1/16, quarter notes at first. Switch-ups land on the grid, and the grid
needs no note detection, so it also catches a patch change on the same
note.
- **Notes**: every note start, detected from the audio, or from MIDI notes
sent to the effect (**Notes from**: AUDIO or MIDI).
- **Grid**: every line of the host's beat grid, from 1 bar to 1/16. Use it
when a switch-up changes the patch on the same note: the note tracker does
not see a new note there.
sent to the effect (**Notes from**: AUDIO or MIDI). Use it for basslines
whose parts start off the grid. A note ends after 20 ms of silence, and a
new pitch must hold for 40 ms to start a new part, so growls and wobbles do
not split into many parts. See [Pitch source](../pitch.md).
2. Play the bassline and press **LEARN** just before the switch-up that sits
right in the mix. Its loudness becomes the **Target**. You can also set
Target by hand.
+12
View File
@@ -1,5 +1,17 @@
# Braze changelog
## 0.2.0
- Add **NOTE** as a pitch source: Braze follows a note you set, and the audio
only starts and stops it. Add Key lock and Follow bends for AUDIO detection.
- Detection holds its notes: a new note needs a steady pitch for 40 ms, octave
jumps need 80 ms down and 200 ms up. Growls, wobbles and formant basses no
longer flicker between notes. Follow bends brings back the old fast behaviour.
- When detection is unsure, Braze keeps the detune of the last sure note, and
the readout shows **?**.
- The editor opens on a MAIN page. **ADVANCED** in the header adds Phase, Gate
and Snap.
## 0.1.0
- Introduce Braze, a detuned copy of the bass whose beating stays on the tempo
+14 -3
View File
@@ -1,6 +1,6 @@
# Braze
Braze makes a reese's movement play in time. Current version **0.1.0**.
Braze makes a reese's movement play in time. Current version **0.2.0**.
Two layers detuned against each other beat: harmonic k of a note at f0 swells
and fades k·f0·(r − 1) times per second, where r is the detune ratio. With a
@@ -19,6 +19,9 @@ redistributed packages.
## Work with it
The editor opens on the **MAIN** page with Rate, harmonic, Depth, Low cut, Width
and Output. The **ADVANCED** switch in the header adds Phase, Gate and Snap.
1. Put Braze on a single bass layer: a plain saw, or a reese whose own detune
you have turned down.
2. Pick **Rate**: the note value one beat lasts. 1/8 at 140 BPM is 4.67 beats
@@ -29,7 +32,7 @@ redistributed packages.
quarter triplets.
4. Set **Depth**. At 100 % the beating goes down to full nulls; 50–70 % keeps
the note present.
5. Use **Phase** to move where the beat peaks. At 0° all harmonics peak
5. On the ADVANCED page, use **Phase** to move where the beat peaks. At 0° all harmonics peak
together on every beat of the chosen harmonic's cycle.
The display shows one bar: a row per harmonic with the level it gets from the
@@ -47,6 +50,14 @@ Other controls:
- **Snap to semitone** rounds the detected pitch. Leave it off on detuned or
bent notes: the copy jumps by one period of the pitch in use.
### Pitch source
**Pitch from** in the header chooses where the note comes from: **AUDIO**
detects it, **MIDI** follows MIDI notes sent to Braze, and **NOTE** uses a note
you set. For heavy FM, formant or noise basses, NOTE with the drop's root is the
safest choice. Key lock and Follow bends tune the detection. While detection is unsure of the note, Braze keeps the detune of the last sure note and the readout shows **?**. See
[Pitch source](../pitch.md).
### How the detune is made
The copy is the bass read from a delay line. Its delay falls by one period of
@@ -72,5 +83,5 @@ Noise, chords and the attack of a note are not periodic, so the copy's jumps
are heard there as a short blur. Before the first note, the copy assumes 55 Hz.
Braze changes the waveform in time, so pitch detection after it can be less
sure: put tracked plugins before Braze, or use MIDI. See
sure: put tracked plugins before Braze, or use MIDI or NOTE. See
[Signal chain](../chain.md).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 285 KiB

+8
View File
@@ -1,5 +1,13 @@
# Crucible changelog
## 0.4.0
- A slice is named by a note only when at least three in five pitch reads over
its body agree on it. Growls, slides and noise are saved without a note
instead of with a wrong one.
- Add the song **Key**: slice names use its spelling, such as Ab instead of G#.
Without a key, names use sharps as before.
## 0.3.0
- **Keep takes** (on by default) saves every capture's loop to a `takes`
+10 -4
View File
@@ -1,7 +1,7 @@
# Crucible
Crucible melts the bars you just played into ready-to-use samples. Current version
**0.3.0**.
**0.4.0**.
Resampling is the core loop of riddim sound design: bounce, open in Edison, chop,
trim, declick, normalise, find the key, name, save, load into a sampler. Crucible
@@ -33,7 +33,11 @@ Each export creates a folder named after the date, time and tempo, holding:
- `loop.wav`: the whole take, for Slicex or Edison;
- `s01_F1.wav`, `s02_G#1.wav`, …: one file per slice, named by its detected root
note. The root is also stored in the file's sampler chunk;
note. The root is also stored in the file's sampler chunk. A slice gets a
note only when most of it holds one clear pitch; a growl, a slide or noise is
saved as `s03.wav`, because a wrong note name is worse than none. Choose the
song's **Key** to spell the names in it: in F minor a slice is `s02_Ab1.wav`,
not `s02_G#1.wav`. Without a key, names use sharps;
- `wavetable.wav` when **Wavetable** is on: up to 256 single-cycle frames of 2048
samples from the selected slice. Each frame starts on a rising zero crossing, so
frames morph without phase jumps. Serum reads the frame size from the file; in
@@ -58,6 +62,8 @@ default), every capture's loop is also written at once to
or a crash loses nothing. Existing files are never overwritten.
Bar alignment follows the host's song position and time signature. Root notes are
detected between 35 Hz and 1 kHz. Unpitched slices, such as drums or noise, get
no note in their name. Transient slicing finds hits that rise out of quieter
detected between 35 Hz and 1 kHz. The pitch is read at up to 12 points across
each slice after its attack; at least three reads in five must be clear and on
the same note. Unpitched slices, such as drums
or noise, get no note in their name. Transient slicing finds hits that rise out of quieter
audio; for legato basses, slice on the grid.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 231 KiB

+14 -1
View File
@@ -1,6 +1,19 @@
# Forge changelog
## Unreleased (0.1.0)
## 0.2.0
- Add **NOTE** as a pitch source: Forge follows a note you set, and the audio
only starts and stops it. Add Key lock and Follow bends for AUDIO detection.
- Detection holds its notes: a new note needs a steady pitch for 40 ms, octave
jumps need 80 ms down and 200 ms up. Growls, wobbles and formant basses no
longer flicker between notes. Follow bends brings back the old fast behaviour.
- All modules share the pitch source, Key lock and Follow bends.
- When detection is unsure, each module acts as its standalone plugin: Lathe
passes the audio, Temper keeps its tuning, Knurl fades its texture out.
- Each module page opens on its MAIN controls. **ADVANCED** in the header shows
every control on all pages.
## 0.1.0
- Introduce Forge, one tracked channel strip: Lathe, Temper and Knurl behind a
single note tracker, in any of the six orders, each module on or off.
+11 -4
View File
@@ -1,7 +1,7 @@
# Forge
Forge is one tracked channel strip: Lathe, Temper and Knurl run behind a single
note tracker, in the order you choose. Current version **0.1.0** (unreleased).
note tracker, in the order you choose. Current version **0.2.0**.
Each of the three plugins detects the played note itself. In a row, their
lookaheads add up (Lathe 67 ms, Temper 68 ms, Knurl 71 ms: more than 200 ms),
@@ -23,20 +23,27 @@ synth. Keep the font license with redistributed packages.
## Work with it
1. Choose **Pitch from** in the header: **AUDIO** detects the note from the
input; **MIDI** follows MIDI notes sent to the effect. All modules use it.
input; **MIDI** follows MIDI notes sent to the effect; **NOTE** uses a note
you set. All modules use it, with the same Key lock and Follow bends. See
[Pitch source](../pitch.md).
2. The **Chain** row shows the modules in processing order. Click a module's
name to edit it below; **ON** switches it on or off. The button between two
modules swaps them. The default order is Lathe, Temper, Knurl, as in the
standalone chain.
3. Each module's page is its standalone editor: [Lathe](../lathe/README.md),
[Temper](../temper/README.md) and [Knurl](../knurl/README.md) explain the
controls. Temper's **AUTO GAIN** sits in the Chain row while its page is
open.
controls. Each page opens on its MAIN controls; the **ADVANCED** switch in
the header shows every control on all pages. Temper's **AUTO GAIN** sits in
the Chain row while its page is open.
4. The **Gate** knob on every page is the one shared tracker gate: turning it on
one page turns it on all.
**Bypass** in the header crossfades the whole strip to the delayed input.
While detection is unsure of the note, each module does what its standalone
plugin does: Lathe passes the audio, Temper keeps the last sure tuning, and
Knurl fades its texture out.
## How it works
The tracker reads the strip's input, and the audio is delayed by its lookahead
Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 295 KiB

+161 -3
View File
@@ -91,6 +91,163 @@ Patina. Findings from the audit:
- **Settings handling differs.** Patina has presets and A/B; the others do not.
Lathe's drawn gains and Knurl's drawn pattern cannot be automated.
## Producer feedback (2026-11-06)
Feedback from producers who tried the suite: most plugins are confusing to use.
One example: "why does it try to detect the key? It switches around fast,
because sounds usually aren't just one kind of note." Anvil "was kind of cool,
but didn't do what we expected." Patina's feedback (threshold, ratio) is handled
in Patina 0.5.0.
### What goes wrong
1. **Nine of thirteen plugins depend on one hidden guess: the note.** Anvil,
Lathe, Temper, Knurl, Planish, Braze, Forge, Ballast (Notes mode) and
Crucible (file names) all read the pitch from the audio. Patina's Tone guard
also does, but it is off by default. Our tests use saws and sines, where
tracking is perfect. Real riddim basses are FM growls, formant sweeps, noise,
pitch drops, stacked octaves and layers. On those, "the note" is often not
one note.
2. **The tracker is built to react fast, not to stay put.** In
`common-dsp/src/tracker.rs`, a move of 0.7 semitone that holds for two 5 ms
hops starts a new note. Smaller moves retune every 5 ms. Frames below
clarity 0.5 are skipped, but the last guess stays in use. On a growl, the
formant and FM movement reads as a pitch moving every few milliseconds, and
octave errors count as new notes. The NOTE readout flickers, and everything
tuned to the note moves with it. That is the "switches around fast".
3. **A wrong guess is worse than no guess.** When tracking is wrong, a tracked
plugin does something no normal plugin would do:
- Anvil plays a sub at the wrong pitch.
- Lathe moves its bands onto the wrong frequencies.
- Temper's Floor, Push and Bright filters jump.
- Planish's band edges jump, so the bands pump.
- Braze's beating leaves the grid.
- Knurl's cycle clock runs at the wrong rate.
- Crucible names a slice after a note it is not.
A producer cannot tell this from "the plugin is broken".
4. **The plugins explain themselves in DSP terms.** Harmonic numbers, cycles,
periods, splits, phase and allpass sections. A producer reads the category
("EQ", "distortion", "OTT", "sub") and expects that behaviour. When the
tracked behaviour differs, it feels random.
5. **The escape route is hidden.** MIDI as the pitch source fixes the guess,
but in FL Studio it needs the channel's MIDI output port and the effect's
input port set to the same number. Few producers know how to do that.
6. **Panels show everything at once.** Most plugins put 10–20 controls on the
front panel, with equal weight. The one or two that matter for the first
result do not stand out.
### Principles for the next round
- **Never act on a guess you are unsure of.** Every tracked plugin falls back
to something neutral when tracking is unsure: pass the audio through, keep
the original sub, or use fixed-Hz settings. It does not fall back to the last
guess. Show this state: the NOTE readout dims or shows "—".
- **Stable by default.** A new note needs a confident pitch held for 40–60 ms.
An octave jump needs longer still. Inside a note, the tuning holds and does
not retune every hop, unless the user turns on "Follow bends".
- **Let the producer say the note.** Add a third pitch source next to AUDIO and
MIDI: **NOTE** or **KEY**. The producer picks the root (for example F), and
the plugin uses it with no detection at all. Most riddim drops sit on one or
two roots. A scale lock goes in between: detection may only pick notes in the
chosen key. This removes the micro-jumps and most wrong notes.
- **Name controls by outcome, not by mechanism.** Use names like "Sub level"
and "Tighten". Show one big main control per plugin, and move the rest under
an "Advanced" fold.
- **Test on real material.** Collect 5–10 short stems: a growl, an FM bass, a
reese, an 808 with a slide, a formant bass, a layered drop, a noise riser.
Add a tracker metric for note changes per second and the share of time the
tracker is wrong or unsure. Every tracker change must lower it on the stems,
not only pass the saw tests.
### Per plugin
- **Anvil.** It does not do what producers expected. The candidates are below;
ask the tester which one they meant.
- (a) They expected a sub *enhancer*: add weight under the existing sound,
not replace it.
- (b) They expected it to follow their growl, and it played wrong sub notes.
The wrong sub notes are the worst failure in the suite.
- (c) They expected a simple "fix my low end" (mono, tight, level), and the
front panel offers Phase, Retrigger, Kick lock, Ghost and Audition.
Fixes:
- Keep the original sub (Keep 100 %) whenever tracking is unsure, and switch
to the clean sine only on a confident note.
- Add the NOTE source with a root picker.
- Add an Enhance mode that adds the sine under the original instead of
replacing it.
- Keep three main controls: Split, Sub level, Mode. Fold the rest.
- **Lathe.** A harmonic EQ is a new idea for most producers. Show the band
centres in Hz on the plot, next to the harmonic numbers. When tracking is
unsure, pass the audio through.
- **Temper.** Producers judge it as distortion first. Floor, Push and Bright
should keep their last confident tuning, or fall back to fixed Hz, instead of
jumping. Consider Floor/Bright with fixed-Hz values as the default, and
tracked values as an option.
- **Knurl.** It needs the cycle clock, so it needs a pitch. NOTE mode and
fade-out on unsure tracking matter most here.
- **Planish.** When tracking is unsure, fall back to fixed band edges (like OTT)
instead of jumping. Crossfade the edges when the note changes.
- **Braze.** It needs a pitch. Use NOTE mode, hold the detune through unsure
stretches, and do not jump.
- **Ballast.** Make **Grid** the default for Parts from. A switch-up lands on
the grid, and Grid needs no tracking.
- **Crucible.** Name a slice by its note only when one note is confident over
most of the slice. Otherwise leave the note out (or write "?"). An optional
song key field avoids enharmonic names that do not fit the key.
- **Forge.** It inherits everything above from the shared tracker.
- **Weld, Quench, Patina.** These use no tracking by default. Weld's display is
dense: lead with Recovered dB and one ALIGN button, and fold the plots.
### Order
1. Shared tracker: confidence, stability and hold, plus the stem corpus and
metric. One change in `common-dsp` fixes the flicker in nine plugins.
2. NOTE/KEY pitch source in the shared tracker settings and in each tracked
plugin's header.
3. "Unsure means neutral" fallback in each tracked plugin, Anvil first.
4. Anvil Enhance mode and a reduced front panel. Ask the tester what they
expected first.
5. Front-panel pass on every plugin: a main control, outcome names, and an
Advanced fold.
### Status (2026-11-06)
Done in Anvil 0.4.0, Lathe 0.3.0, Temper 0.3.0, Knurl 0.3.0, Planish 0.2.0,
Braze 0.2.0, Ballast 0.2.0, Forge 0.2.0, Crucible 0.4.0 and Weld 0.4.0. Not
tested in FL Studio yet, and the tester has not been asked which Anvil they
expected.
1. Done. The tracker needs a steady pitch for 40 ms to start a note (80 ms an
octave down, 200 ms up), holds within a quarter semitone, ends a note after
20 ms of silence, and reports unsure after about 40 ms of disagreeing reads.
Follow bends keeps the old fast behaviour. The corpus is synthetic
(`common-dsp/src/stems.rs`, `examples/tracker_report.rs`): extra note starts
per second on formant, wobble and reese fell from 28.5, 11.8 and 1.3 to 0.
Wobble is still unsure 37 % of the time and the hard growl 4.5 %.
2. Done: NOTE source with a note picker, Key lock (minor, major, root only) and
Follow bends, shared through `common-params`. Ballast has no NOTE: its parts
need note starts from the audio or MIDI.
3. Done. Anvil plays the original sub, Lathe passes the audio, Knurl fades out,
Temper, Planish and Braze hold the last sure note. Planish slides its edges
over 6 ms on a new note. Crucible names slices only on a sure note and
spells them for a song key.
4. Done: Replace / Enhance mode, and a MAIN page with Mode, Split and Sub level.
5. Done for Anvil, Lathe, Temper, Knurl, Planish, Braze, Forge and Weld: a MAIN
page and an ADVANCED switch. Lathe labels harmonics in Hz. Ballast defaults
to Grid.
Still open:
- Real stems. Score the tracker on 5–10 real drops with `tracker_report` and
tune on those, not only on the synthetic corpus.
- Outcome names. Controls keep their old names; only the pages changed.
- Temper with fixed-Hz Floor and Bright as the default.
- Weld's single ALIGN button.
- Panel pass for Quench, Patina, Crucible, Rivet and Ballast. Their panels were
already short, so they were left as they are.
- `tools/editor-check` has a clippy `chunks_exact` lint outside the plugin
checks.
## Research findings (2026-11)
From the papers in `research/` (local, not committed). Each finding names where
@@ -162,7 +319,8 @@ it goes in the suite. They are starting points to test by ear, not rules.
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).
from detection with 80 ms lookahead, from MIDI, or from a set note. Enhance
mode adds the sine under the original sub instead. See [Anvil](anvil/README.md).
### Crucible — resampler (problem 3)
@@ -282,7 +440,7 @@ one period of the note, so heavy settings do not ripple at f0. See [Planish](pla
(prototype `scripts/fatness.py`); Anvil Ghost harmonics and Phone listen
(done, Anvil 0.3.0); Braze (done, 0.1.0); Anvil Kick lock with offset
audition (done, Anvil 0.3.0).
4. Forge (done, unreleased 0.1.0): Lathe, Temper and Knurl behind one tracker.
4. Forge (done, released 0.1.0): Lathe, Temper and Knurl behind one tracker.
5. Assay, one plugin for the display-only ideas.
6. Texture plugins (Anneal, Swarf, Etch, Bellows) and the note-keyed sequencer.
@@ -303,7 +461,7 @@ before Knurl) and the repeated MIDI routing. The standalone plugins stay.
Sharing one tracker between separate `.clap` files would need named shared
memory across DLLs, which is fragile; one strip is simpler.
Built (unreleased 0.1.0, see [Forge](forge/README.md)). Lathe, Temper and Knurl
Built (released 0.1.0, see [Forge](forge/README.md)). Lathe, Temper and Knurl
became modules (a `Tracked` trait in common-dsp: `ahead`, `event`, `process`);
each standalone plugin is now its tracker plus its module, Lathe and Temper
bit-exact against the old engines, Knurl one sample later (its interpolation
+10 -1
View File
@@ -1,7 +1,16 @@
# Knurl changelog
## Unreleased
## 0.3.0
- Add **NOTE** as a pitch source: Knurl follows a note you set, and the audio
only starts and stops it. Add Key lock and Follow bends for AUDIO detection.
- Detection holds its notes: a new note needs a steady pitch for 40 ms, octave
jumps need 80 ms down and 200 ms up. Growls, wobbles and formant basses no
longer flicker between notes. Follow bends brings back the old fast behaviour.
- When detection is unsure, Knurl fades its texture out, and the readout shows
**?**.
- The editor opens on a MAIN page. **ADVANCED** in the header adds Edge, the
Grit burst's Tone, Position and Length, and Gate.
- The engine is now a module that also runs inside Forge. The sound is
unchanged; the latency is one sample longer.
+14 -7
View File
@@ -1,7 +1,7 @@
# Knurl
Knurl gives every cycle of the note its own level, timing and grit. Current
version **0.2.0**.
version **0.3.0**.
Knurling presses a ridged pattern into metal. Riddim and dubstep basses get their
character the same way, from surface texture: rattle, growl, fry, grit. Usually
@@ -22,6 +22,10 @@ redistributed packages.
## Work with it
The editor opens on the **MAIN** page with the pattern, Level, Time, Grit, Mix
and Output. The **ADVANCED** switch in the header adds Edge, the Grit burst's
Tone, Position and Length, and Gate.
1. Play the bass. The note readout shows what Knurl tracks; the scope overlays
the last eight cycles of the output, so the texture is visible cycle by cycle.
2. Choose a **Pattern**. The pattern sets the level of each cycle and repeats:
@@ -50,13 +54,16 @@ note, so identical notes sound identical, which keeps a riddim line tight.
**Mix** blends with the delayed dry signal; **Output** trims the level.
### Pitch from MIDI
### Pitch source
For basses whose fundamental is unclear (heavy FM, formant or noise patches),
select **MIDI** and route the bass's MIDI notes to Knurl. In FL Studio, set the
bass channel's MIDI output port and Knurl's input port to the same number.
**Gate** sets the input level that counts as a note in AUDIO mode; **Snap to
semitone** rounds the detected pitch (turn it off for slides).
**Pitch from** in the header chooses where the note comes from: **AUDIO**
detects it, **MIDI** follows MIDI notes sent to Knurl, and **NOTE** uses a note
you set. For heavy FM, formant or noise basses, NOTE with the drop's root is the
safest choice. Key lock and Follow bends tune the detection. While detection is unsure of the note, Knurl fades its texture out, and the readout shows **?**. See
[Pitch source](../pitch.md).
**Gate** (ADVANCED page) sets the input level that counts as a note in AUDIO
mode; **Snap to semitone** rounds the detected pitch (turn it off for slides).
## Timing and limits
Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 261 KiB

+13
View File
@@ -1,5 +1,18 @@
# Lathe changelog
## 0.3.0
- Add **NOTE** as a pitch source: Lathe follows a note you set, and the audio
only starts and stops it. Add Key lock and Follow bends for AUDIO detection.
- Detection holds its notes: a new note needs a steady pitch for 40 ms, octave
jumps need 80 ms down and 200 ms up. Growls, wobbles and formant basses no
longer flicker between notes. Follow bends brings back the old fast behaviour.
- When detection is unsure, Lathe passes the audio unchanged instead of cutting
a guessed note's harmonics, and the readout shows **?**.
- The harmonic bars show where harmonics 1, 2, 4, 8, 16 and 32 sit in Hz.
- The editor opens on a MAIN page. **ADVANCED** in the header adds Harmonics,
Smooth, Gate and Snap.
## 0.2.0
- Change the CLAP ID to `io.narl.bass-foundry.lathe`. Hosts see this as a new
+13 -6
View File
@@ -1,7 +1,7 @@
# Lathe
Lathe is an EQ whose bands sit on the harmonics of the note being played.
Current version **0.2.0**.
Current version **0.3.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
@@ -20,8 +20,13 @@ processing. Keep the font license with redistributed packages.
## Work with it
The editor opens on the **MAIN** page with the bars, the macros and Width.
The **ADVANCED** switch in the header adds Harmonics, Smooth, Gate and Snap.
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).
While a note plays, harmonics 1, 2, 4, 8, 16 and 32 also show where they
sit in Hz.
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:
@@ -34,7 +39,7 @@ processing. Keep the font license with redistributed packages.
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:
Other controls (Harmonics, Smooth, Gate and Snap on the ADVANCED page):
- **Harmonics** is how many harmonics are shaped (8–32). Higher harmonics pass
unchanged.
@@ -50,11 +55,13 @@ Other controls:
The drawn gains are saved with the project but are not automation parameters;
the macros are.
### Pitch from MIDI
### Pitch source
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.
**Pitch from** in the header chooses where the note comes from: **AUDIO**
detects it, **MIDI** follows MIDI notes sent to Lathe, and **NOTE** uses a note
you set. For heavy FM, formant or noise basses, NOTE with the drop's root is the
safest choice. Key lock and Follow bends tune the detection. While detection is unsure of the note, Lathe passes the audio unchanged and the readout shows **?**. See
[Pitch source](../pitch.md).
## Timing and limits
Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 282 KiB

+62
View File
@@ -0,0 +1,62 @@
# Pitch source in the tracked plugins
Anvil, Lathe, Temper, Knurl, Planish, Braze and Forge follow the note the bass
plays. **Pitch from** in each header says where that note comes from.
- **AUDIO** detects the note from the audio. This is the default.
- **MIDI** takes the notes from MIDI input. In FL Studio, set the bass channel's
MIDI output port and the effect's input port to the same number.
- **NOTE** uses a note you set, for example F1. The audio only starts and stops
notes; nothing is detected. Most riddim drops sit on one or two roots, so this
is the safest choice for growls, FM basses and anything detection struggles
with. Click the note button next to NOTE to pick the note and its octave.
## When detection is unsure
Real basses are often not one clear note: FM growls, formant sweeps, detuned
reeses, layers and noise. Detection therefore stays put:
- A new note needs a steady pitch for 40 ms. A jump down an octave needs 80 ms
and a jump up an octave 200 ms, because a misread octave is the commonest
error.
- Inside a note, the tuning only moves when the pitch drifts more than a quarter
of a semitone for good.
- When the pitch keeps reading differently from the note (about 40 ms of
disagreeing reads, or 400 ms without any clear read), the note turns
**unsure**. The NOTE readout then shows **?**.
While the note is unsure, a plugin never acts on a guess:
| Plugin | While unsure |
| --- | --- |
| Anvil | Plays your original sub instead of the sine (Enhance: adds nothing) |
| Lathe | Passes the audio unchanged |
| Knurl | Fades its texture out |
| Temper, Planish, Braze | Keep the tuning of the last sure note |
| Forge | Each module as above |
## Key lock and Follow bends
With AUDIO selected, the button next to the switch opens two options:
- **Key lock** lets detection pick only notes of a key: **Minor**, **Major**,
or **Root only** (any octave of the root, so detection only picks the
octave). Pick the root below. A drop in F minor with Key lock on F minor can
no longer read an F♯.
- **Follow bends** retunes on every bend and vibrato and switches notes after
10 ms, as the plugins did before. It follows slides and 808 glides closely,
but it flickers on growls. Leave it off unless the bass is clean and bends a
lot.
## Measuring detection on your own stems
`cargo run -p common-dsp --release --example tracker_report` scores the
detection on a set of synthetic stems (growls, wobble, reese, formant bass, 808
slide, layered drop, noise riser). Pass WAV files to score real stems:
```text
cargo run -p common-dsp --release --example tracker_report -- --note F1 drop.wav
```
It prints note starts per second, the share of time with a wrong sure note, and
the share of time detection was unsure.
+13
View File
@@ -1,5 +1,18 @@
# Planish changelog
## 0.2.0
- Add **NOTE** as a pitch source: Planish follows a note you set, and the audio
only starts and stops it. Add Key lock and Follow bends for AUDIO detection.
- Detection holds its notes: a new note needs a steady pitch for 40 ms, octave
jumps need 80 ms down and 200 ms up. Growls, wobbles and formant basses no
longer flicker between notes. Follow bends brings back the old fast behaviour.
- When detection is unsure, the band edges hold on the last sure note, and the
readout shows **?**. On a new note the edges slide over 6 ms instead of
jumping.
- The editor opens on a MAIN page. **ADVANCED** in the header adds the band
gains, Smooth, Gate and Snap.
## 0.1.0
- Introduce Planish, upward and downward compression in four bands whose edges
+19 -11
View File
@@ -1,7 +1,7 @@
# Planish
Planish is upward and downward compression (OTT) in four bands whose edges are
harmonic numbers of the played note. Current version **0.1.0**.
harmonic numbers of the played note. Current version **0.2.0**.
Planishing hammers a metal surface smooth and even. OTT does the same to a bass:
it pushes loud bands down and lifts quiet ones, and it is on almost every riddim
@@ -23,6 +23,10 @@ license with redistributed packages.
## Work with it
The editor opens on the **MAIN** page with the split, Drive, Depth, Upward,
Downward, Time and Output. The **ADVANCED** switch in the header adds the band
gains, Smooth, Gate and Snap.
1. Play the bass. The note readout shows what Planish tracks. The ruler at the
top shows harmonics 1–64 and the four bands; the columns below show each
band's level (steel) and gain (pink for a cut, blue for a lift).
@@ -42,20 +46,23 @@ license with redistributed packages.
- **Time** scales every attack and release. Lower bands are slower: at
100 %, attack is 30, 15, 8 and 4 ms and release 200, 160, 120 and 90 ms
from band 1 to band 4.
4. **Band 1–4** set each band's gain after its compressor. Like the
4. On the ADVANCED page, **Band 1–4** set each band's gain after its compressor. Like the
compression, they are scaled by Depth.
**Output** trims the level. **Bypass** crossfades to the delayed dry signal.
### Pitch from MIDI
### Pitch source
For basses whose fundamental is unclear (heavy FM, formant or noise patches),
and when Planish comes after Knurl, select **MIDI** and route the bass's MIDI
notes to Planish. In FL Studio, set the bass channel's MIDI output port and
Planish's input port to the same number. **Gate** sets the input level that
counts as a note in AUDIO mode. **Snap to semitone** rounds the detected pitch;
it is off by default, because the level is measured over one period of the
pitch in use, and a detuned patch fits its real period better.
**Pitch from** in the header chooses where the note comes from: **AUDIO**
detects it, **MIDI** follows MIDI notes sent to Planish, and **NOTE** uses a note
you set. For heavy FM, formant or noise basses, NOTE with the drop's root is the
safest choice. Key lock and Follow bends tune the detection. While detection is unsure of the note, the band edges hold on the last sure note and the readout shows **?**. See
[Pitch source](../pitch.md).
**Gate** (ADVANCED page) sets the input level that counts as a note in AUDIO
mode. **Snap to semitone** rounds the detected pitch; it is off by default,
because the level is measured over one period of the pitch in use, and a
detuned patch fits its real period better.
## Measured
@@ -73,7 +80,8 @@ Planish delays the audio by about 67 ms of pitch-detection lookahead and
reports it to the host for delay compensation, so the edges are on the new note
when its attack leaves the plugin. Pitch range is 30–500 Hz, one note at a time.
Before the first note the edges sit on 55 Hz; between notes they hold the last
note.
note. On a new note the edges slide to it over 6 ms, so the crossovers do not
click.
An edge is a slope, not a wall. The crossovers are 24 dB per octave, so the
harmonics next to an edge partly reach the next band, by the same amount on
Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

After

Width:  |  Height:  |  Size: 272 KiB

+10 -9
View File
@@ -9,16 +9,17 @@ workflows are in `.gitea/workflows/`. A plugin's version is the `version` in
| --- | --- | --- |
| Rivet | `rivet-v0.5.0` | `rivet-windows-x86_64-0.5.0.zip` |
| Patina | `patina-v0.5.0` | `patina-windows-x86_64-0.5.0.zip` |
| Anvil | `anvil-v0.3.0` | `anvil-windows-x86_64-0.3.0.zip` |
| Crucible | `crucible-v0.3.0` | `crucible-windows-x86_64-0.3.0.zip` |
| Lathe | `lathe-v0.2.0` | `lathe-windows-x86_64-0.2.0.zip` |
| Temper | `temper-v0.2.0` | `temper-windows-x86_64-0.2.0.zip` |
| Weld | `weld-v0.3.0` | `weld-windows-x86_64-0.3.0.zip` |
| Knurl | `knurl-v0.2.0` | `knurl-windows-x86_64-0.2.0.zip` |
| Planish | `planish-v0.1.0` | `planish-windows-x86_64-0.1.0.zip` |
| Braze | `braze-v0.1.0` | `braze-windows-x86_64-0.1.0.zip` |
| Anvil | `anvil-v0.4.0` | `anvil-windows-x86_64-0.4.0.zip` |
| Crucible | `crucible-v0.4.0` | `crucible-windows-x86_64-0.4.0.zip` |
| Lathe | `lathe-v0.3.0` | `lathe-windows-x86_64-0.3.0.zip` |
| Temper | `temper-v0.3.0` | `temper-windows-x86_64-0.3.0.zip` |
| Weld | `weld-v0.4.0` | `weld-windows-x86_64-0.4.0.zip` |
| Knurl | `knurl-v0.3.0` | `knurl-windows-x86_64-0.3.0.zip` |
| Planish | `planish-v0.2.0` | `planish-windows-x86_64-0.2.0.zip` |
| Braze | `braze-v0.2.0` | `braze-windows-x86_64-0.2.0.zip` |
| Quench | `quench-v0.1.0` | `quench-windows-x86_64-0.1.0.zip` |
| Ballast | `ballast-v0.1.0` | `ballast-windows-x86_64-0.1.0.zip` |
| Ballast | `ballast-v0.2.0` | `ballast-windows-x86_64-0.2.0.zip` |
| Forge | `forge-v0.2.0` | `forge-windows-x86_64-0.2.0.zip` |
## Make a release
+12
View File
@@ -1,5 +1,17 @@
# Temper changelog
## 0.3.0
- Add **NOTE** as a pitch source: Temper follows a note you set, and the audio
only starts and stops it. Add Key lock and Follow bends for AUDIO detection.
- Detection holds its notes: a new note needs a steady pitch for 40 ms, octave
jumps need 80 ms down and 200 ms up. Growls, wobbles and formant basses no
longer flicker between notes. Follow bends brings back the old fast behaviour.
- When detection is unsure, Temper keeps the tuning of the last sure note, and
the readout shows **?**.
- The editor opens on a MAIN page. **ADVANCED** in the header adds Push at,
Floor, Bright, Normalize and Gate.
## 0.2.0
- Change the CLAP ID to `io.narl.bass-foundry.temper`. Hosts see this as a new
+11 -5
View File
@@ -1,6 +1,6 @@
# Temper
Temper is a distortion that drives every note the same. Current version **0.2.0**.
Temper is a distortion that drives every note the same. Current version **0.3.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
@@ -20,6 +20,10 @@ with redistributed packages.
## Work with it
The editor opens on the **MAIN** page with Curve, Drive, Bias, Push, Mix and
Output. The **ADVANCED** switch in the header adds Push at, Floor, Bright,
Normalize and Gate.
1. Choose a **Curve**:
- **Soft**: tanh, smooth and rounded.
- **Hard**: clipping with a narrow knee, dense and square-like.
@@ -47,11 +51,13 @@ Other controls:
- **Gate** is the input level that counts as a note.
- **Mix** blends with the delayed dry signal; **Output** trims the level.
### Pitch from MIDI
### Pitch source
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.
**Pitch from** in the header chooses where the note comes from: **AUDIO**
detects it, **MIDI** follows MIDI notes sent to Temper, and **NOTE** uses a note
you set. For heavy FM, formant or noise basses, NOTE with the drop's root is the
safest choice. Key lock and Follow bends tune the detection. While detection is unsure of the note, Temper keeps the tuning of the last sure note and the readout shows **?**. See
[Pitch source](../pitch.md).
## Timing and limits
Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 250 KiB

+7
View File
@@ -1,5 +1,12 @@
# Weld changelog
## 0.4.0
- The editor opens on a MAIN page with Recovered, the band plot, Mode,
Response, Output and the time offset. **ADVANCED** in the header adds the
phase plot, Sections, Low, High and Gate. Sound and saved settings do not
change.
## 0.3.0
- Show the **delay spread**: how much the alignment's group delay changes
+6 -2
View File
@@ -1,7 +1,7 @@
# Weld
Weld fuses two bass layers so they add up instead of cancelling. Current version
**0.3.0**.
**0.4.0**.
Bass patches are layered: a sub, one or more mid growls, a top. Where the layers
overlap in the mids, they partly cancel. The causes are a polarity difference, a
@@ -37,6 +37,10 @@ plugins. Keep the font license with redistributed packages.
## Work with it
The editor opens on the **MAIN** page: Recovered, the band plot, Mode,
Response, Output and the time offset. The **ADVANCED** switch in the header adds
the phase plot, Sections, Low, High and Gate.
1. Leave **Mode** on **FOLLOW** and play the part where both layers sound. After
a second or two, Weld shows its alignment: polarity, and the allpass sections
with their frequency and Q.
@@ -47,7 +51,7 @@ plugins. Keep the font license with redistributed packages.
- The upper plot shows each band against a perfectly aligned sum (0 dB). Red
is what the unprocessed sum loses to cancellation; pink is the part Weld
wins back. The white line is the level with Weld.
- The lower plot shows the phase of this layer against the other one, before
- On the ADVANCED page, the lower plot shows the phase of this layer against the other one, before
(grey) and after (pink), and Weld's own phase curve (blue). After alignment
the pink line sits near 0°.
- **Delay spread** under the sections is how much Weld's delay changes
Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 235 KiB

+37 -7
View File
@@ -10,6 +10,10 @@
//! only the harmonics the patch lacks are filled: a harmonic the patch already has would
//! meet the added one at an unrelated phase and could cancel.
//!
//! The sine only plays while the note is sure. While tracking is unsure, the original sub
//! comes back in its place (Replace) or the added sine fades out (Enhance), so a wrong guess
//! never plays a wrong sub note.
//!
//! Kick lock reads the kick from the sidechain. Hits are found on the undelayed sidechain,
//! so with the lookahead each hit ducks the sub exactly when the delayed kick plays, and
//! restarts the sub's phase at the kick plus an offset, also before the kick. The audition
@@ -18,9 +22,9 @@
mod settings;
use common_dsp::meter::HarmonicMeter;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig};
use common_dsp::tracker::{Fallback, NoteEvent, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi, midi_to_hz, Biquad, Crossover};
pub use settings::{Settings, Source, KICK_OFFSET_MAX};
pub use settings::{Mode, Settings, Source, KICK_OFFSET_MAX};
use std::f32::consts::TAU;
/// Detectable fundamental range. The lowest sets the lookahead (two periods).
@@ -30,6 +34,8 @@ pub const SCOPE: usize = 256;
const ATTACK_MS: f32 = 2.;
const RELEASE_MS: f32 = 15.;
const CROSSFADE_MS: f32 = 3.;
/// Crossfade between the sine and the original sub when tracking turns unsure or sure.
const TRUST_MS: f32 = 15.;
/// Highest Ghost harmonic. Harmonic 10 of a 30 Hz sub is 300 Hz.
pub const GHOST_TOP: usize = 10;
/// Ghost harmonics shown and generated: 2..=GHOST_TOP.
@@ -76,6 +82,8 @@ pub struct AnalyzerFrame {
pub hz: f32,
/// MIDI note of the current sub, rounded.
pub note: Option<u8>,
/// Tracking is unsure: the original sub plays instead of the sine.
pub neutral: bool,
pub low_db: f32,
pub sub_db: f32,
/// Harmonics 2..=GHOST_TOP of the current note, peak dB relative to the sub: what the
@@ -106,6 +114,7 @@ impl Default for AnalyzerFrame {
Self {
hz: 0.,
note: None,
neutral: false,
low_db: -120.,
sub_db: -120.,
patch_harmonics: [-120.; GHOST_COUNT],
@@ -308,6 +317,11 @@ pub struct Engine {
keep: f32,
output: f32,
bypass: f32,
fallback: Fallback,
neutral: bool,
/// 1 while the sine plays, 0 while the original sub stands in for it.
trust: f32,
trust_coef: f32,
meter_coef: f32,
low_power: f32,
sub_power: f32,
@@ -355,6 +369,10 @@ impl Engine {
keep: 0.,
output: 1.,
bypass: 0.,
fallback: Fallback::default(),
neutral: false,
trust: 1.,
trust_coef: coefficient(rate, TRUST_MS / 3.),
meter_coef: coefficient(rate, 150.),
low_power: 0.,
sub_power: 0.,
@@ -397,7 +415,7 @@ impl Engine {
}
if self
.tracker
.set(s.source == Source::Midi, s.gate, s.glide > 0.)
.set(s.pitch.source(s.source.mode(), s.gate, s.glide > 0.))
{
self.synth.voice.releasing = true;
}
@@ -416,6 +434,9 @@ impl Engine {
}
self.tracker.reset();
self.synth = Synth::default();
self.fallback.reset();
self.neutral = false;
self.trust = 1.;
self.low_env = 0.;
self.low_power = 0.;
self.sub_power = 0.;
@@ -638,6 +659,7 @@ impl Engine {
};
let (synth, settings) = (&mut self.synth, &self.settings);
self.tracker.process(mono, |e| synth.event(settings, e));
self.neutral = self.fallback.neutral(self.tracker.state());
let len = self.delay.len();
self.delay[self.delay_at] = x;
self.delay_at = (self.delay_at + 1) % len;
@@ -683,8 +705,10 @@ impl Engine {
let duck = self.kick_events();
self.update_fill();
let (sub, ghost) = self.oscillator();
let sub = sub * self.level * shape * duck;
let generated = sub + ghost * self.level * shape * duck;
self.trust += (f32::from(!self.neutral) - self.trust) * self.trust_coef;
let gain = self.level * shape * duck * self.trust;
let sub = sub * gain;
let generated = sub + ghost * gain;
if self.settings.kick_lock {
let kick_low = self.kick.low.process(kick_late);
self.measure_kick(kick_low, sub);
@@ -699,11 +723,16 @@ impl Engine {
self.scope_sub[self.scope_at] = generated;
self.scope_at = (self.scope_at + 1) % (2 * SCOPE);
}
// Replace keeps `keep` of the original, and all of it while the sine is out.
let keep = match s.mode {
Mode::Replace => self.keep + (1. - self.keep) * (1. - self.trust),
Mode::Enhance => 1.,
};
let wet = |l: f32, h: f32| {
if monitor.solo {
generated
} else {
h + self.keep * l + generated
h + keep * l + generated
}
};
let w = [wet(l0, h0), wet(l1, h1)];
@@ -736,6 +765,7 @@ impl Engine {
kick_sum_db: self.kick.sum_db,
kick_offset: self.kick.offset,
audition_sums: self.kick.sums,
neutral: self.neutral,
..AnalyzerFrame::default()
};
let sub = self.level.max(1e-6);
@@ -751,7 +781,7 @@ impl Engine {
f.ghost_harmonics[i] = rel(self.fill[i] * sub);
}
let v = self.synth.voice;
if v.amp > 0. && !v.releasing {
if v.amp > 0. && !v.releasing && !self.neutral {
f.hz = v.hz;
f.note = Some(hz_to_midi(v.hz).round().clamp(0., 127.) as u8);
}
+37
View File
@@ -1,4 +1,5 @@
//! Host-independent controls. Levels in dB, times in milliseconds, frequencies in Hz.
use common_dsp::tracker::{Pitch, PitchMode};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
@@ -8,12 +9,45 @@ pub enum Source {
Audio,
/// Notes come from MIDI note input; the audio only supplies the band above the split.
Midi,
/// Note starts are detected, the pitch is the set note: nothing is guessed.
Note,
}
impl Source {
pub fn mode(self) -> PitchMode {
match self {
Source::Audio => PitchMode::Audio,
Source::Midi => PitchMode::Midi,
Source::Note => PitchMode::Note,
}
}
pub fn from_mode(mode: PitchMode) -> Self {
match mode {
PitchMode::Audio => Source::Audio,
PitchMode::Midi => Source::Midi,
PitchMode::Note => Source::Note,
}
}
}
/// What happens to the original sub.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Mode {
/// The band below the split is replaced by the clean sine (plus Keep of the original).
#[default]
Replace,
/// The original sub stays, and the sine is added under it for weight.
Enhance,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// Note mode's note, the key lock and Follow bends.
#[serde(default)]
pub pitch: Pitch,
#[serde(default)]
pub mode: Mode,
/// Peak level of the new sub in dBFS, the same for every note.
pub level: f32,
/// Crossover frequency: below it the original sub is replaced.
@@ -75,6 +109,8 @@ impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
pitch: Pitch::default(),
mode: Mode::Replace,
level: -9.,
split: 90.,
dynamics: 0.,
@@ -99,6 +135,7 @@ impl Default for Settings {
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
self.pitch.validate()?;
let finite = [
self.level,
self.split,
+71 -1
View File
@@ -1,4 +1,4 @@
use anvil_core::{Engine, Monitor, Settings, Source};
use anvil_core::{Engine, Mode, Monitor, Settings, Source};
use common_dsp::Saw;
const RATE: f32 = 48000.;
@@ -488,3 +488,73 @@ fn the_audition_finds_where_kick_and_sub_sum() {
);
}
}
#[test]
fn unsure_tracking_brings_back_the_original_sub() {
let (mut e, l) = engine(Settings::default());
// A clear note, then low noise the tracker cannot read.
let mut x = notes(&[(0.1, 0.6, 55., 0.5)], 0.6);
let mut seed = 3u32;
let mut lp = 0f32;
for _ in 0..seconds(1.5) {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
lp += 0.05 * ((seed as f32 / u32::MAX as f32 - 0.5) - lp);
x.push(4. * lp);
}
let solo = render(&mut e, &x, true);
let sine = rms(&solo[seconds(0.3) + l..seconds(0.5) + l]);
let unsure = rms(&solo[seconds(1.5) + l..seconds(2.0) + l]);
assert!(
db(unsure) < db(sine) - 40.,
"{} vs {}",
db(unsure),
db(sine)
);
// The full output carries the original low band again.
let (mut e, l) = engine(Settings::default());
let y = render(&mut e, &x, false);
let a = rms(&x[seconds(1.5)..seconds(2.0)]);
let b = rms(&y[seconds(1.5) + l..seconds(2.0) + l]);
assert!((db(a) - db(b)).abs() < 1., "{} vs {}", db(a), db(b));
}
#[test]
fn enhance_keeps_the_original_and_adds_the_sine() {
let x = tones(&[(55., 0.3)], 1.2);
for (mode, keeps) in [(Mode::Replace, false), (Mode::Enhance, true)] {
let (mut e, l) = engine(Settings {
mode,
octave: -1,
..Settings::default()
});
let y = render(&mut e, &x, false);
// Whole cycles of 27.5 Hz: 0.4 s.
let w = &y[seconds(0.5) + l..seconds(0.9) + l];
let original = amplitude(w, 55.);
let added = amplitude(w, 27.5);
assert!((db(added) + 9.).abs() < 1., "{mode:?}: {}", db(added));
if keeps {
assert!((db(original) - db(0.3)).abs() < 1., "{}", db(original));
} else {
assert!(db(original) < db(0.3) - 12., "{}", db(original));
}
}
}
#[test]
fn note_mode_plays_the_set_note_whatever_the_audio() {
let (mut e, l) = engine(Settings {
source: Source::Note,
pitch: common_dsp::tracker::Pitch {
note: 29,
..Default::default()
},
..Settings::default()
});
let x = notes(&[(0.1, 1.1, 61.74, 0.5)], 1.3);
let y = render(&mut e, &x, true);
let f = frequency(&y[seconds(0.3) + l..seconds(1.0) + l]);
assert!((f / 43.654 - 1.).abs() < 0.005, "{f}");
}
+14 -1
View File
@@ -72,11 +72,12 @@ checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
[[package]]
name = "anvil"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"anvil-core",
"anvil-ui",
"clap-sys",
"common-params",
"crossbeam-queue",
"nih_plug",
"nih_plug_egui",
@@ -340,11 +341,23 @@ dependencies = [
[[package]]
name = "common-dsp"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "common-params"
version = "0.1.0"
dependencies = [
"common-dsp",
"nih_plug",
]
[[package]]
name = "common-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"egui",
]
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "anvil"
version = "0.3.0"
version = "0.4.0"
edition = "2021"
license = "ISC"
@@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
anvil-core = { path = "../core" }
common-params = { path = "../../common/params" }
anvil-ui = { path = "../ui" }
crossbeam-queue = "0.3"
+61 -10
View File
@@ -1,4 +1,5 @@
use anvil_core::{Settings, Source, KICK_OFFSET_MAX};
use anvil_core::{Mode, Settings, Source, KICK_OFFSET_MAX};
use common_params::PitchParams;
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::Arc;
@@ -10,6 +11,49 @@ pub enum SourceParam {
#[id = "midi"]
#[name = "MIDI"]
Midi,
#[id = "note"]
Note,
}
impl From<SourceParam> for Source {
fn from(s: SourceParam) -> Self {
match s {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
SourceParam::Note => Source::Note,
}
}
}
impl From<Source> for SourceParam {
fn from(s: Source) -> Self {
match s {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
Source::Note => SourceParam::Note,
}
}
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum ModeParam {
#[id = "replace"]
Replace,
#[id = "enhance"]
Enhance,
}
impl From<ModeParam> for Mode {
fn from(m: ModeParam) -> Self {
match m {
ModeParam::Replace => Mode::Replace,
ModeParam::Enhance => Mode::Enhance,
}
}
}
impl From<Mode> for ModeParam {
fn from(m: Mode) -> Self {
match m {
Mode::Replace => ModeParam::Replace,
Mode::Enhance => ModeParam::Enhance,
}
}
}
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)
@@ -20,6 +64,10 @@ pub struct AnvilParams {
pub editor: Arc<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[nested(group = "Pitch")]
pub pitch: PitchParams,
#[id = "mode"]
pub mode: EnumParam<ModeParam>,
#[id = "level"]
pub level: FloatParam,
#[id = "split"]
@@ -65,6 +113,8 @@ impl Default for AnvilParams {
Self {
editor: EguiState::from_size(anvil_ui::WIDTH, anvil_ui::HEIGHT),
source: EnumParam::new("Pitch source", SourceParam::Audio),
pitch: PitchParams::default(),
mode: EnumParam::new("Mode", ModeParam::Replace),
level: linear("Sub level", d.level, -30., 0., " dBFS"),
split: FloatParam::new(
"Split",
@@ -114,10 +164,9 @@ impl Default for AnvilParams {
impl AnvilParams {
pub fn settings(&self) -> Settings {
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
source: self.source.value().into(),
pitch: self.pitch.pitch(),
mode: self.mode.value().into(),
level: self.level.value(),
split: self.split.value(),
dynamics: self.dynamics.value(),
@@ -206,14 +255,16 @@ impl AnvilParams {
});
}
once(new.source != old.source, &|| {
let v = match new.source {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
setter.begin_set_parameter(&self.source);
setter.set_parameter(&self.source, v);
setter.set_parameter(&self.source, new.source.into());
setter.end_set_parameter(&self.source);
});
once(new.mode != old.mode, &|| {
setter.begin_set_parameter(&self.mode);
setter.set_parameter(&self.mode, new.mode.into());
setter.end_set_parameter(&self.mode);
});
self.pitch.apply(setter, &old.pitch, &new.pitch);
}
fn continuous(&self, i: usize) -> &FloatParam {
[
+175 -22
View File
@@ -1,6 +1,6 @@
//! Anvil editor, shared by the CLAP wrapper and the headless renderer.
use anvil_core::{
AnalyzerFrame, Settings, Source, AUDITION, AUDITION_HITS, GHOST_COUNT, KICK_OFFSET_MAX,
AnalyzerFrame, Mode, Settings, Source, AUDITION, AUDITION_HITS, GHOST_COUNT, KICK_OFFSET_MAX,
PHONE_LOW, SCOPE,
};
use common_ui::*;
@@ -20,10 +20,13 @@ pub struct State {
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
/// The advanced page shows instead of the main one. Not saved.
pub advanced: bool,
}
impl Default for State {
fn default() -> Self {
Self {
advanced: false,
solo: false,
phone: false,
audition: false,
@@ -206,7 +209,8 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
ui.add_space(2.);
header(ui, "ANVIL", "SUB ANCHOR", |ui| {
let mut advanced = state.advanced;
header_paged(ui, "ANVIL", "SUB ANCHOR", Some(&mut advanced), |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.");
@@ -228,14 +232,11 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
state.phone = !state.phone;
}
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");
let mut mode = settings.source.mode();
pitch_from(ui, &mut mode, &mut settings.pitch);
settings.source = Source::from_mode(mode);
});
state.advanced = advanced;
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
@@ -251,15 +252,24 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
kick_panel(ui, settings, state);
});
if state.advanced {
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
kick_panel(ui, settings, state);
});
} else {
let height = ui.available_height() - 64.;
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_min_height(height);
ui.vertical(|ui| main_controls(ui, settings, state));
});
}
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
@@ -268,6 +278,7 @@ 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.neutral => ("?".into(), "unsure: original sub plays".into()),
_ => ("—".into(), "no note".into()),
};
ui.label(
@@ -284,15 +295,137 @@ fn note_panel(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame) {
RichText::new(format!(
"{:.0} ms lookahead · {}",
frame.latency as f32 / frame.sample_rate * 1000.,
match settings.source {
Source::Audio => "detecting pitch",
Source::Midi => "following MIDI notes",
}
pitch_status(settings.source.mode(), &settings.pitch)
))
.size(12.)
.color(MUTED),
);
}
/// The main page: what happens to the original sub, where the sub ends and how loud the
/// new one plays.
fn main_controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let defaults = Settings::default();
ui.add_space(24.);
ui.columns(3, |c| {
c[0].vertical_centered(|ui| {
caption(ui, "MODE");
ui.add_space(6.);
ui.spacing_mut().item_spacing.x = 8.;
let r = choice(
ui,
&mut settings.mode,
&[
(
Mode::Replace,
"REPLACE",
"Swap the sub below Split for a clean sine: one level and phase on every note.",
),
(
Mode::Enhance,
"ENHANCE",
"Keep the original sub and add the clean sine under it for weight.",
),
],
);
if let Some(r) = r {
hint(&r, state, "Replace swaps the sub; Enhance keeps it and adds weight.");
}
ui.add_space(14.);
let text = match settings.mode {
Mode::Replace => {
"The band below Split is replaced by a clean sine on the played note. \
Every note gets the same level and starts at the same phase."
}
Mode::Enhance => {
"Your sub stays as it is. A clean sine on the played note is added \
under it. Try Octave −1 for a sub-octave."
}
};
ui.label(RichText::new(text).size(13.).color(MUTED));
ui.add_space(10.);
ui.label(
RichText::new(
"When the note is unclear, Anvil plays your original sub instead of guessing.",
)
.size(12.)
.color(DIM),
);
});
knob(
&mut c[1],
state,
"Split",
&mut settings.split,
40. ..=200.,
defaults.split,
" Hz",
120.,
"Set it just above the sub. Below it the sub is replaced (or enhanced).",
);
knob(
&mut c[2],
state,
"Sub level",
&mut settings.level,
-30. ..=0.,
defaults.level,
" dBFS",
120.,
"Peak level of the new sub. Every note gets exactly this level.",
);
});
ui.add_space(28.);
ui.separator();
ui.add_space(12.);
ui.columns(4, |c| {
let mut octave = settings.octave as f32;
knob(
&mut c[0],
state,
"Octave",
&mut octave,
-2. ..=1.,
0.,
"",
64.,
"Move the new sub up or down an octave against the played note.",
);
settings.octave = octave.round() as i32;
knob(
&mut c[1],
state,
"Ghost",
&mut settings.ghost,
0. ..=100.,
defaults.ghost,
" %",
64.,
"Lets phones and laptops hear the sub note: adds its harmonics the patch lacks.",
);
knob(
&mut c[2],
state,
"Keep",
&mut settings.keep,
0. ..=100.,
defaults.keep,
" %",
64.,
"Blend some of the original sub back under the new one (Replace mode).",
);
knob(
&mut c[3],
state,
"Output",
&mut settings.output,
-12. ..=12.,
defaults.output,
" dB",
64.,
"Output trim.",
);
});
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let defaults = Settings::default();
ui.columns(6, |c| {
@@ -452,6 +585,26 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
"SNAP TO SEMITONE",
"Round the detected pitch. Turn off for slides and pitch bends.",
);
ui.separator();
caption(ui, "MODE");
for (mode, label, help) in [
(
Mode::Replace,
"REPLACE",
"Swap the sub below Split for the clean sine.",
),
(
Mode::Enhance,
"ENHANCE",
"Keep the original sub and add the sine under it.",
),
] {
let r = ui.selectable_label(settings.mode == mode, RichText::new(label).size(13.));
if r.clicked() {
settings.mode = mode;
}
hint(&r, state, help);
}
});
}
fn kick_panel(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
+10 -2
View File
@@ -11,7 +11,7 @@
mod settings;
use common_dsp::loudness::{lufs, KWeighting};
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig};
use common_dsp::tracker::{NoteEvent, PitchMode, PitchSource, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, gain_to_db};
pub use settings::{
Grid, Segment, Settings, Source, FROM_MAX, FROM_MIN, RANGE_MAX, TARGET_MAX, TARGET_MIN,
@@ -216,7 +216,15 @@ impl Engine {
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
self.tracker.set(s.source == Source::Midi, s.gate, false);
self.tracker.set(PitchSource {
mode: if s.source == Source::Midi {
PitchMode::Midi
} else {
PitchMode::Audio
},
gate_db: s.gate,
..PitchSource::default()
});
if s.from != self.settings.from || self.fresh {
self.from_coef = if s.from > FROM_MIN {
SvfCoef::highpass(self.rate, s.from, Q)
+3 -2
View File
@@ -22,9 +22,10 @@ pub enum Source {
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Segment {
/// Every note start.
#[default]
Notes,
/// Every line of the host's beat grid, so a patch change on the same note counts too.
/// The default: a switch-up lands on the grid, and the grid needs no note tracking.
#[default]
Grid,
}
@@ -91,7 +92,7 @@ impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
segment: Segment::Notes,
segment: Segment::Grid,
grid: Grid::Quarter,
target: -14.,
amount: 100.,
+2
View File
@@ -117,6 +117,7 @@ fn note_starts_from_the_audio_cut_the_parts() {
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(Settings {
target,
segment: Segment::Notes,
..Settings::default()
})
.unwrap();
@@ -151,6 +152,7 @@ fn midi_note_starts_cut_legato_parts() {
e.set_settings(Settings {
target,
source: Source::Midi,
segment: Segment::Notes,
..Settings::default()
})
.unwrap();
+5 -1
View File
@@ -140,7 +140,7 @@ dependencies = [
[[package]]
name = "ballast"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"ballast-core",
"ballast-ui",
@@ -340,11 +340,15 @@ dependencies = [
[[package]]
name = "common-dsp"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "common-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"egui",
]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ballast"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "ISC"
+1 -1
View File
@@ -88,7 +88,7 @@ impl Default for BallastParams {
Self {
editor: EguiState::from_size(ballast_ui::WIDTH, ballast_ui::HEIGHT),
source: EnumParam::new("Note source", SourceParam::Audio),
segment: EnumParam::new("Parts from", SegmentParam::Notes),
segment: EnumParam::new("Parts from", SegmentParam::Grid),
grid: EnumParam::new("Grid", grid_param(d.grid)),
target: linear("Target", d.target, TARGET_MIN, TARGET_MAX, " LUFS"),
amount: linear("Amount", d.amount, 0., 100., " %"),
+4 -2
View File
@@ -181,7 +181,9 @@ impl Engine {
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
self.tracker.set(s.source == Source::Midi, s.gate, true);
// Unsure tracking sends no events, so the beating holds its last sure note.
self.tracker
.set(s.pitch.source(s.source.mode(), s.gate, true));
if s.low_cut != self.settings.low_cut || self.fresh {
let q = std::f32::consts::FRAC_1_SQRT_2;
let hz = s.low_cut.min(0.45 * self.rate);
@@ -340,7 +342,7 @@ impl Engine {
let beat_hz = self.tempo / 60. / s.rate.quarters();
f.ratio = 1. + (beat_hz / (s.harmonic as f64 * hz as f64)) as f32;
}
if self.tracker.sounding() && hz > 0. {
if self.tracker.active() && hz > 0. {
f.note = Some(hz_to_midi(hz).round().clamp(0., 127.) as u8);
}
f
+24
View File
@@ -1,4 +1,5 @@
//! Host-independent controls. Levels in dB, amounts in percent, angles in degrees.
use common_dsp::tracker::{Pitch, PitchMode};
use serde::{Deserialize, Serialize};
/// Highest harmonic the beat rate can be set on.
@@ -14,6 +15,24 @@ pub enum Source {
Audio,
/// Notes come from MIDI note input.
Midi,
/// Note starts are detected, the pitch is the set note: nothing is guessed.
Note,
}
impl Source {
pub fn mode(self) -> PitchMode {
match self {
Source::Audio => PitchMode::Audio,
Source::Midi => PitchMode::Midi,
Source::Note => PitchMode::Note,
}
}
pub fn from_mode(mode: PitchMode) -> Self {
match mode {
PitchMode::Audio => Source::Audio,
PitchMode::Midi => Source::Midi,
PitchMode::Note => Source::Note,
}
}
}
/// One beat cycle lasts this note value.
@@ -75,6 +94,9 @@ impl Rate {
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// Note mode's note, the key lock and Follow bends.
#[serde(default)]
pub pitch: Pitch,
/// How long one beat cycle of `harmonic` lasts.
pub rate: Rate,
/// The harmonic that beats exactly at `rate`. Harmonic n beats n/harmonic times as fast.
@@ -100,6 +122,7 @@ impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
pitch: Pitch::default(),
rate: Rate::Eighth,
// Harmonic 4 sits above the low cut on every bass note, and on F1 it asks for
// about 45 cents of detune, a usual reese spread.
@@ -117,6 +140,7 @@ impl Default for Settings {
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
self.pitch.validate()?;
let finite = [
self.depth,
self.low_cut,
+14 -1
View File
@@ -184,11 +184,12 @@ dependencies = [
[[package]]
name = "braze"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"braze-core",
"braze-ui",
"clap-sys",
"common-params",
"crossbeam-queue",
"nih_plug",
"nih_plug_egui",
@@ -340,11 +341,23 @@ dependencies = [
[[package]]
name = "common-dsp"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "common-params"
version = "0.1.0"
dependencies = [
"common-dsp",
"nih_plug",
]
[[package]]
name = "common-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"egui",
]
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "braze"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "ISC"
@@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"]
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" }
braze-core = { path = "../core" }
common-params = { path = "../../common/params" }
braze-ui = { path = "../ui" }
crossbeam-queue = "0.3"
+28 -9
View File
@@ -1,4 +1,5 @@
use braze_core::{Rate, Settings, Source, HARMONIC_MAX, LOW_CUT_MAX};
use common_params::PitchParams;
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::Arc;
@@ -10,6 +11,26 @@ pub enum SourceParam {
#[id = "midi"]
#[name = "MIDI"]
Midi,
#[id = "note"]
Note,
}
impl From<SourceParam> for Source {
fn from(s: SourceParam) -> Self {
match s {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
SourceParam::Note => Source::Note,
}
}
}
impl From<Source> for SourceParam {
fn from(s: Source) -> Self {
match s {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
Source::Note => SourceParam::Note,
}
}
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum RateParam {
@@ -70,6 +91,8 @@ pub struct BrazeParams {
pub editor: Arc<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[nested(group = "Pitch")]
pub pitch: PitchParams,
#[id = "rate"]
pub rate: EnumParam<RateParam>,
#[id = "harmonic"]
@@ -97,6 +120,7 @@ impl Default for BrazeParams {
Self {
editor: EguiState::from_size(braze_ui::WIDTH, braze_ui::HEIGHT),
source: EnumParam::new("Pitch source", SourceParam::Audio),
pitch: PitchParams::default(),
rate: EnumParam::new("Rate", to_param(d.rate)),
harmonic: IntParam::new(
"Harmonic",
@@ -130,10 +154,8 @@ impl BrazeParams {
pub fn settings(&self) -> Settings {
let rate = self.rate.value();
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
source: self.source.value().into(),
pitch: self.pitch.pitch(),
rate: RATES
.iter()
.find(|(p, _)| *p == rate)
@@ -207,13 +229,10 @@ impl BrazeParams {
}
}
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.set_parameter(&self.source, new.source.into());
setter.end_set_parameter(&self.source);
}
self.pitch.apply(setter, &old.pitch, &new.pitch);
}
}
+64 -52
View File
@@ -14,12 +14,15 @@ pub struct State {
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
/// The advanced page shows its extra controls. Not saved.
pub advanced: bool,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
gesture_active: false,
advanced: false,
help: DEFAULT_HELP.into(),
}
}
@@ -194,22 +197,26 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
ui.add_space(2.);
header(ui, "BRAZE", "TEMPO-LOCKED BEATING", |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.));
let mut advanced = state.advanced;
header_paged(
ui,
"BRAZE",
"TEMPO-LOCKED BEATING",
Some(&mut advanced),
|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.source = source;
settings.bypass = !settings.bypass;
}
}
caption(ui, "PITCH FROM");
});
ui.separator();
let mut mode = settings.source.mode();
pitch_from(ui, &mut mode, &mut settings.pitch);
settings.source = Source::from_mode(mode);
},
);
state.advanced = advanced;
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
@@ -279,10 +286,7 @@ fn note_panel(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame) {
RichText::new(format!(
"{:.0} ms lookahead · {}",
frame.latency as f32 / frame.sample_rate * 1000.,
match settings.source {
Source::Audio => "detecting pitch",
Source::Midi => "following MIDI notes",
}
pitch_status(settings.source.mode(), &settings.pitch)
))
.size(12.)
.color(MUTED),
@@ -326,7 +330,8 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
});
ui.add_space(8.);
ui.separator();
ui.columns(6, |c| {
let advanced = state.advanced;
ui.columns(if advanced { 6 } else { 4 }, |c| {
knob(
&mut c[0],
state,
@@ -362,30 +367,35 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
"Moves the right channel's beat against the left. 100 %: one side peaks while \
the other nulls. Check the mono sum.",
);
let at = if advanced {
knob(
&mut c[3],
state,
"Phase",
&mut settings.phase,
0. ..=360.,
d.phase,
"°",
72.,
"Where the beat peaks. 0° puts the peak of the chosen harmonic on the beat.",
);
knob(
&mut c[4],
state,
"Gate",
&mut settings.gate,
-60. ..=-20.,
d.gate,
" dB",
72.,
"Input level that counts as a note. Raise it if tails retrigger.",
);
5
} else {
3
};
knob(
&mut c[3],
state,
"Phase",
&mut settings.phase,
0. ..=360.,
d.phase,
"°",
72.,
"Where the beat peaks. 0° puts the peak of the chosen harmonic on the beat.",
);
knob(
&mut c[4],
state,
"Gate",
&mut settings.gate,
-60. ..=-20.,
d.gate,
" dB",
72.,
"Input level that counts as a note. Raise it if tails retrigger.",
);
knob(
&mut c[5],
&mut c[at],
state,
"Output",
&mut settings.output,
@@ -396,15 +406,17 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
"Output trim.",
);
});
ui.add_space(4.);
let r = ui.selectable_label(settings.snap, RichText::new("SNAP TO SEMITONE").size(13.));
if r.clicked() {
settings.snap = !settings.snap;
if state.advanced {
ui.add_space(4.);
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. Off keeps the copy's jumps on the real period, which is \
seamless on detuned patches.",
);
}
hint(
&r,
state,
"Round the detected pitch. Off keeps the copy's jumps on the real period, which is \
seamless on detuned patches.",
);
}
+1
View File
@@ -5,3 +5,4 @@ edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true }
@@ -0,0 +1,118 @@
//! Scores the note tracker on bass stems.
//!
//! ```text
//! cargo run -p common-dsp --release --example tracker_report
//! cargo run -p common-dsp --release --example tracker_report -- [--sub] [--bends]
//! [--note F1] [--key free|minor|major|root] stem.wav ...
//! ```
//! Without files it scores the synthetic corpus in `common_dsp::stems`, once with the stable
//! default and once with Follow bends. With files, `--note` names the one note each file
//! plays (for a wrong-note share); without it only note starts and unsure time are reported.
use common_dsp::parse_note;
use common_dsp::stems::{self, Score, Truth};
use common_dsp::tracker::{PitchSource, Scale, TrackerConfig};
use std::{env, fs, process};
fn row(name: &str, s: Score) {
println!(
"{name:<14} {:>6.1}s {:>8.2} {:>8.2} {:>7.1}% {:>7.1}%",
s.seconds,
s.starts,
s.extra_starts,
s.wrong * 100.,
s.unsure * 100.
);
}
fn header() {
println!(
"{:<14} {:>7} {:>8} {:>8} {:>8} {:>8}",
"stem", "length", "starts/s", "extra/s", "wrong", "unsure"
);
}
fn main() {
let mut cfg = TrackerConfig::BASS;
let mut source = PitchSource::default();
let mut note = None;
let mut files = vec![];
let mut args = env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--sub" => cfg = TrackerConfig::SUB,
"--bends" => source.bends = true,
"--note" => {
let n = args.next().and_then(|n| parse_note(&n));
let Some(n) = n else {
eprintln!("--note needs a note name such as F1");
process::exit(2);
};
note = Some(n);
source.note = n;
}
"--key" => {
source.scale = match args.next().as_deref() {
Some("free") => Scale::Free,
Some("minor") => Scale::Minor,
Some("major") => Scale::Major,
Some("root") => Scale::Root,
_ => {
eprintln!("--key needs free, minor, major or root");
process::exit(2);
}
};
}
_ => files.push(a),
}
}
if files.is_empty() {
let rate = 48000.;
for bends in [false, true] {
println!(
"\n{}",
if bends {
"Follow bends"
} else {
"Stable (default)"
}
);
header();
let s = PitchSource { bends, ..source };
for stem in stems::all(rate) {
row(
stem.name,
stems::score(rate, cfg, s, &stem.samples, &stem.truth),
);
}
}
return;
}
header();
for path in files {
let wave = match fs::read(&path)
.map_err(|e| e.to_string())
.and_then(|b| common_dsp::wav::decode(&b).map_err(str::to_string))
{
Ok(w) => w,
Err(e) => {
eprintln!("{path}: {e}");
continue;
}
};
let channels = wave.channels as usize;
let mono: Vec<f32> = wave
.samples
.chunks(channels)
.map(|c| c.iter().sum::<f32>() / channels as f32)
.collect();
// With a known note, quiet samples are skipped rather than counted as silence.
let truth = match note {
Some(n) => vec![Truth::Note(n as f32); mono.len()],
None => vec![],
};
let name = path.rsplit('/').next().unwrap_or(&path);
row(
name,
stems::score(wave.rate as f32, cfg, source, &mono, &truth),
);
}
}
+33 -2
View File
@@ -1,7 +1,9 @@
//! Shared allocation-free DSP building blocks: filters, pitch detection, notes, WAV files.
//! Shared allocation-free DSP building blocks: filters, pitch detection, notes, WAV files,
//! and synthetic test stems.
pub mod loudness;
pub mod meter;
pub mod oversample;
pub mod stems;
pub mod svf;
pub mod tracker;
pub mod wav;
@@ -216,10 +218,39 @@ pub fn hz_to_midi(hz: f32) -> f32 {
}
/// Scientific note name: MIDI 60 = C4, 43.65 Hz = F1. FL Studio labels MIDI 60 as C5.
pub fn note_name(note: u8) -> String {
note_name_spelled(note, false)
}
/// Note name with sharps, or with flats (Db, Eb, Gb, Ab, Bb) for keys written in flats.
pub fn note_name_spelled(note: u8, flats: bool) -> String {
const SHARPS: [&str; 12] = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
const FLATS: [&str; 12] = [
"C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B",
];
let names = if flats { FLATS } else { SHARPS };
format!("{}{}", names[note as usize % 12], note as i32 / 12 - 1)
}
/// Reads a scientific note name such as "F1" or "c#2", the inverse of [`note_name`].
pub fn parse_note(text: &str) -> Option<u8> {
const NAMES: [&str; 12] = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
format!("{}{}", NAMES[note as usize % 12], note as i32 / 12 - 1)
let text = text.trim();
let split = text.find(|c: char| c.is_ascii_digit() || c == '-')?;
let (pitch, octave) = text.split_at(split);
const FLATS: [&str; 12] = [
"C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B",
];
let class = NAMES
.iter()
.position(|n| n.eq_ignore_ascii_case(pitch))
.or_else(|| FLATS.iter().position(|n| n.eq_ignore_ascii_case(pitch)))?
as i32;
let octave: i32 = octave.parse().ok()?;
u8::try_from((octave + 1) * 12 + class)
.ok()
.filter(|n| *n <= 127)
}
pub fn db_to_gain(db: f32) -> f32 {
10f32.powf(db / 20.)
+414
View File
@@ -0,0 +1,414 @@
//! Synthetic bass stems with known notes, and a score for how well the tracker follows them.
//!
//! The stems imitate what makes real bass hard to track: FM growls, detuned reeses, moving
//! formants over a weak fundamental, 808 slides, layered octaves and noise. They are a stand-in
//! until real stems are collected; `examples/tracker_report.rs` scores real WAV files too.
use crate::tracker::{NoteEvent, PitchSource, Tracker, TrackerConfig};
use crate::{midi_to_hz, Biquad, Saw};
use std::f32::consts::TAU;
/// What the tracker should report for one input sample.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Truth {
/// One note sounds at this fractional MIDI pitch.
Note(f32),
/// No note sounds; reporting one is wrong.
Silent,
/// A transition (attack, slide): not scored.
Skip,
}
pub struct Stem {
pub name: &'static str,
pub samples: Vec<f32>,
pub truth: Vec<Truth>,
}
/// Deterministic white noise in -1..1.
struct Noise(u32);
impl Noise {
fn sample(&mut self) -> f32 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 17;
self.0 ^= self.0 << 5;
self.0 as f32 / u32::MAX as f32 * 2. - 1.
}
}
/// Notes as (start s, end s, MIDI note). Samples outside every note are silent; the first
/// `SKIP_S` of each note is not scored.
const SKIP_S: f32 = 0.03;
fn truth(rate: f32, len: usize, notes: &[(f32, f32, f32)]) -> Vec<Truth> {
(0..len)
.map(|i| {
let t = i as f32 / rate;
match notes.iter().find(|(a, b, _)| t >= *a && t < *b) {
Some((a, _, n)) if t - a >= SKIP_S => Truth::Note(*n),
Some(_) => Truth::Skip,
None => Truth::Silent,
}
})
.collect()
}
/// Short fade at note edges so onsets are clean.
fn envelope(t: f32, a: f32, b: f32) -> f32 {
((t - a) / 0.005).clamp(0., 1.) * ((b - t) / 0.005).clamp(0., 1.)
}
fn growl(rate: f32) -> Stem {
// FM: modulator at twice the carrier, index wobbling at 3 Hz, then driven.
let notes = [(0.1, 1.9, 29.), (2.0, 3.9, 32.)];
let len = (4. * rate) as usize;
let (mut pc, mut pm) = (0f32, 0f32);
let samples = (0..len)
.map(|i| {
let t = i as f32 / rate;
let Some(&(a, b, n)) = notes.iter().find(|(a, b, _)| t >= *a && t < *b) else {
return 0.;
};
let hz = midi_to_hz(n);
pc = (pc + hz / rate).fract();
pm = (pm + 2. * hz / rate).fract();
let index = 3. + 3. * (TAU * 3. * t).sin();
let x = (TAU * pc + index * (TAU * pm).sin()).sin();
0.5 * (2.5 * x).tanh() * envelope(t, a, b)
})
.collect();
Stem {
name: "growl",
samples,
truth: truth(rate, len, &notes),
}
}
fn hard_growl(rate: f32) -> Stem {
// FM at a 1:1 ratio, index sweeping 2–12 at 6 Hz, hard drive, then a sweeping resonance.
let notes = [(0.1, 1.0, 31.), (1.0, 2.0, 34.), (2.1, 3.9, 29.)];
let len = (4. * rate) as usize;
let (mut pc, mut pm) = (0f32, 0f32);
let mut band = Biquad::default();
let samples = (0..len)
.map(|i| {
let t = i as f32 / rate;
let Some(&(a, b, n)) = notes.iter().find(|(a, b, _)| t >= *a && t < *b) else {
return 0.;
};
if i % 32 == 0 {
let sweep = 0.5 + 0.5 * (TAU * 6. * t).sin();
band = retuned(band, Biquad::peak(rate, 200. + 1800. * sweep, 3., 12.));
}
let hz = midi_to_hz(n);
pc = (pc + hz / rate).fract();
pm = (pm + hz / rate).fract();
let index = 7. + 5. * (TAU * 6. * t).sin();
let x = (4. * (TAU * pc + index * (TAU * pm).sin()).sin()).tanh();
0.15 * band.process(x) * envelope(t, a, b)
})
.collect();
Stem {
name: "hard growl",
samples,
truth: truth(rate, len, &notes),
}
}
fn wobble(rate: f32) -> Stem {
// A saw on G1 through a resonant low-pass wobbling 80–1500 Hz at 1/8 notes of 140 BPM.
let notes = [(0.1, 3.9, 31.)];
let len = (4. * rate) as usize;
let mut saw = Saw::new(rate, midi_to_hz(31.));
let mut lp = [Biquad::default(); 2];
let samples = (0..len)
.map(|i| {
let t = i as f32 / rate;
let (a, b, _) = notes[0];
if t < a || t >= b {
return 0.;
}
if i % 32 == 0 {
let lfo = 0.5 - 0.5 * (TAU * (140. / 60. * 2.) * t).cos();
let hz = 80. * (1500f32 / 80.).powf(lfo);
lp[0] = retuned(lp[0], Biquad::lowpass(rate, hz, 6.));
lp[1] = retuned(lp[1], Biquad::lowpass(rate, hz, 0.7));
}
let y = lp[0].process(0.3 * saw.sample());
lp[1].process(y) * envelope(t, a, b)
})
.collect();
Stem {
name: "wobble",
samples,
truth: truth(rate, len, &notes),
}
}
fn reese(rate: f32) -> Stem {
// Two saws 12 cents apart, lowpassed; A1 then D2 legato.
let notes = [(0.1, 2.0, 33.), (2.0, 3.9, 38.)];
let len = (4. * rate) as usize;
let mut lp = [Biquad::lowpass(rate, 900., 0.7); 2];
let mut saws: Option<(f32, Saw, Saw)> = None;
let samples = (0..len)
.map(|i| {
let t = i as f32 / rate;
let Some(&(_, _, n)) = notes.iter().find(|(a, b, _)| t >= *a && t < *b) else {
return 0.;
};
if saws.as_ref().map(|s| s.0) != Some(n) {
let hz = midi_to_hz(n);
saws = Some((
n,
Saw::new(rate, hz * 2f32.powf(0.06 / 12.)),
Saw::new(rate, hz * 2f32.powf(-0.06 / 12.)),
));
}
let (_, a, b) = saws.as_mut().unwrap();
let x = 0.3 * (a.sample() + b.sample());
let y = lp[0].process(x);
let y = lp[1].process(y);
y * envelope(t, notes[0].0, notes[1].1)
})
.collect();
Stem {
name: "reese",
samples,
truth: truth(rate, len, &notes),
}
}
fn formant(rate: f32) -> Stem {
// A saw on E1 through two sweeping band-passes; the fundamental is 18 dB down.
let notes = [(0.1, 3.9, 28.)];
let len = (4. * rate) as usize;
let hz = midi_to_hz(28.);
let mut saw = Saw::new(rate, hz);
let mut phase = 0f32;
let mut bands = [Biquad::default(); 2];
let samples = (0..len)
.map(|i| {
let t = i as f32 / rate;
let (a, b, _) = notes[0];
if t < a || t >= b {
return 0.;
}
if i % 32 == 0 {
let sweep = 0.5 + 0.5 * (TAU * 1.5 * t).sin();
bands[0] = retuned(bands[0], Biquad::peak(rate, 300. + 500. * sweep, 4., 18.));
bands[1] = retuned(bands[1], Biquad::peak(rate, 900. + 900. * sweep, 4., 18.));
}
phase = (phase + hz / rate).fract();
let x = 0.05 * saw.sample();
let y = bands[0].process(x);
let y = bands[1].process(y);
(0.4 * y + 0.06 * (TAU * phase).sin()) * envelope(t, a, b)
})
.collect();
Stem {
name: "formant",
samples,
truth: truth(rate, len, &notes),
}
}
fn retuned(mut old: Biquad, new: Biquad) -> Biquad {
old.retune(new);
old
}
fn slide808(rate: f32) -> Stem {
// A saturated sine on C2 that slides down to G1 over 60 ms, decaying slowly.
let notes = [(0.1, 1.0, 36.), (1.0, 3.0, 31.)];
let len = (3.2 * rate) as usize;
let mut phase = 0f32;
let samples = (0..len)
.map(|i| {
let t = i as f32 / rate;
if !(0.1..3.0).contains(&t) {
return 0.;
}
let n = if t < 1. {
36.
} else {
36. - 5. * ((t - 1.) / 0.06).min(1.)
};
phase = (phase + midi_to_hz(n) / rate).fract();
let decay = (-(t - 0.1) / 2.5).exp();
0.6 * decay * (2. * (TAU * phase).sin()).tanh() * envelope(t, 0.1, 3.)
})
.collect();
let mut truth = truth(rate, len, &notes);
// The slide itself is not scored.
for (i, v) in truth.iter_mut().enumerate() {
let t = i as f32 / rate;
if (1.0..1.1).contains(&t) {
*v = Truth::Skip;
}
}
Stem {
name: "808 slide",
samples,
truth,
}
}
fn layered(rate: f32) -> Stem {
// Sub sine on F1 under an FM growl an octave up, plus noise: the drop's low end.
let notes = [(0.1, 3.9, 29.)];
let len = (4. * rate) as usize;
let hz = midi_to_hz(29.);
let (mut sub, mut pc, mut pm) = (0f32, 0f32, 0f32);
let mut noise = Noise(0x9e37_79b9);
let samples = (0..len)
.map(|i| {
let t = i as f32 / rate;
let (a, b, _) = notes[0];
if t < a || t >= b {
return 0.;
}
sub = (sub + hz / rate).fract();
pc = (pc + 2. * hz / rate).fract();
pm = (pm + 3. * hz / rate).fract();
let index = 2. + 2. * (TAU * 2. * t).sin();
let growl = (2. * (TAU * pc + index * (TAU * pm).sin()).sin()).tanh();
(0.3 * (TAU * sub).sin() + 0.35 * growl + 0.05 * noise.sample()) * envelope(t, a, b)
})
.collect();
Stem {
name: "layered",
samples,
truth: truth(rate, len, &notes),
}
}
fn riser(rate: f32) -> Stem {
// Low-passed noise swelling up: no note at all.
let len = (3. * rate) as usize;
let mut noise = Noise(0x1234_5678);
let mut lp = [Biquad::lowpass(rate, 600., 0.7); 2];
let samples = (0..len)
.map(|i| {
let t = i as f32 / rate;
let x = noise.sample() * 0.6 * (t / 3.);
let y = lp[0].process(x);
lp[1].process(y)
})
.collect();
Stem {
name: "noise riser",
samples,
truth: vec![Truth::Silent; len],
}
}
/// The corpus at sample rate `rate`.
pub fn all(rate: f32) -> Vec<Stem> {
vec![
growl(rate),
hard_growl(rate),
wobble(rate),
reese(rate),
formant(rate),
slide808(rate),
layered(rate),
riser(rate),
]
}
/// How the tracker did on one stem. Shares are of the scored samples.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Score {
pub seconds: f32,
/// Note starts per second beyond the stem's real notes (0 when no truth is known).
pub extra_starts: f32,
/// Note starts per second.
pub starts: f32,
/// Share of note samples where the tracker reports a sure note that is wrong by more
/// than half a semitone, plus silent samples where it reports a sure note.
pub wrong: f32,
/// Share of note samples where the tracker is unsure or reports no note.
pub unsure: f32,
}
/// Runs `samples` through a tracker and scores its output against `truth` (input-aligned;
/// empty when unknown).
pub fn score(
rate: f32,
cfg: TrackerConfig,
source: PitchSource,
samples: &[f32],
truth: &[Truth],
) -> Score {
let mut t = Tracker::new(rate, cfg);
t.set(source);
let latency = t.latency();
let mut pitch = 0f32;
let mut starts = 0usize;
let (mut wrong, mut unsure, mut scored) = (0usize, 0usize, 0usize);
for i in 0..samples.len() + latency {
let x = samples.get(i).copied().unwrap_or(0.);
t.process(x, |e| match e {
NoteEvent::Start { pitch: p, .. } => {
pitch = p;
starts += 1;
}
NoteEvent::Move { pitch: p } => pitch = p,
NoteEvent::Stop => {}
});
let Some(at) = i.checked_sub(latency) else {
continue;
};
let sure = t.active();
match truth.get(at) {
Some(Truth::Note(n)) => {
scored += 1;
if !sure {
unsure += 1;
} else if (pitch - n).abs() > 0.5 {
wrong += 1;
}
}
Some(Truth::Silent) => {
scored += 1;
if sure {
wrong += 1;
}
}
_ => {}
}
}
let seconds = samples.len() as f32 / rate;
let notes = count_notes(truth);
let share = |n: usize| {
if scored > 0 {
n as f32 / scored as f32
} else {
0.
}
};
Score {
seconds,
extra_starts: if truth.is_empty() {
0.
} else {
starts.saturating_sub(notes) as f32 / seconds
},
starts: starts as f32 / seconds,
wrong: share(wrong),
unsure: share(unsure),
}
}
/// Number of distinct notes in a truth track (a slide counts as a new note).
fn count_notes(truth: &[Truth]) -> usize {
let mut last = None;
let mut n = 0;
for v in truth {
match v {
Truth::Note(p) if last != Some(*p) => {
n += 1;
last = Some(*p);
}
Truth::Silent => last = None,
_ => {}
}
}
n
}
+447 -66
View File
@@ -3,7 +3,14 @@
//! 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};
//!
//! Detection is built to stay put. A new note needs a confident pitch held for `NOTE_MS`
//! (`OCTAVE_MS` for an octave jump), and inside a note the tuning only moves when the pitch
//! drifts past `DEADBAND` for good. When the pitch reads keep disagreeing (FM growls, noise,
//! formant sweeps), the tracker reports itself unsure: [`Tracker::active`] turns false, and
//! tracked processing should fall back to something neutral rather than act on a guess.
use crate::{coefficient, db_to_gain, gain_to_db, hz_to_midi, Biquad, Yin};
use serde::{Deserialize, Serialize};
const EVENTS: usize = 64;
const HOP_MS: f32 = 5.;
@@ -12,6 +19,162 @@ 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;
/// A different pitch must hold this long before it becomes the new note.
const NOTE_MS: f32 = 40.;
/// A jump down an octave must hold this long: octave errors are the commonest misread.
const OCTAVE_MS: f32 = 80.;
/// A jump up an octave must hold this long. A signal one octave up still repeats at the
/// note's period, and detuned or FM basses read an octave up whenever their odd harmonics
/// cancel, so these reads count as agreeing with the note.
const OCTAVE_UP_MS: f32 = 200.;
/// A new note's reads must stay within this spread (semitones): a swept resonance drags the
/// reads along continuously, a real note change settles.
const STEADY: f32 = 0.5;
/// A note ends when the start of the analysis window (output time) stays this long below the
/// gate's release level. Faster than the gate, whose envelope needs over 100 ms to fall.
const QUIET_MS: f32 = 20.;
/// Inside a note, the tuning moves only when the settled pitch is this far (semitones) off.
const DEADBAND: f32 = 0.25;
/// Per-hop step of the settled pitch towards each agreeing read.
const SETTLE: f32 = 0.2;
/// Trust kept per disagreeing read: about 40 ms of them make the note unsure.
const TRUST_DISAGREE: f32 = 0.85;
/// Trust kept per hop with no read: about 400 ms without one make the note unsure. Growls
/// read only now and then, between stretches YIN cannot read at all.
const TRUST_SILENT: f32 = 0.985;
/// Share of the missing trust one agreeing read restores.
const TRUST_AGREE: f32 = 0.5;
const TRUST_LOST: f32 = 0.3;
const TRUST_REGAINED: f32 = 0.6;
/// Where notes come from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PitchMode {
/// Pitch and note starts are detected from the audio.
#[default]
Audio,
/// Notes come from MIDI note input.
Midi,
/// Note starts come from the audio, the pitch is the fixed `note`: no detection at all.
Note,
}
/// Key lock for detected pitches. The root is the pitch class of [`PitchSource::note`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Scale {
/// Any pitch, including between notes.
#[default]
Free,
/// Natural minor.
Minor,
Major,
/// Only the root, in any octave: detection picks the octave.
Root,
}
impl Scale {
fn steps(self) -> &'static [i32] {
match self {
Scale::Free => &[],
Scale::Minor => &[0, 2, 3, 5, 7, 8, 10],
Scale::Major => &[0, 2, 4, 5, 7, 9, 11],
Scale::Root => &[0],
}
}
/// The in-key note nearest `pitch`, or `pitch` itself when the scale is free.
pub fn snap(self, root: u8, pitch: f32) -> f32 {
let steps = self.steps();
if steps.is_empty() {
return pitch;
}
let root = (root % 12) as i32;
let base = pitch.round() as i32;
let mut best = base as f32;
let mut distance = f32::MAX;
for n in base - 7..=base + 7 {
if steps.contains(&(n - root).rem_euclid(12)) {
let d = (n as f32 - pitch).abs();
if d < distance {
distance = d;
best = n as f32;
}
}
}
best
}
}
/// The user's pitch-source choices, shared by every tracked plugin.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PitchSource {
pub mode: PitchMode,
/// Detection threshold in dBFS for note starts (Audio and Note modes).
pub gate_db: f32,
/// Legato notes slide instead of restarting.
pub glide: bool,
/// The note played in Note mode (MIDI number); its pitch class is the key root.
pub note: u8,
pub scale: Scale,
/// Follow bends and vibrato on every hop and switch notes after 10 ms, as before 2026-11.
/// Fast, but it flickers on growls.
pub bends: bool,
}
impl Default for PitchSource {
fn default() -> Self {
Self {
mode: PitchMode::Audio,
gate_db: -42.,
glide: false,
note: DEFAULT_NOTE,
scale: Scale::Free,
bends: false,
}
}
}
/// F1, the commonest riddim root.
pub const DEFAULT_NOTE: u8 = 29;
/// Range of the Note control: C0 to C5.
pub const NOTE_MIN: u8 = 12;
pub const NOTE_MAX: u8 = 72;
/// The stored part of a plugin's pitch source beside its mode and gate: Note mode's note, the
/// key lock and Follow bends. Plugins keep it as one `pitch` field in their settings.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Pitch {
pub note: u8,
pub scale: Scale,
pub bends: bool,
}
impl Default for Pitch {
fn default() -> Self {
Self {
note: DEFAULT_NOTE,
scale: Scale::Free,
bends: false,
}
}
}
impl Pitch {
pub fn validate(&self) -> Result<(), &'static str> {
if (NOTE_MIN..=NOTE_MAX).contains(&self.note) {
Ok(())
} else {
Err("Note out of range")
}
}
/// The full tracker settings for `mode`, a gate threshold in dBFS and legato slides.
pub fn source(self, mode: PitchMode, gate_db: f32, glide: bool) -> PitchSource {
PitchSource {
mode,
gate_db,
glide,
note: self.note,
scale: self.scale,
bends: self.bends,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TrackerConfig {
@@ -40,6 +203,40 @@ impl TrackerConfig {
};
}
/// The tracker's note state at one output sample.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct NoteState {
/// A note sounds.
pub sounding: bool,
/// The sounding note's pitch is trusted ([`Tracker::active`]); false while none sounds.
pub sure: bool,
}
impl NoteState {
/// A note sounds but its pitch is not trusted: tracked processing should step back.
pub fn unsure(self) -> bool {
self.sounding && !self.sure
}
}
/// Latches "step back" across note ends: a module goes neutral while its note is unsure and
/// returns only on a sure note, so a tail after an unsure stretch stays neutral too.
#[derive(Clone, Copy, Debug, Default)]
pub struct Fallback {
neutral: bool,
}
impl Fallback {
/// Updates from this sample's note state; true while the module should sound neutral.
pub fn neutral(&mut self, note: NoteState) -> bool {
if note.sounding {
self.neutral = !note.sure;
}
self.neutral
}
pub fn reset(&mut self) {
self.neutral = false;
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum NoteEvent {
/// A new note. `glide` asks to slide from the previous pitch instead of restarting.
@@ -67,14 +264,50 @@ struct Event {
kind: Kind,
}
/// A pitch that may become the next note.
#[derive(Clone, Copy, Debug)]
struct Candidate {
mean: f32,
low: f32,
high: f32,
reads: u32,
}
impl Candidate {
fn new(p: f32) -> Self {
Self {
mean: p,
low: p,
high: p,
reads: 1,
}
}
/// Adds a read, or returns None when it does not fit this candidate.
fn add(self, p: f32) -> Option<Self> {
let (low, high) = (self.low.min(p), self.high.max(p));
(high - low < STEADY).then(|| Self {
mean: self.mean + (p - self.mean) / (self.reads + 1) as f32,
low,
high,
reads: self.reads + 1,
})
}
}
/// One analysis window's result.
#[derive(Clone, Copy, Debug)]
enum Read {
Pitch(f32),
/// Sound YIN cannot read: no evidence either way.
Unclear,
Silent,
}
/// 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,
source: PitchSource,
detect: [Biquad; 2],
decimate: usize,
decimate_count: usize,
@@ -85,6 +318,9 @@ pub struct Tracker {
yin: Yin,
hop: usize,
hop_count: usize,
note_hops: u32,
octave_hops: u32,
octave_up_hops: u32,
gate_env: f32,
gate_release: f32,
gate_open: bool,
@@ -96,8 +332,17 @@ pub struct Tracker {
/// Output-time note state.
sounding: bool,
pending: bool,
/// The note ended on silence while the gate stayed open: the next sound starts a note.
rest: bool,
quiet_hops: u32,
quiet_need: u32,
tracked: f32,
candidate: Option<(f32, u8)>,
/// Running pitch of the agreeing reads inside the note.
settled: f32,
candidate: Option<Candidate>,
/// 1 while reads agree with the note, falling towards 0 while they do not.
trust: f32,
sure: bool,
held: [u8; 16],
held_len: usize,
}
@@ -110,13 +355,13 @@ impl Tracker {
// 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;
let hop = ((detect_rate * HOP_MS * 0.001) as usize).max(1);
let hop_ms = hop as f32 / detect_rate * 1000.;
Self {
rate,
cfg,
latency: analysis * decimate,
midi: false,
gate_db: -42.,
glide: false,
source: PitchSource::default(),
detect: [Biquad::lowpass(rate, cfg.detect_lowpass, q); 2],
decimate,
decimate_count: 0,
@@ -125,8 +370,11 @@ impl Tracker {
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,
hop_count: 0,
note_hops: (NOTE_MS / hop_ms).round().max(2.) as u32,
octave_hops: (OCTAVE_MS / hop_ms).round().max(2.) as u32,
octave_up_hops: (OCTAVE_UP_MS / hop_ms).round().max(2.) as u32,
gate_env: 0.,
gate_release: 1. - coefficient(rate, 30.),
gate_open: false,
@@ -140,8 +388,14 @@ impl Tracker {
event_len: 0,
sounding: false,
pending: false,
rest: false,
quiet_hops: 0,
quiet_need: (QUIET_MS / hop_ms).round().max(1.) as u32,
tracked: 0.,
settled: 0.,
candidate: None,
trust: 0.,
sure: false,
held: [0; 16],
held_len: 0,
}
@@ -153,22 +407,28 @@ impl Tracker {
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;
pub fn source(&self) -> PitchSource {
self.source
}
/// Applies the pitch-source choices. Returns true when the mode changed, which drops every
/// pending and held note: the caller should release its sound.
pub fn set(&mut self, source: PitchSource) -> bool {
let changed = source.mode != self.source.mode;
self.source = source;
if changed {
self.release_all();
}
changed
}
fn midi(&self) -> bool {
self.source.mode == PitchMode::Midi
}
fn release_all(&mut self) {
self.sounding = false;
self.pending = false;
self.rest = false;
self.quiet_hops = 0;
self.sure = false;
self.held_len = 0;
self.event_len = 0;
self.candidate = None;
@@ -192,10 +452,22 @@ impl Tracker {
pub fn note_off(&mut self, note: u8) {
self.push(Kind::NoteOff(note.min(127)));
}
/// True while a note sounds at output time.
/// True while a note sounds at output time, sure or not.
pub fn sounding(&self) -> bool {
self.sounding
}
/// True while tracked processing should act: a note sounds and its pitch is trusted.
/// MIDI and Note modes are always sure; detection is unsure from an onset until a pitch
/// reads, and while reads keep disagreeing with the note.
pub fn active(&self) -> bool {
self.sounding && (self.source.mode != PitchMode::Audio || self.sure)
}
pub fn state(&self) -> NoteState {
NoteState {
sounding: self.sounding,
sure: self.active(),
}
}
fn push(&mut self, kind: Kind) {
if self.event_len == EVENTS {
// Dropping the oldest keeps the newest note state; overflow only happens on floods.
@@ -209,45 +481,81 @@ impl Tracker {
};
self.event_len += 1;
}
/// Detected pitch of the most recent analysis window, in fractional MIDI notes.
fn estimate(&mut self) -> Option<f32> {
/// Detected pitch of the most recent analysis window, in fractional MIDI notes, snapped to
/// the key when one is set.
fn estimate(&mut self) -> Read {
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(
// The window starts at output time: a quiet head means the note has ended there.
let head = self.hop.min(n);
let power = self.scratch[..head].iter().map(|v| v * v).sum::<f32>() / head as f32;
let quiet = db_to_gain(self.source.gate_db - GATE_HYSTERESIS_DB);
if power < quiet * quiet {
return Read::Silent;
}
let s = self.source;
match self.yin.estimate(
&self.scratch,
self.detect_rate,
self.cfg.min_hz,
self.cfg.max_hz,
)?;
(clarity >= CLARITY).then(|| hz_to_midi(hz))
) {
Some((hz, clarity)) if clarity >= CLARITY => {
Read::Pitch(s.scale.snap(s.note, hz_to_midi(hz)))
}
_ => Read::Unclear,
}
}
fn start(&mut self, pitch: f32, glide: bool, emit: &mut impl FnMut(NoteEvent)) {
self.tracked = pitch;
self.settled = pitch;
self.candidate = None;
self.pending = false;
self.sounding = true;
self.trust = 1.;
self.sure = true;
emit(NoteEvent::Start { pitch, glide });
}
fn stop(&mut self, emit: &mut impl FnMut(NoteEvent)) {
let was = self.sounding || self.pending;
self.sounding = false;
self.pending = false;
self.sure = false;
self.quiet_hops = 0;
self.candidate = None;
emit(NoteEvent::Stop);
if was {
emit(NoteEvent::Stop);
}
}
/// A note starts at output time from `read`: detected (or pending until a pitch reads) in
/// Audio mode, the set note in Note mode.
fn onset(&mut self, read: Read, emit: &mut impl FnMut(NoteEvent)) {
self.rest = false;
self.quiet_hops = 0;
match (self.source.mode, read) {
(PitchMode::Note, _) => self.start(self.source.note as f32, false, emit),
(_, Read::Pitch(p)) => self.start(p, false, emit),
_ => {
self.pending = true;
self.sounding = true;
self.sure = false;
self.trust = 0.;
}
}
}
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 => {
match (self.source.mode, kind) {
(PitchMode::Audio | PitchMode::Note, Kind::Onset) => {
let read = self.estimate();
self.onset(read, emit);
}
(PitchMode::Audio | PitchMode::Note, Kind::Release) => {
self.rest = false;
self.stop(emit);
}
(PitchMode::Midi, Kind::NoteOn(n)) => {
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);
@@ -260,9 +568,9 @@ impl Tracker {
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);
self.start(n as f32, legato && self.source.glide, emit);
}
Kind::NoteOff(n) if midi => {
(PitchMode::Midi, Kind::NoteOff(n)) => {
let len = self.held_len;
let Some(i) = self.held[..len].iter().position(|h| *h == n) else {
return;
@@ -274,48 +582,119 @@ impl Tracker {
self.stop(emit);
} else if top {
let next = self.held[self.held_len - 1];
self.start(next as f32, self.glide, emit);
self.start(next as f32, self.source.glide, emit);
}
}
_ => {}
}
}
/// Moves the trust score one hop: `Some(true)` for a read that agrees with the note,
/// `Some(false)` for one that does not, `None` for no read. Hysteresis on `sure`.
fn trust(&mut self, agree: Option<bool>) {
match agree {
Some(true) => self.trust += (1. - self.trust) * TRUST_AGREE,
Some(false) => self.trust *= TRUST_DISAGREE,
None => self.trust *= TRUST_SILENT,
}
if self.sure && self.trust < TRUST_LOST {
self.sure = false;
} else if !self.sure && self.trust > TRUST_REGAINED {
self.sure = true;
}
}
/// 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 {
if self.midi() || !(self.sounding || self.rest) {
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));
}
let read = self.estimate();
if let Read::Silent = read {
if self.sounding && !self.pending {
self.quiet_hops += 1;
if self.quiet_hops >= self.quiet_need {
self.stop(emit);
self.rest = true;
} else {
self.trust(Some(false));
}
_ => self.candidate = Some((p, 1)),
}
return;
}
self.quiet_hops = 0;
if self.rest {
return self.onset(read, emit);
}
if self.source.mode == PitchMode::Note {
return;
}
if self.pending {
if let Read::Pitch(p) = read {
self.start(p, false, emit);
}
return;
}
let p = match read {
Read::Pitch(p) => p,
Read::Unclear => return self.trust(None),
Read::Silent => unreachable!(),
};
let offset = p - self.tracked;
if offset.abs() < JUMP {
self.candidate = None;
self.trust(Some(true));
if self.source.bends {
self.tracked += 0.3 * offset;
emit(NoteEvent::Move {
pitch: self.tracked,
});
} else {
self.settled += SETTLE * (p - self.settled);
if (self.settled - self.tracked).abs() >= DEADBAND {
self.tracked = self.settled;
emit(NoteEvent::Move {
pitch: self.tracked,
});
}
}
return;
}
let octaves = (offset / 12.).round();
let octave = octaves != 0. && (offset - 12. * octaves).abs() < JUMP;
let up = octave && octaves > 0.;
let candidate = if self.source.bends {
// The 0.1 rule: two reads within half a semitone.
match self.candidate {
Some(c) if (c.mean - p).abs() < 0.5 => Candidate {
mean: p,
reads: c.reads + 1,
..c
},
_ => Candidate::new(p),
}
} else {
self.candidate = None;
self.tracked += 0.3 * (p - self.tracked);
emit(NoteEvent::Move {
pitch: self.tracked,
});
self.candidate
.and_then(|c| c.add(p))
.unwrap_or(Candidate::new(p))
};
let need = match (self.source.bends, octave, up) {
(true, _, _) => 2,
(false, false, _) => self.note_hops,
(false, true, false) => self.octave_hops,
(false, true, true) => self.octave_up_hops,
};
if candidate.reads >= need {
let pitch = if self.source.bends { p } else { candidate.mean };
self.start(pitch, self.source.glide, emit);
} else {
self.candidate = Some(candidate);
self.trust(Some(up && !self.source.bends));
}
}
/// 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 {
if !self.midi() {
let a = d.abs();
self.gate_env = if a > self.gate_env {
a
@@ -323,13 +702,14 @@ impl Tracker {
self.gate_env * self.gate_release
};
let level = gain_to_db(self.gate_env);
if level > self.gate_db {
let gate = self.source.gate_db;
if level > 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.gate_db - GATE_HYSTERESIS_DB {
} else if self.gate_open && level < gate - GATE_HYSTERESIS_DB {
if self.gate_hold == 0 {
self.gate_open = false;
self.push(Kind::Release);
@@ -409,8 +789,9 @@ pub trait Tracked {
fn ahead(&mut self, _mono: f32) {}
/// A note event due at the next `process` sample.
fn event(&mut self, e: NoteEvent);
/// One sample of audio; `sounding` is the tracker's note state at this sample.
fn process(&mut self, x: [f32; 2], sounding: bool) -> [f32; 2];
/// One sample of audio and the tracker's note state at it. While the note is unsure
/// (see [`Fallback`]) the module should step back to something neutral.
fn process(&mut self, x: [f32; 2], note: NoteState) -> [f32; 2];
}
/// A tracker and the lookahead delay in front of one [`Tracked`] module: the standalone form.
@@ -446,6 +827,6 @@ impl Lookahead {
self.delay[self.at] = x;
self.at = (self.at + 1) % len;
let y = self.delay[self.at];
module.process(y, self.tracker.sounding())
module.process(y, self.tracker.state())
}
}
+133 -3
View File
@@ -1,7 +1,7 @@
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::tracker::{NoteEvent, PitchMode, PitchSource, Scale, Tracker, TrackerConfig};
use common_dsp::{hz_to_midi, Saw};
use std::f32::consts::TAU;
@@ -174,7 +174,7 @@ fn harmonic_meter_reads_saw_harmonics() {
#[test]
fn tracker_reports_notes_at_output_time() {
let mut t = Tracker::new(RATE, TrackerConfig::BASS);
t.set(false, -42., false);
t.set(PitchSource::default());
let l = t.latency();
assert!(l < (0.08 * RATE) as usize, "{l}");
let onset = 4800;
@@ -210,7 +210,11 @@ fn tracker_reports_notes_at_output_time() {
#[test]
fn tracker_delays_midi_by_the_latency() {
let mut t = Tracker::new(RATE, TrackerConfig::BASS);
assert!(t.set(true, -42., true));
assert!(t.set(PitchSource {
mode: PitchMode::Midi,
glide: true,
..PitchSource::default()
}));
let l = t.latency();
let mut events = vec![];
for i in 0..l + 2000 {
@@ -248,3 +252,129 @@ fn tracker_delays_midi_by_the_latency() {
]
);
}
#[test]
fn stable_tracker_holds_its_notes_on_the_stem_corpus() {
let stable = PitchSource::default();
let bends = PitchSource {
bends: true,
..stable
};
let (mut extra_stable, mut extra_bends) = (0., 0.);
for stem in common_dsp::stems::all(RATE) {
let s = common_dsp::stems::score(
RATE,
TrackerConfig::BASS,
stable,
&stem.samples,
&stem.truth,
);
let b =
common_dsp::stems::score(RATE, TrackerConfig::BASS, bends, &stem.samples, &stem.truth);
assert!(s.extra_starts <= 0.25, "{}: {s:?}", stem.name);
assert!(s.wrong <= 0.05, "{}: {s:?}", stem.name);
assert!(s.wrong <= b.wrong + 0.005, "{}: {s:?} vs {b:?}", stem.name);
extra_stable += s.extra_starts;
extra_bends += b.extra_starts;
}
// Follow bends keeps the fast 0.1 behaviour, which flickers on formant basses.
assert!(
extra_bends > 10. * extra_stable.max(1.),
"{extra_stable} vs {extra_bends}"
);
}
#[test]
fn tracker_turns_inactive_when_reads_stop_agreeing() {
let mut t = Tracker::new(RATE, TrackerConfig::BASS);
t.set(PitchSource::default());
let mut saw = Saw::new(RATE, 55.);
let mut seed = 1u32;
let mut active = vec![];
for i in 0..(RATE * 1.5) as usize {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
let noise = seed as f32 / u32::MAX as f32 - 0.5;
let x = if i < 24000 { 0.5 * saw.sample() } else { noise };
t.process(x, |_| {});
active.push(t.active());
}
let l = t.latency();
assert!(active[l + 12000..l + 23000].iter().all(|a| *a));
// Unreadable sound is no evidence against the note, so trust fades slowly: growls read
// only now and then. Within half a second of noise, tracked processing stops acting.
assert!(active[l + 48000..].iter().all(|a| !*a));
}
#[test]
fn note_mode_plays_the_set_note_on_each_onset() {
let mut t = Tracker::new(RATE, TrackerConfig::BASS);
t.set(PitchSource {
mode: PitchMode::Note,
note: 29,
..PitchSource::default()
});
let mut saw = Saw::new(RATE, 220.);
let mut events = vec![];
for i in 0..48000 {
let x = if (4800..20000).contains(&i) || i >= 30000 {
0.5 * saw.sample()
} else {
0.
};
t.process(x, |e| events.push(e));
if i == 15000 {
assert!(t.active());
}
}
let starts: Vec<_> = events
.iter()
.filter_map(|e| match e {
NoteEvent::Start { pitch, .. } => Some(*pitch),
_ => None,
})
.collect();
assert_eq!(starts, vec![29., 29.]);
assert!(!events.iter().any(|e| matches!(e, NoteEvent::Move { .. })));
}
#[test]
fn key_lock_snaps_reads_to_the_scale() {
// F minor: F G Ab Bb C Db Eb. F#1 (30) leans to G1 when read sharp.
assert_eq!(Scale::Minor.snap(29, 30.4), 31.);
assert_eq!(Scale::Minor.snap(29, 29.6), 29.);
assert_eq!(Scale::Major.snap(0, 61.), 60.);
assert_eq!(Scale::Root.snap(29, 40.6), 41.);
assert_eq!(Scale::Root.snap(29, 35.4), 41.);
assert_eq!(Scale::Free.snap(29, 30.37), 30.37);
}
#[test]
fn key_lock_keeps_a_drifting_wobble_on_its_root() {
let stem = common_dsp::stems::all(RATE)
.into_iter()
.find(|s| s.name == "wobble")
.unwrap();
let free = PitchSource::default();
let root = PitchSource {
note: 31,
scale: Scale::Root,
..free
};
let f = common_dsp::stems::score(RATE, TrackerConfig::BASS, free, &stem.samples, &stem.truth);
let r = common_dsp::stems::score(RATE, TrackerConfig::BASS, root, &stem.samples, &stem.truth);
assert!(r.unsure + r.wrong < f.unsure + f.wrong, "{f:?} vs {r:?}");
assert!(r.wrong <= 0.02, "{r:?}");
}
#[test]
fn note_names_parse_back() {
for n in 0..=127u8 {
assert_eq!(common_dsp::parse_note(&common_dsp::note_name(n)), Some(n));
}
assert_eq!(common_dsp::parse_note(" f1 "), Some(29));
assert_eq!(common_dsp::note_name_spelled(30, true), "Gb1");
assert_eq!(common_dsp::parse_note("Gb1"), Some(30));
assert_eq!(common_dsp::parse_note("H2"), None);
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "common-params"
version = "0.1.0"
edition = "2021"
license = "ISC"
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
common-dsp = { path = "../dsp" }
+121
View File
@@ -0,0 +1,121 @@
//! Host parameters shared by every tracked plugin: Note mode's note, the key lock and
//! Follow bends. Plugins nest [`PitchParams`] in their own parameter struct.
use common_dsp::tracker::{Pitch, Scale, NOTE_MAX, NOTE_MIN};
use common_dsp::note_name;
use nih_plug::prelude::*;
use std::sync::Arc;
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum ScaleParam {
#[id = "free"]
#[name = "Off"]
Free,
#[id = "minor"]
Minor,
#[id = "major"]
Major,
#[id = "root"]
#[name = "Root only"]
Root,
}
impl From<ScaleParam> for Scale {
fn from(s: ScaleParam) -> Self {
match s {
ScaleParam::Free => Scale::Free,
ScaleParam::Minor => Scale::Minor,
ScaleParam::Major => Scale::Major,
ScaleParam::Root => Scale::Root,
}
}
}
impl From<Scale> for ScaleParam {
fn from(s: Scale) -> Self {
match s {
Scale::Free => ScaleParam::Free,
Scale::Minor => ScaleParam::Minor,
Scale::Major => ScaleParam::Major,
Scale::Root => ScaleParam::Root,
}
}
}
#[derive(Params)]
pub struct PitchParams {
/// The note played in Note mode; its pitch class is the key root.
#[id = "note"]
pub note: IntParam,
#[id = "scale"]
pub scale: EnumParam<ScaleParam>,
#[id = "bends"]
pub bends: BoolParam,
}
impl Default for PitchParams {
fn default() -> Self {
let d = Pitch::default();
Self {
note: IntParam::new(
"Note",
d.note as i32,
IntRange::Linear {
min: NOTE_MIN as i32,
max: NOTE_MAX as i32,
},
)
.with_value_to_string(Arc::new(|v| note_name(v.clamp(0, 127) as u8)))
.with_string_to_value(Arc::new(|s| parse_note(s).map(i32::from))),
scale: EnumParam::new("Key lock", ScaleParam::Free),
bends: BoolParam::new("Follow bends", d.bends),
}
}
}
impl PitchParams {
pub fn pitch(&self) -> Pitch {
Pitch {
note: (self.note.value().clamp(NOTE_MIN as i32, NOTE_MAX as i32)) as u8,
scale: self.scale.value().into(),
bends: self.bends.value(),
}
}
/// Pushes editor changes to the host, one gesture per change.
pub fn apply(&self, setter: &ParamSetter, old: &Pitch, new: &Pitch) {
if old.note != new.note {
setter.begin_set_parameter(&self.note);
setter.set_parameter(&self.note, new.note as i32);
setter.end_set_parameter(&self.note);
}
if old.scale != new.scale {
setter.begin_set_parameter(&self.scale);
setter.set_parameter(&self.scale, new.scale.into());
setter.end_set_parameter(&self.scale);
}
if old.bends != new.bends {
setter.begin_set_parameter(&self.bends);
setter.set_parameter(&self.bends, new.bends);
setter.end_set_parameter(&self.bends);
}
}
}
/// Reads a note name such as "F1" within the Note range.
pub fn parse_note(text: &str) -> Option<u8> {
common_dsp::parse_note(text).filter(|n| (NOTE_MIN..=NOTE_MAX).contains(n))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn note_names_round_trip() {
for n in NOTE_MIN..=NOTE_MAX {
assert_eq!(parse_note(&note_name(n)), Some(n));
}
assert_eq!(parse_note("f1"), Some(29));
assert_eq!(parse_note("H2"), None);
}
#[test]
fn scale_maps_both_ways() {
for s in [Scale::Free, Scale::Minor, Scale::Major, Scale::Root] {
assert_eq!(Scale::from(ScaleParam::from(s)), s);
}
}
}
+1
View File
@@ -6,3 +6,4 @@ license.workspace = true
[dependencies]
egui = "0.31.1"
common-dsp = { path = "../dsp" }
+232
View File
@@ -1,4 +1,6 @@
//! Shared hardware look for every plugin editor: palette, fonts, knobs, panels.
use common_dsp::note_name;
use common_dsp::tracker::{Pitch, PitchMode, Scale, NOTE_MAX, NOTE_MIN};
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
// Dark anodized hardware: graphite surfaces, one signal colour, steel data traces.
@@ -310,6 +312,16 @@ pub fn readout(ui: &mut egui::Ui, label: &str, value: String, color: Color32) {
}
/// Product header: accent mark, product name, subtitle, then right-aligned controls.
pub fn header(ui: &mut egui::Ui, name: &str, subtitle: &str, right: impl FnOnce(&mut egui::Ui)) {
header_paged(ui, name, subtitle, None, right);
}
/// Product header with the MAIN / ADVANCED page switch after the subtitle.
pub fn header_paged(
ui: &mut egui::Ui,
name: &str,
subtitle: &str,
advanced: Option<&mut bool>,
right: impl FnOnce(&mut egui::Ui),
) {
ui.horizontal(|ui| {
let (mark, _) = ui.allocate_exact_size(Vec2::new(4., 26.), Sense::hover());
ui.painter().rect_filled(mark, 1., ACCENT);
@@ -319,6 +331,10 @@ pub fn header(ui: &mut egui::Ui, name: &str, subtitle: &str, right: impl FnOnce(
.color(TEXT),
);
ui.label(RichText::new(subtitle).size(12.).color(MUTED));
if let Some(advanced) = advanced {
ui.add_space(14.);
page_switch(ui, advanced);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), right);
});
}
@@ -359,3 +375,219 @@ pub fn meter(ui: &mut egui::Ui, label: &str, db: f32, color: Color32, width: f32
);
});
}
const PITCH_CLASSES: [&str; 12] = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
fn scale_name(scale: Scale) -> &'static str {
match scale {
Scale::Free => "off",
Scale::Minor => "minor",
Scale::Major => "major",
Scale::Root => "root only",
}
}
/// Short status of the pitch source for readout lines: "detecting pitch in F minor".
pub fn pitch_status(mode: PitchMode, pitch: &Pitch) -> String {
match (mode, pitch.scale) {
(PitchMode::Audio, Scale::Free) => "detecting pitch".into(),
(PitchMode::Audio, s) => format!(
"detecting pitch, key {} {}",
PITCH_CLASSES[pitch.note as usize % 12],
scale_name(s)
),
(PitchMode::Midi, _) => "following MIDI".into(),
(PitchMode::Note, _) => format!("fixed on {}", note_name(pitch.note)),
}
}
/// Pitch source switch for a header's right-to-left row: AUDIO, MIDI and NOTE, then a button
/// that opens Note mode's note, or the key lock and Follow bends for detection.
pub fn pitch_from(ui: &mut egui::Ui, mode: &mut PitchMode, pitch: &mut Pitch) {
let id = ui.make_persistent_id("pitch-detail");
let detail = match *mode {
PitchMode::Note => Some(note_name(pitch.note)),
PitchMode::Audio => Some(match pitch.scale {
Scale::Free if pitch.bends => "BENDS".into(),
Scale::Free => "KEY OFF".into(),
s => format!(
"{} {}",
PITCH_CLASSES[pitch.note as usize % 12],
match s {
Scale::Minor => "MIN",
Scale::Major => "MAJ",
_ => "ROOT",
}
),
}),
PitchMode::Midi => None,
};
if let Some(detail) = detail {
let help = if *mode == PitchMode::Note {
"The note this plugin follows. Nothing is detected: set the drop's root."
} else {
"Key lock and Follow bends for detection."
};
let r = ui
.button(RichText::new(detail).size(13.).color(ACCENT))
.on_hover_text(help);
if r.clicked() {
ui.memory_mut(|m| m.toggle_popup(id));
}
let note_mode = *mode == PitchMode::Note;
egui::popup_below_widget(
ui,
id,
&r,
egui::PopupCloseBehavior::CloseOnClickOutside,
|ui| {
ui.set_min_width(220.);
if note_mode {
pitch_note(ui, pitch, true);
} else {
caption(ui, "KEY LOCK");
ui.horizontal(|ui| {
for s in [Scale::Free, Scale::Minor, Scale::Major, Scale::Root] {
let help = match s {
Scale::Free => "Any pitch, also between notes.",
Scale::Minor => "Only notes of the natural minor scale.",
Scale::Major => "Only notes of the major scale.",
Scale::Root => "Only the root: detection picks the octave.",
};
let label = RichText::new(scale_name(s).to_uppercase()).size(12.);
if ui
.selectable_label(pitch.scale == s, label)
.on_hover_text(help)
.clicked()
{
pitch.scale = s;
}
}
});
if pitch.scale != Scale::Free {
ui.add_space(4.);
caption(ui, "ROOT");
pitch_note(ui, pitch, false);
}
ui.add_space(6.);
ui.checkbox(&mut pitch.bends, "Follow bends").on_hover_text(
"Retune on every bend and switch notes within 10 ms. Fast, but it \
flickers on growls. Off holds each note until a new one is clear.",
);
}
},
);
}
for (m, label, help) in [
(
PitchMode::Note,
"NOTE",
"You set the note; the audio only starts and stops it. Nothing is detected.",
),
(
PitchMode::Midi,
"MIDI",
"Notes from MIDI input. In FL, set the channel's MIDI output port and this \
effect's input port to the same number.",
),
(
PitchMode::Audio,
"AUDIO",
"Detect the pitch from the audio. When detection is unsure, tracked processing \
steps back instead of guessing.",
),
] {
let r = ui
.selectable_label(*mode == m, RichText::new(label).size(13.))
.on_hover_text(help);
if r.clicked() {
*mode = m;
}
}
caption(ui, "PITCH FROM");
}
/// Twelve pitch-class buttons, and octave steps when `octave` is set.
fn pitch_note(ui: &mut egui::Ui, pitch: &mut Pitch, octave: bool) {
let class = pitch.note % 12;
let base = pitch.note - class;
for row in [0..6, 6..12] {
ui.horizontal(|ui| {
for c in row {
let r = ui.add_sized(
[30., 22.],
egui::SelectableLabel::new(class == c as u8, PITCH_CLASSES[c]),
);
if r.clicked() {
pitch.note = (base + c as u8).clamp(NOTE_MIN, NOTE_MAX);
}
}
});
}
if octave {
ui.horizontal(|ui| {
caption(ui, "OCTAVE");
if ui.button("−").clicked() && pitch.note >= NOTE_MIN + 12 {
pitch.note -= 12;
}
ui.label(
RichText::new(format!("{}", pitch.note as i32 / 12 - 1))
.monospace()
.color(TEXT),
);
if ui.button("+").clicked() && pitch.note + 12 <= NOTE_MAX {
pitch.note += 12;
}
ui.label(RichText::new(note_name(pitch.note)).color(ACCENT));
});
}
}
/// MAIN / ADVANCED page switch. The main page holds the few controls a first result needs;
/// the advanced page holds everything else. Returns true while the advanced page shows.
pub fn page_switch(ui: &mut egui::Ui, advanced: &mut bool) -> bool {
ui.horizontal(|ui| {
for (page, label, help) in [
(
false,
"MAIN",
"The controls that matter for a first result.",
),
(true, "ADVANCED", "Every control, for fine tuning."),
] {
let r = ui
.selectable_label(*advanced == page, RichText::new(label).size(12.))
.on_hover_text(help);
if r.clicked() {
*advanced = page;
}
}
});
*advanced
}
/// Choice drawn as large chips, centred, for a plugin's mode on its main page.
pub fn choice<T: PartialEq + Copy>(
ui: &mut egui::Ui,
value: &mut T,
options: &[(T, &str, &str)],
) -> Option<egui::Response> {
let mut hovered = None;
let n = options.len() as f32;
let width = n * 96. + (n - 1.) * ui.spacing().item_spacing.x;
ui.horizontal(|ui| {
ui.add_space(((ui.available_width() - width) / 2.).max(0.));
for (v, label, help) in options {
let r = ui
.add_sized(
[96., 30.],
egui::SelectableLabel::new(*value == *v, RichText::new(*label).size(14.)),
)
.on_hover_text(*help);
if r.clicked() {
*value = *v;
}
if r.hovered() {
hovered = Some(r);
}
}
});
hovered
}
+2 -2
View File
@@ -1,7 +1,7 @@
//! Writes a take as ready-to-use files: the full loop, one file per slice and a wavetable.
use crate::settings::Settings;
use crate::take::{render, wavetable, Take, TABLE};
use common_dsp::{note_name, wav};
use common_dsp::wav;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq)]
@@ -101,7 +101,7 @@ pub fn export(
let mut files = 1;
for (i, s) in take.slices.iter().enumerate().filter(|(_, s)| s.enabled) {
let name = match s.root {
Some(n) => format!("s{:02}_{}.wav", i + 1, note_name(n)),
Some(n) => format!("s{:02}_{}.wav", i + 1, settings.note_name(n)),
None => format!("s{:02}.wav", i + 1),
};
wav::write(
+1 -1
View File
@@ -8,7 +8,7 @@ mod take;
pub use export::{export, save_take, stamp, Exported};
pub use player::{Clip, Player, Queues};
pub use ring::{Marker, Recorder, Ring, Transport, MARKERS};
pub use settings::{default_folder, Division, Mode, Settings};
pub use settings::{default_folder, Division, Mode, Settings, SongKey};
pub use take::{
capture, render, root_key, transients, wavetable, Segment, Slice, Take, FALLBACK_TEMPO,
OVERVIEW, TABLE, TABLE_FRAMES,
+38
View File
@@ -48,6 +48,34 @@ impl Division {
}
}
/// The song's key, used to spell slice names.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct SongKey {
/// Pitch class of the tonic, 0 = C.
pub root: u8,
pub minor: bool,
}
impl SongKey {
/// True for keys written with flats: F, Bb, Eb, Ab, Db, Gb major and D, G, C, F, Bb, Eb
/// minor.
pub fn flats(self) -> bool {
let major = if self.minor {
(self.root + 3) % 12
} else {
self.root % 12
};
matches!(major, 5 | 10 | 3 | 8 | 1 | 6)
}
pub fn name(self) -> String {
let n = common_dsp::note_name_spelled(self.root % 12 + 12, self.flats());
format!(
"{} {}",
n.trim_end_matches(|c: char| c.is_ascii_digit()),
if self.minor { "minor" } else { "major" }
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Settings {
@@ -68,6 +96,8 @@ pub struct Settings {
pub keep_takes: bool,
/// Export folder; empty selects the default.
pub folder: String,
/// Song key for slice names: flats in flat keys. None spells with sharps.
pub key: Option<SongKey>,
}
impl Default for Settings {
fn default() -> Self {
@@ -82,6 +112,7 @@ impl Default for Settings {
wavetable: false,
keep_takes: true,
folder: String::new(),
key: None,
}
}
}
@@ -102,6 +133,13 @@ impl Settings {
} else {
2.
};
if let Some(k) = &mut self.key {
k.root %= 12;
}
}
/// Name of a slice's note, spelled for the song key.
pub fn note_name(&self, note: u8) -> String {
common_dsp::note_name_spelled(note, self.key.is_some_and(SongKey::flats))
}
pub fn folder(&self) -> PathBuf {
if !self.folder.trim().is_empty() {
+13 -9
View File
@@ -245,31 +245,35 @@ pub fn transients(x: &[f32], rate: f32, sensitivity: f32) -> Vec<usize> {
cuts
}
/// Root note of a slice, if it is clearly pitched.
/// Root note of a slice, only when one note is sure over most of it. A growl, a pitch
/// drop or noise gets no name rather than a wrong one.
pub fn root_key(yin: &mut Yin, mono: &[f32], rate: f32) -> Option<u8> {
let window = (2 * yin.max_lag() + 2).min(mono.len());
if window < (rate / 1000.) as usize * 4 || mono.len() < window {
return None;
}
// Skip the attack: the body of a hit carries its pitch.
let mut notes: Vec<f32> = Vec::new();
// Skip the attack: the body of a hit carries its pitch. Spread up to 12 reads over it.
let start = (mono.len() / 5).min(mono.len() - window);
let step = (window / 2).max(1);
let mut at = start;
while at + window <= mono.len() && notes.len() < 6 {
let span = mono.len() - window - start;
let count = (span / (window / 2).max(1) + 1).min(12);
let mut notes: Vec<f32> = Vec::with_capacity(count);
for k in 0..count {
let at = start + if count > 1 { span * k / (count - 1) } else { 0 };
if let Some((hz, clarity)) = yin.estimate(&mono[at..at + window], rate, 35., 1000.) {
if clarity > 0.7 {
notes.push(hz_to_midi(hz));
}
}
at += step;
}
if notes.is_empty() {
return None;
}
notes.sort_by(f32::total_cmp);
let n = notes[notes.len() / 2].round();
(0. ..=127.).contains(&n).then_some(n as u8)
let median = notes[notes.len() / 2];
let n = median.round();
// At least 60 % of all reads, unclear ones included, must sit on that note.
let agree = notes.iter().filter(|p| (*p - n).abs() < 0.5).count();
(agree * 5 >= count * 3 && (0. ..=127.).contains(&n)).then_some(n as u8)
}
/// Builds a take from the newest `bars` complete bars, or from the newest audio when stopped.
+39
View File
@@ -189,6 +189,45 @@ fn root_key_is_detected_per_slice() {
assert_eq!(silent.slices[0].root, None);
}
#[test]
fn a_slice_whose_pitch_moves_gets_no_note_name() {
// Half the slice on F1, half on B1: no one note holds over most of it.
let mut a = Saw::new(RATE, 43.65);
let mut b = Saw::new(RATE, 61.74);
let x: Vec<f32> = (0..48000)
.map(|i| 0.5 * if i < 26000 { a.sample() } else { b.sample() })
.collect();
let take = take_of(x, 120., 1);
assert_eq!(take.slices[0].root, None);
}
#[test]
fn song_key_spells_slice_names() {
let mut s = Settings::default();
assert_eq!(s.note_name(39), "D#2");
s.key = Some(SongKey {
root: 5,
minor: true,
});
assert_eq!(s.note_name(39), "Eb2");
assert_eq!(s.key.unwrap().name(), "F minor");
s.key = Some(SongKey {
root: 4,
minor: false,
});
assert_eq!(s.note_name(39), "D#2");
assert!(SongKey {
root: 10,
minor: false
}
.flats());
assert!(!SongKey {
root: 9,
minor: true
}
.flats());
}
#[test]
fn wavetable_frames_start_on_rising_zero_crossings_and_match() {
let mut saw = Saw::new(RATE, 110.);
+5 -1
View File
@@ -309,11 +309,15 @@ dependencies = [
[[package]]
name = "common-dsp"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "common-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"egui",
]
@@ -450,7 +454,7 @@ checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crucible"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"clap-sys",
"crucible-core",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "crucible"
version = "0.3.0"
version = "0.4.0"
edition = "2021"
license = "ISC"
+30 -4
View File
@@ -1,8 +1,8 @@
//! Crucible editor, shared by the CLAP wrapper and the headless renderer.
use common_ui::*;
use crucible_core::{
capture, export, render, save_take, Division, Exported, Mode, Queues, Ring, Settings, Take,
Transport, OVERVIEW,
capture, export, render, save_take, Division, Exported, Mode, Queues, Ring, Settings, SongKey,
Take, Transport, OVERVIEW,
};
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use std::sync::{Arc, Mutex};
@@ -342,7 +342,7 @@ fn waveform(ui: &mut egui::Ui, settings: &Settings, state: &mut State, link: &Li
};
p.rect_filled(r, 0., fill);
if r.width() > 26. {
let text = s.root.map_or("·".into(), common_dsp::note_name);
let text = s.root.map_or("·".into(), |n| settings.note_name(n));
p.text(
Pos2::new(r.center().x, rect.top() + 3.),
egui::Align2::CENTER_TOP,
@@ -447,6 +447,30 @@ fn waveform(ui: &mut egui::Ui, settings: &Settings, state: &mut State, link: &Li
}
}
/// Song key picker: spells slice names to fit the key (Eb, not D#, in a flat key).
fn song_key(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let text = settings
.key
.map_or("KEY —".into(), |k| format!("KEY {}", k.name()));
let r = egui::ComboBox::from_id_salt("song-key")
.selected_text(RichText::new(text).size(13.))
.width(130.)
.show_ui(ui, |ui| {
ui.selectable_value(&mut settings.key, None, "No key (sharps)");
for minor in [true, false] {
for root in 0..12 {
let k = SongKey { root, minor };
ui.selectable_value(&mut settings.key, Some(k), k.name());
}
}
});
hint(
&r.response,
state,
"The song's key: slice names use its spelling, such as Eb instead of D#.",
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link: &Link) {
let before = settings.clone();
ui.horizontal(|ui| {
@@ -517,6 +541,7 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link:
state,
"Save every capture's loop to the takes folder at once, so a reload or crash loses nothing.",
);
song_key(ui, settings, state);
ui.separator();
let take = state.take;
let slice = state.slice;
@@ -528,7 +553,8 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link:
RichText::new(format!(
"SLICE {} · {} · {:.2} s",
i + 1,
s.root.map_or("no pitch".into(), common_dsp::note_name),
s.root
.map_or("no clear note".into(), |n| settings.note_name(n)),
seconds
))
.size(13.)
+8 -7
View File
@@ -7,7 +7,7 @@
//! its audio by its own latency, so the strip's latency never changes. Allocation-free after
//! `new`.
mod settings;
use common_dsp::tracker::{NoteEvent, Tracked, Tracker, TrackerConfig};
use common_dsp::tracker::{NoteEvent, NoteState, Tracked, Tracker, TrackerConfig};
use common_dsp::{coefficient, hz_to_midi};
pub use settings::{Settings, Source, Stage, ORDERS, STAGES};
@@ -44,7 +44,7 @@ impl Default for AnalyzerFrame {
struct Slot {
events: [NoteEvent; EVENTS],
len: usize,
sounding: bool,
note: NoteState,
/// The undelayed input, one lookahead ahead of the audio this slot belongs to.
ahead: f32,
}
@@ -53,7 +53,7 @@ impl Default for Slot {
Self {
events: [NoteEvent::Stop; EVENTS],
len: 0,
sounding: false,
note: NoteState::default(),
ahead: 0.,
}
}
@@ -155,7 +155,8 @@ impl Engine {
/// Tracker settings and how far back each position reads the note events.
fn route(&mut self, s: &Settings) {
// Each module decides itself whether a legato note slides.
self.tracker.set(s.source == Source::Midi, s.gate, true);
self.tracker
.set(s.pitch.source(s.source.mode(), s.gate, true));
let mut at = 0;
for (i, stage) in s.order.into_iter().enumerate() {
self.offsets[i] = at;
@@ -198,7 +199,7 @@ impl Engine {
..Slot::default()
};
self.tracker.process(mono, |e| slot.push(e));
slot.sounding = self.tracker.sounding();
slot.note = self.tracker.state();
let len = self.line.len();
self.line_at = (self.line_at + 1) % len;
self.line[self.line_at] = slot;
@@ -221,7 +222,7 @@ impl Engine {
for &e in &slot.events[..slot.len] {
m.event(e);
}
v = m.process(v, slot.sounding);
v = m.process(v, slot.note);
}
// The pitch at the output: the oldest slot is the last module's input time minus its
@@ -231,7 +232,7 @@ impl Engine {
self.pitch.event(false, e);
}
self.pitch.step(1.);
self.sounding = last.sounding;
self.sounding = last.note.sure;
let len = self.dry.len();
self.dry_at = (self.dry_at + 1) % len;
+32 -17
View File
@@ -1,4 +1,5 @@
//! Host-independent controls of the strip and its modules.
use common_dsp::tracker::{Pitch, PitchMode};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
@@ -8,6 +9,24 @@ pub enum Source {
Audio,
/// Notes come from MIDI note input.
Midi,
/// Note starts are detected, the pitch is the set note: nothing is guessed.
Note,
}
impl Source {
pub fn mode(self) -> PitchMode {
match self {
Source::Audio => PitchMode::Audio,
Source::Midi => PitchMode::Midi,
Source::Note => PitchMode::Note,
}
}
pub fn from_mode(mode: PitchMode) -> Self {
match mode {
PitchMode::Audio => Source::Audio,
PitchMode::Midi => Source::Midi,
PitchMode::Note => Source::Note,
}
}
}
/// One module of the strip.
@@ -45,6 +64,9 @@ pub const ORDERS: [[Stage; STAGES]; 6] = [
pub struct Settings {
/// Pitch source of the one tracker every module follows.
pub source: Source,
/// Note mode's note, the key lock and Follow bends.
#[serde(default)]
pub pitch: Pitch,
/// Detection threshold in dBFS for note starts (Audio source).
pub gate: f32,
/// Modules in processing order.
@@ -60,6 +82,7 @@ impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
pitch: Pitch::default(),
gate: lathe_core::Settings::default().gate,
order: ORDERS[0],
lathe: lathe_core::Settings::default(),
@@ -71,6 +94,7 @@ impl Default for Settings {
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
self.pitch.validate()?;
let ok = (-60.0..=-20.).contains(&self.gate) && ORDERS.contains(&self.order);
if !ok {
return Err("invalid settings");
@@ -94,26 +118,17 @@ impl Settings {
Stage::Knurl => self.knurl.bypass = !on,
}
}
/// Copies the strip's pitch source and gate into every module.
/// Copies the strip's pitch source, pitch details and gate into every module.
pub fn share(&mut self) {
let midi = self.source == Source::Midi;
let mode = self.source.mode();
self.lathe.gate = self.gate;
self.temper.gate = self.gate;
self.knurl.gate = self.gate;
self.lathe.source = if midi {
lathe_core::Source::Midi
} else {
lathe_core::Source::Audio
};
self.temper.source = if midi {
temper_core::Source::Midi
} else {
temper_core::Source::Audio
};
self.knurl.source = if midi {
knurl_core::Source::Midi
} else {
knurl_core::Source::Audio
};
self.lathe.pitch = self.pitch;
self.temper.pitch = self.pitch;
self.knurl.pitch = self.pitch;
self.lathe.source = lathe_core::Source::from_mode(mode);
self.temper.source = temper_core::Source::from_mode(mode);
self.knurl.source = knurl_core::Source::from_mode(mode);
}
}
+14 -1
View File
@@ -309,11 +309,23 @@ dependencies = [
[[package]]
name = "common-dsp"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "common-params"
version = "0.1.0"
dependencies = [
"common-dsp",
"nih_plug",
]
[[package]]
name = "common-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"egui",
]
@@ -643,9 +655,10 @@ checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "forge"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"clap-sys",
"common-params",
"crossbeam-queue",
"forge-core",
"forge-ui",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "forge"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "ISC"
@@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"]
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" }
forge-core = { path = "../core" }
common-params = { path = "../../common/params" }
lathe-core = { path = "../../lathe/core" }
temper-core = { path = "../../temper/core" }
knurl-core = { path = "../../knurl/core" }
+33 -9
View File
@@ -1,3 +1,4 @@
use common_params::PitchParams;
use forge_core::{Settings, Source, ORDERS};
use knurl_core::{Seed, GRIT_OFF, STEPS, STEP_MIN};
use lathe_core::HARMONICS;
@@ -13,6 +14,26 @@ pub enum SourceParam {
#[id = "midi"]
#[name = "MIDI"]
Midi,
#[id = "note"]
Note,
}
impl From<SourceParam> for Source {
fn from(s: SourceParam) -> Self {
match s {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
SourceParam::Note => Source::Note,
}
}
}
impl From<Source> for SourceParam {
fn from(s: Source) -> Self {
match s {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
Source::Note => SourceParam::Note,
}
}
}
/// The six orders of `ORDERS`, in the same order.
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
@@ -281,6 +302,8 @@ pub struct ForgeParams {
pub editor: Arc<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[nested(group = "Pitch")]
pub pitch: PitchParams,
#[id = "gate"]
pub gate: FloatParam,
#[id = "order"]
@@ -300,6 +323,7 @@ impl Default for ForgeParams {
Self {
editor: EguiState::from_size(forge_ui::WIDTH, forge_ui::HEIGHT),
source: EnumParam::new("Pitch source", SourceParam::Audio),
pitch: PitchParams::default(),
gate: linear("Gate", d.gate, -60., -20., " dB"),
order: EnumParam::new("Order", OrderParam::LatheTemperKnurl),
bypass: BoolParam::new("Bypass", false).make_bypass(),
@@ -401,10 +425,8 @@ impl ForgeParams {
.unwrap_or(0);
let curve = self.temper.curve.value();
let mut s = Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
source: self.source.value().into(),
pitch: self.pitch.pitch(),
gate: self.gate.value(),
order: ORDERS[order],
lathe: lathe_core::Settings {
@@ -576,11 +598,13 @@ impl ForgeParams {
old.knurl.steps as i32,
new.knurl.steps as i32,
);
let source = |s: Source| match s {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
switch(setter, &self.source, source(old.source), source(new.source));
switch(
setter,
&self.source,
SourceParam::from(old.source),
SourceParam::from(new.source),
);
self.pitch.apply(setter, &old.pitch, &new.pitch);
let order =
|s: &Settings| ORDER_PARAMS[ORDERS.iter().position(|o| *o == s.order).unwrap_or(0)];
switch(setter, &self.order, order(old), order(new));
+24 -14
View File
@@ -14,6 +14,8 @@ pub struct State {
pub knurl: knurl_ui::State,
/// Module whose page is shown.
pub page: Stage,
/// Module pages show their advanced controls. Not saved.
pub advanced: bool,
latency: u32,
sample_rate: f32,
/// True while a pointer button is held, so knob drags become one host gesture.
@@ -27,6 +29,7 @@ impl Default for State {
temper: temper_ui::State::default(),
knurl: knurl_ui::State::default(),
page: Stage::Lathe,
advanced: false,
latency: 0,
sample_rate: 48000.,
gesture_active: false,
@@ -121,22 +124,29 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
state.gesture_active = ui.input(|i| i.pointer.any_down());
settings.share();
ui.add_space(2.);
header(ui, "FORGE", "TRACKED CHANNEL STRIP", |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.));
let mut advanced = state.advanced;
header_paged(
ui,
"FORGE",
"TRACKED CHANNEL STRIP",
Some(&mut advanced),
|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.source = source;
settings.bypass = !settings.bypass;
}
}
caption(ui, "PITCH FROM");
});
ui.separator();
let mut mode = settings.source.mode();
pitch_from(ui, &mut mode, &mut settings.pitch);
settings.source = Source::from_mode(mode);
},
);
state.advanced = advanced;
state.lathe.advanced = advanced;
state.temper.advanced = advanced;
state.knurl.advanced = advanced;
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
+6 -4
View File
@@ -8,7 +8,7 @@
//! after `new`; mono-safe (both channels get the same treatment).
mod settings;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{Lookahead, NoteEvent, PitchFollower, Tracked, TrackerConfig};
use common_dsp::tracker::{Lookahead, NoteEvent, NoteState, PitchFollower, Tracked, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi};
pub use settings::{Seed, Settings, Source, GRIT_OFF, PATTERNS, STEPS, STEP_MIN};
@@ -402,8 +402,10 @@ impl Tracked for Module {
};
self.pitch.event(self.settings.snap, e);
}
fn process(&mut self, x: [f32; 2], sounding: bool) -> [f32; 2] {
self.run(x, sounding)
fn process(&mut self, x: [f32; 2], note: NoteState) -> [f32; 2] {
// The texture needs a trusted cycle clock: it fades out while tracking is unsure, as
// it does between notes.
self.run(x, note.sure)
}
}
@@ -444,7 +446,7 @@ impl Engine {
fn set_tracker(&mut self, s: &Settings) {
self.lookahead
.tracker
.set(s.source == Source::Midi, s.gate, false);
.set(s.pitch.source(s.source.mode(), s.gate, false));
}
pub fn reset(&mut self) {
self.lookahead.reset();
+24
View File
@@ -1,4 +1,5 @@
//! Host-independent controls. Gains in dB; amounts relative to one cycle in percent.
use common_dsp::tracker::{Pitch, PitchMode};
use serde::{Deserialize, Serialize};
/// Pattern steps: one per cycle, repeating.
@@ -14,6 +15,24 @@ pub enum Source {
Audio,
/// Notes come from MIDI note input.
Midi,
/// Note starts are detected, the pitch is the set note: nothing is guessed.
Note,
}
impl Source {
pub fn mode(self) -> PitchMode {
match self {
Source::Audio => PitchMode::Audio,
Source::Midi => PitchMode::Midi,
Source::Note => PitchMode::Note,
}
}
pub fn from_mode(mode: PitchMode) -> Self {
match mode {
PitchMode::Audio => Source::Audio,
PitchMode::Midi => Source::Midi,
PitchMode::Note => Source::Note,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
@@ -29,6 +48,9 @@ pub enum Seed {
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// Note mode's note, the key lock and Follow bends.
#[serde(default)]
pub pitch: Pitch,
/// Gain per pattern step, 0 down to `STEP_MIN`.
pub pattern: [f32; STEPS],
/// Steps that get a grit burst.
@@ -104,6 +126,7 @@ impl Default for Settings {
fn default() -> Self {
let mut s = Self {
source: Source::Audio,
pitch: Pitch::default(),
pattern: [0.; STEPS],
grit_steps: [true; STEPS],
steps: 1,
@@ -127,6 +150,7 @@ impl Default for Settings {
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
self.pitch.validate()?;
let finite = [
self.edge,
self.level_jitter,
+14 -1
View File
@@ -309,11 +309,23 @@ dependencies = [
[[package]]
name = "common-dsp"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "common-params"
version = "0.1.0"
dependencies = [
"common-dsp",
"nih_plug",
]
[[package]]
name = "common-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"egui",
]
@@ -861,9 +873,10 @@ dependencies = [
[[package]]
name = "knurl"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"clap-sys",
"common-params",
"crossbeam-queue",
"knurl-core",
"knurl-ui",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "knurl"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
license = "ISC"
@@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"]
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" }
knurl-core = { path = "../core" }
common-params = { path = "../../common/params" }
knurl-ui = { path = "../ui" }
crossbeam-queue = "0.3"
serde = { version = "1", features = ["derive"] }
+28 -9
View File
@@ -1,3 +1,4 @@
use common_params::PitchParams;
use knurl_core::{Seed, Settings, Source, GRIT_OFF, STEPS, STEP_MIN};
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
@@ -10,6 +11,26 @@ pub enum SourceParam {
#[id = "midi"]
#[name = "MIDI"]
Midi,
#[id = "note"]
Note,
}
impl From<SourceParam> for Source {
fn from(s: SourceParam) -> Self {
match s {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
SourceParam::Note => Source::Note,
}
}
}
impl From<Source> for SourceParam {
fn from(s: Source) -> Self {
match s {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
Source::Note => SourceParam::Note,
}
}
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SeedParam {
@@ -50,6 +71,8 @@ pub struct KnurlParams {
pub pattern: RwLock<Pattern>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[nested(group = "Pitch")]
pub pitch: PitchParams,
#[id = "seed"]
pub seed: EnumParam<SeedParam>,
#[id = "steps"]
@@ -86,6 +109,7 @@ impl Default for KnurlParams {
editor: EguiState::from_size(knurl_ui::WIDTH, knurl_ui::HEIGHT),
pattern: RwLock::new(Pattern::default()),
source: EnumParam::new("Pitch source", SourceParam::Audio),
pitch: PitchParams::default(),
seed: EnumParam::new("Random", SeedParam::Same),
steps: IntParam::new(
"Steps",
@@ -131,10 +155,8 @@ impl KnurlParams {
pub fn settings(&self, last: &Pattern) -> Settings {
let pattern = self.pattern.try_read().map(|p| *p).unwrap_or(*last);
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
source: self.source.value().into(),
pitch: self.pitch.pitch(),
pattern: pattern.gains.map(|g| {
if g.is_finite() {
g.clamp(STEP_MIN, 0.)
@@ -238,12 +260,9 @@ impl KnurlParams {
switch(setter, &self.bypass, new.bypass);
}
if new.source != old.source {
let v = match new.source {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
switch(setter, &self.source, v);
switch(setter, &self.source, new.source.into());
}
self.pitch.apply(setter, &old.pitch, &new.pitch);
if new.seed != old.seed {
let v = match new.seed {
Seed::Same => SeedParam::Same,
+70 -63
View File
@@ -18,12 +18,15 @@ pub struct State {
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
/// The advanced page shows its extra controls. Not saved.
pub advanced: bool,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
gesture_active: false,
advanced: false,
help: DEFAULT_HELP.into(),
}
}
@@ -70,7 +73,8 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
ui.add_space(2.);
header(ui, "KNURL", "CYCLE TEXTURE", |ui| {
let mut advanced = state.advanced;
header_paged(ui, "KNURL", "CYCLE TEXTURE", Some(&mut advanced), |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.");
@@ -78,14 +82,11 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
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");
let mut mode = settings.source.mode();
pitch_from(ui, &mut mode, &mut settings.pitch);
settings.source = Source::from_mode(mode);
});
state.advanced = advanced;
body(ui, settings, state);
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
@@ -401,20 +402,11 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
hint(&plus, state, help);
});
ui.add_space(4.);
ui.columns(10, |c| {
let advanced = state.advanced;
ui.columns(if advanced { 10 } else { 5 }, |c| {
knob(
&mut c[0],
state,
"Edge",
&mut settings.edge,
5. ..=50.,
d.edge,
" %",
"How soft each step change is, as a share of a cycle. Low is sharper and buzzier.",
);
knob(
&mut c[1],
state,
"Level",
&mut settings.level_jitter,
0. ..=6.,
@@ -423,7 +415,7 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
"Random level per cycle: a rough, living surface.",
);
knob(
&mut c[2],
&mut c[1],
state,
"Time",
&mut settings.time_jitter,
@@ -433,7 +425,7 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
"Random timing per cycle, in percent of a period: rattle and scrape.",
);
knob(
&mut c[3],
&mut c[2],
state,
"Grit",
&mut settings.grit,
@@ -442,48 +434,63 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
" dB",
"Level of the noise burst in each cycle, relative to the input. Fully left is off.",
);
let at = if advanced {
knob(
&mut c[3],
state,
"Edge",
&mut settings.edge,
5. ..=50.,
d.edge,
" %",
"How soft each step change is, as a share of a cycle. Low is sharper and buzzier.",
);
knob(
&mut c[4],
state,
"Tone",
&mut settings.tone,
4. ..=64.,
d.tone,
" ×f0",
"Lowest frequency of the grit, in harmonics of the note.",
);
knob(
&mut c[5],
state,
"Position",
&mut settings.position,
0. ..=100.,
d.position,
" %",
"Where in the cycle the grit burst sits.",
);
knob(
&mut c[6],
state,
"Length",
&mut settings.length,
2. ..=50.,
d.length,
" %",
"Length of the grit burst, as a share of a cycle.",
);
knob(
&mut c[7],
state,
"Gate",
&mut settings.gate,
-60. ..=-20.,
d.gate,
" dB",
"Input level that counts as a note (AUDIO). Raise it if tails retrigger.",
);
8
} else {
3
};
knob(
&mut c[4],
state,
"Tone",
&mut settings.tone,
4. ..=64.,
d.tone,
" ×f0",
"Lowest frequency of the grit, in harmonics of the note.",
);
knob(
&mut c[5],
state,
"Position",
&mut settings.position,
0. ..=100.,
d.position,
" %",
"Where in the cycle the grit burst sits.",
);
knob(
&mut c[6],
state,
"Length",
&mut settings.length,
2. ..=50.,
d.length,
" %",
"Length of the grit burst, as a share of a cycle.",
);
knob(
&mut c[7],
state,
"Gate",
&mut settings.gate,
-60. ..=-20.,
d.gate,
" dB",
"Input level that counts as a note (AUDIO). Raise it if tails retrigger.",
);
knob(
&mut c[8],
&mut c[at],
state,
"Mix",
&mut settings.mix,
@@ -493,7 +500,7 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
"Blend with the delayed dry signal.",
);
knob(
&mut c[9],
&mut c[at + 1],
state,
"Output",
&mut settings.output,
+24 -6
View File
@@ -6,7 +6,9 @@
mod settings;
use common_dsp::meter::HarmonicMeter;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{Lookahead, NoteEvent, PitchFollower, Tracked, TrackerConfig};
use common_dsp::tracker::{
Fallback, Lookahead, NoteEvent, NoteState, PitchFollower, Tracked, TrackerConfig,
};
use common_dsp::{coefficient, db_to_gain, hz_to_midi};
pub use settings::{Settings, Source, GAIN_MAX, GAIN_MIN, HARMONICS};
@@ -22,8 +24,10 @@ const GAIN_MS: f32 = 20.;
pub struct AnalyzerFrame {
/// Fundamental the filters are tuned to, 0 before the first note.
pub hz: f32,
/// Sounding note, rounded; None between notes.
/// Sounding note, rounded; None between notes and while tracking is unsure.
pub note: Option<u8>,
/// Tracking is unsure, so the audio passes unchanged.
pub neutral: bool,
/// Harmonic levels in dBFS of the delayed input and of the output (index 0 = fundamental).
pub input: [f32; HARMONICS],
pub output: [f32; HARMONICS],
@@ -35,6 +39,7 @@ impl Default for AnalyzerFrame {
Self {
hz: 0.,
note: None,
neutral: false,
input: [-120.; HARMONICS],
output: [-120.; HARMONICS],
latency: 0,
@@ -64,7 +69,11 @@ pub struct Module {
bypass: f32,
meter_in: HarmonicMeter,
meter_out: HarmonicMeter,
/// A sure note sounds: the readout shows it.
sounding: bool,
fallback: Fallback,
/// Unsure tracking: every band fades to 0 dB and the audio passes unchanged.
neutral: bool,
/// Smoothed controls start at the next settings instead of gliding from defaults.
fresh: bool,
}
@@ -103,6 +112,8 @@ impl Module {
meter_in: HarmonicMeter::new(rate, 85.),
meter_out: HarmonicMeter::new(rate, 85.),
sounding: false,
fallback: Fallback::default(),
neutral: false,
fresh: true,
};
m.set_settings(settings)?;
@@ -129,6 +140,8 @@ impl Module {
self.pitch = PitchFollower::default();
self.hz = 0.;
self.sounding = false;
self.fallback.reset();
self.neutral = false;
for ch in &mut self.state {
for f in ch.iter_mut() {
f.reset();
@@ -143,6 +156,9 @@ impl Module {
fn targets(&self) -> [f32; BANDS] {
let s = &self.settings;
let mut t = [0.; BANDS];
if self.neutral {
return t;
}
for k in 1..=s.harmonics as usize {
t[k - 1] = s.effective(k);
t[HARMONICS + k - 1] = s.between;
@@ -187,6 +203,7 @@ impl Module {
sample_rate: self.rate,
input: *self.meter_in.levels(),
output: *self.meter_out.levels(),
neutral: self.neutral,
..AnalyzerFrame::default()
};
if self.sounding && self.hz > 0. {
@@ -211,8 +228,9 @@ impl Tracked for Module {
};
self.pitch.event(self.settings.snap, e);
}
fn process(&mut self, y: [f32; 2], sounding: bool) -> [f32; 2] {
self.sounding = sounding;
fn process(&mut self, y: [f32; 2], note: NoteState) -> [f32; 2] {
self.sounding = note.sure;
self.neutral = self.fallback.neutral(note);
let first = self.hz == 0.;
let (hz, jump) = self.pitch.step(self.hz_coef);
let first = first && jump;
@@ -238,7 +256,7 @@ impl Tracked for Module {
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 sounding { self.hz } else { 0. };
let hz = if note.sure { self.hz } else { 0. };
let (dry, wet) = if self.channels == 2 {
(0.5 * (y[0] + y[1]), 0.5 * (out[0] + out[1]))
} else {
@@ -286,7 +304,7 @@ impl Engine {
fn set_tracker(&mut self, s: &Settings) {
self.lookahead
.tracker
.set(s.source == Source::Midi, s.gate, s.smooth > 0.);
.set(s.pitch.source(s.source.mode(), s.gate, s.smooth > 0.));
}
pub fn reset(&mut self) {
self.lookahead.reset();
+24
View File
@@ -1,4 +1,5 @@
//! Host-independent controls. Gains in dB, times in milliseconds.
use common_dsp::tracker::{Pitch, PitchMode};
use serde::{Deserialize, Serialize};
/// Editable harmonics: the fundamental is harmonic 1.
@@ -13,12 +14,33 @@ pub enum Source {
Audio,
/// Notes come from MIDI note input.
Midi,
/// Note starts are detected, the pitch is the set note: nothing is guessed.
Note,
}
impl Source {
pub fn mode(self) -> PitchMode {
match self {
Source::Audio => PitchMode::Audio,
Source::Midi => PitchMode::Midi,
Source::Note => PitchMode::Note,
}
}
pub fn from_mode(mode: PitchMode) -> Self {
match mode {
PitchMode::Audio => Source::Audio,
PitchMode::Midi => Source::Midi,
PitchMode::Note => Source::Note,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// Note mode's note, the key lock and Follow bends.
#[serde(default)]
pub pitch: Pitch,
/// 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.
@@ -45,6 +67,7 @@ impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
pitch: Pitch::default(),
gains: [0.; HARMONICS],
tilt: 0.,
odd_even: 0.,
@@ -78,6 +101,7 @@ impl Settings {
if !finite {
return Err("non-finite setting");
}
self.pitch.validate()?;
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)
+41
View File
@@ -137,3 +137,44 @@ fn settings_are_validated_and_serialise() {
assert!(e.set_settings(bad).is_err());
assert_eq!(e.settings(), s);
}
#[test]
fn note_mode_cuts_the_set_notes_harmonics() {
let mut s = Settings::default();
s.gains[2] = -12.;
s.source = Source::Note;
s.pitch.note = 29;
let hz = 43.65;
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);
assert!((b[2] - a[2] + 12.).abs() < 0.7, "{a:?} {b:?}");
assert!((b[1] - a[1]).abs() < 1.);
}
#[test]
fn unsure_tracking_passes_the_audio_through() {
let mut s = Settings::default();
s.gains[0] = -12.;
s.gains[1] = -12.;
s.gains[2] = -12.;
let (mut e, l) = engine(s);
// A clear note, then noise detection cannot read.
let mut x = saw(55., 0.5, 0.5);
let mut seed = 7u32;
for _ in 0..RATE as usize {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
x.push((seed as f32 / u32::MAX as f32 - 0.5) * 0.5);
}
let y = render(&mut e, &x);
let tail = (RATE * 1.0) as usize;
let mut worst = 0f32;
for i in tail..x.len() {
worst = worst.max((y[i] - x[i - l]).abs());
}
assert!(worst < 1e-3, "{worst}");
}
+14 -1
View File
@@ -309,11 +309,23 @@ dependencies = [
[[package]]
name = "common-dsp"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "common-params"
version = "0.1.0"
dependencies = [
"common-dsp",
"nih_plug",
]
[[package]]
name = "common-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"egui",
]
@@ -861,9 +873,10 @@ dependencies = [
[[package]]
name = "lathe"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"clap-sys",
"common-params",
"crossbeam-queue",
"lathe-core",
"lathe-ui",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "lathe"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
license = "ISC"
@@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"]
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" }
common-params = { path = "../../common/params" }
lathe-ui = { path = "../ui" }
crossbeam-queue = "0.3"
+28 -9
View File
@@ -1,3 +1,4 @@
use common_params::PitchParams;
use lathe_core::{Settings, Source, HARMONICS};
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
@@ -10,6 +11,26 @@ pub enum SourceParam {
#[id = "midi"]
#[name = "MIDI"]
Midi,
#[id = "note"]
Note,
}
impl From<SourceParam> for Source {
fn from(s: SourceParam) -> Self {
match s {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
SourceParam::Note => Source::Note,
}
}
}
impl From<Source> for SourceParam {
fn from(s: Source) -> Self {
match s {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
Source::Note => SourceParam::Note,
}
}
}
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)
@@ -26,6 +47,8 @@ pub struct LatheParams {
pub gains: RwLock<[f32; HARMONICS]>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[nested(group = "Pitch")]
pub pitch: PitchParams,
#[id = "tilt"]
pub tilt: FloatParam,
#[id = "odd-even"]
@@ -56,6 +79,7 @@ impl Default for LatheParams {
editor: EguiState::from_size(lathe_ui::WIDTH, lathe_ui::HEIGHT),
gains: RwLock::new(d.gains),
source: EnumParam::new("Pitch source", SourceParam::Audio),
pitch: PitchParams::default(),
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"),
@@ -83,10 +107,8 @@ impl LatheParams {
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,
},
source: self.source.value().into(),
pitch: self.pitch.pitch(),
gains: gains.map(|g| {
if g.is_finite() {
g.clamp(lathe_core::GAIN_MIN, lathe_core::GAIN_MAX)
@@ -174,13 +196,10 @@ impl LatheParams {
}
}
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.set_parameter(&self.source, new.source.into());
setter.end_set_parameter(&self.source);
}
self.pitch.apply(setter, &old.pitch, &new.pitch);
}
}
+57 -32
View File
@@ -14,6 +14,8 @@ pub struct State {
pub help: String,
/// Last point of a draw stroke in the harmonic editor: (harmonic index, dB).
stroke: Option<(usize, f32)>,
/// The advanced page shows its extra controls. Not saved.
pub advanced: bool,
}
impl Default for State {
fn default() -> Self {
@@ -22,6 +24,7 @@ impl Default for State {
gesture_active: false,
help: DEFAULT_HELP.into(),
stroke: None,
advanced: false,
}
}
}
@@ -77,6 +80,13 @@ fn y_gain(rect: Rect, y: f32) -> f32 {
}
/// Bar editor of the per-harmonic gains, with the effective curve and measured levels.
fn hz_label(hz: f32) -> String {
if hz >= 1000. {
format!("{:.1}k", hz / 1000.)
} else {
format!("{hz:.0}")
}
}
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());
@@ -199,6 +209,17 @@ fn harmonic_editor(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State
FontId::proportional(10.),
label_color,
);
// Where the harmonic sits now, at the octaves of the fundamental.
let hz = state.analyzer.hz;
if hz > 0. && (k + 1).is_power_of_two() {
p.text(
Pos2::new(x0 + col * 0.5, gains.bottom() - 2.),
egui::Align2::CENTER_BOTTOM,
hz_label(hz * (k + 1) as f32),
FontId::proportional(9.),
DIM,
);
}
// 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)]
@@ -269,7 +290,8 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
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 mut advanced = state.advanced;
header_paged(ui, "LATHE", "HARMONIC EQ", Some(&mut advanced), |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.");
@@ -277,14 +299,11 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
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");
let mut mode = settings.source.mode();
pitch_from(ui, &mut mode, &mut settings.pitch);
settings.source = Source::from_mode(mode);
});
state.advanced = advanced;
body(ui, settings, state);
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
@@ -326,6 +345,7 @@ fn note_panel(
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.neutral => ("?".into(), "unsure: passing through".into()),
_ if frame.hz > 0. => ("—".into(), format!("holding {:.1} Hz", frame.hz)),
_ => ("—".into(), "no note yet".into()),
};
@@ -340,10 +360,7 @@ fn note_panel(
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",
}
pitch_status(settings.source.mode(), &settings.pitch)
))
.size(12.)
.color(MUTED),
@@ -397,21 +414,24 @@ fn note_panel(
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;
if state.advanced {
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.",
);
}
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| {
let advanced = state.advanced;
ui.columns(if advanced { 9 } else { 6 }, |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.,
@@ -420,17 +440,22 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
"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.,
let last = if advanced {
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.");
7
} else {
4
};
knob(&mut c[last], 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.,
knob(&mut c[last + 1], state, "Output", &mut settings.output, -12. ..=12., d.output, " dB", 54.,
"Output trim.");
});
}
+4
View File
@@ -309,11 +309,15 @@ dependencies = [
[[package]]
name = "common-dsp"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "common-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"egui",
]
+26 -4
View File
@@ -34,6 +34,8 @@ pub const ATTACK_MS: [f32; BANDS] = [30., 15., 8., 4.];
pub const RELEASE_MS: [f32; BANDS] = [200., 160., 120., 90.];
/// Filter coefficients are recomputed this often, in samples.
const RETUNE: usize = 16;
/// A new note slides the band edges over about this long instead of snapping them.
const EDGE_MS: f32 = 6.;
const Q: f32 = std::f32::consts::FRAC_1_SQRT_2;
const SILENT: f32 = -120.;
@@ -176,6 +178,9 @@ pub struct Engine {
pitch: PitchFollower,
hz: f32,
hz_coef: f32,
edge_coef: f32,
/// The edges are sliding to a new note.
sliding: bool,
countdown: usize,
edges: [f32; BANDS - 1],
coefs: Coefs,
@@ -226,6 +231,8 @@ impl Engine {
pitch: PitchFollower::default(),
hz: 0.,
hz_coef: 1.,
edge_coef: coefficient(rate, EDGE_MS / 3.),
sliding: false,
countdown: 0,
edges: [0.; BANDS - 1],
coefs: Coefs {
@@ -264,7 +271,7 @@ impl Engine {
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.);
.set(s.pitch.source(s.source.mode(), s.gate, s.smooth > 0.));
self.hz_coef = coefficient(self.rate, s.smooth / 3.);
if s.time != self.settings.time || self.fresh {
let scale = s.time * 0.01;
@@ -296,6 +303,7 @@ impl Engine {
self.levels = [SILENT; BANDS];
self.gains = [0.; BANDS];
self.countdown = 0;
self.sliding = false;
self.fresh = true;
}
pub fn note_on(&mut self, note: u8) {
@@ -346,9 +354,23 @@ impl Engine {
self.delay_at = (self.delay_at + 1) % len;
let y = self.delay[self.delay_at];
// Unsure tracking sends no events, so the edges hold the last sure note. A new note
// slides them instead of snapping, so the bands do not pump on note changes.
let (hz, jump) = self.pitch.step(self.hz_coef);
self.hz = hz;
if jump || self.countdown == 0 {
let first = self.hz == 0.;
if jump && !first {
self.sliding = true;
}
if self.sliding {
self.hz += (hz - self.hz) * self.edge_coef;
if (hz - self.hz).abs() < 1e-3 * hz {
self.hz = hz;
self.sliding = false;
}
} else {
self.hz = hz;
}
if (jump && first) || self.countdown == 0 {
self.retune();
self.countdown = RETUNE;
}
@@ -404,7 +426,7 @@ impl Engine {
sample_rate: self.rate,
..AnalyzerFrame::default()
};
if self.tracker.sounding() && self.hz > 0. {
if self.tracker.active() && self.hz > 0. {
f.note = Some(hz_to_midi(self.hz).round().clamp(0., 127.) as u8);
}
f
+24
View File
@@ -1,4 +1,5 @@
//! Host-independent controls. Levels in dB, times in milliseconds, amounts in percent.
use common_dsp::tracker::{Pitch, PitchMode};
use serde::{Deserialize, Serialize};
/// Bands: the fundamental, low harmonics, mid harmonics and the top.
@@ -15,12 +16,33 @@ pub enum Source {
Audio,
/// Notes come from MIDI note input.
Midi,
/// Note starts are detected, the pitch is the set note: nothing is guessed.
Note,
}
impl Source {
pub fn mode(self) -> PitchMode {
match self {
Source::Audio => PitchMode::Audio,
Source::Midi => PitchMode::Midi,
Source::Note => PitchMode::Note,
}
}
pub fn from_mode(mode: PitchMode) -> Self {
match mode {
PitchMode::Audio => Source::Audio,
PitchMode::Midi => Source::Midi,
PitchMode::Note => Source::Note,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// Note mode's note, the key lock and Follow bends.
#[serde(default)]
pub pitch: Pitch,
/// Highest harmonic in the bottom band. 1 keeps the fundamental on its own.
pub low: u32,
/// Highest harmonic in the second band.
@@ -54,6 +76,7 @@ impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
pitch: Pitch::default(),
low: 1,
mid: 4,
high: 16,
@@ -73,6 +96,7 @@ impl Default for Settings {
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
self.pitch.validate()?;
let finite = [
self.depth,
self.time,
+14 -1
View File
@@ -309,11 +309,23 @@ dependencies = [
[[package]]
name = "common-dsp"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "common-params"
version = "0.1.0"
dependencies = [
"common-dsp",
"nih_plug",
]
[[package]]
name = "common-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"egui",
]
@@ -1440,9 +1452,10 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "planish"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"clap-sys",
"common-params",
"crossbeam-queue",
"nih_plug",
"nih_plug_egui",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "planish"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "ISC"
@@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"]
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" }
planish-core = { path = "../core" }
common-params = { path = "../../common/params" }
planish-ui = { path = "../ui" }
crossbeam-queue = "0.3"
+28 -9
View File
@@ -1,3 +1,4 @@
use common_params::PitchParams;
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use planish_core::{Settings, Source, BANDS, GAIN_MAX, GAIN_MIN, TOP_MAX};
@@ -10,6 +11,26 @@ pub enum SourceParam {
#[id = "midi"]
#[name = "MIDI"]
Midi,
#[id = "note"]
Note,
}
impl From<SourceParam> for Source {
fn from(s: SourceParam) -> Self {
match s {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
SourceParam::Note => Source::Note,
}
}
}
impl From<Source> for SourceParam {
fn from(s: Source) -> Self {
match s {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
Source::Note => SourceParam::Note,
}
}
}
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)
@@ -26,6 +47,8 @@ pub struct PlanishParams {
pub editor: Arc<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[nested(group = "Pitch")]
pub pitch: PitchParams,
#[id = "depth"]
pub depth: FloatParam,
#[id = "time"]
@@ -68,6 +91,7 @@ impl Default for PlanishParams {
Self {
editor: EguiState::from_size(planish_ui::WIDTH, planish_ui::HEIGHT),
source: EnumParam::new("Pitch source", SourceParam::Audio),
pitch: PitchParams::default(),
depth: linear("Depth", d.depth, 0., 100., " %"),
time: linear("Time", d.time, 10., 1000., " %"),
upward: linear("Upward", d.upward, 0., 100., " %"),
@@ -92,10 +116,8 @@ impl PlanishParams {
pub fn settings(&self) -> Settings {
let gains = [&self.gain_1, &self.gain_2, &self.gain_3, &self.gain_4];
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
source: self.source.value().into(),
pitch: self.pitch.pitch(),
low: self.low.value() as u32,
mid: self.mid.value() as u32,
high: self.high.value() as u32,
@@ -180,13 +202,10 @@ impl PlanishParams {
}
}
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.set_parameter(&self.source, new.source.into());
setter.end_set_parameter(&self.source);
}
self.pitch.apply(setter, &old.pitch, &new.pitch);
}
}
+33 -28
View File
@@ -17,12 +17,15 @@ pub struct State {
pub help: String,
/// Band edge being dragged on the harmonic ruler.
dragging: Option<usize>,
/// The advanced page shows its extra controls. Not saved.
pub advanced: bool,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
gesture_active: false,
advanced: false,
help: DEFAULT_HELP.into(),
dragging: None,
}
@@ -350,7 +353,8 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
ui.add_space(2.);
header(ui, "PLANISH", "HARMONIC OTT", |ui| {
let mut advanced = state.advanced;
header_paged(ui, "PLANISH", "HARMONIC OTT", Some(&mut advanced), |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.");
@@ -358,14 +362,11 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
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");
let mut mode = settings.source.mode();
pitch_from(ui, &mut mode, &mut settings.pitch);
settings.source = Source::from_mode(mode);
});
state.advanced = advanced;
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
@@ -418,10 +419,7 @@ fn note_panel(
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",
}
pitch_status(settings.source.mode(), &settings.pitch)
))
.size(12.)
.color(MUTED),
@@ -462,21 +460,25 @@ fn note_panel(
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;
if state.advanced {
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. Off keeps the level window on the real period, which is steadier on detuned patches.",
);
}
hint(
&r,
state,
"Round the detected pitch. Off keeps the level window on the real period, which is steadier on detuned patches.",
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let d = Settings::default();
ui.columns(12, |c| {
// Main: the OTT controls. Advanced adds the per-band gains, Smooth and Gate.
let advanced = state.advanced;
ui.columns(if advanced { 12 } else { 6 }, |c| {
knob(&mut c[0], state, "Drive", &mut settings.drive, -24. ..=24., d.drive, " dB", 64.,
"Pushes the band levels against the thresholds: more drive, more squash. It does not change the level by itself.");
knob(&mut c[1], state, "Depth", &mut settings.depth, 0. ..=100., d.depth, " %", 64.,
@@ -487,7 +489,8 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
"Cut for bands above the downward threshold: holds loud harmonics down.");
knob(&mut c[4], state, "Time", &mut settings.time, 10. ..=1000., d.time, " %", 64.,
"Scales every attack and release. Lower bands are slower than higher ones.");
for b in 0..BANDS {
let at = if advanced { 9 } else { 5 };
for b in (0..BANDS).filter(|_| advanced) {
let name = format!("Band {}", b + 1);
let help = format!(
"Gain of band {} (harmonics {}) after its compressor, scaled by Depth.",
@@ -497,11 +500,13 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
knob(&mut c[5 + b], state, &name, &mut settings.gains[b], GAIN_MIN..=GAIN_MAX,
d.gains[b], " dB", 54., &help);
}
knob(&mut c[9], 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[10], 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[11], state, "Output", &mut settings.output, -24. ..=24., d.output, " dB", 54.,
if advanced {
knob(&mut c[9], 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[10], 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[if advanced { 11 } else { at }], state, "Output", &mut settings.output, -24. ..=24., d.output, " dB", 54.,
"Output trim.");
});
}
+4
View File
@@ -309,11 +309,15 @@ dependencies = [
[[package]]
name = "common-dsp"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "common-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"egui",
]
+6 -4
View File
@@ -7,7 +7,7 @@ mod settings;
use common_dsp::meter::{HarmonicMeter, HARMONICS};
use common_dsp::oversample::Oversampler;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{Lookahead, NoteEvent, PitchFollower, Tracked, TrackerConfig};
use common_dsp::tracker::{Lookahead, NoteEvent, NoteState, PitchFollower, Tracked, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, gain_to_db, hz_to_midi};
pub use settings::{Curve, Settings, Source};
@@ -381,8 +381,10 @@ impl Tracked for Module {
self.pitch.event(false, e);
self.level.event(e);
}
fn process(&mut self, y: [f32; 2], sounding: bool) -> [f32; 2] {
self.sounding = sounding;
fn process(&mut self, y: [f32; 2], note: NoteState) -> [f32; 2] {
// Unsure tracking sends no retune, so Floor, Push and Bright hold their last sure
// tuning; only the readout drops the note.
self.sounding = note.sure;
self.run(y)
}
}
@@ -424,7 +426,7 @@ impl Engine {
fn set_tracker(&mut self, s: &Settings) {
self.lookahead
.tracker
.set(s.source == Source::Midi, s.gate, true);
.set(s.pitch.source(s.source.mode(), s.gate, true));
}
pub fn reset(&mut self) {
self.lookahead.reset();
+24
View File
@@ -1,4 +1,5 @@
//! Host-independent controls. Levels in dB, harmonic positions as multiples of the fundamental.
use common_dsp::tracker::{Pitch, PitchMode};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
@@ -8,6 +9,24 @@ pub enum Source {
Audio,
/// Notes come from MIDI note input.
Midi,
/// Note starts are detected, the pitch is the set note: nothing is guessed.
Note,
}
impl Source {
pub fn mode(self) -> PitchMode {
match self {
Source::Audio => PitchMode::Audio,
Source::Midi => PitchMode::Midi,
Source::Note => PitchMode::Note,
}
}
pub fn from_mode(mode: PitchMode) -> Self {
match mode {
PitchMode::Audio => Source::Audio,
PitchMode::Midi => Source::Midi,
PitchMode::Note => Source::Note,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
@@ -58,6 +77,9 @@ impl Curve {
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// Note mode's note, the key lock and Follow bends.
#[serde(default)]
pub pitch: Pitch,
/// Gain into the shaper after level normalisation.
pub drive: f32,
pub curve: Curve,
@@ -85,6 +107,7 @@ impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
pitch: Pitch::default(),
drive: 12.,
curve: Curve::Soft,
bias: 0.,
@@ -103,6 +126,7 @@ impl Default for Settings {
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
self.pitch.validate()?;
let finite = [
self.drive,
self.bias,
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more