Compare commits

...
34 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
nvrl 109763c768 chore: release Patina 0.5.0
Check and Windows preview / check (anvil) (push) Successful in 2m22s
Check and Windows preview / check (ballast) (push) Successful in 1m39s
Check and Windows preview / check (braze) (push) Successful in 1m35s
Check and Windows preview / check (crucible) (push) Successful in 1m51s
Check and Windows preview / check (forge) (push) Successful in 2m33s
Check and Windows preview / check (knurl) (push) Successful in 1m35s
Check and Windows preview / check (lathe) (push) Successful in 1m41s
Check and Windows preview / check (patina) (push) Successful in 3m37s
Check and Windows preview / check (planish) (push) Successful in 1m49s
Check and Windows preview / check (quench) (push) Canceled after 1m13s
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 9s
Independent plugin release / build (push) Successful in 3m53s
Independent plugin release / publish (push) Successful in 10s
- Threshold, Ratio and Soft/Hard knee: each band cuts like a small compressor.
- Mark the feedback item released in docs/ideas.md and update the release table.
2026-11-06 17:36:31 +01:00
nvrl 9ca4e4f9dd feat(patina): per-band compressor gain stage with Threshold, Ratio and Knee
Tester feedback asked for a threshold and a ratio. Each band now cuts like a
small compressor: Threshold (dB above the local floor) replaces Selectivity,
Ratio scales the cut per dB above it (inf:1 is the old behaviour), and a
Soft/Hard knee switch sets the curve. A small graph shows bump height in, cut
out. Mix and Output move to the slider panel so the detection panel fits.
common-ui gains rotary_text for knobs with custom value text.
2026-11-06 17:29:54 +01:00
nvrl 7b989f7685 feat(forge): add Forge tracked channel strip
One note tracker reads the input and Lathe, Temper and Knurl follow it as
modules, in any of six orders, each on or off. Every module gets the
tracker's events delayed by the latency of the modules in front of it.

- One module alone matches its standalone plugin sample for sample in
  every order.
- With Knurl's sub-octave pattern in front, Lathe in Forge follows the
  played note; a standalone Lathe after Knurl reads it an octave low.
- About 72 ms latency against about 206 ms for the three in a row.

The module pages reuse the standalone editors. Register Forge in the
catalog, CI, README, docs and website, and drop the built Ballast from
the site's idea list.
2026-11-06 16:30:23 +01:00
nvrl 340376eee2 refactor: run Lathe, Temper and Knurl as tracked modules
Add a Tracked trait and a Lookahead harness to common-dsp. Each engine is
now its tracker plus a module that follows note events aligned with its
input, so the same module can run behind a shared tracker.

Lathe and Temper output is bit-exact against the old engines. Knurl's
interpolation now reads one newer sample inside the module, so its
latency is one sample longer; the output matches within float rounding.

Each editor exposes body() and toggles() besides show(), so another
editor can draw a module's page.
2026-11-06 16:30:22 +01:00
nvrl 4b1a89626e chore: release Quench 0.1.0 and Ballast 0.1.0
Check and Windows preview / check (anvil) (push) Successful in 2m25s
Check and Windows preview / check (ballast) (push) Successful in 2m38s
Check and Windows preview / check (braze) (push) Successful in 1m34s
Check and Windows preview / check (crucible) (push) Successful in 1m53s
Check and Windows preview / check (knurl) (push) Successful in 1m45s
Check and Windows preview / check (lathe) (push) Successful in 1m47s
Check and Windows preview / check (patina) (push) Successful in 2m50s
Check and Windows preview / check (planish) (push) Successful in 1m36s
Check and Windows preview / check (quench) (push) Successful in 2m23s
Check and Windows preview / check (rivet) (push) Successful in 1m47s
Check and Windows preview / check (temper) (push) Successful in 1m46s
Check and Windows preview / check (weld) (push) Successful in 2m58s
Check and Windows preview / previews (push) Successful in 1m56s
Check and Windows preview / windows (push) Successful in 2m48s
Independent plugin release / publish (push) Successful in 10s
Independent plugin release / plan (push) Successful in 8s
Independent plugin release / build (push) Successful in 3m47s
- Quench 0.1.0: first release.
- Ballast 0.1.0: first release.
- Mark both released in docs/ideas.md and add them to the release table.
2026-11-06 16:08:23 +01:00
nvrl d93dd942a8 feat(ballast): add Ballast switch-up leveller
Ballast gives every part of a bassline one gain that brings it to the target
loudness, with no compression inside a part. Parts start at note starts,
detected from the audio or taken from MIDI, or on the host's beat grid, so a
patch change on the same note counts too.

- Loudness is K-weighted over each part's first 100 ms, in 10 ms blocks with
  a relative gate, and every part is weighted from rest so the last part does
  not ring into it.
- The audio path is delayed by the tracker's lookahead, the window and a
  5 ms glide, so each gain is in place on its part's first sample.
- Learn takes the next part's loudness as the target. Amount, Range, Measure
  from (leave the sub out), Gate, Output, Bypass.
- Editor shows the last 32 parts in and out, the target, and the spread of
  the last 16 parts.
- Registered in the catalog, workflows, editor-check, README, chain and
  development docs, and the website copy.
2026-11-06 16:05:19 +01:00
nvrl d77b88c7a9 feat(common-dsp): add ITU-R BS.1770 K-weighting
KWeighting is one channel of the standard's pre-filter and RLB high-pass,
computed from the analog prototypes so every sample rate is exact. lufs()
turns a mean square summed over channels into LUFS. A full-scale 997 Hz sine
reads -3.01 LUFS within 0.03 LU from 44.1 to 192 kHz.
2026-11-06 16:05:19 +01:00
nvrl 3d316cbe49 feat(quench): add Quench sub-safe clipper
Quench splits the signal with a Linkwitz-Riley crossover and lets the sub
pass unclipped. The top is clipped symmetrically at the ceiling minus the
sub's held peak, so it does not pump at the sub's rate and the sum stays
under the ceiling. What the clip removes is high-passed at the split, so its
products stay out of the low band. Two correction passes and an oversampled
hard clip hold the ceiling; a safety clip catches the downsampler's ringing.

- Controls: Drive, Ceiling, Split, Hold, Soft, Bypass.
- Listen to Quench, a plain clipper at the same settings, or the delta.
- Editor meter compares how much the low band changed for Quench and for a
  plain clip, with a scope of the output, the sub and the top's room.
- Measured: sub level +0.04 dB against -2.2 dB for a plain clip, low-band
  products -35 dB against -16 dB, at 0.5-1.2 dB less RMS.
- Registered in the catalog, workflows, editor-check, README, chain and
  development docs, and the website copy.
2026-11-06 15:51:36 +01:00
nvrl 77ee4a9078 chore: release Anvil 0.3.0, Weld 0.3.0 and Braze 0.1.0
Check and Windows preview / check (anvil) (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 (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 (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 / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 8s
Independent plugin release / build (push) Successful in 3m12s
- Anvil 0.3.0: Ghost harmonics, Phone listen, Kick lock and the offset
  audition. Anvil now has a Kick sidechain input.
- Weld 0.3.0: delay spread display.
- Braze 0.1.0: first release.
- Mark the released items in docs/ideas.md and add Braze to the release
  table.
2026-11-06 14:16:42 +01:00
nvrl a42bca0e92 style(weld): format the delay spread tests 2026-11-06 14:16:42 +01:00
nvrl fadea4c471 feat(anvil): add Kick lock with offset audition
Check and Windows preview / check (anvil) (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 (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 (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
Anvil gets a sidechain input named Kick. With Kick lock on, kicks are
found on the undelayed sidechain, so with the lookahead each hit ducks
the sub (Duck, Release) when the delayed kick plays and restarts the sub
at Phase, shifted by Offset (-30 to +30 ms, also before the kick).

The Audition steps the offset from -30 to +30 ms, 4 kicks each, and
measures how kick and sub sum below 150 Hz after each hit; Keep takes the
offset heard. On a 55 Hz kick burst against a 55 Hz sub the sum peaks at
0 ms (+2.4 dB) and nearly again one period away (+/-20 ms), so the bars
check the choice by ear rather than make it.
2026-11-06 14:09:59 +01:00
nvrl 330ec54150 feat(braze): add Braze tempo-locked beating
Check and Windows preview / check (anvil) (push) Successful in 2m15s
Check and Windows preview / check (braze) (push) Successful in 2m26s
Check and Windows preview / check (crucible) (push) Successful in 1m52s
Check and Windows preview / check (knurl) (push) Successful in 1m42s
Check and Windows preview / check (lathe) (push) Successful in 1m44s
Check and Windows preview / check (patina) (push) Successful in 2m38s
Check and Windows preview / check (planish) (push) Successful in 1m29s
Check and Windows preview / check (rivet) (push) Successful in 1m39s
Check and Windows preview / check (temper) (push) Successful in 1m37s
Check and Windows preview / check (weld) (push) Canceled after 2s
Check and Windows preview / previews (push) Canceled after 0s
Check and Windows preview / windows (push) Canceled after 0s
Adds a detuned copy of the bass whose beating stays on the tempo grid on
every note. The chosen harmonic k beats once per Rate note value, and
harmonic n beats n/k times as fast, so every harmonic is on the grid.

- The copy is read from a delay line whose delay falls one period of the
  tracked note per beat cycle (r = 1 + B/(k f0)) and jumps back exactly
  one period, crossfaded over 6 ms. On a periodic note the jump has no
  seam: the output matches the ideal sum within 0.003 on harmonics 1-4.
- The delay follows the host's beat position, shifted by the lookahead,
  so beat peaks land on the grid; free-running when stopped.
- Low cut: LR4 high-pass on the copy, the bass split by the same
  crossover, so the sub does not beat and keeps its level.
- Depth, Width, Phase, Gate, Output, Snap, Bypass. Pitch from audio
  (about 67 ms lookahead) or MIDI.
- Editor: one bar of the beat per harmonic with the grid and a playhead.
- Catalog, workflows, editor-check, docs, signal chain and site copy.
2026-11-06 14:02:58 +01:00
nvrl e7f3346077 feat(anvil): add Ghost harmonics and Phone listen
Ghost adds the sub's harmonics 2–10 above Ghost from, so phones hear the
note. The band above the split is measured per harmonic and only missing
harmonics are filled, so added ones cannot cancel the patch's own. The sub
level does not change. Phone monitors the output in mono, 400 Hz–7 kHz.
2026-11-06 13:46:15 +01:00
nvrl 4f1e237d66 docs(ideas): record design checks for Anvil Ghost harmonics 2026-11-06 13:34:42 +01:00
nvrl 1479854503 feat(scripts): prototype the momentary fatness lane
scripts/fatness.py measures momentary bass fatness of rendered files with
the final model from Schmedeke's dissertation (section 5.4.3, adjusted R²
.773): two logistic terms on the C-weighted level and the MPEG-7 harmonic
ratio below 200 Hz, over 50 ms windows, after normalising to -23 LUFS
(BS.1770 integrated, gated).

The thesis does not state the dB(C) of its reference, so --offset maps
dBFS to dB(C) (default 100). On the seven Devath references, drops read
4-6, breakdowns near 0, and kick gaps show as 50 ms dips; SPL<200 medians
of 67-69 dB(C) suggest the offset may be 2-5 dB low.

Also corrects docs/ideas.md, which quoted the thesis's preliminary linear
model instead of the final one.
2026-11-06 13:34:04 +01:00
nvrl 8ecdc03832 docs(ideas): add findings from the research collection
Adds a Research findings section with sourced numbers (Schmedeke fatness
model, virtual bass weighting, group-delay audibility, timbral tension,
coupled controls, layer coherence, heard timing of layered hits, loopback
FM, beat rates of detuned layers) and where each goes in the suite.

New problems 19-21, new ideas Braze, Arc and the fatness lane, additions
to Anvil, Weld, Crucible, Forge and Loupe, and a research track in the
build order. Also updates stale release notes for Patina and Planish.
2026-11-06 13:17:29 +01:00
nvrl d536e3127a feat(weld): show the alignment's delay spread
Weld's allpass sections add group delay between 500 Hz and 4 kHz, where
listeners hear it best. Liski et al. (2021) measured a threshold of about
0.64 ms for a positive group-delay peak on clicks. Weld now reports how much
its delay varies over that range and, above the threshold, suggests
comparing attacks in SUM and SUM DRY.

Common fits from Weld's own tests spread 0.82-1.03 ms, so the line is a
neutral note, not a warning: sustained bass hides much more than a click.

- Fit::group_delay (analytic, per section) and Fit::smear_ms in weld-core.
- Report::smear_ms, filled by the analyzer from the applied fit.
- Tests against the numeric phase slope and the analog 4Q/w0 peak.
- Display only; the audio path does not change.
2026-11-06 13:17:29 +01:00
nvrl 340ff13342 chore: release Crucible 0.3.0, Patina 0.4.0 and Planish 0.1.0
Check and Windows preview / check (anvil) (push) Successful in 2m22s
Check and Windows preview / check (crucible) (push) Successful in 1m55s
Check and Windows preview / check (knurl) (push) Successful in 1m42s
Check and Windows preview / check (lathe) (push) Successful in 1m41s
Check and Windows preview / check (patina) (push) Successful in 2m43s
Check and Windows preview / check (planish) (push) Successful in 2m20s
Check and Windows preview / check (rivet) (push) Successful in 1m42s
Check and Windows preview / check (temper) (push) Successful in 1m39s
Check and Windows preview / check (weld) (push) Successful in 2m40s
Check and Windows preview / previews (push) Successful in 1m37s
Check and Windows preview / windows (push) Successful in 2m34s
Independent plugin release / publish (push) Successful in 9s
Independent plugin release / plan (push) Successful in 7s
Independent plugin release / build (push) Successful in 3m8s
- Crucible 0.3.0: Keep takes.
- Patina 0.4.0: Tone guard and the Bass line preset.
- Planish 0.1.0 is already at its first version.
- Update the current version in every plugin guide; several still named an
  older release.
2026-11-06 12:58:53 +01:00
nvrl c06d193b18 feat(planish): add Planish harmonic OTT
Upward and downward compression in four bands whose edges are harmonic
numbers of the tracked note (default 1, 2-4, 5-16, 17 and up), so the
same setting squashes the same harmonics on every note. A fixed-band
OTT puts harmonic 2 of F1 and of C2 in different bands.

- Three Linkwitz-Riley splits at sqrt(k(k+1)) x f0 with allpass
  compensation; at Depth 0 the bands sum within 0.05 dB.
- Band level is the RMS over one period of the note, so heavy settings
  do not ripple at f0: a 40 Hz sine at Drive +12 dB, Time 10 % gets no
  harmonics above -110 dB.
- At Depth 100 % on a saw, harmonics 1-12 change by the same amount on
  F1, C2 and G2 (within 0.2 dB).
- Depth, Drive (on the detector only), Upward, Downward, Time, a gain
  per band; edges Low 1-8, Mid 2-16, High 3-48 with four splits.
- Pitch from audio with about 67 ms lookahead, or from MIDI.
- Editor: harmonic ruler with draggable edges, level and gain per band.
- Catalog, workflows, editor-check, docs, signal chain and site copy.
2026-11-06 12:51:49 +01:00
nvrl 22870127c8 feat(common-dsp): second-order SVF allpass
SvfCoef::allpass has the phase of a Linkwitz-Riley split at the same
frequency, so a crossover tree can keep its lower bands in phase with
the upper ones. The test checks it against a real LR4 split sample by
sample and for flat magnitude.
2026-11-06 12:51:49 +01:00
nvrl 53bc26d14d feat(patina): Tone guard for bass lines
Patina detects the played note (30-500 Hz) and remembers, over about
4 s, how high each spectral bump is at a fixed frequency and relative
to the note. Bumps that follow the note (a boosted harmonic the patch
is built on) get less cut; bumps fixed in Hz (formants, room modes)
are cut as before. On a saw line F1-F2 with harmonic 6 boosted, the
cut on that harmonic drops from 5.78 dB to 0.44 dB at 100 %, while a
fixed formant at 1.5 kHz stays at 5.78 dB. One held note is cut as
normal, since the two cases cannot be told apart.

- New parameter tone-guard (0-100 %, default 0), appended at the end;
  0 % is the old behaviour and old settings load unchanged.
- New Bass line preset.
- Editor: Tone guard control, NOTE readout and a strip over the
  spectrum where cuts are held back.
- No added latency, no allocation on the audio thread; patina-core now
  depends on common-dsp.
2026-11-06 12:24:11 +01:00
nvrl 6d868a63c4 feat(crucible): keep every captured take
With Keep takes on (default), each capture is written to
<folder>/takes/ as a WAV named by time, tempo and length, so a take
is not lost when the next capture replaces it. Files are never
overwritten: a clash gets a numbered suffix. Saving runs off the UI
thread and the status line shows the path or the error.
2026-11-06 12:24:10 +01:00
nvrl 839e46aec9 docs: signal chain guide and suite audit
docs/chain.md gives the plugin order for a bass track and the bus, the
latency each plugin adds, and the measured octave error of downstream
note trackers after Knurl: from C2 up the tracker reads an octave low
about 85 % of the time, and timing jitter adds 5-25 false note starts
per second. Knurl and Anvil guides point there and recommend MIDI as
the pitch source after Knurl.

docs/ideas.md gains the production-flow audit, next steps for every
released plugin, new ideas (Forge, Planish, Quench, Scribe, Bevel,
Peen) and a build order.
2026-11-06 12:24:10 +01:00
nvrl 444b9d93da feat!: change CLAP IDs to io.narl.bass-foundry.<product>, release all plugins
Check and Windows preview / check (anvil) (push) Successful in 2m11s
Check and Windows preview / check (crucible) (push) Successful in 1m43s
Check and Windows preview / check (knurl) (push) Successful in 1m37s
Check and Windows preview / check (lathe) (push) Successful in 1m39s
Check and Windows preview / check (patina) (push) Successful in 2m25s
Check and Windows preview / check (rivet) (push) Successful in 1m50s
Check and Windows preview / check (temper) (push) Successful in 1m45s
Check and Windows preview / check (weld) (push) Successful in 3m9s
Check and Windows preview / previews (push) Successful in 1m50s
Check and Windows preview / windows (push) Successful in 2m46s
Independent plugin release / publish (push) Successful in 9s
Independent plugin release / plan (push) Successful in 8s
Independent plugin release / build (push) Successful in 2m10s
The io.storyboard.* IDs were a leftover from Motif Storyboard and named
old functions (motif, sub-anchor, layer-align) instead of the products.
Every plugin now uses io.narl.bass-foundry.<product>, and IDs are frozen
from here on, even across renames.

Hosts see the new IDs as different plugins: delete the old .clap files,
rescan, and add the plugins again in existing projects. Rivet session
JSON and Patina parameter IDs are unchanged.

- Rivet 0.5.0, Patina 0.3.0; Anvil, Crucible, Lathe, Temper, Weld and
  Knurl 0.2.0.
- Docs, changelogs and the release table describe the change.
- Website: upgrade note in the Install section.
2026-11-06 11:51:11 +01:00
nvrl 7e1776bb04 feat(website): add the fl.narl.io site
Check and Windows preview / check (anvil) (push) Successful in 49s
Check and Windows preview / check (crucible) (push) Successful in 48s
Check and Windows preview / check (knurl) (push) Successful in 40s
Check and Windows preview / check (lathe) (push) Successful in 41s
Check and Windows preview / check (patina) (push) Successful in 56s
Check and Windows preview / check (rivet) (push) Successful in 45s
Check and Windows preview / check (temper) (push) Successful in 43s
Check and Windows preview / check (weld) (push) Successful in 1m20s
Check and Windows preview / previews (push) Successful in 1m15s
Check and Windows preview / windows (push) Successful in 2m31s
Astro site for the suite: a home page with the plugin cards, install
steps, principles, the metalwork names and the ideas on the bench, one
page per plugin, and the Impressum and privacy policy. Plugin names,
versions, download links and editor previews come from the catalog, the
wrapper manifests and docs/*/assets at build time.

Served by unprivileged nginx in Docker behind Traefik on fl.narl.io,
the same setup as proton.narl.io: no access logs, CSP 'self', no
JavaScript, legal pages filled from the environment at start.
2026-11-06 11:30:12 +01:00
nvrl 5cc3770737 feat(knurl): add Knurl cycle texture
Check and Windows preview / check (anvil) (push) Successful in 2m1s
Check and Windows preview / check (crucible) (push) Successful in 1m31s
Check and Windows preview / check (knurl) (push) Successful in 2m4s
Check and Windows preview / check (lathe) (push) Successful in 1m27s
Check and Windows preview / check (patina) (push) Successful in 1m54s
Check and Windows preview / check (rivet) (push) Successful in 1m30s
Check and Windows preview / check (temper) (push) Successful in 1m31s
Check and Windows preview / check (weld) (push) Successful in 2m31s
Check and Windows preview / previews (push) Successful in 1m26s
Independent plugin release / plan (push) Successful in 7s
Check and Windows preview / windows (push) Successful in 2m26s
Independent plugin release / build (push) Successful in 3m0s
Independent plugin release / publish (push) Successful in 8s
Knurl follows the played note and treats every cycle of the wave on its
own: a repeating level pattern over 1-16 cycles makes subharmonics in tune
with every note, random level and timing per cycle add rattle, and a noise
burst per cycle, highpassed at a multiple of f0, adds grit. Amounts are
counted in cycles, so the texture is the same on F1 and on C2.

- Cycle clock from the shared note tracker, restarted at each note; the
  audio is delayed by the lookahead plus 4.2 ms of timing-jitter headroom.
- Per-cycle randomness from a hash of the cycle number: Same every note
  or Free.
- Patterns Off, Double, Triple, Fry (default), Gallop, Stutter or drawn;
  grit switchable per step. Pattern persisted as knurl-pattern.
- Editor: last eight cycles overlaid, pattern bars with the playing step.
- Tests match the prototype: Double gives f0/2 at -8.19 dB, Triple f0/3
  at -10.68 dB, equal on F1 and C2, no added clicks, bit-exact when off,
  no allocation on the audio thread.
2026-11-06 08:49:11 +01:00
nvrl f4c0a833e6 ci: check editor-check formatting in the product check
Check and Windows preview / check (anvil) (push) Successful in 39s
Check and Windows preview / check (crucible) (push) Successful in 44s
Check and Windows preview / check (lathe) (push) Successful in 40s
Check and Windows preview / check (patina) (push) Successful in 55s
Check and Windows preview / check (rivet) (push) Successful in 46s
Check and Windows preview / check (temper) (push) Successful in 44s
Check and Windows preview / check (weld) (push) Successful in 1m18s
Check and Windows preview / previews (push) Successful in 55s
Check and Windows preview / windows (push) Successful in 1m24s
Every product adds a fixture to tools/editor-check, but only the CI
previews job checked its formatting, so an unformatted fixture passed
the local product check and failed CI.
2026-11-06 08:34:14 +01:00
242 changed files with 42972 additions and 650 deletions

No files matched your search

+13 -1
View File
@@ -22,7 +22,7 @@ jobs:
strategy:
fail-fast: false
matrix:
plugin: [rivet, patina, anvil, crucible, lathe, temper, weld]
plugin: [rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish, braze, quench, ballast, forge]
steps:
- uses: https://github.com/actions/checkout@v4
with:
@@ -61,6 +61,12 @@ jobs:
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/lathe.png --plugin lathe
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/temper.png --plugin temper
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/weld.png --plugin weld
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/knurl.png --plugin knurl
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/planish.png --plugin planish
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/braze.png --plugin braze
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/quench.png --plugin quench
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/ballast.png --plugin ballast
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/forge.png --plugin forge
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
name: editor-previews
@@ -91,6 +97,12 @@ jobs:
python3 scripts/build-plugin.py --plugin lathe --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin temper --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin weld --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin knurl --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin planish --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin braze --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin quench --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin ballast --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin forge --target x86_64-pc-windows-gnu
python3 scripts/package-windows.py
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
+1 -1
View File
@@ -1,7 +1,7 @@
name: Independent plugin release
on:
push:
tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*", "lathe-v*", "temper-v*", "weld-v*"]
tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*", "lathe-v*", "temper-v*", "weld-v*", "knurl-v*", "planish-v*", "braze-v*", "quench-v*", "ballast-v*", "forge-v*"]
permissions:
contents: read
Generated
+128
View File
@@ -55,12 +55,50 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "ballast-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
"serde_json",
]
[[package]]
name = "ballast-ui"
version = "0.1.0"
dependencies = [
"ballast-core",
"common-dsp",
"common-ui",
"egui",
]
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "braze-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
"serde_json",
]
[[package]]
name = "braze-ui"
version = "0.1.0"
dependencies = [
"braze-core",
"common-dsp",
"common-ui",
"egui",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -70,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",
]
@@ -164,12 +206,59 @@ version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7e7a64c02cf7a5b51e745a9e45f60660a286f151c238b9d397b3e923f5082f"
[[package]]
name = "forge-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"knurl-core",
"lathe-core",
"serde",
"serde_json",
"temper-core",
]
[[package]]
name = "forge-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"egui",
"forge-core",
"knurl-core",
"knurl-ui",
"lathe-core",
"lathe-ui",
"temper-core",
"temper-ui",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "knurl-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
"serde_json",
]
[[package]]
name = "knurl-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"egui",
"knurl-core",
]
[[package]]
name = "lathe-core"
version = "0.1.0"
@@ -285,6 +374,7 @@ dependencies = [
name = "patina-core"
version = "0.2.0"
dependencies = [
"common-dsp",
"rustfft",
"serde",
"serde_json",
@@ -307,6 +397,25 @@ dependencies = [
"patina-core",
]
[[package]]
name = "planish-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
"serde_json",
]
[[package]]
name = "planish-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"egui",
"planish-core",
]
[[package]]
name = "primal-check"
version = "0.3.4"
@@ -331,6 +440,25 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "quench-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
"serde_json",
]
[[package]]
name = "quench-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"egui",
"quench-core",
]
[[package]]
name = "quote"
version = "1.0.47"
+13 -2
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/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"]
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/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"
@@ -24,5 +24,16 @@ opt-level = 2
opt-level = 2
[profile.dev.package.temper-core]
opt-level = 2
[profile.dev.package.knurl-core]
opt-level = 2
[profile.dev.package.planish-core]
opt-level = 2
[profile.dev.package.weld-core]
opt-level = 2
[profile.dev.package.quench-core]
opt-level = 2
[profile.dev.package.ballast-core]
opt-level = 2
[profile.dev.package.forge-core]
opt-level = 2
+41 -11
View File
@@ -2,6 +2,8 @@
**Rust CLAP plugins for writing, designing and mixing bass music in FL Studio.**
Website: [fl.narl.io](https://fl.narl.io)
![CLAP](https://img.shields.io/badge/format-CLAP-6c5ce7)
![FL Studio 2024.1+](https://img.shields.io/badge/FL%20Studio-2024.1%2B-ff8a00)
![Windows x64](https://img.shields.io/badge/platform-Windows%20x64-0078d4)
@@ -20,21 +22,31 @@ note. Each plugin is a separate product with its own version, guide and release.
| --- | --- | --- | --- | --- |
| **[Rivet](docs/rivet/README.md)** | Tells a MIDI motif as a story on a storyboard of roles and joins the seams into one phrase | Channel Rack | none | [Guide](docs/rivet/README.md) · [Changes](docs/rivet/CHANGELOG.md) |
| **[Patina](docs/patina/README.md)** | Smooths harsh, ringing resonances while plain harmonic tone and transients pass | Any mixer track | one analysis frame | [Guide](docs/patina/README.md) · [Changes](docs/patina/CHANGELOG.md) |
| **[Anvil](docs/anvil/README.md)** | Replaces the sub with a clean mono sine: one level and one start phase on every note | Bass bus | 80 ms | [Guide](docs/anvil/README.md) · [Changes](docs/anvil/CHANGELOG.md) |
| **[Anvil](docs/anvil/README.md)** | Replaces the sub with a clean mono sine: one level and one start phase on every note, locked to the kick | Bass bus, kick as sidechain | 80 ms | [Guide](docs/anvil/README.md) · [Changes](docs/anvil/CHANGELOG.md) |
| **[Crucible](docs/crucible/README.md)** | Records the last bars all the time, then slices, cleans and exports key-labelled samples with one button | End of the chain to resample | none | [Guide](docs/crucible/README.md) · [Changes](docs/crucible/CHANGELOG.md) |
| **[Lathe](docs/lathe/README.md)** | EQ with one band on each harmonic of the played note: a cut on harmonic 3 stays on harmonic 3 | Bass, after the synth | ≈ 67 ms | [Guide](docs/lathe/README.md) · [Changes](docs/lathe/CHANGELOG.md) |
| **[Temper](docs/temper/README.md)** | Distortion that drives every note the same: level and tone filters follow the note | Bass | ≈ 67 ms + oversampling | [Guide](docs/temper/README.md) · [Changes](docs/temper/CHANGELOG.md) |
| **[Weld](docs/weld/README.md)** | Phase-aligns one bass layer to another layer through the sidechain, so the layers sum in the mids instead of cancelling | One layer's track, other layer as sidechain | none | [Guide](docs/weld/README.md) · [Changes](docs/weld/CHANGELOG.md) |
| **[Knurl](docs/knurl/README.md)** | Gives every cycle of the note its own level, timing and grit: in-tune subharmonic growl, rattle and fry on every note | Bass | ≈ 71 ms | [Guide](docs/knurl/README.md) · [Changes](docs/knurl/CHANGELOG.md) |
| **[Planish](docs/planish/README.md)** | Upward and downward compression (OTT) in bands set by harmonic number: band 2 holds harmonics 2–4 on every note | Bass | ≈ 67 ms | [Guide](docs/planish/README.md) · [Changes](docs/planish/CHANGELOG.md) |
| **[Braze](docs/braze/README.md)** | Adds a detuned copy whose beating stays on the tempo grid on every note: reese movement in time | Bass | ≈ 67 ms | [Guide](docs/braze/README.md) · [Changes](docs/braze/CHANGELOG.md) |
| **[Quench](docs/quench/README.md)** | Clips the top as hard as you like while the sub passes clean: the low end keeps its level under a loud master | Master or bass bus, last | < 1 ms (oversampling) | [Guide](docs/quench/README.md) · [Changes](docs/quench/CHANGELOG.md) |
| **[Ballast](docs/ballast/README.md)** | Levels every note or switch-up to one loudness with a single gain each: no compression inside a part | Bass, after the sound design | ≈ 172 ms | [Guide](docs/ballast/README.md) · [Changes](docs/ballast/CHANGELOG.md) |
| **[Forge](docs/forge/README.md)** | Lathe, Temper and Knurl as one channel strip behind one note tracker, in any order: one lookahead, and no module hears Knurl's sub-octave | Bass, after the synth | ≈ 72 ms | [Guide](docs/forge/README.md) · [Changes](docs/forge/CHANGELOG.md) |
Plugins with latency report it to the host, so FL Studio compensates the delay.
Anvil, Lathe and Temper detect pitch from the audio. They can also take the
pitch from MIDI.
Anvil, Lathe, Temper, Knurl, Planish, Braze, Ballast and Forge detect notes from the audio. They can also take the
pitch from MIDI. The order of the plugins matters: see [Signal chain](docs/chain.md).
The names come from metalwork: a **rivet** joins pieces; **patina** changes a
surface and keeps its character; an **anvil** is the solid base that everything
is struck on; a **crucible** melts material down to be cast again; a **lathe**
turns a piece so each cut lands the same all the way round; **tempering** sets
how hard and how tough a metal ends up; a **weld** fuses two pieces into one.
how hard and how tough a metal ends up; a **weld** fuses two pieces into one;
**knurling** presses a ridged grip pattern into a surface; **planishing**
hammers a surface smooth and even; **brazing** joins two metals with a filler; **quenching** hardens hot metal in one
fast step; **ballast** is the weight that keeps a ship level; a **forge** is the hearth where
every piece is heated and worked.
The suite is the **foundry**: the workshop where the bass gets cast, shaped and
finished. Hosts list the plugins under the vendor name *Bass Foundry*.
@@ -55,7 +67,18 @@ finished. Hosts list the plugins under the vendor name *Bass Foundry*.
</tr>
<tr>
<td><a href="docs/weld/README.md"><img src="docs/weld/assets/editor.png" alt="Weld editor"></a><br><b>Weld</b>: layer phase alignment</td>
<td></td>
<td><a href="docs/knurl/README.md"><img src="docs/knurl/assets/editor.png" alt="Knurl editor"></a><br><b>Knurl</b>: cycle texture</td>
</tr>
<tr>
<td><a href="docs/planish/README.md"><img src="docs/planish/assets/editor.png" alt="Planish editor"></a><br><b>Planish</b>: harmonic OTT</td>
<td><a href="docs/braze/README.md"><img src="docs/braze/assets/editor.png" alt="Braze editor"></a><br><b>Braze</b>: tempo-locked beating</td>
</tr>
<tr>
<td><a href="docs/quench/README.md"><img src="docs/quench/assets/editor.png" alt="Quench editor"></a><br><b>Quench</b>: sub-safe clipper</td>
<td><a href="docs/ballast/README.md"><img src="docs/ballast/assets/editor.png" alt="Ballast editor"></a><br><b>Ballast</b>: switch-up leveller</td>
</tr>
<tr>
<td><a href="docs/forge/README.md"><img src="docs/forge/assets/editor.png" alt="Forge editor"></a><br><b>Forge</b>: tracked channel strip</td>
</tr>
</table>
@@ -83,11 +106,11 @@ Keep the font license (`licenses/`) with redistributed packages.
Every branch and pull-request pipeline also builds a combined Windows preview
ZIP, `bass-foundry-windows-x86_64.zip`, in its **windows** job. Use it to test unreleased changes.
> **Upgrading from Motif Storyboard or Resonance Compressor?** These are now
> Rivet and Patina. The CLAP IDs and saved state are unchanged, so projects
> still load. Delete the old `.clap` files and rescan. The vendor name changed
> from "Rivet & Patina" to "Bass Foundry", so FL Studio may list the plugins
> in a new vendor folder after the rescan.
> **Upgrading from an earlier build?** The CLAP IDs changed from
> `io.storyboard.*` to `io.narl.bass-foundry.<product>`, so hosts see each
> plugin as new. Delete the old `.clap` files, rescan, and add the plugins again
> in existing projects. Motif Storyboard and Resonance Compressor are now Rivet
> and Patina, and the vendor is "Bass Foundry".
## Build from source
@@ -115,7 +138,7 @@ cargo run --manifest-path tools/editor-check/Cargo.toml -- target/editor/lathe.p
```
Plugin names for the scripts are `rivet`, `patina`, `anvil`, `crucible`,
`lathe`, `temper` and `weld`. Builds go to `dist/<target>/<Name>.clap`. No
`lathe`, `temper`, `weld`, `knurl`, `planish`, `braze`, `quench`, `ballast` and `forge`. Builds go to `dist/<target>/<Name>.clap`. No
script installs anything into a DAW. [Development and testing](docs/development.md)
has the full command list.
@@ -152,6 +175,12 @@ plugins/
lathe/ core · ui · plugin
temper/ core · ui · plugin
weld/ core · ui · plugin
knurl/ core · ui · plugin
planish/ core · ui · plugin
braze/ core · ui · plugin
quench/ core · ui · plugin
ballast/ core · ui · plugin
forge/ core · ui · plugin
common/
ui/ Shared look: palette, embedded fonts, knobs, panels, meters
dsp/ Shared filters, pitch tracking, oversampling, meters, WAV files
@@ -160,6 +189,7 @@ scripts/ Build, check, packaging, release and analysis tools
docs/<plugin>/ Guide, changelog and editor preview for each plugin
docs/ideas.md Problems in bass-music production and the plugin ideas for them
references/ Local reference audio; never distributed
website/ The fl.narl.io site: Astro, nginx container, reads catalog and previews
```
Each product follows the same split:
+36
View File
@@ -1,5 +1,41 @@
# 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
above a chosen frequency, so phones hear the note. Anvil measures the patch's own
harmonics above the split and fills only the ones that are missing. The sub level
does not change.
- Add **Phone**, a listen mode: mono, band-limited to 400 Hz–7 kHz. Not saved.
- Show the patch's harmonics and the added ones in an inset in the scope.
- Add Kick lock: with the kick in the sidechain, each kick ducks the sub
(**Duck**, **Release**) and restarts it at Phase, shifted by **Offset**
(−30 to +30 ms, also before the kick).
- Add the offset **Audition**: steps the offset from −30 to +30 ms, 4 kicks each,
shows how kick and sub summed per offset, and **Keep** takes the offset heard.
- Anvil now has a sidechain input named Kick. Without Kick lock it is ignored.
## 0.2.0
- Change the CLAP ID to `io.narl.bass-foundry.anvil`. Hosts see this as a new
plugin: delete the old `.clap` file, rescan, and add the plugin again in
existing projects.
## 0.1.0
- Introduce Anvil, a sub anchor for bass music.
+97 -15
View File
@@ -1,6 +1,6 @@
# Anvil
Anvil gives every bass note the same solid sub. Current version **0.1.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,42 +20,124 @@ 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).
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
its energy below that, so on a phone the note is gone. The ear rebuilds a note's
pitch from its harmonics, so harmonics alone carry the note.
**Ghost** adds the sub's harmonics 2–10 above **Ghost from**, falling 1/k like a
saw. At 100 %, harmonic k sits at 2/k of the sub's level. They fade in over half
an octave above Ghost from, and never reach below Split. The sub itself does not
change, so the weight on a club system stays where Sub level puts it.
Anvil measures each harmonic of the patch above the split and adds only what is
missing. A harmonic the patch already has meets an added one at an unrelated
phase and could cancel it, so Ghost leaves those alone. The inset in the scope
shows both, per harmonic: what the patch carries (grey) and what Ghost adds
(accent). A riddim patch usually needs little; 808s and clean sine subs need the
most. Each new note is measured from its first 60 ms; during that time every
harmonic counts as missing.
Drive works differently: it saturates the sine itself, adds only odd harmonics,
and changes the sub's own shape.
### Kick lock
With **Retrigger**, every note starts at Phase, but a kick that falls in the
middle of a long note meets the sub at whatever phase it has reached. Kick lock
listens to the kick through the sidechain. On each kick it dips the sub by
**Duck** (coming back over **Release**) and restarts the sub at Phase, so the
kick and the sub meet the same way on every hit.
Set it up in FL Studio:
1. Select the kick's mixer track. Right-click the route arrow under the bass
bus and choose **Sidechain to this track**. The kick still goes to the
master on its own; the sidechain only lets Anvil hear it.
2. In Anvil's plugin wrapper, open the settings (gear icon) and set the
sidechain input to the kick's track.
3. Turn on **Kick lock**. The kick count under the switch must rise with the
kicks.
**Offset** moves the restart against the kick, from 30 ms before it to 30 ms
after. Kicks are found on the undelayed sidechain, so with the lookahead the
sub can restart before the kick is heard.
Where the kick and sub sound like one hit is not always where they sum
loudest: offsets one period of the note apart sum the same, and the attack of
the kick decides where the hit is heard. So the offset is set by ear:
1. Turn on **Audition** and play a loop with kicks and a long bass note. Each
offset from −30 to +30 ms plays for 4 kicks; the line under the bars says
which one plays now.
2. Press **Keep** when kick and sub sound like one hit. Offset takes that
value and the audition stops.
The bars show, per offset, how the kick and the sub summed below 150 Hz in the
60 ms after each hit: +3 dB is a full sum of equal levels, 0 dB unrelated,
below 0 dB they cancel. Use them to check the choice, not to make it.
Other controls:
- **Dynamics** at 0 % holds the level for the whole note. Raise it to let the sub
follow the original's volume shape, for example a wobble.
- **Drive** adds upper harmonics so the sub reads on phones and laptops.
- **Drive** saturates the sine: odd harmonics and a thicker sub.
- **Octave** moves the sub against the detected note. Use −1 for a bass played
one octave up.
- **Glide** slides between overlapping notes; 0 jumps.
- **Gate** is the input level that counts as a note. Raise it if tails or reverb
start new notes.
- **Keep** blends some of the original low band back under the new sub.
- **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.
### Pitch from MIDI
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).
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 source
**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: 243 KiB

After

Width:  |  Height:  |  Size: 248 KiB

+25
View File
@@ -0,0 +1,25 @@
# 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
gain that brings it to the Target loudness. No compression inside a part.
- Parts start at note starts (detected from the audio, or from MIDI) or on the
host's beat grid, from 1 bar to 1/16.
- Loudness is K-weighted (ITU-R BS.1770) over each part's first 100 ms, in
10 ms blocks with a relative gate, and each part is weighted from rest.
- The gain glides over 5 ms and is in place on the part's first sample.
- **Learn** takes the next part's loudness as the target. **Amount**,
**Range**, **Measure from** (leave the sub out of the measurement), **Gate**,
**Output**, **Bypass**.
- Editor: the last 32 parts, their loudness in and out, the target, and the
spread of the last 16 parts.
- About 172 ms of latency: the tracker's lookahead, the window and the glide.
+84
View File
@@ -0,0 +1,84 @@
# Ballast
Ballast levels every note or switch-up of a bassline to one loudness, with a
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
different loudness and gets hand-set gain or clipping. A compressor would even
them out but also change their envelope and punch. Ballast measures each part's
loudness and gives the whole part one gain: inside a part nothing moves.
![Ballast](assets/editor.png)
## Install on Windows
Copy `Ballast.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio
2024.1 or newer, open Options → Manage plugins, enable Verify plugins, and Find
installed plugins. Load Ballast in the mixer slot of the bass, after its sound
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). 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.
3. Watch the display: one bar per part with its loudness as it came in, and a
mark where Ballast puts it. The marks line up on the target line.
4. Lower **Amount** to keep some of the differences: 50 % halves them.
The panel shows the spread of the last 16 parts, in and out: the standard
deviation of their loudness in dB. In the editor preview four patches spread
by 7 dB come out within 0.4 dB; the rest is what Range left.
Other controls:
- **Range** is the most boost or cut a part gets.
- **Measure from**: the measurement hears only what is above this frequency;
the gain still applies to the whole signal. When Anvil makes the sub on the
bus, set it to Anvil's Split: Anvil replaces the sub anyway, so only the part
above it should decide the gain.
- **Gate** is the note-start threshold (Audio). A part quieter than the gate
keeps the last part's gain instead of being lifted, so rests and tails are
not pulled up.
- **Output** trims the level; **Bypass** crossfades to the delayed input.
### How it measures
A part's loudness is its K-weighted level (ITU-R BS.1770, the weighting behind
LUFS) over its first 100 ms, or over all of it when it is shorter. The level is
counted in 10 ms blocks, and blocks 20 dB under the part's loudest block are
left out. Each part is weighted from rest, as if it played alone, so the last
part's level does not ring into it.
The gain glides to the new value over 5 ms and is in place on the part's first
sample, so the attack already has the new level. Later parts of a long note keep
that gain: Ballast does not follow the note's envelope.
## Timing and limits
Latency is the note tracker's lookahead (about 67 ms), the 100 ms measuring
window and the 5 ms glide, about 172 ms in all, reported to the host for delay
compensation. That is too much for playing live through Ballast: add it for the
mix.
In Grid mode the grid follows the host's beat position while it plays and runs
on at the last tempo when it stops. A note start found by a change of pitch can
come about 20 ms early, inside the gap before the note;
the block gate keeps that gap out of the measurement. Two parts closer than
10 ms are merged.
Ballast decides from the first 100 ms of a part. A patch that swells after
that, or a note whose loudness depends on what plays with it, is levelled by
its start only. It levels loudness, not brightness: two patches at the same
loudness can still differ in tone.
Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

+30
View File
@@ -0,0 +1,30 @@
# 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
grid on every note.
- The chosen harmonic (1–8) beats once per Rate note value (1/1 to 1/32, and
triplets); harmonic n beats n/harmonic times as fast, so every harmonic beats
on the grid. The beat peaks land on the host's beat position.
- The detune follows the tracked note. The copy jumps by exactly one period of
the note, so the jumps are seamless on a steady note.
- Low cut keeps the copy out of the sub, so the sub neither beats nor changes
level.
- Depth, Width (right channel's beat against the left), Phase, Gate, Output,
Snap to semitone, Bypass.
- Detect pitch and note starts from the audio with about 67 ms of latency, or
follow MIDI notes sent to the effect.
- Editor: one bar of the beat per harmonic with the grid and a playhead.
+87
View File
@@ -0,0 +1,87 @@
# Braze
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
fixed detune that rate changes with every note, so the movement of a reese
never lines up with the track. Braze adds a detuned copy of the bass and sets
its detune on every note so that the beating follows the tempo.
![Braze](assets/editor.png)
## Install on Windows
Copy `Braze.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio 2024.1
or newer, open Options → Manage plugins, enable Verify plugins, and Find installed
plugins. Load Braze in the mixer slot of the bass. Keep the font license with
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
per second.
3. Pick the **harmonic** that should beat at Rate. Harmonic n beats
n/harmonic times as fast, so all of them beat on the grid: with harmonic 4 at
1/8, harmonic 2 beats in quarters, harmonic 8 in sixteenths, harmonic 3 in
quarter triplets.
4. Set **Depth**. At 100 % the beating goes down to full nulls; 50–70 % keeps
the note present.
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
beating, the grid at Rate, and the playhead.
Other controls:
- **Low cut** removes the copy below a frequency, so the sub does not beat.
The sub keeps its level. Harmonics under the cut show *cut* in the display.
Under 20 Hz the cut is off. If the chosen harmonic is under the cut on a
note, it does not beat: the note panel says so; pick a higher harmonic.
- **Width** moves the right channel's beat against the left, up to half a
cycle. At 100 % one side peaks while the other nulls. Check the mono sum.
- **Gate** is the input level that counts as a note.
- **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
the note per beat cycle of the chosen harmonic, which tunes the copy up by
exactly r = 1 + B/(k·f0) (B: beats per second, k: the chosen harmonic). When it
has fallen a whole period, it jumps back by one period: on a steady note both
read positions hold the same waveform, so the jump is crossfaded over 6 ms
without a seam. At a beat peak the copy lines up with the bass sample for
sample.
The low detune that low notes need is large: harmonic 1 at 1/8 and 140 BPM on
F1 (43.7 Hz) is r = 1.107, about 1.8 semitones. Higher harmonics need less:
harmonic 4 asks for about 45 cents on F1.
## Timing and limits
Detection uses about 67 ms of lookahead, reported to the host for delay
compensation. Pitch range is 30–500 Hz, one voice. The beat position comes from
the host while it plays, shifted by the lookahead so the beat stays on the
timeline. When the host is stopped, the beat runs on freely at the last tempo.
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 or NOTE. See
[Signal chain](../chain.md).
Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

+78
View File
@@ -0,0 +1,78 @@
# Signal chain
The order of the Bass Foundry plugins in a chain matters. Anvil, Lathe, Temper,
Knurl, Planish, Braze, Ballast and Forge each detect the pitch from the audio they receive. If a plugin earlier
in the chain changes the audio, the pitch detection after it can go wrong.
## Recommended order
```text
bass track: synth → Lathe → Temper → Planish → Braze → Knurl → Patina → Ballast → Weld
or: synth → Forge (Lathe, Temper, Knurl) → Planish → Braze → Patina → Ballast → Weld
bass bus: Anvil → (bus compression) → Quench → Crucible
master: … → Quench (last, or before a true-peak limiter)
```
- **Lathe and Temper before Knurl.** They hear the plain synth, where the pitch
is clear.
- **Or Forge in their place.** Forge runs Lathe, Temper and Knurl behind one
tracker that reads its input, so the order inside it does not upset the pitch
detection, and the three cost one lookahead (about 72 ms) instead of three.
Planish and Braze then come after Knurl: run them from MIDI.
- **Planish after the distortion, before Knurl.** OTT usually comes after
distortion. Planish's band edges follow the tracked note, so it also needs a
clear pitch: after Knurl, use MIDI.
- **Braze after Planish.** Its copy beats against the bass, so a pitch
detector after it is less sure: Knurl after Braze works best in MIDI mode.
Weld after Braze sees the beating as a looser match between the layers; if
it reads Loose, move Braze to the bus after Anvil, with Low cut at or above
Anvil's Split. There it reads the pitch from Anvil's clean sub, and the sub
stays out of the beating.
- **Ballast after the sound design.** It levels what the patch finally sounds
like, so it comes after the distortion, OTT and Patina. It finds note starts
like the other tracked plugins: after Knurl, use MIDI or Grid. Weld and Anvil
come after it. On the bus with Anvil, set Measure from to Anvil's Split.
- **Knurl last among the pitch-tracked plugins.** See below. Ballast only
needs note starts; after Knurl, run it from MIDI or the grid.
- **Patina after the tracked plugins.** It does not detect pitch, so the order
does not change its result.
- **Weld after every plugin with latency on its track.** Weld can delay its own
layer but cannot move it earlier. If FL Studio does not compensate the
sidechain route, any latency added after Weld's input on the main layer makes
that layer late.
- **Anvil on the bus, in MIDI mode when Knurl is in the chain.** For Kick
lock, send the kick's track to Anvil as a sidechain.
- **Quench last on the bus or the master.** It clips the top and leaves the sub
alone, so put it where you would put a clipper. On the bass bus after Anvil,
set its Split at or a little above Anvil's Split: Anvil's sub then passes
Quench untouched. Quench does not detect pitch, so it can sit after Knurl.
- **Crucible at the end of the chain you want to resample.**
## Knurl and pitch detection
Knurl's patterns make every second (or third) cycle quieter. That is a real
sub-octave in the signal, so a pitch detector after Knurl hears the note one
octave low, or with Triple an octave and a fifth low. Measured with the suite's
tracker on a saw bass:
- From C2 (65 Hz) upwards, Double, Fry (the default), Triple and Gallop make
the detector read the wrong octave for about 85 % of the note.
- Below C2 the false sub-octave is under 30 Hz, out of the detection range, so
low notes are tracked correctly.
- Level and time jitter add 5–25 false note starts per second. Anvil restarts
its phase on each, Lathe, Temper and Planish retune on each.
When a tracked plugin must come after Knurl, select **MIDI** as its pitch source
and route the bass's MIDI notes to it. Inside Forge this does not apply: its one
tracker reads the input before Knurl.
## Latency
Each tracked plugin adds its own lookahead: Anvil 80 ms, Lathe, Temper,
Planish and Braze about 67 ms, Knurl about 71 ms, Patina about 45 ms, Ballast
about 172 ms, Quench under 1 ms. Forge holds Lathe, Temper and Knurl in about
72 ms, against about 206 ms for the three plugins in a row. A full chain is
about 400 ms. FL
Studio compensates this for playback and rendering, but playing a MIDI keyboard
through the full chain is not practical. Write with a shorter chain and add the
rest for the mix.
+21
View File
@@ -1,5 +1,26 @@
# 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`
subfolder of the export folder at once. Takes are still not part of the
project, but a reload or a crash no longer loses them. Existing files are
never overwritten.
## 0.2.0
- Change the CLAP ID to `io.narl.bass-foundry.crucible`. Hosts see this as a new
plugin: delete the old `.clap` file, rescan, and add the plugin again in
existing projects.
## 0.1.0
- Introduce Crucible, a resampler that always records its input.
+14 -5
View File
@@ -1,7 +1,7 @@
# Crucible
Crucible melts the bars you just played into ready-to-use samples. Current version
**0.1.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
@@ -52,9 +56,14 @@ the playlist or Edison. **Open folder** shows the latest export in Explorer.
Crucible adds no latency and passes audio through untouched. Auditioned slices are
mixed into its output only while they play. The history holds 72 s, enough for
16 bars at 60 BPM. The newest eight takes stay in memory until the plugin is
removed; they are not saved with the project, so export what you want to keep.
removed; they are not saved with the project. With **Keep takes** on (the
default), every capture's loop is also written at once to
`takes/<date>_<time>_<tempo>bpm_<bars>bars.wav` in the export folder, so a reload
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: 220 KiB

After

Width:  |  Height:  |  Size: 231 KiB

+27 -14
View File
@@ -25,7 +25,10 @@ Patina has `render` for offline WAV evaluation. The shared crates are:
- `common-ui`: palette, embedded fonts, header, panels, knobs, meters.
- `common-dsp`: filters, YIN pitch tracking, the note tracker used by Anvil,
Lathe and Temper, 4× oversampling, meters and WAV files.
Lathe, Temper, Knurl, Planish, Braze, Ballast and Forge (with the `Tracked`
module trait that lets Lathe, Temper and Knurl run behind one shared tracker),
4× oversampling (also Quench's), BS.1770 K-weighting (Ballast), meters and WAV
files.
The wrappers stay separate workspaces, each with its own lockfile, so the
NIH-plug Git dependency stays out of the root workspace. Host identities and
@@ -44,7 +47,8 @@ python3 scripts/check-plugin.py --plugin <product>
python3 -m unittest discover -s scripts/tests -v
```
Products: `rivet`, `patina`, `anvil`, `crucible`, `lathe`, `temper`, `weld`.
Products: `rivet`, `patina`, `anvil`, `crucible`, `lathe`, `temper`, `weld`,
`knurl`, `planish`, `braze`, `quench`, `ballast`, `forge`.
`check-plugin.py` runs formatting, tests and clippy for one product's packages
and its wrapper, without compiling the other products. Run it for every product
@@ -135,19 +139,28 @@ identify plugins by CLAP ID, so the vendor name does not affect saved projects.
| Product | Former display name | CLAP ID |
| --- | --- | --- |
| Rivet | Motif Storyboard | `io.storyboard.motif` |
| Patina | Resonance Compressor | `io.storyboard.resonance-compressor` |
| Anvil | (new) | `io.storyboard.sub-anchor` |
| Crucible | (new) | `io.storyboard.resampler` |
| Lathe | (new) | `io.storyboard.harmonic-eq` |
| Temper | (new) | `io.storyboard.tracked-distortion` |
| Weld | (new) | `io.storyboard.layer-align` |
| Rivet | Motif Storyboard | `io.narl.bass-foundry.rivet` |
| Patina | Resonance Compressor | `io.narl.bass-foundry.patina` |
| Anvil | (new) | `io.narl.bass-foundry.anvil` |
| Crucible | (new) | `io.narl.bass-foundry.crucible` |
| Lathe | (new) | `io.narl.bass-foundry.lathe` |
| Temper | (new) | `io.narl.bass-foundry.temper` |
| Weld | (new) | `io.narl.bass-foundry.weld` |
| Knurl | (new) | `io.narl.bass-foundry.knurl` |
| Planish | (new) | `io.narl.bass-foundry.planish` |
| Braze | (new) | `io.narl.bass-foundry.braze` |
| Quench | (new) | `io.narl.bass-foundry.quench` |
| Ballast | (new) | `io.narl.bass-foundry.ballast` |
| Forge | (new) | `io.narl.bass-foundry.forge` |
The `io.storyboard.` prefix is historical and stays, because changing a CLAP ID
breaks existing projects. Rivet keeps `story-session-v1` and its session
migration. Patina keeps all parameter IDs. The copyright notice keeps its
original holder name. Replace old plugin files before rescanning, so no CLAP ID
is installed twice, and check that existing projects still load in FL Studio.
CLAP IDs use the form `io.narl.bass-foundry.<product>`. Builds tagged before
this scheme used `io.storyboard.*` IDs; hosts treat the new IDs as different
plugins, so projects saved with those builds need the plugin added again. From now on an ID is frozen: if a product is renamed, its CLAP ID
stays. A new plugin gets `io.narl.bass-foundry.<slug>` with its product slug.
Rivet keeps `story-session-v1` and its session migration. Patina keeps all
parameter IDs. The copyright notice keeps its original holder name. Delete old
plugin files before rescanning, so FL Studio does not list both builds.
See [releases](releases.md) for tagged publishing. Runs on the real Gitea runner
and checks in FL Studio are separate from the local checks.
+26
View File
@@ -0,0 +1,26 @@
# Forge changelog
## 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.
- Every module follows the note read from the strip's input, so a module after
Knurl no longer hears Knurl's sub-octave.
- One latency of about 72 ms, the same in every order, instead of more than
200 ms for the three plugins in a row.
- A single module on its own gives the same output, sample for sample, as its
standalone plugin.
- Editor: the chain, and each module's standalone editor as its page.
+70
View File
@@ -0,0 +1,70 @@
# 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.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),
and a tracker after Knurl hears Knurl's sub-octave and follows a note an octave
low. Forge reads the note once, from its input, and every module follows that
one tracker. In a test with Knurl's sub-octave pattern in front of Lathe, a
standalone Lathe after Knurl followed the note an octave low in every frame;
Lathe inside Forge followed the played note in every frame.
![Forge](assets/editor.png)
## Install on Windows
Copy `Forge.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio
2024.1 or newer, open Options → Manage plugins, enable Verify plugins, and Find
installed plugins. Load Forge in the mixer slot of the bass, right after the
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; **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. 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
once. Every module gets the note events delayed by the latency of the modules
in front of it, so each module sees a note start exactly on the attack of the
audio it receives. A single module on its own in Forge gives the same output,
sample for sample, as its standalone plugin, in every position of the chain.
Temper's **Normalize** measures each note's level one lookahead ahead. In Forge
it measures the strip's input, not the output of the modules in front of it.
Lathe and Knurl change the level about equally on every note, so the notes
still reach the shaper at one level.
## Timing and limits
Latency is the tracker's lookahead (about 67 ms), Temper's oversampling filters
(under 1 ms) and Knurl's room for timing jitter (about 4 ms): about 72 ms in
all, reported to the host. A module that is off still delays its audio by its
own latency, so the latency stays the same in every order and with any module
off.
Changing the order while audio plays switches the routing at once and can
click. The standalone plugins stay: use them when you need only one module, or
when another plugin must sit between two of them.
Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

+527 -19
View File
@@ -35,6 +35,283 @@ Each entry names the problem first. Entries are grouped as released or idea. Nam
automation lanes or chopping.
15. There is no meter for texture. Two switch-ups can sound "the same" without
a clear reason, and generic analysers show only level and spectrum.
16. OTT and multiband compression use fixed band edges, so the same setting
squashes different harmonics on every note.
17. Riddim masters rely on clipping. A clipper that also clips the sub turns
the low end into distortion.
18. Resampled basslines have no MIDI, so tools that need the notes have nothing
to follow.
19. A clean sub carries the note on a club system but vanishes on phones and
laptops. Distortion makes the note readable there, but also changes the
patch.
20. Detuned layers (reese) beat at a rate that changes with every note and every
harmonic, so the movement is never in time with the track.
21. Kick and sub on the same MIDI tick are not heard at the same moment: the
heard hit depends on the attack shapes and on the offset between them.
## Signal flow and suite audit (2026-11)
How the released plugins fit into one production:
```text
WRITE Rivet (motif → storyboard) ──MIDI──► synth
DESIGN synth → Lathe → Temper → Knurl → Patina
RESAMPLE Crucible (capture → slice → export) ──► sampler / wavetable synth ──► DESIGN
LAYER layer A + layer B → Weld (mids) → bass bus → Anvil (sub)
MIX gap: loudness, clipping, OTT, mono check, kick against sub, reference
```
Writing and sound design are covered. Mixing and mastering have only Anvil and
Patina. Findings from the audit:
- **Latency adds up.** A full chain (Knurl 71 + Temper 67 + Lathe 67 + Patina
about 45 + Anvil 80 ms) is about 330 ms. FL Studio compensates it for
playback, but a chain this long cannot be played live.
- **MIDI mode keeps the lookahead.** `Tracker::latency()` depends on the pitch
range only. With MIDI as the source, the note is known at its attack, so the
lookahead could be close to zero. This needs a latency change at runtime,
which must be checked in FL Studio first.
- **Knurl confuses every tracker after it.** Measured: Knurl output fed into
`TrackerConfig::BASS`. From C2 (65 Hz) upwards, Double, Fry (the default),
Gallop and Triple make the next tracker read the note one octave (Triple:
an octave and a fifth) too low for about 85 % of the note. Below C2 the false
sub-octave is under 30 Hz, out of range, so low notes are safe. Level and time
jitter also cause 5–25 false note starts per second. Anvil on the bus after
Knurl then plays the sub an octave low and restarts its phase. Until there is
a shared tracker: put Knurl after the other tracked plugins, and use MIDI as
the source for any tracked plugin after Knurl. See [Signal chain](chain.md).
- **Each tracked plugin needs its own MIDI route.** Four plugins in MIDI mode
are four FL routing setups.
- **Weld and delay compensation are untested together.** If the main layer's
chain has more latency than the sidechain layer and FL does not compensate
the sidechain route, the main layer arrives late, which an allpass cannot
fix. Put Weld after every plugin with latency on that track.
- **Crucible lost takes on reload.** Takes are not saved with the project.
Fixed: Keep takes writes every capture's loop to disk at once.
- **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
it goes in the suite. They are starting points to test by ear, not rules.
- **A published bass fatness model.** Schmedeke's dissertation (Hamburg 2021,
"BASS DROP!", section 5.4.3) models momentary fatness of dance-music excerpts
over 50 ms windows from two inputs only, with adjusted R² 0.773:
`l = 10 / (1 + 4.5487·exp(0.13·(62.9366 − SPL<200)))`,
`h = 10 / (1 + 1.4317·exp(2.5575·(0.3299 − HR<200)))`,
`Fat = 0.14689·l·h` (0 to 14.7). SPL<200 is the C-weighted level below
200 Hz in dB(C) with the material normalised to −23 LUFS; HR<200 is the
MPEG-7 harmonic ratio (how periodic) of the same band. Chapter 4's earlier
linear model on synthetic tones (R² 0.597) points the same way: a tonal low
band is fatter, a noisy one is not. Anvil's clean sine maximises HR; Quench
keeps it when the top is clipped. The thesis does not state the dB(C) of its
reference, so the dBFS-to-dB(C) offset is an assumption. Measured with
`scripts/fatness.py` on the seven Devath references (offset 100 dB): drops
sit at 4–6, breakdowns near 0, kick gaps show as 50 ms dips; SPL<200 medians
are 67–69 dB(C), a few dB under the thesis's "mostly 70–80", so the offset
may be 2–5 dB low. Goes into Assay as one lane, never as an overall score.
- **Virtual bass from known harmonics.** Moliner, Rämö and Välimäki (DAFx 2020)
boost the harmonics that exist with weights that follow the sound's spectral
envelope (limits 10 dB/oct below, 3 dB/oct above) and generate the missing
ones, so a small speaker suggests the fundamental. Their nonlinear stage fails
intermodulation tests, so they use it on transients only. The suite knows f0,
so it can add harmonics at exactly k·f0 with no intermodulation (problem 19).
Anvil's sub is a pure sine, so Lathe has no harmonics there to boost.
- **Group-delay audibility.** Liski, Mäkivirta and Välimäki (2021): between
500 Hz and 4 kHz, listeners hear a group-delay peak from about 0.64 ms
(positive) or 0.56 ms (negative) on clicks over headphones; a fast-decaying
hi-hat hides it. Weld's allpass sections add positive group delay there.
Done: Weld shows the delay spread (released in 0.3.0). Measured: common fits from
Weld's own tests spread 0.82–1.03 ms, above the click threshold, so the
display gives a note, not a warning.
- **Timbral tension.** Farbood and Price (2017): more roughness, inharmonicity
and spectral flatness raise judged tension; brightness (centroid) gives no
clear result. These are the measures planned for Loupe, and the controls a
tension macro would move.
- **Coupled one-knob controls.** A 2026 distance renderer derives five distance
cues from one control, so they always agree; in its listening test it roughly
halved placement error and every participant preferred it. A pattern for
macros in Forge.
- **Shared motion binds layers.** Temporal coherence (Elhilali et al. 2009) is
a grouping cue: sub, body and top sound like one sound when their main
movements line up. One modulation source inside Forge gives that by
construction; across separate `.clap` files it needs the shared memory
rejected below.
- **Heard timing of layered hits.** Danielsen et al. (2026): attack shape and
the offset between two layers together set when the compound hit is heard
and whether it fuses. Hove et al. (2014): timing deviations are heard more
clearly in lower voices, so the bass sets the grid (problem 21).
- **Loopback FM.** Smyth (DAFx 2026): an oscillator that frequency-modulates
itself shifts its own sounding pitch as feedback rises. Her closed-form
solution gives the sounding frequency, so a growl can stay on the tracked
note at any feedback.
- **Beating depends on note and harmonic.** For two layers detuned by ratio r,
harmonic k beats at k·f0·(r − 1) Hz (Plomp and Levelt 1965 for the roughness
context). Fixed cents beat faster up the keyboard; fixed Hz beats the same on
every harmonic (problem 20).
- **Do not encode as rules:** a best sub frequency, crossover or kick/bass
offset; one number for loudness, roughness, groove and preference; random
jitter sold as humanisation; more harmonics sold as more physical bass;
largest correlation sold as the best alignment.
## Released
@@ -42,7 +319,8 @@ Each entry names the problem first. Entries are grouped as released or idea. Nam
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)
@@ -75,8 +353,216 @@ delay), so the layers sum instead of cancel. A display shows how much level the
cancellation cost and how much Weld recovered. Anvil solves the sub band; Weld
solves the mids. See [Weld](weld/README.md).
### Knurl — cycle texture (problems 11, 13)
Follows the played note and treats every cycle on its own: a repeating level
pattern (in-tune subharmonics), random level and timing per cycle, and a noise
burst per cycle tuned to the note. Amounts are counted in cycles, so the texture
is the same on every note. Slow per-harmonic drift is postponed. See
[Knurl](knurl/README.md).
### Planish — harmonic OTT (problem 16)
Upward and downward compression in four bands whose edges are harmonic numbers
of the tracked note (default 1, 2–4, 5–16, 17 and up), so the same setting
squashes the same harmonics on every note. Each band's level is measured over
one period of the note, so heavy settings do not ripple at f0. See [Planish](planish/README.md).
## Next for released plugins
- **Anvil**: *Kick lock* and the *offset audition* are done (Anvil 0.3.0, see
[Anvil](anvil/README.md)): the kick through the sidechain ducks the sub and
restarts its phase at an offset (problem 6); the audition steps −30 to +30 ms,
4 kicks each, Keep takes the offset heard, and the low-band sum per offset is
shown apart from that choice (problem 21). Measured on a 55 Hz kick burst
against a 55 Hz sub: +2.4 dB at 0 ms, −4.6 dB at −10 ms, −5.9 dB at +10 ms,
and +1.8 to +2.2 dB again one period away (±20 ms), so the sum alone cannot
pick the hit. Open: a finer second sweep around the kept offset; a gate or
sensitivity for kicks with long tails or a kick bus with other drums.
A sub shape from sine to rounded
triangle. A note attack counted in cycles. *Ghost harmonics* and the
*Phone* listen are done (Anvil 0.3.0, see [Anvil](anvil/README.md)): harmonics
2–10 above a "from" frequency, filled only where the patch lacks them, with
the sub level unchanged (problem 19). Open for Ghost: weights that follow the
patch's own harmonic slope instead of a fixed 1/k; measuring a new note's
harmonics from the lookahead, so its first 60 ms do not count them as
missing; a listening check on an 808 over a real phone.
- **Lathe**: A/B gain snapshots with one automatable Morph parameter. A
keyswitch note that selects the drawn shape (EQ switch-ups without
automation). A dynamic mode per harmonic.
- **Temper**: a harmonic split: harmonics 1–2 clean, 3 and up driven. More than
one Push band. A bias that changes per cycle from Knurl's cycle clock.
- **Knurl**: velocity or note selects the pattern. Stereo grit above a split
only. Drift (planned for 0.3).
- **Patina**: Tone guard (done, Patina 0.4.0). MIDI as its pitch source. M/S
processing. Threshold, Ratio and Soft/Hard knee from tester feedback (done,
Patina 0.5.0). Next, from the same feedback round:
- An absolute level gate (dBFS) so quiet passages are never cut, if the
tester meant a loudness threshold rather than a prominence threshold.
- Narrow rings on dense or noisy material are under-read: a +12 dB ring
(Q 25) on noise reads only 3–6 dB above the floor, so it is cut by 1–3 dB.
Two causes, measured: the joined-tops envelope lifts the floor to the noise
peaks (about 5 dB above the noise mean), and envelope smoothing loses about
3 dB on a narrow ring. A floor that follows Sharpness (1/3–2 octaves) was
tried and rejected: it lost a resonant lowpass, absorbed broad bumps and cut
plain harmonic tones. Better candidates: join tops only across deep gaps
(harmonic spacing), or a mean-based floor for noise-like regions.
- A finer analysis grid (1024 cells) for narrow rings at high frequencies.
- **Weld**: delay spread display (done, Weld 0.3.0). A *gentle* fit option
that keeps the delay spread under a limit, for plucky layers. Alignment slots
per note or per switch-up. The External
sender/receiver fallback if FL's sidechain does not arrive.
- **Crucible**: a heard-onset marker per slice next to the technical cut, set
by ear and written to the WAV (problem 21). A switch-up slicer that cuts where the spectrum changes, for
legato basses. Optional retune of slices to one root. Reopening saved takes
from the `takes` folder in the take list.
- **Rivet**: more than two sound roles, one per switch-up patch. Its MIDI
output as the one note source for the tracked plugins.
- **Suite**: presets and A/B in `common-ui` for every plugin.
## Ideas
### Order
1. Small fixes from the audit. Done: chain-order guide
([Signal chain](chain.md)), Crucible Keep takes. Open: Weld test with delay
compensation (in FL), MIDI mode without lookahead (needs an FL check that a
latency change at runtime is accepted).
2. Patina pitch-aware mode. Done as **Tone guard** (Patina 0.4.0): bumps that
follow the note are protected, bumps fixed in Hz are cut. Measured first:
Patina already cut fixed formants on every note, but it also cut a boosted
harmonic as hard (5.7 dB), at a position that moved with the note.
Open: MIDI as the pitch source.
3. Planish (done, released 0.1.0), then Quench and Ballast (both done,
released 0.1.0): they fill the mix and master gap.
- In parallel, from the research findings, in this order: Weld delay
spread (done, Weld 0.3.0); the fatness lane, as the first Assay piece
(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, 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.
Merges: Gauge, Loupe and Drop compare share one analysis core and one display,
so they become one plugin, **Assay**. The wobble sequencer, Damascus and Chase
are all "step grid, note selects the pattern", so they become one sequencer.
### Forge — one tracked channel strip (problems 9, 11, latency)
One tracker, one latency, and Lathe, Temper, Knurl and later Anneal as modules
in an order you choose. Two further reasons from the research findings: one
modulation source for all modules moves sub, body and top together, which
binds them into one sound; and coupled macros become possible, for example a
*Tension* knob that raises roughness (Knurl), inharmonicity and noise (Anneal)
together at matched loudness, measured by the same descriptors as Loupe. It removes the stacked lookahead, the trackers that
disagree (Knurl's sub-octave cannot confuse a tracker that reads the input
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 (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
needs one newer sample) and equal within float rounding. In the strip one
tracker reads the input; a per-sample record of the tracker's events, note state
and input is replayed to each module, delayed by the latency of the modules in
front of it. Findings:
- One module alone in Forge matches its standalone plugin sample for sample, in
all six orders (tested).
- Knurl's Double-style pattern (0, −12 dB) in front of Lathe: standalone Lathe
after Knurl read the note an octave low in 149 of 149 frames on an A2 saw;
Lathe inside Forge read A2 in 149 of 149.
- Latency 72 ms against 206 ms for the three in a row (48 kHz).
- Temper's Normalize looks one lookahead ahead; in the strip it reads the strip
input, not the modules in front of it. Fine while those change every note's
level alike.
Next for Forge: the *Tension* macro over Knurl (and later Anneal) at matched
loudness; Planish and Braze as modules (both are tracked, and Braze after
Knurl is the next tracker that hears the sub-octave); a short crossfade when
the order changes; one shared modulation source (per-note envelope or LFO in
cycles) that every module can read.
### Braze — tempo-locked beating (problem 20)
Brazing joins two metals with a filler. Braze adds a detuned copy of the bass
whose beating is in time with the track on every note. With the tracked f0, it
sets the detune ratio for each note so that a chosen harmonic beats at a
tempo-synced rate: r = 1 + B/(k·f0), for example an eighth at 140 BPM is
B = 4.67 Hz. A second mode adds a frequency-shifted copy, so every harmonic
beats at the same rate. Controls: rate (synced), target harmonic, depth,
spread across harmonics, mode. It reuses the tracker, and Weld should not
"fix" this beating: put Weld before Braze.
Built (released 0.1.0, see [Braze](braze/README.md)). The detune is a delay
line whose delay falls one period per beat cycle and jumps back one period,
which is seamless on a periodic note, and the delay follows the host's beat
position, so the peaks land on the grid. Harmonic n then beats at n/k · B: every
harmonic is on the grid, so the planned spread control was not needed. A Low
cut (LR4 on the copy, the bass split by the same crossover) keeps the sub out.
Open: the frequency-shift mode. Mixed with the dry bass, a plain single-sideband
shift gives every harmonic the same envelope at the same moment, which is
tremolo; it needs a dispersive allpass on the copy to stagger the harmonics
before it sounds like movement. Also open: a listening check on a real reese,
and how Weld reads a layer after Braze.
### Arc — self-FM growl (research spike)
An arc welds with a sustained spark. Arc runs loopback FM (Smyth, DAFx 2026)
on the tracked note: a time-varying delay line modulated by its own output.
The closed-form sounding frequency lets Arc retune the carrier, so the growl
stays in key while feedback sweeps from clean to torn. Needs listening and an
aliasing check before it becomes a plugin.
### Quench — sub-safe clipper (problem 17)
Quenching hardens hot metal in one fast step. Quench clips only above a split
frequency and adds the clean sub back with its own headroom, so the master can
be clipped hard without distorting the low end. Pairs with Anvil's split.
Built (released 0.1.0, see [Quench](quench/README.md)). Design checks, made
in a prototype before the plugin:
- Splitting and giving the top the instantaneous room, clamp(top, −C − sub,
C − sub), is exactly a plain clip of the sum: the error is the same signal.
The sub-safe part has to come from somewhere else.
- Feeding the clip error back through 1 − lowpass diverges: the complement of
an LR4 lowpass peaks at 1.5× near the split.
- What works: clip the top symmetrically at the ceiling minus the sub's held
peak (no pumping at the sub's rate, the sum stays under the ceiling), then
high-pass the clip error at the split so its products below the split go.
Two clip-and-high-pass passes and an oversampled hard clip hold the ceiling.
Measured: sub level +0.04 dB against −2.2 dB for a plain clip; low-band
products −35 dB against −16 dB. Cost: 0.5–1.2 dB less RMS at the same
ceiling.
- Where it does not help: a top whose fundamental lies within an octave of the
split is partly in the sub band (saw at 98 Hz over a 49 Hz sub: about the same
as a plain clip).
- Next ideas: link the channels' sub peak and offer mono below the split
(with Gauge); give the top the room on the sub's far side too (asymmetric)
with the error still high-passed, and measure whether that wins back the
loudness without pumping; a true-peak ceiling.
### Scribe — audio to MIDI (problem 18)
A scribe marks lines on metal. Scribe runs the note tracker on a bass and
outputs MIDI notes and a `.mid` file. A resample from Crucible then gets its
notes back, so the other plugins can use MIDI as their source.
### Bevel — mono-safe harmonic widener (problem 5)
Pans harmonics of the note alternately (for example odd left, even right) with
complementary filters, so the mono sum stays flat. Pairs with Assay.
### Peen — note-start transient shaper
Peening hardens a surface with hammer blows. Attack and sustain shaping on
every note start, with times counted in cycles, so each switch-up gets the same
punch on every note. Could become part of Ballast.
### Ballast — switch-up loudness leveller (problem 2)
Measures short-term perceived loudness per note or per switch-up against a
@@ -85,6 +571,21 @@ the same loudness without compression changing its character. Earlier working
name: Hit Leveler. Pairs with Anvil: Anvil levels the sub, Ballast the whole
patch.
Built (released 0.1.0, see [Ballast](ballast/README.md)): one gain per part,
parts from note starts or the host grid (a patch change on the same note is not
a new note to the tracker), K-weighted loudness of the first 100 ms, Learn for
the reference. Found while building it:
- A note start from a pitch change comes about 20 ms early, in the gap before
the note; a plain mean over the window then reads the part ~0.9 dB quiet.
Fixed with 10 ms blocks and a relative gate (−20 dB under the loudest block).
- The K-weighting filters ring the last part into the next one: a near-silent
part after a loud one read −18 LUFS instead of under −60. Fixed by weighting
every part from rest.
- Next ideas: a brightness match next to loudness (problem 2 names both; with
Lathe or Patina doing the EQ), a "follow" option for long notes that swell,
and switch-up detection from timbre change instead of the grid.
### Bellows — pitch-tracked resonator
A comb or Karplus-style resonator tuned to the detected note. It turns noise,
@@ -105,6 +606,15 @@ grids built in. Each MIDI note selects a different pattern, so a switch-up is a
different note in the piano roll. ShaperBox overlaps, but it has no note-keyed
patterns. No name yet.
### Fatness lane (problems 2, 8)
Momentary fatness per 50 ms from Schmedeke's model (see Research findings),
drawn as one lane next to sub/mid energy, for a patch and a reference. It
explains why two drops at the same LUFS feel different in weight: a quieter or
noisier low band lowers the value. Prototype: `scripts/fatness.py` (offline,
on rendered files). The first piece of Assay. Open: the dB(C) offset, and how
the lane reads on a single bass patch rather than a full mix.
### Drop compare (problem 8)
Loudness-matched A/B of a drop against a reference, with sub, low-mid, mid and
@@ -113,27 +623,11 @@ bars 2–4". No name yet.
## Texture ideas
These ideas target problems 11–15. Most of them reuse the note tracker in
These ideas target problems 11–15; Knurl, the first of them, is released above.
Most of them reuse the note tracker in
`common-dsp`, so a texture setting means the same thing on every note, the same
way Lathe and Temper work.
### Knurl — pitch-synchronous grit (problems 11, 13)
Knurling presses a ridged pattern into metal. Knurl cuts the signal into single
pitch periods with the note tracker and changes each period on its own:
- level and timing jitter per cycle;
- short noise bursts locked to the cycle;
- period doubling and tripling (a cycle pattern such as "loud, quiet, quiet"),
which gives the subharmonic rattle and growl of riddim basses;
- slow random drift of each harmonic's level and phase, which brings movement
into a printed resample without widening it.
Rates are in cycles, not in Hz or tempo, so the grit has the same grain on F1
and on C2. A pattern editor sets 1–16 cycles. Everything is mono-safe. Latency
is the tracker lookahead, about 67 ms. Recommended first texture build: it is
new, small, and reuses Lathe's tracking.
### Anneal — tone and grit split (problem 12)
Annealing changes how a metal behaves without changing its shape. Anneal
@@ -204,7 +698,21 @@ over time:
- inharmonicity, which shows how metallic a sound is;
- brightness relative to the note (harmonic centroid, not centroid in Hz).
Roughness, inharmonicity and flatness are also the three timbre measures that
raise judged tension (Farbood and Price 2017), so the same lanes show a build-up
getting tenser without a note change.
It places every switch-up of a drop as a point on a texture map. Two patches
that sit on the same point are why a drop sounds repetitive. A reference track
can be loaded for comparison. Display only, so it is a small build, and it fits
with Gauge and Drop compare.
### Further leads
- **Creative extrema stretch** for Crucible: Nielsen (DAFx 2026) drives an
overlap-add stretch by the spacing of local extrema, a free measure of
detail. Cheap and gritty, not transparent; a bass has few extrema per cycle,
so the stretch stays close to cycle-synchronous.
- **Reference to preset:** an offline search over a bounded set of Lathe and
Temper parameters that matches a reference switch-up (ST-ITO, TAPE). Large;
needs learned audio features.
+34
View File
@@ -0,0 +1,34 @@
# Knurl changelog
## 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.
## 0.2.0
- Change the CLAP ID to `io.narl.bass-foundry.knurl`. Hosts see this as a new
plugin: delete the old `.clap` file, rescan, and add the plugin again in
existing projects.
## 0.1.0
- Introduce Knurl, texture per cycle of the played note.
- Step patterns over 1–16 cycles make subharmonics in tune with every note:
Off, Double, Triple, Fry, Gallop, Stutter, or a drawn pattern.
- Random level and timing per cycle, and a noise burst per cycle tuned to the
note (Tone, Position, Length), switchable per step.
- Same every note or Free random sequences.
- Detect pitch and note starts from the audio with about 71 ms of latency, or
follow MIDI notes sent to the effect.
- Editor shows the last eight cycles overlaid and the pattern with the playing
step.
+98
View File
@@ -0,0 +1,98 @@
# Knurl
Knurl gives every cycle of the note its own level, timing and grit. Current
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
that texture comes from long chains of distortion, comb filters, formants and
resampling, and it changes from note to note. Knurl works on single cycles of the
wave instead. It follows the played note and treats each cycle on its own, and
every amount is counted in cycles, not in Hz or tempo. So the texture has the
same grain on F1 and on C2.
![Knurl](assets/editor.png)
## Install on Windows
Copy `Knurl.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio 2024.1
or newer, open Options → Manage plugins, enable Verify plugins, and Find installed
plugins. Load Knurl in a mixer slot of the bass. Keep the font license with
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:
- **Double** (0, −9 dB): every second cycle quieter. This makes a sub-octave
growl, exactly in tune, about 8 dB under the fundamental.
- **Triple** (0, −12, −12 dB): a growl an octave and a fifth down.
- **Fry** (0, −14, −5, −14 dB, the default): an uneven vocal-fry rattle.
- **Gallop** and **Stutter**: rhythmic patterns over six and eight cycles.
- **Off**: every cycle at full level.
3. Draw your own pattern on the bars: drag a bar to set the level of that cycle,
double-click to reset it. **Steps** sets the length (1–16 cycles).
4. Add surface:
- **Level** gives every cycle a random level (standard deviation in dB).
- **Time** moves every cycle a little in time (percent of a period): rattle
and scrape.
- **Grit** adds a short noise burst inside every cycle. **Tone** sets its
lowest frequency in harmonics of the note, **Position** where in the cycle
it sits, and **Length** how long it is. The squares under the pattern bars
switch the burst per step.
5. **Edge** sets how soft each step change is. Low values are sharper and
buzzier; high values smoother.
**Random**: **Same every note** (default) restarts every random sequence at each
note, so identical notes sound identical, which keeps a riddim line tight.
**Free** lets the sequences run on, so every note differs.
**Mix** blends with the delayed dry signal; **Output** trims the level.
### Pitch source
**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
Knurl delays the audio by about 71 ms: 67 ms of pitch-detection lookahead plus
4 ms of room for the timing jitter. The delay is reported to the host for delay
compensation, so the cycle clock knows every note before its attack leaves the
plugin. The cycle clock restarts at every note start, so the pattern always
begins with step 1 on the attack. Pitch range is 30–500 Hz, one note at a time.
Between notes the effects fade out over 20 ms, so tails stay clean.
Knurl works per cycle of the tracked pitch; it does not look for cycle borders in
the waveform. With a stable pitch that is the same thing. With heavy pitch
modulation inside a note the pattern follows the tracked pitch, which can lag a
fast bend. Both channels get the same treatment, so the effect is mono-safe.
Knurl's patterns put a real sub-octave into the signal. A pitch detector after
Knurl (Anvil, Lathe or Temper in Audio mode) then reads notes from C2 upwards
an octave too low. Put Knurl after the other tracked plugins, or use MIDI as
their source. See [Signal chain](../chain.md).
## How it works
A note tracker (shared with Anvil, Lathe and Temper) detects pitch and note
starts ahead of the delayed audio. A cycle clock starts at 0 on every note and
advances by f0 / sample rate. Each cycle gets its step gain and its random level
and time offset from a hash of the cycle number, so the result is deterministic.
Step changes are raised-cosine crossfades over the last **Edge** of a cycle;
level jitter crossfades over half a cycle and time jitter over the whole cycle,
read from the delay line with cubic interpolation. The grit is white noise
through a state-variable highpass at **Tone** × f0, shaped by a sin² window and
scaled to the input level. There is no nonlinearity, so no oversampling is
needed.
Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

+19
View File
@@ -1,5 +1,24 @@
# 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
plugin: delete the old `.clap` file, rescan, and add the plugin again in
existing projects.
## 0.1.0
- Introduce Lathe, a harmonic EQ for bass sound design.
+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.1.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

+33
View File
@@ -1,5 +1,38 @@
# Patina changelog
## 0.5.0
- Each band now works like a small compressor. Add **Ratio** (1:1 to ∞:1,
default ∞:1): low ratios leave small bumps nearly alone and still cut big ones
hard. ∞:1 is the old behaviour.
- Replace Selectivity (%) with **Threshold** (0–18 dB above the local floor,
default 3 dB, which equals the old default of 50 %).
- Add a **Soft/Hard knee** switch. Soft widens the knee from 3 dB to 6 dB;
Hard uses a 1 dB knee and half the Attack time.
- Show the gain curve (bump height in, cut out) under the knobs.
- Mix and Output move to the slider panel under the plot.
- Retune the Vocal harshness, Snare ring, Bright synth and Bass line presets
with Ratio and more Depth.
- Breaking: FL Studio projects saved earlier load Threshold, Ratio and Knee at
their defaults. Settings files for `patina-render` with `selectivity` still
load and convert.
## 0.4.0
- Add **Tone guard** (0–100 %, default 0 %). Patina follows the played note and
remembers, over about four seconds, which bumps stay in Hz and which follow the
note. Bumps that follow the note (a harmonic the bass is built on) are cut
less; resonances fixed in Hz (formant, comb and filter rings) are still cut.
At 0 % Patina works exactly as before. The display shows the note and marks
the held-back region with a blue strip.
- Add the **Bass line** preset (Tone guard 80 %, Low 150 Hz).
## 0.3.0
- Change the CLAP ID to `io.narl.bass-foundry.patina`. Hosts see this as a new
plugin: delete the old `.clap` file, rescan, and add the plugin again in
existing projects.
## 0.2.0
- Rebuild the detector so it finds resonant bumps instead of cutting individual
+56 -19
View File
@@ -1,7 +1,7 @@
# Patina
Patina smooths a sound’s rough surface without erasing its character. Formerly
**Resonance Compressor**; current version **0.2.0**.
**Resonance Compressor**; current version **0.5.0**.
A mixing effect that finds harsh, ringing regions as they happen and pulls them
down, while plain harmonic tone passes. Independently designed in Rust; no claim
@@ -20,18 +20,51 @@ redistributed packages.
## Work with it
Three controls decide what happens:
Each band works like a small compressor. It compares its level with the local floor
around it and cuts only the part that sticks out:
- **Depth** is the largest cut, in dB. 0 dB does nothing.
- **Depth** is the largest cut per band, in dB. 0 dB does nothing.
- **Sharpness** sets what counts as a resonance. Low values follow broad,
smooth bumps (honk, boxiness). High values target narrow rings and whistles.
- **Selectivity** sets how far a bump must stand out before it is cut. Low
values treat any bump; high values treat only the worst.
- **Threshold** is how far (dB) a band must rise above its neighbours before it
is cut. Low values treat any bump; high values treat only the worst.
- **Ratio** is how hard a bump above the threshold is cut. At n:1 a bump ends
1/n as far above the threshold as it started. Low ratios leave small bumps
nearly alone and still cut big ones; ∞:1 flattens every bump to the
threshold (this is how Patina worked before 0.5).
- **Knee**: Soft fades cuts in around the threshold. Hard starts them right at
the threshold and uses half the Attack time.
Start with Depth around 6 dB. Raise Selectivity until only the harsh parts show
cuts, then use Delta to hear what is removed. Restrict Low/High frequency to keep
the source’s body out of reach. Attack and Release set how quickly cuts grow and
recover; Transient guard briefly relaxes cuts on attacks to keep punch.
The small graph under the knobs shows the result: bump height above the floor
in, cut out, with the threshold and the Depth limit.
Start with Depth around 6 dB. Raise Threshold until only the harsh parts show
cuts, then use Delta to hear what is removed. To hit big resonances hard and
leave small ones alone, raise Depth to 12–20 dB and set Ratio to 2:1–4:1.
Restrict Low/High frequency to keep the source’s body out of reach. Attack and
Release set how quickly cuts grow and recover; Transient guard briefly relaxes
cuts on attacks to keep punch.
### Tone guard for bass lines
On a bass line, a bump can mean two things. A resonance fixed in Hz (a formant,
a comb or filter ring) stays at the same frequency on every note: that is what
Patina should cut. A bump that follows the note (a harmonic the patch is built
on, or a Lathe boost) moves with every note: cutting it changes the patch
differently on each note.
**Tone guard** tells them apart. Patina detects the played note (30–500 Hz)
and remembers, over about four seconds, how high each bump was in Hz and how
high it was relative to the note. A bump that has stayed with the note more
than in Hz gets less cut; at 100 % none. A resonance fixed in Hz is cut as
before. It needs a few different notes before it can decide: on one held note
both look the same and Patina cuts as normal. The **NOTE** readout shows what
Patina follows, and a blue strip on top of the plot shows where cuts are held
back. Start from the **Bass line** preset. At 0 % Tone guard is off.
Measured on a saw line F1–F2 with Low at 150 Hz: a +12 dB boost on harmonic 6
is cut by 5.8 dB without Tone guard and by 0.4 dB at 100 %; a formant at 1.5 kHz
is cut by 5.8 dB either way.
Double-click the plot to add one of four focus points. Drag a point up to cut
more in that region, down to protect it. Wheel changes its width. Range handles
@@ -40,7 +73,7 @@ on the plot drag the processing limits.
On the plot, the grey fill is the measured input. The bright line is what the
detector hears (its smoothed spectral envelope); the dim line is what it expects
there. Accent-coloured cuts appear where the bright line rises above the dim one
by more than Selectivity allows. The blue curve is the focus/protect shape.
by more than Threshold allows. The blue curve is the focus/protect shape.
Delta plays delayed dry minus wet before output trim. Use it to inspect changes,
then return to normal monitoring to decide whether the music improved.
@@ -62,8 +95,9 @@ window size persist; Delta does not.
4. The reference is a robust local straight-line fit over 2–3 octaves: spectral
tilt is expected, bumps cannot raise their own reference, and empty regions
(below a fundamental, beyond a filter cutoff) are ignored.
5. Envelope minus reference, above the Selectivity threshold, becomes the cut,
limited by Depth, smoothed with Attack/Release, and linked across stereo.
5. Envelope minus reference is the band's height above its floor. Above
Threshold it becomes a cut through the Knee and Ratio curve, limited by
Depth, smoothed with Attack/Release, and linked across stereo.
## Timing and limits
@@ -76,7 +110,8 @@ The engine uses complex RustFFT with supplied scratch buffers, WOLA reconstructi
the envelope/reference detector above, bounded soft-knee cuts, and temporal
smoothing. Original bin phases are retained. This is not a minimum-phase filter
bank, a live monitoring effect, a limiter, or an automatic separation of tone and
unwanted resonance. No external sidechain, Learn, M/S, or pitch detector in v0.1.
unwanted resonance. No external sidechain, Learn or M/S. The pitch detector
serves Tone guard only.
## Build and offline evaluation
@@ -116,12 +151,14 @@ not a worst-case multi-instance guarantee.
## Existing projects and source layout
The CLAP ID `io.storyboard.resonance-compressor` is unchanged. Version 0.2
replaces Amount, Sensitivity, Max cut, Neighborhood, Cut width, and Knee with
Depth, Sharpness, and Selectivity, so projects saved with 0.1 load those
controls at their defaults. Replace the former plugin file with `Patina.clap` and
rescan; avoid two scanned copies with the same ID. Host project recall still
needs verification in FL Studio.
The CLAP ID is now `io.narl.bass-foundry.patina` (earlier builds used
`io.storyboard.resonance-compressor`). Hosts treat Patina as a new plugin, so
add it again in projects saved with an earlier build. Version 0.2 replaced
Amount, Sensitivity, Max cut, Neighborhood, Cut width, and Knee with Depth,
Sharpness, and Selectivity; version 0.5 replaced Selectivity with Threshold
(dB), Ratio and Knee, so projects saved earlier load Threshold at its default.
Delete the old plugin file, place `Patina.clap`, and
rescan FL Studio.
Source is grouped into `plugins/patina/{core,ui,render,plugin}`. See
[research](research.md), [development](../development.md), and the
Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 417 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.
+27
View File
@@ -0,0 +1,27 @@
# 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
are harmonic numbers of the played note.
- Band edges set by harmonic (Low 1–8, Mid 2–16, High 3–48), with splits
Classic, Sub + body, Tight and Wide top, or dragged on the harmonic ruler.
- Depth, Drive, Upward, Downward, Time and a gain per band.
- Band level measured over one period of the note, so heavy compression does
not ripple at the fundamental.
- Detect pitch and note starts from the audio with about 67 ms of latency, or
follow MIDI notes sent to the effect.
- Editor shows the bands on a harmonic ruler and each band's level and gain.
+105
View File
@@ -0,0 +1,105 @@
# Planish
Planish is upward and downward compression (OTT) in four bands whose edges are
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
patch. A normal OTT splits the bands at fixed frequencies, though. A split at
88 Hz and 2.5 kHz puts harmonic 2 of F1 in the low band but harmonic 2 of C2 in
the mid band, so the same setting squashes different harmonics on every note.
Planish moves its band edges with the note: with the default split, band 1 is
the fundamental, band 2 harmonics 2–4, band 3 harmonics 5–16 and band 4
everything above, on F1 as on G2.
![Planish](assets/editor.png)
## Install on Windows
Copy `Planish.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio
2024.1 or newer, open Options → Manage plugins, enable Verify plugins, and Find
installed plugins. Load Planish in a mixer slot of the bass. Keep the font
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).
2. Choose a split, or drag an edge on the ruler:
- **Classic** (1, 2–4, 5–16, 17+, the default): fundamental on its own.
- **Sub + body** (1–2, 3–6, 7–24, 25+): the octave stays with the sub.
- **Tight** (1, 2, 3–8, 9+): close control of the first harmonics.
- **Wide top** (1, 2–3, 4–32, 33+): one big mid band, the top is fizz only.
3. Set the amount:
- **Depth** blends each band with its compressed copy. 0 % is a clean sum
of the bands; the default is 50 %.
- **Drive** moves the band levels against the thresholds: more drive, more
squash. It does not change the level by itself.
- **Upward** and **Downward** scale the two directions. Downward pushes a
band above −30 dB down at about 20:1; Upward lifts a band below −42 dB at
about 4:1, by at most 24 dB.
- **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. 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 source
**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
- At Depth 0 the bands sum flat: sines from 35 Hz to 12 kHz come out within
0.05 dB.
- The same setting changes the same harmonics on every note. At Depth 100 %
on a saw bass, harmonic 1 is cut by 13.6 dB on F1, C2 and G2; harmonics 1–12
differ by less than 0.2 dB between the three notes.
- No ripple at the fundamental. A 40 Hz sine at Drive +12 dB and Time 10 % gets
no harmonics above −110 dB.
## Timing and limits
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. 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
every note. Both channels get the same gain per band, so the stereo image does
not move. Quiet signals below about −84 dB are not lifted, so noise floors and
tails stay down.
Planish detects the pitch like Lathe and Temper. Put it before Knurl, or use
MIDI: see [Signal chain](../chain.md).
## How it works
A note tracker (shared with Anvil, Lathe, Temper and Knurl) detects pitch and
note starts ahead of the delayed audio. Three Linkwitz–Riley crossovers split
the signal at √(k(k+1)) × f0, halfway in log frequency between harmonic k and
k+1, and allpasses keep the lower bands in phase with the upper ones, so the
bands sum to an allpass. The crossovers retune every 16 samples and glide with
**Smooth** on bends. Each band's level is the RMS over exactly one period of
the note, so a steady note gives a steady level and the gain does not wobble
at f0. The level goes through attack and release smoothing, then a static
curve with an upward and a downward threshold sets the gain.
Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

+16
View File
@@ -0,0 +1,16 @@
# Quench changelog
## 0.1.0
- Introduce Quench, a clipper that clips the top and lets the sub pass clean.
- A Linkwitz–Riley split at **Split** (40–300 Hz). The top is clipped at the
ceiling minus the sub's held peak; **Hold** sets how long that peak holds.
- What the clip removes is high-passed at Split, so its products stay out of
the low band. Two correction passes and an oversampled hard clip hold the
ceiling; a safety clip catches the downsampler's ringing.
- **Drive**, **Ceiling**, **Soft** (knee of the top's clip), **Bypass**.
- Listen to **Quench**, a **Plain** clipper at the same settings, or the
**Delta** that Quench takes away. Not saved.
- Editor: how much the low band changed for Quench and for a plain clip, how
far the top is clipped, the sub's peak and the room it leaves, and a scope.
- 4× oversampling; 37 samples of latency.
+104
View File
@@ -0,0 +1,104 @@
# Quench
Quench clips the top of the mix as hard as you like and lets the sub pass clean.
Current version **0.1.0**.
Riddim and dubstep masters are clipped hard. A plain clipper clips the sum of
the sub and everything above it. Near the sub's peaks only one side of the top
gets clipped, so the clipping takes energy out at the sub's rate: the sub loses
level, and the low end fills with products that were not in the mix. Quench
splits the signal at a frequency you set, never clips the sub, and clips only
the top, in the room the sub leaves under the ceiling.
![Quench](assets/editor.png)
## Install on Windows
Copy `Quench.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio
2024.1 or newer, open Options → Manage plugins, enable Verify plugins, and Find
installed plugins. Load Quench last on the master or on the bass bus. Keep the
font license with redistributed packages.
## Work with it
1. Put Quench where you would put a clipper: last on the master, or last on
the bass bus. Before a true-peak limiter is fine.
2. Set **Ceiling**, the highest output peak.
3. Set **Split** above the highest sub note, or at Anvil's Split when Anvil
makes the sub. Everything below Split passes without clipping.
4. Raise **Drive** until the top clips as hard as you want. The panel shows
how far the top is clipped.
5. Press **PLAIN** to hear a plain clipper at the same settings, then
**QUENCH** again. **DELTA** plays only what Quench takes away. The listen
choice is not saved and goes back to QUENCH when the editor closes.
The panel shows:
- **Low end changed by**: how much the band below Split differs from the
input, against the sub's level, for Quench and for a plain clip of the same
signal. Lower is cleaner. It reads "—" when no sub plays.
- **Top clipped by**: how far the top's loudest peak went past its room.
- **Sub peak · room left**: the sub's peak after Drive and the room it leaves
the top under the ceiling.
- The scope: the output (grey), the sub band (blue), and the sub plus or minus
the top's room (thin lines). The output stays between the thin lines.
Other controls:
- **Hold** keeps the room the sub's peak needs for this long. With a hold
longer than half a period of the lowest note (17 ms at 30 Hz), the top's
clip level stays steady through the sub's cycle and the top does not pump
with the sub. Shorter holds give the top a little more room between sub
peaks, and that room then changes at the sub's rate.
- **Soft** bends the top's clip: 0 % is a hard clip, 100 % a soft curve from
the start. The ceiling stays a hard limit.
- **Bypass** crossfades to the input, delayed by the latency.
When the sub alone reaches the ceiling, the top has no room left. The panel
then warns: lower Drive or the sub. A safety clip still holds the ceiling, and
that clip does cut the sub.
### How it works
A Linkwitz–Riley crossover splits the signal at Split. The sub passes. The top
is clipped symmetrically at the ceiling minus the sub's held peak, so the sum
of sub and top never goes over the ceiling.
Clipping the top also makes products below Split, for example difference
tones between its partials. Quench high-passes what the clip removed at Split,
so those products do not reach the low band. High-passing lets a few peaks
overshoot. Two correction passes clip those again and high-pass the correction,
and a final hard clip at the ceiling takes the small rest. All of this runs at
4× oversampling. A safety clip after the downsampler catches the filters'
ringing; it reads "idle" or a few hundredths of a dB.
Measured on a 50 Hz sub at −10.5 dBFS under a top of five unrelated partials
peaking about 6 dB over the ceiling:
| | Sub level | Low-band products |
| --- | --- | --- |
| Plain clip | −2.2 dB | −16 dB |
| Quench | +0.04 dB | −35 dB |
Low-band products are everything below 110 Hz except the sub, against the
sub's level.
Quench pays for this in loudness. The top gets the room on the sub's quieter
side only, so at the same ceiling Quench measured 0.5–1.2 dB lower in RMS
than a plain clip. Add Drive to make up for it.
## Timing and limits
Latency is the oversampling filters: 37 samples (0.8 ms at 48 kHz), reported
to the host for delay compensation.
The split is a slope, not a wall. A top whose own fundamental lies near
Split, for example a distorted patch an octave above a 49 Hz sub, is partly in
the sub band. That part passes unclipped and takes up room, and Quench helps
less there: on a saw top at 98 Hz it changed the low end about as much as a
plain clip. Keep the top's lowest partial at least an octave above Split.
Quench keeps the ceiling on sample peaks. True peaks between samples can
still go a little over it: put a true-peak limiter after Quench if the master
needs one. Each channel is clipped on its own; Quench does not link the
channels or turn the sub to mono.
Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

+13 -7
View File
@@ -7,13 +7,19 @@ workflows are in `.gitea/workflows/`. A plugin's version is the `version` in
| Plugin | Latest tag | Windows download |
| --- | --- | --- |
| Rivet | `rivet-v0.4.0` | `rivet-windows-x86_64-0.4.0.zip` |
| Patina | `patina-v0.2.0` | `patina-windows-x86_64-0.2.0.zip` |
| Anvil | `anvil-v0.1.0` | `anvil-windows-x86_64-0.1.0.zip` |
| Crucible | `crucible-v0.1.0` | `crucible-windows-x86_64-0.1.0.zip` |
| Lathe | `lathe-v0.1.0` | `lathe-windows-x86_64-0.1.0.zip` |
| Temper | `temper-v0.1.0` | `temper-windows-x86_64-0.1.0.zip` |
| Weld | `weld-v0.1.0` | `weld-windows-x86_64-0.1.0.zip` |
| 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.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.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
+6
View File
@@ -1,5 +1,11 @@
# Rivet changelog
## 0.5.0
- Change the CLAP ID to `io.narl.bass-foundry.rivet`. Hosts see this as a new
plugin: delete the old `.clap` file, rescan, and add the plugin again in
existing projects.
## 0.4.0
- Storyboard: a timeline of blocks with roles (statement, answer, departure,
+6 -6
View File
@@ -1,7 +1,7 @@
# Rivet
Rivet joins short motifs into connected musical phrases, like a fastener joining
individual pieces. Formerly **Motif Storyboard**; current version **0.4.0**.
individual pieces. Formerly **Motif Storyboard**; current version **0.5.0**.
A Rust composition companion for a producer developing their own melodic ideas.
@@ -145,8 +145,8 @@ and silent startup at several sample rates and buffer sizes.
## Existing projects
The name, Cargo packages, source paths, and output filename have changed.
The CLAP ID `io.storyboard.motif`, `story-session-v1` host field, and session
JSON format are preserved. Rescan FL Studio after replacing the old plugin file
with `Rivet.clap`; remove the old copy from scanned folders to avoid duplicate
instances with the same ID. Project recall still needs a check in the target host.
The CLAP ID is now `io.narl.bass-foundry.rivet` (earlier builds used
`io.storyboard.motif`). Hosts treat Rivet as a new plugin, so add it again in
projects saved with an earlier build. The `story-session-v1` host field and the
session JSON format are preserved, so saved sessions still open. Delete the old
plugin file, place `Rivet.clap`, and rescan FL Studio.
+18
View File
@@ -1,5 +1,23 @@
# 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
plugin: delete the old `.clap` file, rescan, and add the plugin again in
existing projects.
## 0.1.0
- Introduce Temper, a note-tracked distortion for bass sound design.
+11 -5
View File
@@ -1,6 +1,6 @@
# Temper
Temper is a distortion that drives every note the same. Current version **0.1.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

+20
View File
@@ -1,5 +1,25 @@
# 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
between 500 Hz and 4 kHz, where listeners hear it best. Above 0.64 ms (the
threshold Liski et al. 2021 measured on clicks) a note suggests comparing
attacks in SUM and SUM DRY. Display only; the sound does not change.
## 0.2.0
- Change the CLAP ID to `io.narl.bass-foundry.weld`. Hosts see this as a new
plugin: delete the old `.clap` file, rescan, and add the plugin again in
existing projects.
## 0.1.0
- Introduce Weld, a phase aligner for layered bass.
+11 -2
View File
@@ -1,7 +1,7 @@
# Weld
Weld fuses two bass layers so they add up instead of cancelling. Current version
**0.1.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,9 +51,14 @@ 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
between 500 Hz and 4 kHz. On clicks, listeners hear about 0.6 ms of change
there (Liski et al. 2021); sustained bass hides much more, and common fits
reach 0.8–1 ms. Above that threshold Weld adds a note: on a plucky layer,
compare the attacks in SUM and SUM DRY. Fewer sections usually spread less.
3. Compare by ear: mute the other layer's own route to the master, set
**Listen** to **SUM**, then to **SUM DRY**. Set Listen back to **LAYER** and
unmute the route for the mix.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 235 KiB

+460 -17
View File
@@ -4,10 +4,27 @@
//! The audio path is delayed by a fixed lookahead. Detection runs on the undelayed input, so
//! a note's pitch is known before its delayed attack leaves the plugin, and the sine starts
//! exactly on that attack.
//!
//! Ghost harmonics add the sub's own harmonics above a chosen frequency, so the note
//! reads on speakers without sub. The band above the split is measured per harmonic and
//! 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
//! plays each offset of a sweep for a few kicks and measures how kick and sub sum in the
//! low band for each one; what sums loudest is not always what sounds like one hit.
mod settings;
use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi, midi_to_hz, Crossover};
pub use settings::{Settings, Source};
use common_dsp::meter::HarmonicMeter;
use common_dsp::svf::{Svf, SvfCoef};
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::{Mode, Settings, Source, KICK_OFFSET_MAX};
use std::f32::consts::TAU;
/// Detectable fundamental range. The lowest sets the lookahead (two periods).
@@ -17,11 +34,46 @@ 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.
pub const GHOST_COUNT: usize = GHOST_TOP - 1;
/// Measurement window per note. Resolves harmonics of fundamentals down to about 35 Hz.
const GHOST_WINDOW_MS: f32 = 60.;
/// Harmonics fade in over this many octaves above the Ghost edge, so a gliding note
/// does not switch them on with a step.
const GHOST_RAMP_OCT: f32 = 0.5;
/// Offsets the audition steps through, in ms.
pub const AUDITION: [f32; 13] = [
-30., -25., -20., -15., -10., -5., 0., 5., 10., 15., 20., 25., 30.,
];
/// Kicks each audition offset plays for.
pub const AUDITION_HITS: u32 = 4;
/// A kick is a rise of the fast sidechain envelope this far above the slow one...
const KICK_RISE: f32 = 2.8;
/// ...above this level (−45 dBFS)...
const KICK_FLOOR: f32 = 0.0056;
/// ...at least this long after the previous kick.
const KICK_HOLD_MS: f32 = 60.;
/// Kick and sub are compared below this frequency, over this long after each hit.
const KICK_LOW_HZ: f32 = 150.;
const SUM_MS: f32 = 60.;
const DUCK_ATTACK_MS: f32 = 1.;
const PENDING: usize = 16;
/// Small speaker listen: a phone-like band, summed to mono.
pub const PHONE_LOW: f32 = 400.;
pub const PHONE_HIGH: f32 = 7000.;
#[derive(Clone, Copy, Debug, Default)]
pub struct Monitor {
/// Hear only the generated sub.
/// Hear only the generated sub and Ghost harmonics.
pub solo: bool,
/// Hear the output through a phone-like speaker band, in mono.
pub phone: bool,
/// Step the kick offset through `AUDITION`, `AUDITION_HITS` kicks each.
pub audition: bool,
}
#[derive(Clone, Copy, Debug)]
@@ -30,8 +82,25 @@ 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
/// patch carries above the split, and what Ghost adds. −120 when absent.
pub patch_harmonics: [f32; GHOST_COUNT],
pub ghost_harmonics: [f32; GHOST_COUNT],
/// Lowest frequency Ghost fills, after the split.
pub ghost_edge: f32,
/// Kicks found in the sidechain since the plugin started.
pub kick_hits: u32,
/// Kick plus sub against kick and sub apart, low band, dB, after the last hit. +3 is a
/// full sum of equal levels, 0 unrelated, below 0 they cancel. NaN before a hit.
pub kick_sum_db: f32,
/// Offset the sub restarted with on the latest kick, in ms.
pub kick_offset: f32,
/// Measured sum per audition offset, dB; NaN where not measured yet.
pub audition_sums: [f32; AUDITION.len()],
/// Original low band and new sub, triggered on a rising zero crossing of the sub.
pub scope_low: [f32; SCOPE],
pub scope_sub: [f32; SCOPE],
@@ -45,8 +114,16 @@ impl Default for AnalyzerFrame {
Self {
hz: 0.,
note: None,
neutral: false,
low_db: -120.,
sub_db: -120.,
patch_harmonics: [-120.; GHOST_COUNT],
ghost_harmonics: [-120.; GHOST_COUNT],
ghost_edge: 300.,
kick_hits: 0,
kick_sum_db: f32::NAN,
kick_offset: 0.,
audition_sums: [f32::NAN; AUDITION.len()],
scope_low: [0.; SCOPE],
scope_sub: [0.; SCOPE],
scope_ms: 60.,
@@ -72,6 +149,8 @@ struct Synth {
old: Voice,
old_gain: f32,
note_peak: f32,
/// Set when a note starts or changes pitch; the Ghost measurement starts over.
fresh: bool,
}
impl Synth {
fn event(&mut self, s: &Settings, e: NoteEvent) {
@@ -111,6 +190,112 @@ impl Synth {
self.voice.hz = hz;
}
self.note_peak = 0.;
self.fresh = true;
}
/// Restarts a sounding sub at the start phase, keeping its pitch.
fn kick(&mut self, s: &Settings) {
let v = self.voice;
if v.amp <= 1e-4 || v.releasing {
return;
}
self.old = v;
self.old_gain = 1.;
self.voice = Voice {
phase: s.phase / 360.,
amp: 0.,
..v
};
}
}
#[derive(Clone, Copy, Debug, Default)]
struct Pending {
/// Output-time sample.
at: u64,
/// Restart the sub (true) or duck it and start measuring (false).
restart: bool,
offset: f32,
/// Audition offset index this hit belongs to.
slot: Option<usize>,
}
/// Kick detection, scheduling, ducking and the sum measurement.
struct Kick {
clock: u64,
fast: f32,
slow: f32,
fast_coef: f32,
slow_coef: f32,
hold: usize,
pending: [Option<Pending>; PENDING],
delay: Vec<f32>,
delay_at: usize,
low: Biquad,
duck: f32,
rising: bool,
attack: f32,
measure: usize,
measure_slot: Option<usize>,
energy: [f32; 3],
hits: u32,
sum_db: f32,
offset: f32,
auditioning: bool,
slot: usize,
slot_hits: u32,
sums: [f32; AUDITION.len()],
counts: [u32; AUDITION.len()],
}
impl Kick {
fn new(rate: f32, latency: usize) -> Self {
Self {
clock: 0,
fast: 0.,
slow: 0.,
fast_coef: coefficient(rate, 0.5),
slow_coef: coefficient(rate, 30.),
hold: 0,
pending: [None; PENDING],
delay: vec![0.; latency + 1],
delay_at: 0,
low: Biquad::lowpass(rate, KICK_LOW_HZ, std::f32::consts::FRAC_1_SQRT_2),
duck: 0.,
rising: false,
attack: coefficient(rate, DUCK_ATTACK_MS / 3.),
measure: 0,
measure_slot: None,
energy: [0.; 3],
hits: 0,
sum_db: f32::NAN,
offset: 0.,
auditioning: false,
slot: 0,
slot_hits: 0,
sums: [f32::NAN; AUDITION.len()],
counts: [0; AUDITION.len()],
}
}
fn reset(&mut self) {
self.fast = 0.;
self.slow = 0.;
self.hold = 0;
self.pending = [None; PENDING];
self.delay.fill(0.);
self.low.reset();
self.duck = 0.;
self.rising = false;
self.measure = 0;
self.measure_slot = None;
self.sum_db = f32::NAN;
self.slot = 0;
self.slot_hits = 0;
self.sums = [f32::NAN; AUDITION.len()];
self.counts = [0; AUDITION.len()];
}
fn schedule(&mut self, p: Pending) {
if let Some(free) = self.pending.iter_mut().find(|p| p.is_none()) {
*free = Some(p);
}
}
}
@@ -132,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,
@@ -140,6 +330,14 @@ pub struct Engine {
scope_at: usize,
scope_stride: usize,
scope_count: usize,
meter: HarmonicMeter,
/// Linear peak of the patch's harmonic k (index k − 2) above the split, this note.
patch: [f32; GHOST_COUNT],
/// Smoothed Ghost gain per harmonic, relative to the sub's peak.
fill: [f32; GHOST_COUNT],
phone_coefs: [SvfCoef; 3],
phone: [Svf; 3],
kick: Kick,
}
impl Engine {
@@ -171,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.,
@@ -179,6 +381,17 @@ impl Engine {
scope_at: 0,
scope_stride: ((rate * 0.06) / SCOPE as f32).round().max(1.) as usize,
scope_count: 0,
meter: HarmonicMeter::new(rate, GHOST_WINDOW_MS),
patch: [0.; GHOST_COUNT],
fill: [0.; GHOST_COUNT],
phone_coefs: [
// Fourth-order Butterworth high-pass, second-order low-pass.
SvfCoef::highpass(rate, PHONE_LOW, 0.541),
SvfCoef::highpass(rate, PHONE_LOW, 1.307),
SvfCoef::lowpass(rate, PHONE_HIGH.min(0.45 * rate), 0.707),
],
phone: [Svf::default(); 3],
kick: Kick::new(rate, latency),
};
e.set_settings(settings)?;
Ok(e)
@@ -202,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;
}
@@ -221,11 +434,57 @@ 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.;
self.scope_low = [0.; 2 * SCOPE];
self.scope_sub = [0.; 2 * SCOPE];
self.meter.reset();
self.patch = [0.; GHOST_COUNT];
self.fill = [0.; GHOST_COUNT];
for f in &mut self.phone {
f.reset();
}
self.kick.reset();
}
/// Lowest frequency Ghost fills.
fn ghost_edge(&self) -> f32 {
self.settings.ghost_from.max(self.settings.split)
}
/// Moves each Ghost gain toward what the patch lacks at the current note.
fn update_fill(&mut self) {
let s = &self.settings;
let v = self.synth.voice;
let amount = s.ghost * 0.01;
let edge = self.ghost_edge();
let sub = self.level.max(1e-6);
for (i, fill) in self.fill.iter_mut().enumerate() {
let k = (i + 2) as f32;
let f = k * v.hz;
let target = if amount > 0. && v.hz > 0. && f < 0.45 * self.rate {
let ramp = ((f / edge).log2() / GHOST_RAMP_OCT).clamp(0., 1.);
// A 1/k slope, like a saw; at 100 % harmonic k sits at 2/k of the sub.
let want = amount * 2. / k * ramp;
(want - self.patch[i] / sub).max(0.)
} else {
0.
};
*fill += (target - *fill) * self.smooth;
}
}
/// Ghost harmonics for a voice at `phase`, without the voice's amplitude.
#[inline]
fn ghost(&self, phase: f32) -> f32 {
let mut out = 0.;
for (i, &g) in self.fill.iter().enumerate() {
if g > 1e-5 {
out += g * (TAU * ((i + 2) as f32 * phase).fract()).sin();
}
}
out
}
/// MIDI note start at the current input sample. Used when the source is MIDI.
pub fn note_on(&mut self, note: u8) {
@@ -243,7 +502,8 @@ impl Engine {
let k = 1. + 6. * drive;
(k * x).tanh() / k.tanh()
}
fn oscillator(&mut self) -> f32 {
/// The shaped sub and the Ghost harmonics, before level and dynamics.
fn oscillator(&mut self) -> (f32, f32) {
let attack = 1. / (self.rate * ATTACK_MS * 0.001);
let release = 1. / (self.rate * RELEASE_MS * 0.001);
let v = &mut self.synth.voice;
@@ -253,21 +513,145 @@ impl Engine {
(v.amp + attack).min(1.)
};
v.hz += (v.target - v.hz) * self.glide;
let mut out = 0.;
let (mut out, mut ghost) = (0., 0.);
let v = self.synth.voice;
if v.amp > 0. {
out += (TAU * v.phase).sin() * v.amp;
v.phase = (v.phase + v.hz / self.rate).fract();
ghost += self.ghost(v.phase) * v.amp;
self.synth.voice.phase = (v.phase + v.hz / self.rate).fract();
}
let s = &mut self.synth;
let s = self.synth;
if s.old_gain > 0. {
let o = &mut s.old;
let o = s.old;
out += (TAU * o.phase).sin() * o.amp * s.old_gain;
o.phase = (o.phase + o.hz / self.rate).fract();
ghost += self.ghost(o.phase) * o.amp * s.old_gain;
let s = &mut self.synth;
s.old.phase = (o.phase + o.hz / self.rate).fract();
s.old_gain = (s.old_gain - 1. / (self.rate * CROSSFADE_MS * 0.001)).max(0.);
}
self.shape(out)
(self.shape(out), ghost)
}
/// One sample without a sidechain.
pub fn process_sample(&mut self, x: [f32; 2], monitor: Monitor) -> [f32; 2] {
self.process_with_kick(x, 0., monitor)
}
/// Finds kicks in the undelayed sidechain and schedules their duck and restart.
fn detect_kick(&mut self, side: f32, audition: bool) {
let rate = self.rate;
let s = self.settings;
let k = &mut self.kick;
if audition != k.auditioning {
k.auditioning = audition;
k.slot = 0;
k.slot_hits = 0;
k.sums = [f32::NAN; AUDITION.len()];
k.counts = [0; AUDITION.len()];
}
let level = side.abs();
k.fast += (level - k.fast) * k.fast_coef;
k.slow += (level - k.slow) * k.slow_coef;
k.hold = k.hold.saturating_sub(1);
if !s.kick_lock || k.hold > 0 || k.fast < KICK_FLOOR || k.fast < KICK_RISE * k.slow {
return;
}
k.hold = (rate * KICK_HOLD_MS * 0.001) as usize;
let (offset, slot) = if audition {
let slot = k.slot;
k.slot_hits += 1;
if k.slot_hits >= AUDITION_HITS {
k.slot_hits = 0;
k.slot = (k.slot + 1) % AUDITION.len();
}
(AUDITION[slot], Some(slot))
} else {
(s.kick_offset, None)
};
// The delayed kick leaves the plugin `latency` samples from now.
let at = k.clock + self.latency as u64;
let shift = (offset * 0.001 * rate).round() as i64;
let duck = Pending {
at,
restart: false,
offset,
slot,
};
k.schedule(duck);
k.schedule(Pending {
at: (at as i64 + shift).max(0) as u64,
restart: true,
..duck
});
}
/// Runs the kick events due at this output sample. Returns the duck gain for the sub.
fn kick_events(&mut self) -> f32 {
let s = self.settings;
let clock = self.kick.clock;
for i in 0..PENDING {
let Some(p) = self.kick.pending[i] else {
continue;
};
if p.at > clock {
continue;
}
self.kick.pending[i] = None;
if p.restart {
self.synth.kick(&s);
self.kick.offset = p.offset;
} else {
let k = &mut self.kick;
k.hits += 1;
k.rising = true;
k.measure = (self.rate * SUM_MS * 0.001) as usize;
k.measure_slot = p.slot;
k.energy = [0.; 3];
}
}
let k = &mut self.kick;
if k.rising {
k.duck += (1. - k.duck) * k.attack;
if k.duck > 0.98 {
k.rising = false;
}
} else {
k.duck -= k.duck * coefficient(self.rate, s.kick_release / 3.);
}
if !s.kick_lock {
k.duck = 0.;
}
1. - (1. - db_to_gain(-s.kick_duck)) * k.duck
}
/// Adds one sample of the delayed kick's and the sub's low band to the sum measurement.
fn measure_kick(&mut self, kick: f32, sub: f32) {
let k = &mut self.kick;
if k.measure == 0 {
return;
}
k.energy[0] += (kick + sub) * (kick + sub);
k.energy[1] += kick * kick;
k.energy[2] += sub * sub;
k.measure -= 1;
if k.measure > 0 {
return;
}
let apart = k.energy[1] + k.energy[2];
if apart < 1e-9 {
return;
}
k.sum_db = 10. * (k.energy[0] / apart).max(1e-6).log10();
if let Some(slot) = k.measure_slot {
let n = k.counts[slot] as f32;
let old = if n > 0. { k.sums[slot] } else { 0. };
k.sums[slot] = (old * n + k.sum_db) / (n + 1.);
k.counts[slot] += 1;
}
}
/// One sample with the kick's sidechain (mono) for Kick lock.
pub fn process_with_kick(&mut self, x: [f32; 2], kick: f32, monitor: Monitor) -> [f32; 2] {
self.detect_kick(kick, monitor.audition);
let len = self.kick.delay.len();
self.kick.delay[self.kick.delay_at] = kick;
self.kick.delay_at = (self.kick.delay_at + 1) % len;
let kick_late = self.kick.delay[self.kick.delay_at];
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
@@ -275,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;
@@ -288,6 +673,19 @@ impl Engine {
(l0, h0)
};
let low = 0.5 * (l0 + l1);
if std::mem::take(&mut self.synth.fresh) {
// A new note: measure its harmonics from its first sample. Until the first
// window completes they count as missing.
self.meter.reset();
self.patch = [0.; GHOST_COUNT];
}
let v = self.synth.voice;
let hz = if v.amp > 0. && !v.releasing { v.hz } else { 0. };
if self.meter.push(0.5 * (h0 + h1), hz) && self.meter.hz() > 0. {
for (p, db) in self.patch.iter_mut().zip(&self.meter.levels()[1..]) {
*p = if *db > -119. { db_to_gain(*db) } else { 0. };
}
}
self.low_env += (low * low - self.low_env) * self.env_coef;
let env = self.low_env.sqrt();
if !self.synth.voice.releasing {
@@ -304,21 +702,37 @@ impl Engine {
self.keep += (s.keep * 0.01 - self.keep) * self.smooth;
self.output += (db_to_gain(s.output) - self.output) * self.smooth;
self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth;
let sub = self.oscillator() * self.level * shape;
let duck = self.kick_events();
self.update_fill();
let (sub, ghost) = self.oscillator();
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);
}
self.kick.clock += 1;
self.low_power += (low * low - self.low_power) * self.meter_coef;
self.sub_power += (sub * sub - self.sub_power) * self.meter_coef;
self.scope_count += 1;
if self.scope_count >= self.scope_stride {
self.scope_count = 0;
self.scope_low[self.scope_at] = low;
self.scope_sub[self.scope_at] = sub;
self.scope_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 {
sub
generated
} else {
h + self.keep * l + sub
h + keep * l + generated
}
};
let w = [wet(l0, h0), wet(l1, h1)];
@@ -326,6 +740,17 @@ impl Engine {
for c in 0..self.channels {
out[c] = (w[c] * self.output) * (1. - self.bypass) + y[c] * self.bypass;
}
if monitor.phone {
let mut m = if self.channels == 2 {
0.5 * (out[0] + out[1])
} else {
out[0]
};
for (f, c) in self.phone.iter_mut().zip(&self.phone_coefs) {
m = f.process(c, m);
}
out = [m, if self.channels == 2 { m } else { 0. }];
}
out
}
pub fn take_analyzer(&self) -> AnalyzerFrame {
@@ -335,10 +760,28 @@ impl Engine {
low_db: 10. * self.low_power.max(1e-12).log10() + 3.01,
sub_db: 10. * self.sub_power.max(1e-12).log10() + 3.01,
scope_ms: (self.scope_stride * SCOPE) as f32 / self.rate * 1000.,
ghost_edge: self.ghost_edge(),
kick_hits: self.kick.hits,
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);
let rel = |g: f32| {
if g > 1e-6 {
20. * (g / sub).log10()
} else {
-120.
}
};
for i in 0..GHOST_COUNT {
f.patch_harmonics[i] = rel(self.patch[i]);
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);
}
+87 -1
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.
@@ -22,6 +56,13 @@ pub struct Settings {
pub dynamics: f32,
/// Soft saturation of the sine, adding upper harmonics for small speakers.
pub drive: f32,
/// Ghost harmonics: adds the sub's harmonics 2–10 above `ghost_from`, so small
/// speakers hear the note. Only harmonics the patch lacks are filled. 0 is off.
#[serde(default)]
pub ghost: f32,
/// Lowest frequency Ghost harmonics reach down to, in Hz (never below `split`).
#[serde(default = "default_ghost_from")]
pub ghost_from: f32,
pub octave: i32,
/// Pitch slide time between overlapping notes.
pub glide: f32,
@@ -37,15 +78,45 @@ pub struct Settings {
/// Round detected pitch to the nearest semitone.
pub snap: bool,
pub bypass: bool,
/// Kick lock: on each kick in the sidechain, duck the sub and restart its phase.
#[serde(default)]
pub kick_lock: bool,
/// How far the sub dips on each kick, in dB.
#[serde(default = "default_kick_duck")]
pub kick_duck: f32,
/// Time for the sub to come back after a kick, in ms.
#[serde(default = "default_kick_release")]
pub kick_release: f32,
/// When the sub restarts against the kick's start, in ms. Negative: before the kick.
#[serde(default)]
pub kick_offset: f32,
}
const GHOST_FROM: f32 = 300.;
const KICK_DUCK: f32 = 6.;
const KICK_RELEASE: f32 = 80.;
/// Range of the kick offset, in ms either side of the kick.
pub const KICK_OFFSET_MAX: f32 = 30.;
fn default_kick_duck() -> f32 {
KICK_DUCK
}
fn default_kick_release() -> f32 {
KICK_RELEASE
}
fn default_ghost_from() -> f32 {
GHOST_FROM
}
impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
pitch: Pitch::default(),
mode: Mode::Replace,
level: -9.,
split: 90.,
dynamics: 0.,
drive: 0.,
ghost: 0.,
ghost_from: GHOST_FROM,
octave: 0,
glide: 0.,
phase: 0.,
@@ -55,21 +126,31 @@ impl Default for Settings {
retrigger: true,
snap: true,
bypass: false,
kick_lock: false,
kick_duck: KICK_DUCK,
kick_release: KICK_RELEASE,
kick_offset: 0.,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
self.pitch.validate()?;
let finite = [
self.level,
self.split,
self.dynamics,
self.drive,
self.ghost,
self.ghost_from,
self.glide,
self.phase,
self.gate,
self.keep,
self.output,
self.kick_duck,
self.kick_release,
self.kick_offset,
]
.iter()
.all(|v| v.is_finite());
@@ -80,12 +161,17 @@ impl Settings {
&& (40.0..=200.).contains(&self.split)
&& (0.0..=100.).contains(&self.dynamics)
&& (0.0..=100.).contains(&self.drive)
&& (0.0..=100.).contains(&self.ghost)
&& (150.0..=600.).contains(&self.ghost_from)
&& (-2..=1).contains(&self.octave)
&& (0.0..=200.).contains(&self.glide)
&& (0.0..=360.).contains(&self.phase)
&& (-60.0..=-20.).contains(&self.gate)
&& (0.0..=100.).contains(&self.keep)
&& (-12.0..=12.).contains(&self.output);
&& (-12.0..=12.).contains(&self.output)
&& (0.0..=24.).contains(&self.kick_duck)
&& (10.0..=300.).contains(&self.kick_release)
&& (-KICK_OFFSET_MAX..=KICK_OFFSET_MAX).contains(&self.kick_offset);
if ok {
Ok(())
} else {
+350 -3
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.;
@@ -19,7 +19,15 @@ fn notes(list: &[(f32, f32, f32, f32)], total: f32) -> Vec<f32> {
}
fn render(e: &mut Engine, x: &[f32], solo: bool) -> Vec<f32> {
x.iter()
.map(|&s| e.process_sample([s, s], Monitor { solo })[0])
.map(|&s| {
e.process_sample(
[s, s],
Monitor {
solo,
..Monitor::default()
},
)[0]
})
.collect()
}
fn rms(x: &[f32]) -> f32 {
@@ -135,7 +143,15 @@ fn midi_notes_set_pitch_and_are_delayed_by_the_latency() {
if i == seconds(0.6) {
e.note_off(33);
}
y.push(e.process_sample([0.; 2], Monitor { solo: true })[0]);
y.push(
e.process_sample(
[0.; 2],
Monitor {
solo: true,
..Monitor::default()
},
)[0],
);
}
assert!(y[..1000 + l].iter().all(|v| *v == 0.));
assert!(y[1000 + l + 50].abs() > 0.);
@@ -211,3 +227,334 @@ fn settings_are_validated_and_serialise() {
assert!(Engine::new(1000., 2).is_err());
assert!(Engine::new(48000., 3).is_err());
}
/// Peak amplitude of the component at `hz`, over a window of whole cycles.
fn amplitude(x: &[f32], hz: f32) -> f32 {
let w = std::f32::consts::TAU * hz / RATE;
let (re, im) = x.iter().enumerate().fold((0., 0.), |(re, im), (n, v)| {
(re + v * (w * n as f32).cos(), im - v * (w * n as f32).sin())
});
2. * (re * re + im * im).sqrt() / x.len() as f32
}
/// Sines (Hz, peak) from 0.1 s to `total` − 0.1 s, with 1 ms fades.
fn tones(list: &[(f32, f32)], total: f32) -> Vec<f32> {
let n = (total * RATE) as usize;
let (a, b, fade) = (seconds(0.1), n - seconds(0.1), seconds(0.001));
(0..n)
.map(|i| {
if i < a || i >= b {
return 0.;
}
let edge = ((i - a).min(b - 1 - i) as f32 / fade as f32).min(1.);
let t = (i - a) as f32 / RATE;
edge * list
.iter()
.map(|&(hz, amp)| amp * (std::f32::consts::TAU * hz * t).sin())
.sum::<f32>()
})
.collect()
}
fn ghost(amount: f32) -> Settings {
Settings {
ghost: amount,
ghost_from: 300.,
..Settings::default()
}
}
#[test]
fn ghost_fills_the_harmonics_a_clean_sub_lacks() {
let (mut e, l) = engine(ghost(100.));
let x = tones(&[(55., 0.5)], 1.);
let y = render(&mut e, &x, true);
// 0.2 s holds exactly 11 cycles of 55 Hz.
let w = &y[seconds(0.5) + l..seconds(0.7) + l];
let sub = amplitude(w, 55.);
assert!((db(sub) + 9.).abs() < 0.5, "{}", db(sub));
for k in 2..=5 {
// Below Ghost from: nothing added, so the sub stays a clean sine there.
let a = amplitude(w, 55. * k as f32);
assert!(db(a / sub) < -60., "harmonic {k}: {}", db(a / sub));
}
for k in 8..=10 {
let a = amplitude(w, 55. * k as f32);
let expect = 20. * (2. / k as f32).log10();
assert!(
(db(a / sub) - expect).abs() < 1.,
"harmonic {k}: {}",
db(a / sub)
);
}
// Harmonic 6 (330 Hz) sits in the fade above 300 Hz: present, but quieter.
let six = db(amplitude(w, 330.) / sub);
assert!(six < 20. * (2f32 / 6.).log10() - 3. && six > -40., "{six}");
}
#[test]
fn ghost_leaves_harmonics_the_patch_already_has() {
let (mut e, l) = engine(ghost(100.));
// The patch carries its 8th harmonic (440 Hz) louder than Ghost would add it.
let x = tones(&[(55., 0.5), (440., 0.2)], 1.);
let y = render(&mut e, &x, false);
let w = &y[seconds(0.5) + l..seconds(0.7) + l];
let eighth = amplitude(w, 440.);
assert!((db(eighth) - db(0.2)).abs() < 0.5, "{}", db(eighth));
// The 9th is missing from the patch, so Ghost fills it.
let ninth = amplitude(w, 495.) / amplitude(w, 55.);
assert!(
(db(ninth) - 20. * (2f32 / 9.).log10()).abs() < 1.,
"{}",
db(ninth)
);
}
#[test]
fn ghost_off_adds_nothing_and_phone_listen_drops_the_sub() {
let x = tones(&[(55., 0.5)], 1.);
let (mut e, l) = engine(ghost(0.));
let y = render(&mut e, &x, true);
let w = &y[seconds(0.5) + l..seconds(0.7) + l];
for k in 2..=10 {
assert!(amplitude(w, 55. * k as f32) < 1e-4, "harmonic {k}");
}
let phone = |amount: f32| {
let (mut e, l) = engine(ghost(amount));
let y: Vec<f32> = x
.iter()
.map(|&s| {
e.process_sample(
[s, s],
Monitor {
solo: true,
phone: true,
audition: false,
},
)[0]
})
.collect();
rms(&y[seconds(0.5) + l..seconds(0.7) + l])
};
// A clean 55 Hz sub all but vanishes on a phone; Ghost makes the note heard.
let (plain, ghosted) = (phone(0.), phone(100.));
assert!(db(plain) < -50., "{}", db(plain));
assert!(
db(ghosted) - db(plain) > 25.,
"{} vs {}",
db(ghosted),
db(plain)
);
}
/// A kick-like burst: a 55 Hz sine starting at phase 0 with a 60 ms decay, at each time.
fn kicks(times: &[f32], total: f32) -> Vec<f32> {
let mut k = vec![0.; seconds(total)];
for &t in times {
for i in 0..seconds(0.2) {
let n = seconds(t) + i;
if n < k.len() {
let t = i as f32 / RATE;
k[n] += 0.8 * (std::f32::consts::TAU * 55. * t).sin() * (-t / 0.06).exp();
}
}
}
k
}
fn render_kick(e: &mut Engine, x: &[f32], k: &[f32], monitor: Monitor) -> Vec<f32> {
x.iter()
.zip(k)
.map(|(&s, &k)| e.process_with_kick([s, s], k, monitor)[0])
.collect()
}
/// Normalised correlation of `y[a..b]` with a sine of `hz` at phase 0 at sample `zero`.
fn sine_match(y: &[f32], a: usize, b: usize, hz: f32, zero: f32) -> f32 {
let w = std::f32::consts::TAU * hz / RATE;
let (mut xy, mut xx, mut yy) = (0., 0., 0.);
for (i, &v) in y.iter().enumerate().take(b).skip(a) {
let r = (w * (i as f32 - zero)).sin();
xy += v * r;
xx += r * r;
yy += v * v;
}
xy / (xx * yy).sqrt()
}
const SOLO: Monitor = Monitor {
solo: true,
phone: false,
audition: false,
};
fn kick_lock(offset: f32) -> Settings {
Settings {
kick_lock: true,
kick_offset: offset,
..Settings::default()
}
}
#[test]
fn a_kick_restarts_the_sub_at_the_start_phase_and_ducks_it() {
// A bass note that started off the kick: before the hit, the sub runs at its own phase.
let x = tones(&[(55., 0.5)], 1.4);
let k = kicks(&[0.613], 1.4);
let (mut e, l) = engine(kick_lock(0.));
let y = render_kick(&mut e, &x, &k, SOLO);
let hit = seconds(0.613) + l;
let after = sine_match(
&y,
hit + seconds(0.004),
hit + seconds(0.04),
55.,
hit as f32,
);
assert!(after > 0.99, "{after}");
let before = rms(&y[hit - seconds(0.1)..hit - seconds(0.01)]);
let ducked = rms(&y[hit + seconds(0.004)..hit + seconds(0.022)]);
let back = rms(&y[hit + seconds(0.4)..hit + seconds(0.5)]);
assert!(
db(before) - db(ducked) > 2.,
"{} vs {}",
db(before),
db(ducked)
);
assert!(
(db(back) - db(before)).abs() < 0.3,
"{} vs {}",
db(back),
db(before)
);
assert_eq!(e.take_analyzer().kick_hits, 1);
}
#[test]
fn the_offset_restarts_the_sub_before_or_after_the_kick() {
let x = tones(&[(55., 0.5)], 1.2);
let k = kicks(&[0.613], 1.2);
for offset in [-10., 7.] {
// No duck: only the phase is checked here.
let (mut e, l) = engine(Settings {
kick_duck: 0.,
..kick_lock(offset)
});
let y = render_kick(&mut e, &x, &k, SOLO);
let start = (seconds(0.613) + l) as f32 + offset * 0.001 * RATE;
let a = start as usize + seconds(0.004);
let m = sine_match(&y, a, a + seconds(0.03), 55., start);
assert!(m > 0.99, "{offset}: {m}");
}
}
#[test]
fn without_kick_lock_the_sidechain_changes_nothing() {
let x = tones(&[(55., 0.5)], 1.);
let k = kicks(&[0.3, 0.6], 1.);
let (mut a, _) = engine(Settings::default());
let (mut b, _) = engine(Settings::default());
let with = render_kick(&mut a, &x, &k, SOLO);
let without = render(&mut b, &x, true);
assert_eq!(with, without);
}
#[test]
fn the_audition_finds_where_kick_and_sub_sum() {
use anvil_core::{AUDITION, AUDITION_HITS};
let hits = AUDITION.len() * AUDITION_HITS as usize;
let total = 0.5 + hits as f32 * 0.2 + 0.4;
let x = tones(&[(55., 0.5)], total);
let times: Vec<f32> = (0..hits).map(|i| 0.5 + i as f32 * 0.2).collect();
let k = kicks(&times, total);
let (mut e, _) = engine(kick_lock(0.));
let monitor = Monitor {
audition: true,
..SOLO
};
render_kick(&mut e, &x, &k, monitor);
let f = e.take_analyzer();
assert_eq!(f.kick_hits as usize, hits);
let sums = f.audition_sums;
assert!(sums.iter().all(|v| v.is_finite()), "{sums:?}");
// The kick is a 55 Hz burst at phase 0, like the sub: they sum fully at 0 ms and
// cancel half a period (9 ms) away.
let best = (0..sums.len())
.max_by(|&a, &b| sums[a].total_cmp(&sums[b]))
.unwrap();
assert_eq!(AUDITION[best], 0., "{sums:?}");
let zero = sums[6];
assert!(zero > 2., "{zero}");
for i in [4, 8] {
assert!(
zero - sums[i] > 4.,
"{} ms: {} vs {zero}",
AUDITION[i],
sums[i]
);
}
}
#[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}");
}
+16 -2
View File
@@ -34,7 +34,12 @@ static ALLOCATOR: CheckedAllocator = CheckedAllocator;
#[test]
fn processing_notes_settings_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings::default();
let mut settings = Settings {
ghost: 80.,
kick_lock: true,
kick_offset: -12.,
..Settings::default()
};
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..96000 {
@@ -58,7 +63,16 @@ fn processing_notes_settings_and_analysis_do_not_allocate_or_free() {
} else {
0.
};
std::hint::black_box(engine.process_sample([x, x], Monitor { solo: i % 3 == 0 }));
let kick = if i % 12000 < 400 { 0.8 } else { 0. };
std::hint::black_box(engine.process_with_kick(
[x, x],
kick,
Monitor {
solo: i % 3 == 0,
phone: i % 5 == 0,
audition: i > 30000,
},
));
if i % 1600 == 0 {
std::hint::black_box(engine.take_analyzer());
}
+14 -1
View File
@@ -72,11 +72,12 @@ checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
[[package]]
name = "anvil"
version = "0.1.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.1.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"
+44 -7
View File
@@ -13,10 +13,12 @@ use std::sync::{
struct Shared {
analyzer: ArrayQueue<AnalyzerFrame>,
solo: AtomicBool,
phone: AtomicBool,
audition: AtomicBool,
}
struct EditorData {
ui: anvil_ui::State,
gestures: [bool; 9],
gestures: [bool; 14],
}
pub struct Anvil {
params: Arc<AnvilParams>,
@@ -32,6 +34,8 @@ impl Default for Anvil {
shared: Arc::new(Shared {
analyzer: ArrayQueue::new(2),
solo: AtomicBool::new(false),
phone: AtomicBool::new(false),
audition: AtomicBool::new(false),
}),
engine: None,
publish_count: 0,
@@ -49,11 +53,21 @@ impl Plugin for Anvil {
AudioIOLayout {
main_input_channels: NonZeroU32::new(2),
main_output_channels: NonZeroU32::new(2),
aux_input_ports: &[new_nonzero_u32(2)],
names: PortNames {
aux_inputs: &["Kick"],
..PortNames::const_default()
},
..AudioIOLayout::const_default()
},
AudioIOLayout {
main_input_channels: NonZeroU32::new(1),
main_output_channels: NonZeroU32::new(1),
aux_input_ports: &[new_nonzero_u32(1)],
names: PortNames {
aux_inputs: &["Kick"],
..PortNames::const_default()
},
..AudioIOLayout::const_default()
},
];
@@ -71,10 +85,12 @@ impl Plugin for Anvil {
params.editor.clone(),
EditorData {
ui: anvil_ui::State::default(),
gestures: [false; 9],
gestures: [false; 14],
},
|ctx, data| {
data.ui.solo = false;
data.ui.phone = false;
data.ui.audition = false;
anvil_ui::style(ctx);
},
move |ctx, setter, data| {
@@ -98,8 +114,11 @@ impl Plugin for Anvil {
data.ui.gesture_active,
&mut data.gestures,
);
// Solo is a monitor only: never saved and never left on after the editor closes.
// Solo and phone listen are monitors only: never saved and never left on
// after the editor closes.
shared.solo.store(data.ui.solo, Ordering::Release);
shared.phone.store(data.ui.phone, Ordering::Release);
shared.audition.store(data.ui.audition, Ordering::Release);
if data.ui.analyzer.note.is_some() {
ctx.request_repaint();
}
@@ -125,6 +144,8 @@ impl Plugin for Anvil {
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
self.shared.solo.store(false, Ordering::Release);
self.shared.phone.store(false, Ordering::Release);
self.shared.audition.store(false, Ordering::Release);
true
}
fn reset(&mut self) {
@@ -136,14 +157,22 @@ impl Plugin for Anvil {
fn process(
&mut self,
buffer: &mut Buffer,
_: &mut AuxiliaryBuffers,
aux: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let _ = engine.set_settings(self.params.settings());
let solo = self.shared.solo.load(Ordering::Acquire) && self.params.editor.is_open();
let open = self.params.editor.is_open();
let monitor = Monitor {
solo: self.shared.solo.load(Ordering::Acquire) && open,
phone: self.shared.phone.load(Ordering::Acquire) && open,
audition: self.shared.audition.load(Ordering::Acquire) && open,
};
// A host that does not connect the sidechain still passes a buffer; treat a
// missing one as silence.
let side = aux.inputs.first().map(|b| b.as_slice_immutable());
let mut next = context.next_event();
for (index, mut sample) in buffer.iter_samples().enumerate() {
while let Some(event) = next {
@@ -170,7 +199,15 @@ impl Plugin for Anvil {
0.
},
];
let out = engine.process_sample(x, Monitor { solo });
let kick = side.map_or(0., |side| {
let n = side.len().min(2);
side[..n]
.iter()
.map(|c| c.get(index).copied().unwrap_or(0.))
.sum::<f32>()
/ n.max(1) as f32
});
let out = engine.process_with_kick(x, kick, monitor);
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
@@ -185,7 +222,7 @@ impl Plugin for Anvil {
}
}
impl ClapPlugin for Anvil {
const CLAP_ID: &'static str = "io.storyboard.sub-anchor";
const CLAP_ID: &'static str = "io.narl.bass-foundry.anvil";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Replace the sub with a clean sine at one level and phase for every note");
const CLAP_MANUAL_URL: Option<&'static str> = None;
+114 -13
View File
@@ -1,4 +1,5 @@
use anvil_core::{Settings, Source};
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"]
@@ -28,6 +76,10 @@ pub struct AnvilParams {
pub dynamics: FloatParam,
#[id = "drive"]
pub drive: FloatParam,
#[id = "ghost"]
pub ghost: FloatParam,
#[id = "ghost_from"]
pub ghost_from: FloatParam,
#[id = "octave"]
pub octave: IntParam,
#[id = "glide"]
@@ -46,6 +98,14 @@ pub struct AnvilParams {
pub snap: BoolParam,
#[id = "bypass"]
pub bypass: BoolParam,
#[id = "kick_lock"]
pub kick_lock: BoolParam,
#[id = "kick_duck"]
pub kick_duck: FloatParam,
#[id = "kick_release"]
pub kick_release: FloatParam,
#[id = "kick_offset"]
pub kick_offset: FloatParam,
}
impl Default for AnvilParams {
fn default() -> Self {
@@ -53,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",
@@ -66,6 +128,17 @@ impl Default for AnvilParams {
.with_unit(" Hz"),
dynamics: linear("Dynamics", d.dynamics, 0., 100., " %"),
drive: linear("Drive", d.drive, 0., 100., " %"),
ghost: linear("Ghost", d.ghost, 0., 100., " %"),
ghost_from: FloatParam::new(
"Ghost from",
d.ghost_from,
FloatRange::Skewed {
min: 150.,
max: 600.,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(" Hz"),
octave: IntParam::new("Octave", d.octave, IntRange::Linear { min: -2, max: 1 }),
glide: linear("Glide", d.glide, 0., 200., " ms"),
phase: linear("Phase", d.phase, 0., 360., "°"),
@@ -75,20 +148,31 @@ impl Default for AnvilParams {
retrigger: BoolParam::new("Retrigger", d.retrigger),
snap: BoolParam::new("Snap to semitone", d.snap),
bypass: BoolParam::new("Bypass", false).make_bypass(),
kick_lock: BoolParam::new("Kick lock", d.kick_lock),
kick_duck: linear("Kick duck", d.kick_duck, 0., 24., " dB"),
kick_release: linear("Kick release", d.kick_release, 10., 300., " ms"),
kick_offset: linear(
"Kick offset",
d.kick_offset,
-KICK_OFFSET_MAX,
KICK_OFFSET_MAX,
" ms",
),
}
}
}
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(),
drive: self.drive.value(),
ghost: self.ghost.value(),
ghost_from: self.ghost_from.value(),
octave: self.octave.value(),
glide: self.glide.value(),
phase: self.phase.value(),
@@ -98,6 +182,10 @@ impl AnvilParams {
retrigger: self.retrigger.value(),
snap: self.snap.value(),
bypass: self.bypass.value(),
kick_lock: self.kick_lock.value(),
kick_duck: self.kick_duck.value(),
kick_release: self.kick_release.value(),
kick_offset: self.kick_offset.value(),
}
}
/// Pushes every changed editor value to the host. Continuous controls hold one
@@ -108,9 +196,9 @@ impl AnvilParams {
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; 9],
open: &mut [bool; 14],
) {
let floats: [(&FloatParam, f32, f32); 8] = [
let floats: [(&FloatParam, f32, f32); 13] = [
(&self.level, old.level, new.level),
(&self.split, old.split, new.split),
(&self.dynamics, old.dynamics, new.dynamics),
@@ -119,6 +207,11 @@ impl AnvilParams {
(&self.phase, old.phase, new.phase),
(&self.gate, old.gate, new.gate),
(&self.keep, old.keep, new.keep),
(&self.ghost, old.ghost, new.ghost),
(&self.ghost_from, old.ghost_from, new.ghost_from),
(&self.kick_duck, old.kick_duck, new.kick_duck),
(&self.kick_release, old.kick_release, new.kick_release),
(&self.kick_offset, old.kick_offset, new.kick_offset),
];
let mut continuous = |i: usize, changed: bool, set: &dyn Fn()| {
if changed {
@@ -136,7 +229,7 @@ impl AnvilParams {
for (i, (p, a, b)) in floats.into_iter().enumerate() {
continuous(i, a != b, &|| setter.set_parameter(p, b));
}
continuous(8, old.output != new.output, &|| {
continuous(13, old.output != new.output, &|| {
setter.set_parameter(&self.output, new.output)
});
let once = |changed: bool, f: &dyn Fn()| {
@@ -153,6 +246,7 @@ impl AnvilParams {
(&self.retrigger, old.retrigger, new.retrigger),
(&self.snap, old.snap, new.snap),
(&self.bypass, old.bypass, new.bypass),
(&self.kick_lock, old.kick_lock, new.kick_lock),
] {
once(a != b, &|| {
setter.begin_set_parameter(p);
@@ -161,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 {
[
@@ -180,6 +276,11 @@ impl AnvilParams {
&self.phase,
&self.gate,
&self.keep,
&self.ghost,
&self.ghost_from,
&self.kick_duck,
&self.kick_release,
&self.kick_offset,
&self.output,
][i]
}
+31 -11
View File
@@ -61,7 +61,7 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
assert_eq!(CStr::from_ptr((*descriptor).name), c"Anvil");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.storyboard.sub-anchor"
c"io.narl.bass-foundry.anvil"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
@@ -83,8 +83,9 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
as *const clap_plugin_audio_ports;
assert!(!ports.is_null());
// Main input plus the kick sidechain; one output.
assert_eq!((*ports).count.unwrap()(plugin, true), 2);
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
as *const clap_plugin_latency;
assert!(!latency.is_null());
@@ -108,6 +109,13 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
.params
.insert("level".into(), ParamValue::F32(-14.));
envelope.params.insert("octave".into(), ParamValue::I32(-1));
envelope.params.insert("ghost".into(), ParamValue::F32(40.));
envelope
.params
.insert("kick_lock".into(), ParamValue::Bool(true));
envelope
.params
.insert("kick_offset".into(), ParamValue::F32(-7.));
envelope
.params
.insert("snap".into(), ParamValue::Bool(false));
@@ -154,14 +162,26 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
let mut left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
// A connected but silent kick sidechain.
let (mut kick_left, mut kick_right) = ([0f32; 512], [0f32; 512]);
let mut kick_channels = [kick_left.as_mut_ptr(), kick_right.as_mut_ptr()];
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
let incoming = clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let incoming = [
clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
},
clap_audio_buffer {
data32: kick_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
},
];
let mut outgoing_audio = clap_audio_buffer {
data32: out_channels.as_mut_ptr(),
data64: ptr::null_mut(),
@@ -182,9 +202,9 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
steady_time: position as i64,
frames_count: frames as u32,
transport: ptr::null(),
audio_inputs: &incoming,
audio_inputs: incoming.as_ptr(),
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 1,
audio_inputs_count: 2,
audio_outputs_count: 1,
in_events: &input,
out_events: &outgoing,
+427 -23
View File
@@ -1,23 +1,35 @@
//! Anvil editor, shared by the CLAP wrapper and the headless renderer.
use anvil_core::{AnalyzerFrame, Settings, Source, SCOPE};
use anvil_core::{
AnalyzerFrame, Mode, Settings, Source, AUDITION, AUDITION_HITS, GHOST_COUNT, KICK_OFFSET_MAX,
PHONE_LOW, SCOPE,
};
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, RichText, Sense, Stroke, Vec2};
pub use common_ui::style;
pub const WIDTH: u32 = 980;
pub const HEIGHT: u32 = 704;
pub const HEIGHT: u32 = 872;
pub struct State {
pub solo: bool,
/// Phone-like listen. A monitor, never saved.
pub phone: bool,
/// Kick offset audition. A monitor, never saved.
pub audition: bool,
pub analyzer: AnalyzerFrame,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
/// 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,
analyzer: AnalyzerFrame::default(),
gesture_active: false,
help: DEFAULT_HELP.into(),
@@ -64,7 +76,63 @@ fn toggle(ui: &mut egui::Ui, state: &mut State, on: &mut bool, label: &str, help
}
hint(&r, state, help);
}
fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32) {
/// Harmonics 2–10 of the current note: what the patch carries above the split (steel)
/// and what Ghost adds (accent), from −48 dB to 0 dB against the sub.
fn harmonics(p: &egui::Painter, rect: egui::Rect, frame: &AnalyzerFrame) {
p.rect_filled(rect, 2., Color32::from_rgba_unmultiplied(12, 13, 16, 230));
p.rect_stroke(
rect,
2.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
p.text(
rect.left_top() + Vec2::new(8., 6.),
egui::Align2::LEFT_TOP,
"GHOST",
FontId::proportional(10.),
ACCENT,
);
p.text(
rect.right_top() + Vec2::new(-8., 6.),
egui::Align2::RIGHT_TOP,
format!("from {:.0} Hz", frame.ghost_edge),
FontId::proportional(10.),
MUTED,
);
let area = egui::Rect::from_min_max(
rect.left_top() + Vec2::new(8., 22.),
rect.right_bottom() - Vec2::new(8., 16.),
);
let slot = area.width() / GHOST_COUNT as f32;
let height = |db: f32| ((db + 48.) / 48.).clamp(0., 1.) * area.height();
for i in 0..GHOST_COUNT {
let x = area.left() + slot * i as f32;
let bar = |left: f32, db: f32, color: Color32| {
let h = height(db);
if h > 0.5 {
p.rect_filled(
egui::Rect::from_min_max(
Pos2::new(left, area.bottom() - h),
Pos2::new(left + slot * 0.36, area.bottom()),
),
1.,
color,
);
}
};
bar(x + slot * 0.12, frame.patch_harmonics[i], STEEL);
bar(x + slot * 0.52, frame.ghost_harmonics[i], ACCENT);
p.text(
Pos2::new(x + slot * 0.5, area.bottom() + 2.),
egui::Align2::CENTER_TOP,
format!("{}", i + 2),
FontId::proportional(9.),
DIM,
);
}
}
fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32, ghost: bool) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, 2., SCREEN);
@@ -123,6 +191,11 @@ fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32) {
FontId::proportional(11.),
MUTED,
);
if ghost {
let size = Vec2::new(200., 92.);
let corner = rect.right_bottom() - Vec2::new(8., 8.);
harmonics(&p, egui::Rect::from_min_max(corner - size, corner), frame);
}
p.rect_stroke(
rect,
2.,
@@ -136,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.");
@@ -145,19 +219,24 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
}
let r = ui
.selectable_label(state.solo, RichText::new("SOLO SUB").size(13.))
.on_hover_text("Hear only the generated sub.");
.on_hover_text("Hear only the generated sub and Ghost harmonics.");
if r.clicked() {
state.solo = !state.solo;
}
ui.separator();
for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] {
let r = ui.selectable_label(settings.source == source, RichText::new(label).size(13.));
if r.clicked() {
settings.source = source;
}
let r = ui
.selectable_label(state.phone, RichText::new("PHONE").size(13.))
.on_hover_text(format!(
"Listen as on a phone: mono, nothing below {PHONE_LOW:.0} Hz. Not saved."
));
if r.clicked() {
state.phone = !state.phone;
}
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| {
@@ -169,14 +248,28 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
scope(ui, &frame, TOP);
scope(ui, &frame, TOP, settings.ghost > 0.);
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(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));
}
@@ -185,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(
@@ -201,18 +295,140 @@ 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(4, |c| {
ui.columns(6, |c| {
knob(
&mut c[0],
state,
@@ -255,7 +471,30 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
defaults.drive,
" %",
88.,
"Adds upper harmonics so the sub reads on small speakers.",
"Saturates the sine: odd harmonics, thicker sub. Changes the sub itself.",
);
knob(
&mut c[4],
state,
"Ghost",
&mut settings.ghost,
0. ..=100.,
defaults.ghost,
" %",
88.,
"Adds the sub's harmonics 2–10 above Ghost from, so phones hear the note. \
Only harmonics the patch lacks are added. The sub itself stays the same.",
);
knob(
&mut c[5],
state,
"Ghost from",
&mut settings.ghost_from,
150. ..=600.,
defaults.ghost_from,
" Hz",
88.,
"Lowest frequency Ghost adds. Phones cut off around 300–500 Hz. Never below Split.",
);
});
ui.add_space(6.);
@@ -346,5 +585,170 @@ 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) {
let defaults = Settings::default();
let frame = state.analyzer;
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(150.);
caption(ui, "KICK LOCK");
toggle(
ui,
state,
&mut settings.kick_lock,
"KICK LOCK",
"Route the kick into Anvil's sidechain. On each kick the sub dips and \
restarts at Phase, so kick and sub hit together.",
);
ui.add_space(6.);
let status = if !settings.kick_lock {
"off".to_string()
} else if frame.kick_hits == 0 {
"no kick in the sidechain".to_string()
} else if frame.kick_sum_db.is_finite() {
format!("{} kicks · sum {:+.1} dB", frame.kick_hits, frame.kick_sum_db)
} else {
format!("{} kicks", frame.kick_hits)
};
ui.label(RichText::new(status).size(12.).color(MUTED));
});
ui.columns(4, |c| {
knob(
&mut c[0],
state,
"Duck",
&mut settings.kick_duck,
0. ..=24.,
defaults.kick_duck,
" dB",
54.,
"How far the sub dips on each kick.",
);
knob(
&mut c[1],
state,
"Release",
&mut settings.kick_release,
10. ..=300.,
defaults.kick_release,
" ms",
54.,
"Time for the sub to come back after a kick.",
);
knob(
&mut c[2],
state,
"Offset",
&mut settings.kick_offset,
-KICK_OFFSET_MAX..=KICK_OFFSET_MAX,
defaults.kick_offset,
" ms",
54.,
"When the sub restarts against the kick. Negative: before it. Use Audition to find it by ear.",
);
c[3].vertical(|ui| audition(ui, settings, state));
});
});
}
/// The audition: each offset plays for a few kicks; bars show how kick and sub summed.
fn audition(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let frame = state.analyzer;
ui.horizontal(|ui| {
let r = ui.selectable_label(state.audition, RichText::new("AUDITION").size(12.));
if r.clicked() {
state.audition = !state.audition;
}
hint(
&r,
state,
&format!(
"Steps the offset from −30 to +30 ms, {AUDITION_HITS} kicks each. Press KEEP \
when kick and sub sound like one hit. Not saved."
),
);
let r = ui.add_enabled(
state.audition && frame.kick_hits > 0,
egui::Button::new(RichText::new("KEEP").size(12.)),
);
if r.clicked() {
settings.kick_offset = frame.kick_offset;
state.audition = false;
}
hint(
&r,
state,
"Set Offset to the one playing now and stop the audition.",
);
});
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 58.), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, 2., SCREEN);
let slot = rect.width() / AUDITION.len() as f32;
// −9 dB to +3 dB: +3 is a full sum of equal levels.
let height = |db: f32| ((db + 9.) / 12.).clamp(0.02, 1.) * (rect.height() - 14.);
for (i, &offset) in AUDITION.iter().enumerate() {
let x = rect.left() + slot * i as f32;
let db = frame.audition_sums[i];
let playing = state.audition && (offset - frame.kick_offset).abs() < 0.01;
if db.is_finite() {
let h = height(db);
p.rect_filled(
egui::Rect::from_min_max(
Pos2::new(x + slot * 0.2, rect.bottom() - 12. - h),
Pos2::new(x + slot * 0.8, rect.bottom() - 12.),
),
1.,
if playing { ACCENT } else { STEEL },
);
}
if (offset - settings.kick_offset).abs() < 0.01 {
// The kept offset.
let (cx, base) = (x + slot * 0.5, rect.bottom() - 2.);
p.add(egui::Shape::convex_polygon(
vec![
Pos2::new(cx, base - 7.),
Pos2::new(cx + 4., base),
Pos2::new(cx - 4., base),
],
ACCENT,
Stroke::NONE,
));
} else if i % 2 == 0 {
p.text(
Pos2::new(x + slot * 0.5, rect.bottom() - 1.),
egui::Align2::CENTER_BOTTOM,
format!("{offset:+.0}"),
FontId::proportional(8.),
DIM,
);
}
}
let line = if state.audition {
format!("playing {:+.0} ms", frame.kick_offset)
} else {
"sum per offset, low band".into()
};
ui.label(RichText::new(line).size(11.).color(MUTED));
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "ballast-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
common-dsp = { path = "../../common/dsp" }
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+482
View File
@@ -0,0 +1,482 @@
//! Levels each part of a bassline, a note or a step of the beat grid, to one loudness with a
//! single gain per part. No compression: the gain does not move inside a part, so a patch
//! keeps its own envelope and punch.
//!
//! A part's loudness is the K-weighted (ITU-R BS.1770) mean square of its first 100 ms, or
//! of all of it when it is shorter, counted in 10 ms blocks; blocks 20 dB under the loudest
//! one are left out, so a gap at the start does not pull the reading down. Each part is
//! weighted from rest, as if it played alone. The audio path is delayed by the note tracker's lookahead
//! plus that window plus a 5 ms ramp, so every part's gain is known before its attack leaves
//! the plugin, and the ramp to it ends exactly on the attack.
mod settings;
use common_dsp::loudness::{lufs, KWeighting};
use common_dsp::svf::{Svf, SvfCoef};
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,
};
/// Each part is measured over this much of its start.
pub const WINDOW_MS: f32 = 100.;
/// The gain glides to a new part's value over this long, ending on its first sample.
pub const RAMP_MS: f32 = 5.;
/// A boundary closer than this to the last one is ignored: the parts are merged.
const MIN_PART_MS: f32 = 10.;
/// The window is measured in blocks of this length.
const BLOCK_MS: f32 = 10.;
const BLOCKS: usize = (WINDOW_MS / BLOCK_MS) as usize;
/// Blocks this far under the part's loudest block do not count: a note start found by a
/// pitch change can come early, inside the gap before the note.
const RELATIVE_GATE_DB: f64 = 20.;
/// Parts shown in the editor.
pub const HISTORY: usize = 32;
/// Parts used for the spread readout.
const SPREAD: usize = 16;
const PENDING: usize = 32;
const Q: f32 = std::f32::consts::FRAC_1_SQRT_2;
/// Tempo the grid runs at before the host reports one.
const FALLBACK_BPM: f64 = 120.;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Part {
/// Loudness of the part's measured start, in LUFS.
pub loudness: f32,
/// Gain given to the part, in dB.
pub gain: f32,
/// Under the gate: the part kept the previous gain.
pub gated: bool,
}
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
/// Latest parts, oldest first; the first `count` entries are valid.
pub history: [Part; HISTORY],
pub count: usize,
/// Gain applied at the output right now, in dB.
pub gain: f32,
/// Standard deviation of the loudness of the last 16 parts over the gate, before and after
/// Ballast. NaN with fewer than two.
pub spread_in: f32,
pub spread_out: f32,
/// Waiting for the next part to take as the target.
pub learning: bool,
/// The loudness last taken by Learn, and a number that changes each time one is taken.
pub learned: f32,
pub learn_id: u32,
pub tempo: f64,
pub synced: bool,
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
history: [Part::default(); HISTORY],
count: 0,
gain: 0.,
spread_in: f32::NAN,
spread_out: f32::NAN,
learning: false,
learned: f32::NAN,
learn_id: 0,
tempo: FALLBACK_BPM,
synced: false,
latency: 0,
sample_rate: 48000.,
}
}
}
/// Gain in dB that brings `loudness` to the target, at `amount` (0–1), within ±`range`.
pub fn part_gain(loudness: f32, target: f32, amount: f32, range: f32) -> f32 {
((target - loudness) * amount).clamp(-range, range)
}
#[derive(Clone, Copy, Default)]
struct Pending {
start: u64,
gain: f32,
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
tracker: Tracker,
lookahead: usize,
window: usize,
ramp: usize,
min_part: usize,
delay: Vec<[f32; 2]>,
delay_at: usize,
weighting: [KWeighting; 2],
from: [[Svf; 2]; 2],
from_coef: SvfCoef,
/// Samples of the aligned stream: the input delayed by the tracker's lookahead.
clock: u64,
open: bool,
part_start: u64,
block: usize,
blocks: [(f64, usize); BLOCKS],
measured: usize,
last_gain: f32,
queue: [Pending; PENDING],
queue_head: usize,
queue_len: usize,
gain: f32,
ramp_from: f32,
ramp_to: f32,
ramp_left: usize,
history: [Part; HISTORY],
history_at: usize,
history_len: usize,
learn: bool,
learned: f32,
learn_id: u32,
tempo: f64,
/// Beat position of the aligned stream.
beats: f64,
synced: bool,
cell: i64,
output: f32,
bypass: f32,
smooth: f32,
fresh: bool,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(8000.0..=384000.).contains(&rate) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let lookahead = tracker.latency();
let samples = |ms: f32| ((rate * ms * 0.001).round() as usize).max(1);
let (window, ramp) = (samples(WINDOW_MS), samples(RAMP_MS));
let mut e = Self {
rate,
channels,
settings: Settings::default(),
tracker,
lookahead,
window,
ramp,
min_part: samples(MIN_PART_MS),
delay: vec![[0.; 2]; lookahead + window + ramp + 1],
delay_at: 0,
weighting: [KWeighting::new(rate); 2],
from: [[Svf::default(); 2]; 2],
from_coef: SvfCoef::IDENTITY,
clock: 0,
open: false,
part_start: 0,
block: samples(BLOCK_MS),
blocks: [(0., 0); BLOCKS],
measured: 0,
last_gain: 0.,
queue: [Pending::default(); PENDING],
queue_head: 0,
queue_len: 0,
gain: 1.,
ramp_from: 1.,
ramp_to: 1.,
ramp_left: 0,
history: [Part::default(); HISTORY],
history_at: 0,
history_len: 0,
learn: false,
learned: f32::NAN,
learn_id: 0,
tempo: FALLBACK_BPM,
beats: 0.,
synced: false,
cell: 0,
output: 1.,
bypass: 0.,
smooth: coefficient(rate, 10.),
fresh: true,
};
e.set_settings(Settings::default())?;
e.fresh = true;
Ok(e)
}
/// Lookahead of the tracker, the measuring window and the ramp.
pub fn latency_samples(&self) -> u32 {
(self.lookahead + self.window + self.ramp) as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.settings
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
self.tracker.set(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)
} else {
SvfCoef::IDENTITY
};
}
self.settings = s;
if std::mem::take(&mut self.fresh) {
self.output = db_to_gain(s.output);
self.bypass = f32::from(s.bypass);
}
Ok(())
}
/// Host tempo in BPM and, while playing, the beat position at the block's first sample.
pub fn set_transport(&mut self, tempo: Option<f64>, position: Option<f64>) {
if let Some(t) = tempo.filter(|t| t.is_finite() && *t > 0.) {
self.tempo = t;
}
match position.filter(|p| p.is_finite()) {
Some(p) => {
// The parts are cut on the aligned stream, `lookahead` samples behind the input.
self.beats = p - self.lookahead as f64 / self.rate as f64 * self.tempo / 60.;
self.synced = true;
}
None => self.synced = false,
}
}
pub fn note_on(&mut self, note: u8) {
self.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.tracker.note_off(note);
}
/// Takes the loudness of the next part over the gate as the new target.
pub fn learn(&mut self) {
self.learn = true;
}
pub fn reset(&mut self) {
self.tracker.reset();
self.delay.fill([0.; 2]);
self.delay_at = 0;
for k in &mut self.weighting {
k.reset();
}
self.from = [[Svf::default(); 2]; 2];
self.clock = 0;
self.open = false;
self.blocks = [(0., 0); BLOCKS];
self.measured = 0;
self.last_gain = 0.;
self.queue_len = 0;
self.gain = 1.;
self.ramp_left = 0;
self.history_len = 0;
self.learn = false;
self.fresh = true;
let s = self.settings;
let _ = self.set_settings(s);
}
/// Mean square of the measured blocks, without those far under the loudest.
fn gated_mean(&self) -> f64 {
let used = self.measured.div_ceil(self.block).min(BLOCKS);
let blocks = &self.blocks[..used];
let loudest = blocks
.iter()
.map(|(s, n)| s / (*n).max(1) as f64)
.fold(0., f64::max);
let floor = loudest * 10f64.powf(-RELATIVE_GATE_DB / 10.);
let (sum, n) = blocks
.iter()
.filter(|(s, n)| s / (*n).max(1) as f64 >= floor)
.fold((0., 0), |(a, b), (s, n)| (a + s, b + n));
sum / n.max(1) as f64
}
fn close(&mut self) {
self.open = false;
let s = self.settings;
let loudness = lufs(self.gated_mean());
let gated = loudness < s.gate;
let gain = if gated {
self.last_gain
} else {
part_gain(loudness, s.target, s.amount * 0.01, s.range)
};
if !gated {
self.last_gain = gain;
if std::mem::take(&mut self.learn) {
self.learned = loudness;
self.learn_id = self.learn_id.wrapping_add(1);
}
}
if self.queue_len < PENDING {
let at = (self.queue_head + self.queue_len) % PENDING;
self.queue[at] = Pending {
start: self.part_start,
gain,
};
self.queue_len += 1;
}
self.history[self.history_at] = Part {
loudness,
gain,
gated,
};
self.history_at = (self.history_at + 1) % HISTORY;
self.history_len = (self.history_len + 1).min(HISTORY);
}
// Channels index the delayed frame, the filters and the output at once.
#[allow(clippy::needless_range_loop)]
pub fn process_sample(&mut self, x: [f32; 2]) -> [f32; 2] {
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
x[0]
};
let mut started = false;
self.tracker.process(mono, |e| {
if matches!(e, NoteEvent::Start { .. }) {
started = true;
}
});
let len = self.delay.len();
self.delay[self.delay_at] = x;
let aligned = self.delay[(self.delay_at + len - self.lookahead) % len];
self.delay_at = (self.delay_at + 1) % len;
let delayed = self.delay[self.delay_at];
// Grid lines are counted on the aligned stream even in Notes mode, so switching modes
// does not cut a part.
let cell = (self.beats / self.settings.grid.quarters()).floor() as i64;
self.beats += self.tempo / 60. / self.rate as f64;
let crossed = cell != self.cell;
self.cell = cell;
let boundary = match self.settings.segment {
Segment::Notes => started,
Segment::Grid => crossed,
};
if boundary && !(self.open && self.measured < self.min_part) {
if self.open {
self.close();
}
self.open = true;
self.part_start = self.clock;
self.blocks = [(0., 0); BLOCKS];
self.measured = 0;
// Each part is weighted from rest, as if it played alone: the filters' memory of
// the last part would otherwise ring into this one.
for k in &mut self.weighting {
k.reset();
}
self.from = [[Svf::default(); 2]; 2];
}
let mut power = 0f64;
for c in 0..self.channels {
let f = &mut self.from[c];
let v = f[0].process(&self.from_coef, aligned[c]);
let v = f[1].process(&self.from_coef, v);
let w = self.weighting[c].process(v) as f64;
power += w * w;
}
if self.open {
let b = &mut self.blocks[(self.measured / self.block).min(BLOCKS - 1)];
b.0 += power;
b.1 += 1;
self.measured += 1;
if self.measured >= self.window {
self.close();
}
}
// A part's ramp starts `ramp` samples before its first sample reaches the output.
if self.queue_len > 0 {
let next = self.queue[self.queue_head];
if self.clock >= next.start + self.window as u64 {
self.queue_head = (self.queue_head + 1) % PENDING;
self.queue_len -= 1;
self.ramp_from = self.gain;
self.ramp_to = db_to_gain(next.gain);
self.ramp_left = self.ramp;
}
}
if self.ramp_left > 0 {
self.ramp_left -= 1;
let t = 1. - self.ramp_left as f32 / self.ramp as f32;
let t = t * t * (3. - 2. * t);
self.gain = self.ramp_from + (self.ramp_to - self.ramp_from) * t;
}
self.clock += 1;
let s = self.settings;
self.output += (db_to_gain(s.output) - self.output) * self.smooth;
self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth;
if (f32::from(s.bypass) - self.bypass).abs() < 1e-6 {
self.bypass = f32::from(s.bypass);
}
let mut out = [0.; 2];
for c in 0..self.channels {
let wet = delayed[c] * self.gain * self.output;
out[c] = if self.bypass >= 1. {
delayed[c]
} else {
wet + (delayed[c] - wet) * self.bypass
};
}
out
}
fn spread(&self, after: bool) -> f32 {
let (mut n, mut sum, mut sq) = (0usize, 0f64, 0f64);
for i in 0..self.history_len {
if n == SPREAD {
break;
}
let p = self.history[(self.history_at + HISTORY - 1 - i) % HISTORY];
if p.gated {
continue;
}
let v = (p.loudness + if after { p.gain } else { 0. }) as f64;
n += 1;
sum += v;
sq += v * v;
}
if n < 2 {
return f32::NAN;
}
let mean = sum / n as f64;
((sq / n as f64 - mean * mean).max(0.)).sqrt() as f32
}
/// Readings for the editor. Never allocates.
pub fn take_analyzer(&self) -> AnalyzerFrame {
let mut f = AnalyzerFrame {
count: self.history_len,
gain: gain_to_db(self.gain),
spread_in: self.spread(false),
spread_out: self.spread(true),
learning: self.learn,
learned: self.learned,
learn_id: self.learn_id,
tempo: self.tempo,
synced: self.synced,
latency: self.latency_samples(),
sample_rate: self.rate,
..AnalyzerFrame::default()
};
let first = (self.history_at + HISTORY - self.history_len) % HISTORY;
for i in 0..self.history_len {
f.history[i] = self.history[(first + i) % HISTORY];
}
f
}
}
+134
View File
@@ -0,0 +1,134 @@
//! Host-independent controls. Loudness in LUFS, gains in dB, amounts in percent.
use serde::{Deserialize, Serialize};
pub const TARGET_MIN: f32 = -40.;
pub const TARGET_MAX: f32 = 0.;
pub const RANGE_MAX: f32 = 24.;
/// Lowest frequency the measurement can start from. At or under this the measurement hears
/// the full band.
pub const FROM_MIN: f32 = 20.;
pub const FROM_MAX: f32 = 300.;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Source {
/// Note starts are detected from the incoming bass.
#[default]
Audio,
/// Note starts come from MIDI note input.
Midi,
}
/// Where one leveled part ends and the next begins.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Segment {
/// Every note start.
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,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Grid {
Bar,
Half,
#[default]
Quarter,
Eighth,
Sixteenth,
}
impl Grid {
pub const ALL: [Grid; 5] = [
Grid::Bar,
Grid::Half,
Grid::Quarter,
Grid::Eighth,
Grid::Sixteenth,
];
/// Length in quarter notes.
pub fn quarters(self) -> f64 {
match self {
Grid::Bar => 4.,
Grid::Half => 2.,
Grid::Quarter => 1.,
Grid::Eighth => 0.5,
Grid::Sixteenth => 0.25,
}
}
pub fn label(self) -> &'static str {
match self {
Grid::Bar => "1 BAR",
Grid::Half => "1/2",
Grid::Quarter => "1/4",
Grid::Eighth => "1/8",
Grid::Sixteenth => "1/16",
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
pub segment: Segment,
pub grid: Grid,
/// Loudness every part should land at, measured over its first 100 ms.
pub target: f32,
/// Share of the correction that is applied.
pub amount: f32,
/// Most boost or cut, in dB.
pub range: f32,
/// The measurement hears only what is above this frequency; the gain still applies to
/// the full band. Set it to Anvil's Split when Anvil makes the sub.
pub from: f32,
/// Note-start threshold (Audio source), and the loudness under which a part keeps the
/// last gain instead of being lifted.
pub gate: f32,
pub output: f32,
pub bypass: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
segment: Segment::Grid,
grid: Grid::Quarter,
target: -14.,
amount: 100.,
range: 12.,
from: FROM_MIN,
gate: -48.,
output: 0.,
bypass: false,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
let finite = [
self.target,
self.amount,
self.range,
self.from,
self.gate,
self.output,
]
.iter()
.all(|v| v.is_finite());
if !finite {
return Err("non-finite setting");
}
let ok = (TARGET_MIN..=TARGET_MAX).contains(&self.target)
&& (0.0..=100.).contains(&self.amount)
&& (0.0..=RANGE_MAX).contains(&self.range)
&& (FROM_MIN..=FROM_MAX).contains(&self.from)
&& (-60.0..=-20.).contains(&self.gate)
&& (-12.0..=12.).contains(&self.output);
if ok {
Ok(())
} else {
Err("setting out of range")
}
}
}
+334
View File
@@ -0,0 +1,334 @@
use ballast_core::{part_gain, Engine, Grid, Segment, Settings, Source, RAMP_MS, WINDOW_MS};
use common_dsp::loudness::{lufs, KWeighting};
use std::f64::consts::TAU;
const RATE: f32 = 48000.;
const BPM: f64 = 120.;
/// One quarter at 120 BPM.
const QUARTER: usize = 24000;
/// Two patches a switch-up might alternate: a loud saw and a quiet sine, an octave apart.
fn patch(i: usize, which: usize) -> f32 {
let t = i as f64 / RATE as f64;
if which.is_multiple_of(2) {
let phase = (55. * t).fract();
(0.5 * (2. * phase - 1.)) as f32
} else {
(0.1 * (TAU * 110. * t).sin()) as f32
}
}
fn grid_engine(s: Settings) -> Engine {
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(Settings {
segment: Segment::Grid,
grid: Grid::Quarter,
..s
})
.unwrap();
e
}
/// Runs `input` through the engine in 512-sample blocks with the transport playing from 0.
fn run(e: &mut Engine, input: &[f32]) -> Vec<f32> {
let mut out = Vec::with_capacity(input.len());
for (i, &v) in input.iter().enumerate() {
if i % 512 == 0 {
e.set_transport(Some(BPM), Some(i as f64 / RATE as f64 * BPM / 60.));
}
out.push(e.process_sample([v, v])[0]);
}
out
}
/// K-weighted loudness of both channels (the same signal) over `window` samples from `start`.
fn loudness(x: &[f32], start: usize, window: usize) -> f32 {
let mut k = KWeighting::new(RATE);
// Settle the filter on what came before, as the engine's own measurement does.
for &v in &x[start.saturating_sub(4800)..start] {
k.process(v);
}
let sum: f64 = x[start..start + window]
.iter()
.map(|&v| (k.process(v) as f64).powi(2))
.sum();
lufs(2. * sum / window as f64)
}
fn window() -> usize {
(RATE * WINDOW_MS * 0.001) as usize
}
#[test]
fn every_part_lands_at_the_target() {
let target = -18.;
let mut e = grid_engine(Settings {
target,
..Settings::default()
});
let latency = e.latency_samples() as usize;
let parts = 10;
let input: Vec<f32> = (0..parts * QUARTER + latency)
.map(|i| patch(i, i / QUARTER))
.collect();
let out = run(&mut e, &input);
for p in 2..parts {
let start = p * QUARTER;
let before = loudness(&input, start, window());
let after = loudness(&out, start + latency, window());
assert!(
(after - target).abs() < 0.3,
"part {p}: {before:.1} → {after:.1}"
);
}
let f = e.take_analyzer();
assert!(f.spread_in > 5., "{}", f.spread_in);
assert!(f.spread_out < 0.1, "{}", f.spread_out);
}
#[test]
fn the_gain_holds_still_inside_a_part_and_is_in_place_on_its_attack() {
let mut e = grid_engine(Settings {
target: -18.,
..Settings::default()
});
let latency = e.latency_samples() as usize;
let ramp = (RATE * RAMP_MS * 0.001) as usize;
let input: Vec<f32> = (0..6 * QUARTER + latency)
.map(|i| patch(i, i / QUARTER) + 0.2)
.collect();
let out = run(&mut e, &input);
for p in 2..5 {
let start = p * QUARTER;
assert!(input[start].abs() > 0.05);
let gain = out[start + latency] / input[start];
for n in start..start + QUARTER - ramp {
if input[n].abs() < 0.05 {
continue;
}
let g = out[n + latency] / input[n];
assert!(
(g / gain - 1.).abs() < 1e-4,
"part {p}, sample {n}: {g} vs {gain}"
);
}
}
}
#[test]
fn note_starts_from_the_audio_cut_the_parts() {
let target = -18.;
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(Settings {
target,
segment: Segment::Notes,
..Settings::default()
})
.unwrap();
let latency = e.latency_samples() as usize;
// 300 ms notes with 100 ms gaps, alternating the two patches.
let note = 19200;
let input: Vec<f32> = (0..8 * note + latency)
.map(|i| {
if i % note < 14400 && i < 8 * note {
patch(i, i / note)
} else {
0.
}
})
.collect();
let out = run(&mut e, &input);
let f = e.take_analyzer();
let parts = f.history[..f.count].iter().filter(|p| !p.gated).count();
assert_eq!(parts, 8, "{:?}", &f.history[..f.count]);
for n in 2..8 {
// The tracker finds the start a few milliseconds in; measure past that.
let start = n * note + 960;
let after = loudness(&out, start + latency, window());
assert!((after - target).abs() < 0.5, "note {n}: {after:.1}");
}
}
#[test]
fn midi_note_starts_cut_legato_parts() {
let target = -18.;
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(Settings {
target,
source: Source::Midi,
segment: Segment::Notes,
..Settings::default()
})
.unwrap();
let latency = e.latency_samples() as usize;
let note = QUARTER;
let mut out = Vec::new();
let input: Vec<f32> = (0..6 * note + latency)
.map(|i| patch(i, i / note))
.collect();
for (i, &v) in input.iter().enumerate() {
if i % note == 0 {
// Legato: the new note starts before the old one ends, on the same pitch.
e.note_on(33);
if i > 0 {
e.note_off(33);
e.note_on(33);
}
}
out.push(e.process_sample([v, v])[0]);
}
for n in 2..6 {
let after = loudness(&out, n * note + latency, window());
assert!((after - target).abs() < 0.3, "note {n}: {after:.1}");
}
}
#[test]
fn quiet_parts_keep_the_last_gain() {
let mut e = grid_engine(Settings {
target: -18.,
gate: -40.,
..Settings::default()
});
// A loud part, then a part far under the gate, then the loud patch again. The parts change
// on zero crossings of a 110 Hz sine, so no step rings into the quiet part.
let input: Vec<f32> = (0..4 * QUARTER)
.map(|i| {
let v = 0.5 * (TAU * 110. * i as f64 / RATE as f64).sin() as f32;
if i / QUARTER == 2 {
v * 0.001
} else {
v
}
})
.collect();
run(&mut e, &input);
let f = e.take_analyzer();
let parts = &f.history[..f.count];
// The first part is the silence before the input reaches the measurement.
let quiet = 1 + parts[1..]
.iter()
.position(|p| p.gated)
.expect("a gated part");
assert_eq!(parts[quiet].gain, parts[quiet - 1].gain);
}
#[test]
fn amount_and_range_scale_the_correction() {
assert_eq!(part_gain(-10., -14., 1., 12.), -4.);
assert_eq!(part_gain(-10., -14., 0.5, 12.), -2.);
assert_eq!(part_gain(-40., -14., 1., 12.), 12.);
assert_eq!(part_gain(0., -14., 1., 6.), -6.);
let mut e = grid_engine(Settings {
target: -18.,
amount: 50.,
..Settings::default()
});
let input: Vec<f32> = (0..4 * QUARTER).map(|i| patch(i, i / QUARTER)).collect();
run(&mut e, &input);
let f = e.take_analyzer();
for p in &f.history[1..f.count] {
let full = -18. - p.loudness;
assert!((p.gain - 0.5 * full).abs() < 1e-3, "{p:?}");
}
}
#[test]
fn learn_takes_the_next_part_as_the_target() {
let mut e = grid_engine(Settings::default());
let input: Vec<f32> = (0..3 * QUARTER).map(|i| patch(i, i / QUARTER)).collect();
run(&mut e, &input[..QUARTER + 100]);
e.learn();
assert!(e.take_analyzer().learning);
let before = e.take_analyzer().learn_id;
run(&mut e, &input[QUARTER + 100..]);
let f = e.take_analyzer();
assert!(!f.learning);
assert_ne!(f.learn_id, before);
let taken = f.history[..f.count]
.iter()
.find(|p| p.loudness == f.learned)
.expect("learned a part's loudness");
assert!(!taken.gated);
}
#[test]
fn the_measurement_can_leave_out_the_sub() {
// The same top, once with a loud sub under it.
let top = |i: usize| (0.05 * (TAU * 1000. * i as f64 / RATE as f64).sin()) as f32;
let sub = |i: usize| (0.5 * (TAU * 50. * i as f64 / RATE as f64).sin()) as f32;
let gains = |from: f32| {
let mut e = grid_engine(Settings {
target: -24.,
range: 24.,
from,
..Settings::default()
});
let input: Vec<f32> = (0..4 * QUARTER)
.map(|i| {
top(i)
+ if (i / QUARTER).is_multiple_of(2) {
sub(i)
} else {
0.
}
})
.collect();
run(&mut e, &input);
let f = e.take_analyzer();
let p = &f.history[..f.count];
(p[p.len() - 2].gain - p[p.len() - 1].gain).abs()
};
assert!(gains(20.) > 10.);
assert!(gains(200.) < 1., "{}", gains(200.));
}
#[test]
fn bypass_is_the_input_delayed_by_the_latency() {
let mut e = grid_engine(Settings {
bypass: true,
target: -30.,
..Settings::default()
});
let latency = e.latency_samples() as usize;
let lookahead = latency - window() - (RATE * RAMP_MS * 0.001) as usize;
// The tracker's lookahead: two periods of 30 Hz and a margin, about 67 ms.
assert!((lookahead as f32 / RATE - 0.067).abs() < 0.005);
let input: Vec<f32> = (0..2 * QUARTER).map(|i| patch(i, i / QUARTER)).collect();
let out = run(&mut e, &input);
for i in latency..input.len() {
assert_eq!(out[i], input[i - latency]);
}
}
#[test]
fn settings_are_validated_and_serialise() {
assert!(Settings::default().validate().is_ok());
for bad in [
Settings {
target: 3.,
..Settings::default()
},
Settings {
range: 30.,
..Settings::default()
},
Settings {
from: 10.,
..Settings::default()
},
Settings {
amount: f32::NAN,
..Settings::default()
},
] {
assert!(bad.validate().is_err());
assert!(Engine::new(RATE, 2).unwrap().set_settings(bad).is_err());
}
let s = Settings {
segment: Segment::Grid,
grid: Grid::Eighth,
source: Source::Midi,
target: -9.,
..Settings::default()
};
let json = serde_json::to_string(&s).unwrap();
assert_eq!(serde_json::from_str::<Settings>(&json).unwrap(), s);
assert!(serde_json::from_str::<Settings>(r#"{"target":0,"oops":1}"#).is_err());
}
+86
View File
@@ -0,0 +1,86 @@
use ballast_core::{Engine, Grid, Segment, Settings, Source};
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
};
struct CheckedAllocator;
thread_local! {
static CHECK:Cell<bool>=const {Cell::new(false)};
static OPERATIONS:Cell<usize>=const {Cell::new(0)};
}
fn record() {
if CHECK.try_with(Cell::get).unwrap_or(false) {
let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1));
}
}
unsafe impl GlobalAlloc for CheckedAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
record();
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
record();
unsafe { System.dealloc(pointer, layout) }
}
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
record();
unsafe { System.realloc(pointer, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CheckedAllocator = CheckedAllocator;
#[test]
fn processing_notes_settings_transport_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings::default();
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..96000 {
if i % 512 == 0 {
let playing = (i / 24000) % 2 == 0;
engine.set_transport(
Some(140.),
playing.then_some(i as f64 / 48000. * 140. / 60.),
);
}
if i % 4800 == 0 {
let step = i / 4800;
settings.segment = if step % 2 == 0 {
Segment::Grid
} else {
Segment::Notes
};
settings.grid = Grid::ALL[step % Grid::ALL.len()];
settings.source = if step % 4 < 2 {
Source::Audio
} else {
Source::Midi
};
settings.from = 20. + 30. * (step % 5) as f32;
settings.bypass = step % 7 == 6;
engine.set_settings(settings).unwrap();
engine.note_on(33 + (step % 12) as u8);
if step % 3 == 0 {
engine.learn();
}
}
if i % 4800 == 2400 {
engine.note_off(33 + (i / 4800 % 12) as u8);
}
let t = i as f32 / 48000.;
let x = if (i / 2400) % 2 == 0 {
(std::f32::consts::TAU * 55. * t).sin() * 0.5
} else {
0.
};
std::hint::black_box(engine.process_sample([x, -x]));
if i % 1600 == 0 {
std::hint::black_box(engine.take_analyzer());
}
}
engine.reset();
CHECK.with(|v| v.set(false));
assert_eq!(OPERATIONS.with(Cell::get), 0);
}
+2385
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "ballast"
version = "0.2.0"
edition = "2021"
license = "ISC"
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
ballast-core = { path = "../core" }
ballast-ui = { path = "../ui" }
crossbeam-queue = "0.3"
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+210
View File
@@ -0,0 +1,210 @@
//! CLAP audio effect. All DSP state is owned by the processing thread.
mod params;
use ballast_core::{AnalyzerFrame, Engine};
use crossbeam_queue::ArrayQueue;
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::{BallastParams, FLOATS};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
struct Shared {
analyzer: ArrayQueue<AnalyzerFrame>,
/// Set by the editor's Learn button, taken by the audio thread.
learn: AtomicBool,
}
struct EditorData {
ui: ballast_ui::State,
gestures: [bool; FLOATS],
}
pub struct Ballast {
params: Arc<BallastParams>,
shared: Arc<Shared>,
engine: Option<Engine>,
publish_count: usize,
publish_interval: usize,
}
impl Default for Ballast {
fn default() -> Self {
Self {
params: Arc::new(BallastParams::default()),
shared: Arc::new(Shared {
analyzer: ArrayQueue::new(2),
learn: AtomicBool::new(false),
}),
engine: None,
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for Ballast {
const NAME: &'static str = "Ballast";
const VENDOR: &'static str = "Bass Foundry";
const URL: &'static str = "";
const EMAIL: &'static str = "";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
AudioIOLayout {
main_input_channels: NonZeroU32::new(2),
main_output_channels: NonZeroU32::new(2),
..AudioIOLayout::const_default()
},
AudioIOLayout {
main_input_channels: NonZeroU32::new(1),
main_output_channels: NonZeroU32::new(1),
..AudioIOLayout::const_default()
},
];
const MIDI_INPUT: MidiConfig = MidiConfig::Basic;
const SAMPLE_ACCURATE_AUTOMATION: bool = false;
type SysExMessage = ();
type BackgroundTask = ();
fn params(&self) -> Arc<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
let params = self.params.clone();
let shared = self.shared.clone();
create_egui_editor(
params.editor.clone(),
EditorData {
ui: ballast_ui::State::default(),
gestures: [false; FLOATS],
},
|ctx, _| ballast_ui::style(ctx),
move |ctx, setter, data| {
while let Some(frame) = shared.analyzer.pop() {
data.ui.accept_frame(frame);
}
let before = params.settings();
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("ballast-editor")
.min_size(egui::vec2(
ballast_ui::WIDTH as f32 - 80.,
ballast_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
ballast_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
if std::mem::take(&mut data.ui.learn_request) {
shared.learn.store(true, Ordering::Release);
}
ctx.request_repaint_after(std::time::Duration::from_millis(40));
},
)
}
fn initialize(
&mut self,
layout: &AudioIOLayout,
config: &BufferConfig,
context: &mut impl InitContext<Self>,
) -> bool {
let Ok(mut engine) = Engine::new(
config.sample_rate,
layout.main_input_channels.map_or(0, |v| v.get() as usize),
) else {
return false;
};
let settings = self.params.settings();
if engine.set_settings(settings).is_err() {
return false;
}
context.set_latency_samples(engine.latency_samples());
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
true
}
fn reset(&mut self) {
if let Some(e) = self.engine.as_mut() {
e.reset();
}
self.publish_count = 0;
}
fn process(
&mut self,
buffer: &mut Buffer,
_: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let settings = self.params.settings();
let _ = engine.set_settings(settings);
if self.shared.learn.swap(false, Ordering::AcqRel) {
engine.learn();
}
let transport = context.transport();
engine.set_transport(
transport.tempo,
if transport.playing {
transport.pos_beats()
} else {
None
},
);
let mut next = context.next_event();
for (index, mut sample) in buffer.iter_samples().enumerate() {
while let Some(event) = next {
if event.timing() as usize > index {
break;
}
match event {
NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => {
engine.note_on(note)
}
NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => {
engine.note_off(note)
}
NoteEvent::Choke { note, .. } => engine.note_off(note),
_ => {}
}
next = context.next_event();
}
let x = [
*sample.get_mut(0).unwrap(),
if engine.channels() == 2 {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let out = engine.process_sample(x);
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
let _ = self.shared.analyzer.force_push(engine.take_analyzer());
self.publish_count = 0;
}
}
// The lookahead delay must drain after input stops.
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for Ballast {
const CLAP_ID: &'static str = "io.narl.bass-foundry.ballast";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Levels every note or switch-up to one loudness with a single gain each");
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Utility,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Ballast);
+203
View File
@@ -0,0 +1,203 @@
use ballast_core::{
Grid, Segment, Settings, Source, FROM_MAX, FROM_MIN, RANGE_MAX, TARGET_MAX, TARGET_MIN,
};
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::Arc;
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SourceParam {
#[id = "audio"]
Audio,
#[id = "midi"]
#[name = "MIDI"]
Midi,
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SegmentParam {
#[id = "notes"]
Notes,
#[id = "grid"]
Grid,
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum GridParam {
#[id = "bar"]
#[name = "1 bar"]
Bar,
#[id = "1-2"]
#[name = "1/2"]
Half,
#[id = "1-4"]
#[name = "1/4"]
Quarter,
#[id = "1-8"]
#[name = "1/8"]
Eighth,
#[id = "1-16"]
#[name = "1/16"]
Sixteenth,
}
const GRIDS: [(GridParam, Grid); 5] = [
(GridParam::Bar, Grid::Bar),
(GridParam::Half, Grid::Half),
(GridParam::Quarter, Grid::Quarter),
(GridParam::Eighth, Grid::Eighth),
(GridParam::Sixteenth, Grid::Sixteenth),
];
fn grid_param(g: Grid) -> GridParam {
GRIDS
.iter()
.find(|(_, v)| *v == g)
.map_or(GridParam::Quarter, |(p, _)| *p)
}
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
}
/// Continuous controls, in the order of the gesture slots.
pub const FLOATS: usize = 6;
#[derive(Params)]
pub struct BallastParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[id = "segment"]
pub segment: EnumParam<SegmentParam>,
#[id = "grid"]
pub grid: EnumParam<GridParam>,
#[id = "target"]
pub target: FloatParam,
#[id = "amount"]
pub amount: FloatParam,
#[id = "range"]
pub range: FloatParam,
#[id = "from"]
pub from: FloatParam,
#[id = "gate"]
pub gate: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "bypass"]
pub bypass: BoolParam,
}
impl Default for BallastParams {
fn default() -> Self {
let d = Settings::default();
Self {
editor: EguiState::from_size(ballast_ui::WIDTH, ballast_ui::HEIGHT),
source: EnumParam::new("Note source", SourceParam::Audio),
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., " %"),
range: linear("Range", d.range, 0., RANGE_MAX, " dB"),
from: FloatParam::new(
"Measure from",
d.from,
FloatRange::Skewed {
min: FROM_MIN,
max: FROM_MAX,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(" Hz"),
gate: linear("Gate", d.gate, -60., -20., " dB"),
output: linear("Output", d.output, -12., 12., " dB"),
bypass: BoolParam::new("Bypass", false).make_bypass(),
}
}
}
impl BallastParams {
pub fn settings(&self) -> Settings {
let grid = self.grid.value();
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
segment: match self.segment.value() {
SegmentParam::Notes => Segment::Notes,
SegmentParam::Grid => Segment::Grid,
},
grid: GRIDS
.iter()
.find(|(p, _)| *p == grid)
.map_or(Grid::Quarter, |(_, g)| *g),
target: self.target.value(),
amount: self.amount.value(),
range: self.range.value(),
from: self.from.value(),
gate: self.gate.value(),
output: self.output.value(),
bypass: self.bypass.value(),
}
}
fn floats(&self) -> [&FloatParam; FLOATS] {
[
&self.target,
&self.amount,
&self.range,
&self.from,
&self.gate,
&self.output,
]
}
fn values(s: &Settings) -> [f32; FLOATS] {
[s.target, s.amount, s.range, s.from, s.gate, s.output]
}
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
/// while the pointer is down; switches are set in a single gesture.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; FLOATS],
) {
let pairs = Self::values(old).into_iter().zip(Self::values(new));
for (i, (p, (a, b))) in self.floats().into_iter().zip(pairs).enumerate() {
if a != b {
if !open[i] {
open[i] = true;
setter.begin_set_parameter(p);
}
setter.set_parameter(p, b);
}
if !dragging && open[i] {
setter.end_set_parameter(p);
open[i] = false;
}
}
if new.bypass != old.bypass {
setter.begin_set_parameter(&self.bypass);
setter.set_parameter(&self.bypass, new.bypass);
setter.end_set_parameter(&self.bypass);
}
if new.source != old.source {
let v = match new.source {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
setter.begin_set_parameter(&self.source);
setter.set_parameter(&self.source, v);
setter.end_set_parameter(&self.source);
}
if new.segment != old.segment {
let v = match new.segment {
Segment::Notes => SegmentParam::Notes,
Segment::Grid => SegmentParam::Grid,
};
setter.begin_set_parameter(&self.segment);
setter.set_parameter(&self.segment, v);
setter.end_set_parameter(&self.segment);
}
if new.grid != old.grid {
setter.begin_set_parameter(&self.grid);
setter.set_parameter(&self.grid, grid_param(new.grid));
setter.end_set_parameter(&self.grid);
}
}
}
+216
View File
@@ -0,0 +1,216 @@
//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW.
use clap_sys::{
audio_buffer::*,
events::*,
ext::{audio_ports::*, latency::*, state::*},
factory::plugin_factory::*,
host::*,
process::*,
stream::*,
version::*,
};
use std::{
ffi::{c_char, c_void, CStr},
io::{Cursor, Read},
ptr,
};
unsafe extern "C" fn extension(_: *const clap_host, _: *const c_char) -> *const c_void {
ptr::null()
}
unsafe extern "C" fn request(_: *const clap_host) {}
unsafe extern "C" fn count(_: *const clap_input_events) -> u32 {
0
}
unsafe extern "C" fn event(_: *const clap_input_events, _: u32) -> *const clap_event_header {
ptr::null()
}
unsafe extern "C" fn push(_: *const clap_output_events, _: *const clap_event_header) -> bool {
true
}
unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 {
// SAFETY: The test owns the Vec for the entire synchronous save call.
let bytes = unsafe { &mut *((*stream).ctx as *mut Vec<u8>) };
bytes.extend_from_slice(unsafe {
std::slice::from_raw_parts(buffer as *const u8, size as usize)
});
size as i64
}
unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
// SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage.
let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor<Vec<u8>>) };
cursor
.read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) })
.unwrap() as i64
}
#[test]
fn effect_ports_state_latency_and_audio_lifecycle() {
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
unsafe {
let entry = &ballast::clap_entry;
assert!(entry.init.unwrap()(c"ballast.clap".as_ptr()));
let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr())
as *const clap_plugin_factory;
assert!(!factory.is_null());
let factory = &*factory;
assert_eq!(factory.get_plugin_count.unwrap()(factory), 1);
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(CStr::from_ptr((*descriptor).name), c"Ballast");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.narl.bass-foundry.ballast"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
clap_version: CLAP_VERSION,
host_data: ptr::null_mut(),
name: c"Storyboard test host".as_ptr(),
vendor: c"Tests".as_ptr(),
url: c"".as_ptr(),
version: c"1.0".as_ptr(),
get_extension: Some(extension),
request_restart: Some(request),
request_process: Some(request),
request_callback: Some(request),
};
let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id);
assert!(!plugin.is_null());
let p = &*plugin;
assert!(p.init.unwrap()(plugin));
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
as *const clap_plugin_audio_ports;
assert!(!ports.is_null());
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
as *const clap_plugin_latency;
assert!(!latency.is_null());
let state =
p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state;
assert!(!state.is_null());
let state = &*state;
let save = || {
let mut bytes = Vec::new();
let stream = clap_ostream {
ctx: &mut bytes as *mut Vec<u8> as *mut c_void,
write: Some(write),
};
assert!(state.save.unwrap()(plugin, &stream));
bytes
};
let mut envelope: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
use nih_plug::wrapper::state::ParamValue;
// Amount 0 passes the input through the lookahead delay unchanged.
envelope.params.insert("amount".into(), ParamValue::F32(0.));
envelope
.params
.insert("target".into(), ParamValue::F32(-20.));
envelope.params.insert("from".into(), ParamValue::F32(100.));
envelope
.params
.insert("segment".into(), ParamValue::String("grid".into()));
envelope
.params
.insert("grid".into(), ParamValue::String("1-8".into()));
envelope
.params
.insert("source".into(), ParamValue::String("midi".into()));
let encoded = serde_json::to_vec(&envelope).unwrap();
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
framed.extend_from_slice(&encoded);
let mut bytes = Cursor::new(framed);
let stream = clap_istream {
ctx: &mut bytes as *mut Cursor<Vec<u8>> as *mut c_void,
read: Some(read),
};
assert!(state.load.unwrap()(plugin, &stream));
let restored: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
assert_eq!(
serde_json::to_value(&restored.params).unwrap(),
serde_json::to_value(&envelope.params).unwrap()
);
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
// The tracker's 67 ms, the 100 ms measuring window and the 5 ms ramp.
assert!((delay as f64 / rate - 0.172).abs() < 0.005, "{delay}");
assert!(p.start_processing.unwrap()(plugin));
let total = delay + 9600;
let mut position = 0;
let (mut energy_in, mut energy_out) = (0f64, 0f64);
while position < total {
let frames = [1, 127, 512, 31][position % 4].min(total - position);
// A 1 kHz tone at amount 0.
let tone = |n: usize| {
0.25 * (std::f64::consts::TAU * 1000. * n as f64 / rate).sin() as f32
};
let mut input_left = [0f32; 512];
let mut input_right = [0f32; 512];
for i in 0..frames {
input_left[i] = tone(position + i);
input_right[i] = -tone(position + i);
}
let mut left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
let incoming = clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let mut outgoing_audio = clap_audio_buffer {
data32: out_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let input = clap_input_events {
ctx: ptr::null_mut(),
size: Some(count),
get: Some(event),
};
let outgoing = clap_output_events {
ctx: ptr::null_mut(),
try_push: Some(push),
};
let process = clap_process {
steady_time: position as i64,
frames_count: frames as u32,
transport: ptr::null(),
audio_inputs: &incoming,
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 1,
audio_outputs_count: 1,
in_events: &input,
out_events: &outgoing,
};
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
for i in 0..frames {
let n = position + i;
if n < delay {
assert!(left[i].abs() < 1e-6, "output before the lookahead");
} else if n > delay + 4800 {
energy_in += (tone(n - delay) as f64).powi(2);
energy_out += (left[i] as f64).powi(2) + (right[i] as f64).powi(2);
}
}
position += frames;
}
let ratio_db = 10. * (energy_out / (2. * energy_in)).log10();
assert!(ratio_db.abs() < 0.2, "{ratio_db}");
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}
p.destroy.unwrap()(plugin);
entry.deinit.unwrap()();
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "ballast-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
ballast-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
common-dsp = { path = "../../common/dsp" }
+376
View File
@@ -0,0 +1,376 @@
//! Ballast editor, shared by the CLAP wrapper and the headless renderer.
use ballast_core::{
AnalyzerFrame, Grid, Segment, Settings, Source, FROM_MAX, FROM_MIN, HISTORY, RANGE_MAX,
TARGET_MAX, TARGET_MIN,
};
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
pub use common_ui::style;
pub const WIDTH: u32 = 980;
pub const HEIGHT: u32 = 660;
pub struct State {
pub analyzer: AnalyzerFrame,
/// Set by the Learn button; the wrapper passes it to the engine and clears it.
pub learn_request: bool,
/// The last Learn result already applied. None until the first frame arrives, so an old
/// result is not applied again when the editor opens.
learn_seen: Option<u32>,
learned: Option<f32>,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
learn_request: false,
learn_seen: None,
learned: None,
gesture_active: false,
help: DEFAULT_HELP.into(),
}
}
}
const DEFAULT_HELP: &str = "Put Ballast on the bass after its sound design. Press LEARN on the \
switch-up that sits right, or set Target.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
match self.learn_seen {
Some(id) if id != frame.learn_id && frame.learned.is_finite() => {
self.learned = Some(frame.learned);
}
_ => {}
}
self.learn_seen = Some(frame.learn_id);
self.analyzer = frame;
}
}
fn hint(r: &egui::Response, state: &mut State, text: &str) {
if r.hovered() || r.has_focus() {
state.help = text.into();
}
}
#[allow(clippy::too_many_arguments)]
fn knob(
ui: &mut egui::Ui,
state: &mut State,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
help: &str,
) {
ui.vertical_centered(|ui| {
let r = rotary(ui, name, value, range, default, unit, 72., ACCENT);
hint(&r, state, help);
});
}
const LOW: f32 = -42.;
const HIGH: f32 = 0.;
/// The last parts: their loudness in (bars), after Ballast (marks) and the target.
fn parts(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame, height: f32) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, 2., SCREEN);
p.text(
rect.left_top() + Vec2::new(10., 8.),
egui::Align2::LEFT_TOP,
format!("LAST {HISTORY} PARTS · LOUDNESS OF THE FIRST 100 MS"),
FontId::proportional(11.),
MUTED,
);
p.text(
rect.right_top() + Vec2::new(-12., 8.),
egui::Align2::RIGHT_TOP,
"bar: in · mark: out",
FontId::proportional(11.),
MUTED,
);
let area = Rect::from_min_max(
rect.left_top() + Vec2::new(44., 30.),
rect.right_bottom() - Vec2::new(12., 12.),
);
let y = |lufs: f32| {
let t = ((lufs - LOW) / (HIGH - LOW)).clamp(0., 1.);
area.bottom() - t * area.height()
};
for db in [-36., -24., -12., 0.] {
p.line_segment(
[
Pos2::new(area.left(), y(db)),
Pos2::new(area.right(), y(db)),
],
Stroke::new(1.0_f32, Color32::from_rgb(32, 35, 40)),
);
p.text(
Pos2::new(area.left() - 6., y(db)),
egui::Align2::RIGHT_CENTER,
format!("{db:.0}"),
FontId::monospace(10.),
DIM,
);
}
let column = area.width() / HISTORY as f32;
// Newest part on the right.
let offset = HISTORY - frame.count;
for i in 0..frame.count {
let part = frame.history[i];
let x = area.left() + column * (offset + i) as f32;
let bar = Rect::from_min_max(
Pos2::new(x + column * 0.2, y(part.loudness)),
Pos2::new(x + column * 0.8, area.bottom()),
);
let color = if part.gated {
DIM.gamma_multiply(0.6)
} else {
STEEL.gamma_multiply(0.45)
};
p.rect_filled(bar, 1., color);
if !part.gated {
let out = y(part.loudness + part.gain);
p.line_segment(
[
Pos2::new(x + column * 0.1, out),
Pos2::new(x + column * 0.9, out),
],
Stroke::new(3.0_f32, ACCENT),
);
}
}
let t = y(settings.target);
p.line_segment(
[Pos2::new(area.left(), t), Pos2::new(area.right(), t)],
Stroke::new(1.0_f32, ACCENT.gamma_multiply(0.5)),
);
p.text(
Pos2::new(area.left() + 4., t - 3.),
egui::Align2::LEFT_BOTTOM,
format!("TARGET {:.1} LUFS", settings.target),
FontId::proportional(10.),
ACCENT,
);
p.rect_stroke(
rect,
2.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
backdrop(ui);
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
if let Some(l) = state.learned.take() {
settings.target = l.clamp(TARGET_MIN, TARGET_MAX);
}
ui.add_space(2.);
header(ui, "BALLAST", "SWITCH-UP LEVELLER", |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();
if settings.segment == Segment::Notes {
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, "NOTES FROM");
ui.separator();
}
for (segment, label) in [(Segment::Grid, "GRID"), (Segment::Notes, "NOTES")] {
let r =
ui.selectable_label(settings.segment == segment, RichText::new(label).size(13.));
if r.clicked() {
settings.segment = segment;
}
}
caption(ui, "PARTS FROM");
});
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(250.);
ui.set_min_height(TOP);
ui.vertical(|ui| readings(ui, settings, state, &frame));
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
parts(ui, settings, &frame, TOP);
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
const TOP: f32 = 300.;
fn readings(ui: &mut egui::Ui, settings: &Settings, state: &mut State, frame: &AnalyzerFrame) {
caption(ui, "SPREAD OF THE LAST PARTS");
let known = frame.spread_in.is_finite();
ui.label(
RichText::new(if known {
format!("{:.1} · {:.1} dB", frame.spread_in, frame.spread_out)
} else {
"—".into()
})
.font(FontId::new(34., egui::FontFamily::Name("medium".into())))
.color(if known { TEXT } else { DIM }),
);
ui.label(
RichText::new("in · out, standard deviation")
.size(12.)
.color(MUTED),
);
ui.add_space(12.);
readout(ui, "GAIN NOW", format!("{:+.1} dB", frame.gain), ACCENT);
readout(ui, "TARGET", format!("{:.1} LUFS", settings.target), TEXT);
let label = if frame.learning || state.learn_request {
"LISTENING"
} else {
"LEARN"
};
let r = ui.selectable_label(
frame.learning || state.learn_request,
RichText::new(label).size(13.),
);
if r.clicked() {
state.learn_request = true;
}
hint(
&r,
state,
"Takes the loudness of the next part over the gate as the target. Press it just \
before the switch-up that sits right in the mix.",
);
ui.add_space(10.);
ui.label(
RichText::new(format!(
"{:.0} ms lookahead · {}",
frame.latency as f32 / frame.sample_rate.max(1.) * 1000.,
match settings.segment {
Segment::Notes => match settings.source {
Source::Audio => "parts at detected notes".to_string(),
Source::Midi => "parts at MIDI notes".to_string(),
},
Segment::Grid => format!(
"grid at {:.1} BPM, {}",
frame.tempo,
if frame.synced {
"host transport"
} else {
"free running"
}
),
}
))
.size(12.)
.color(MUTED),
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let d = Settings::default();
if settings.segment == Segment::Grid {
ui.horizontal(|ui| {
caption(ui, "ONE PART EVERY");
ui.add_space(6.);
for grid in Grid::ALL {
let r = ui
.selectable_label(settings.grid == grid, RichText::new(grid.label()).size(13.));
if r.clicked() {
settings.grid = grid;
}
hint(
&r,
state,
"A new part starts on every line of this grid, also when the note does not \
change.",
);
}
});
ui.add_space(8.);
ui.separator();
}
ui.columns(6, |c| {
knob(
&mut c[0],
state,
"Target",
&mut settings.target,
TARGET_MIN..=TARGET_MAX,
d.target,
" LUFS",
"Loudness every part lands at, over its first 100 ms.",
);
knob(
&mut c[1],
state,
"Amount",
&mut settings.amount,
0. ..=100.,
d.amount,
" %",
"Share of the correction. 50 % halves the differences between parts.",
);
knob(
&mut c[2],
state,
"Range",
&mut settings.range,
0. ..=RANGE_MAX,
d.range,
" dB",
"Most boost or cut a part gets.",
);
knob(
&mut c[3],
state,
"Measure from",
&mut settings.from,
FROM_MIN..=FROM_MAX,
d.from,
" Hz",
"The measurement hears only what is above this. Set it to Anvil's Split when \
Anvil makes the sub; the gain still applies to the whole signal.",
);
knob(
&mut c[4],
state,
"Gate",
&mut settings.gate,
-60. ..=-20.,
d.gate,
" dB",
"Note-start threshold, and the loudness under which a part keeps the last gain.",
);
knob(
&mut c[5],
state,
"Output",
&mut settings.output,
-12. ..=12.,
d.output,
" dB",
"Output trim.",
);
});
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "braze-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
common-dsp = { path = "../../common/dsp" }
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+358
View File
@@ -0,0 +1,358 @@
//! Tempo-locked beating: adds a detuned copy of the bass whose beating stays in time with
//! the track on every note.
//!
//! Two layers detuned by a ratio r beat at k·f0·(r − 1) on harmonic k, so a fixed detune
//! beats at a different rate on every note. Braze sets the detune per note from the tracked
//! fundamental: harmonic `harmonic` beats once per `rate` note value, and harmonic n beats
//! n/harmonic times as fast, so every harmonic beats on the tempo grid.
//!
//! The copy is the bass read from a delay line whose delay falls by one period of the note
//! per beat cycle of the chosen harmonic. That shifts the copy up by exactly the wanted
//! ratio. When the delay has fallen by a whole period it jumps back by one period: for a
//! periodic note both read positions hold the same waveform, so the jump is crossfaded over
//! a few milliseconds without a seam. The delay is set from the host's beat position, so
//! the beat's peaks land on the grid, not just at the right rate. At a beat peak the copy
//! lines up with the bass exactly.
//!
//! Low cut removes the copy below a frequency with a 4th-order Linkwitz–Riley high-pass,
//! so the sub does not beat. The bass is split by the same crossover; its two bands are in
//! phase with the copy's high-pass at every frequency, so the beat peaks stay on the grid,
//! and the loudness correction for the copy applies above the cut only, so the sub keeps
//! its level.
mod settings;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{PitchFollower, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi};
pub use settings::{Rate, Settings, Source, HARMONIC_MAX, LOW_CUT_MAX, LOW_CUT_OFF};
pub const MIN_HZ: f32 = TrackerConfig::BASS.min_hz;
pub const MAX_HZ: f32 = TrackerConfig::BASS.max_hz;
/// Fundamental the copy assumes before the first note.
pub const FALLBACK_HZ: f32 = 55.;
/// Tempo when the host reports none.
pub const DEFAULT_TEMPO: f64 = 120.;
/// Crossfade between the old and the new read position.
const FADE_MS: f32 = 6.;
/// A beat position that moves further than this (in cycles of the chosen harmonic) from
/// one sample to the next counts as a jump: a locate, a loop, or a changed control.
const JUMP: f64 = 1e-3;
/// Pitch smoothing for bends and slides.
const PITCH_MS: f32 = 5.;
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
/// Fundamental the copy is tuned to, 0 before the first note.
pub hz: f32,
/// Sounding note, rounded; None between notes.
pub note: Option<u8>,
/// Beat position at the output, in quarter notes.
pub beats: f64,
pub tempo: f64,
/// True when the beat position comes from the host's transport.
pub synced: bool,
/// Detune of the copy as a ratio, 1 before the first note.
pub ratio: f32,
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
hz: 0.,
note: None,
beats: 0.,
tempo: DEFAULT_TEMPO,
synced: false,
ratio: 1.,
latency: 0,
sample_rate: 48000.,
}
}
}
/// Beat cycles of the chosen harmonic at `beats` quarter notes, for one channel.
pub fn cycles(s: &Settings, beats: f64, channel: usize) -> f64 {
beats / s.rate.quarters() + s.phase as f64 / 360. + channel as f64 * s.width as f64 * 0.005
}
/// Level of harmonic `n` in the sum, against the bass alone, at `cycles` of the chosen
/// harmonic, with the copy at `depth` (0–1). Includes the output's loudness correction.
pub fn envelope(s: &Settings, n: u32, cycles: f64, depth: f32) -> f32 {
let theta = std::f64::consts::TAU * n as f64 / s.harmonic as f64 * cycles;
let (re, im) = (1. + depth as f64 * theta.cos(), depth as f64 * theta.sin());
((re * re + im * im).sqrt() / (1. + depth as f64 * depth as f64).sqrt()) as f32
}
#[derive(Clone, Copy, Debug, Default)]
struct Tap {
/// Delay in samples.
delay: f32,
/// Delay change per sample, so a fading tap keeps its course.
slope: f32,
}
#[derive(Clone, Copy, Debug, Default)]
struct Channel {
active: Tap,
old: Tap,
/// Weight of the old tap, falling to 0.
fade: f32,
/// Whole cycles the active tap's delay is counted from.
whole: f64,
/// Beat cycle position (`cycles / harmonic + 0.5`) of the previous sample.
last: f64,
started: bool,
copy_high: [Svf; 2],
high: [Svf; 2],
low: [Svf; 2],
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
latency: usize,
audio: Vec<[f32; 2]>,
audio_at: usize,
tracker: Tracker,
pitch: PitchFollower,
pitch_coef: f32,
hz: f32,
beats: f64,
tempo: f64,
synced: bool,
state: [Channel; 2],
/// High-pass and low-pass of the low cut's crossover; None when off.
low_cut: Option<(SvfCoef, SvfCoef)>,
fade_step: f32,
smooth: f32,
depth: f32,
output: f32,
bypass: f32,
fresh: bool,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(8000.0..=768000.).contains(&rate) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let latency = tracker.latency();
// The copy reads up to half a period (plus a fade) on either side of the dry path.
let reach = (rate / MIN_HZ).ceil() as usize + 8;
let mut e = Self {
rate,
channels,
settings: Settings::default(),
latency,
audio: vec![[0.; 2]; latency + reach],
audio_at: 0,
tracker,
pitch: PitchFollower::default(),
pitch_coef: coefficient(rate, PITCH_MS),
hz: 0.,
beats: 0.,
tempo: DEFAULT_TEMPO,
synced: false,
state: [Channel::default(); 2],
low_cut: None,
fade_step: 1. / (rate * FADE_MS * 0.001),
smooth: coefficient(rate, 10.),
depth: 0.,
output: 1.,
bypass: 0.,
fresh: true,
};
e.set_settings(Settings::default())?;
e.fresh = true;
Ok(e)
}
pub fn latency_samples(&self) -> u32 {
self.latency as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.settings
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
// 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);
self.low_cut = (s.low_cut >= LOW_CUT_OFF).then(|| {
(
SvfCoef::highpass(self.rate, hz, q),
SvfCoef::lowpass(self.rate, hz, q),
)
});
}
self.settings = s;
if std::mem::take(&mut self.fresh) {
self.depth = s.depth * 0.01;
self.output = db_to_gain(s.output);
self.bypass = f32::from(s.bypass);
}
Ok(())
}
/// Tempo and, while the host plays, its beat position in quarter notes at the first
/// input sample of the next block. Without a position the beat runs on freely.
pub fn set_transport(&mut self, tempo: Option<f64>, position: Option<f64>) {
self.tempo = tempo
.filter(|t| t.is_finite() && *t > 0.)
.unwrap_or(DEFAULT_TEMPO)
.clamp(20., 999.);
match position.filter(|p| p.is_finite()) {
Some(p) => {
// The output lags the input by the lookahead, and the host compensates it:
// what leaves now belongs `latency` samples earlier on the timeline.
self.beats = p - self.latency as f64 / self.rate as f64 * self.tempo / 60.;
self.synced = true;
}
None => self.synced = false,
}
}
pub fn reset(&mut self) {
self.audio.fill([0.; 2]);
self.tracker.reset();
self.pitch = PitchFollower::default();
self.hz = 0.;
self.state = [Channel::default(); 2];
self.fresh = true;
}
pub fn note_on(&mut self, note: u8) {
self.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.tracker.note_off(note);
}
/// The audio `back` samples (fractional) before the newest one, cubic Hermite.
#[inline]
fn read(&self, back: f32, c: usize) -> f32 {
let len = self.audio.len();
let back = back.clamp(1., (len - 3) as f32);
let whole = back.floor();
let t = back - whole;
let base = whole as usize;
let at = |back: usize| self.audio[(self.audio_at + len - back) % len][c];
// y0 is one sample newer than y1; y2 and y3 are older.
let (y0, y1, y2, y3) = (at(base - 1), at(base), at(base + 1), at(base + 2));
let c1 = 0.5 * (y2 - y0);
let c2 = y0 - 2.5 * y1 + 2. * y2 - 0.5 * y3;
let c3 = 0.5 * (y3 - y0) + 1.5 * (y1 - y2);
((c3 * t + c2) * t + c1) * t + y1
}
// Channels index the input, the state and the output at once.
#[allow(clippy::needless_range_loop)]
pub fn process_sample(&mut self, x: [f32; 2]) -> [f32; 2] {
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
x[0]
};
let (pitch, snap) = (&mut self.pitch, self.settings.snap);
self.tracker.process(mono, |e| pitch.event(snap, e));
let (hz, jump) = self.pitch.step(self.pitch_coef);
self.hz = hz;
let len = self.audio.len();
self.audio_at = (self.audio_at + 1) % len;
self.audio[self.audio_at] = x;
let s = self.settings;
approach(&mut self.depth, s.depth * 0.01, self.smooth);
approach(&mut self.output, db_to_gain(s.output), self.smooth);
approach(&mut self.bypass, f32::from(s.bypass), self.smooth);
let period = self.rate / if hz > 0. { hz } else { FALLBACK_HZ };
let step = self.tempo / 60. / self.rate as f64 / s.rate.quarters() / s.harmonic as f64;
let centre = self.latency as f32;
let mut out = [0.; 2];
for c in 0..self.channels {
// Position in beat cycles of the chosen harmonic, shifted so a cycle starts at
// the delay's highest point and the beat peaks (delay = centre) at cycle 0.
let u = cycles(&s, self.beats, c) / s.harmonic as f64 + 0.5;
let ch = self.state[c];
let moved = (u - ch.last - step).abs() > JUMP;
let mut ch = ch;
if !ch.started || jump || moved || u.floor() != ch.whole {
if ch.started {
ch.old = ch.active;
ch.fade = 1.;
}
ch.whole = u.floor();
ch.started = true;
}
let delay = centre + period * (0.5 - (u - ch.whole) as f32);
ch.active.slope = if ch.fade == 1. {
0.
} else {
delay - ch.active.delay
};
ch.active.delay = delay;
ch.last = u;
let mut copy = self.read(ch.active.delay, c);
if ch.fade > 0. {
ch.old.delay += ch.old.slope;
let w = ch.fade * ch.fade * (3. - 2. * ch.fade);
copy += (self.read(ch.old.delay, c) - copy) * w;
ch.fade = (ch.fade - self.fade_step).max(0.);
}
let delayed = self.audio[(self.audio_at + len - self.latency) % len][c];
let g = self.depth;
let norm = 1. / (1. + g * g).sqrt();
let wet = match &self.low_cut {
Some((high, low)) => {
let lr4 = |f: &mut [Svf; 2], c: &SvfCoef, x: f32| {
let first = f[0].process(c, x);
f[1].process(c, first)
};
let copy = lr4(&mut ch.copy_high, high, copy);
let above = lr4(&mut ch.high, high, delayed);
let below = lr4(&mut ch.low, low, delayed);
below + (above + g * copy) * norm
}
None => (delayed + g * copy) * norm,
};
self.state[c] = ch;
out[c] = wet * self.output * (1. - self.bypass) + delayed * self.bypass;
}
self.beats += self.tempo / 60. / self.rate as f64;
out
}
pub fn take_analyzer(&self) -> AnalyzerFrame {
let s = self.settings;
let hz = self.hz;
let mut f = AnalyzerFrame {
hz,
beats: self.beats,
tempo: self.tempo,
synced: self.synced,
latency: self.latency as u32,
sample_rate: self.rate,
..AnalyzerFrame::default()
};
if hz > 0. {
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.active() && hz > 0. {
f.note = Some(hz_to_midi(hz).round().clamp(0., 127.) as u8);
}
f
}
}
/// One-pole step that lands exactly on the target, so switched states end bit-exact.
fn approach(value: &mut f32, target: f32, coef: f32) {
*value += (target - *value) * coef;
if (target - *value).abs() < 1e-6 {
*value = target;
}
}
+170
View File
@@ -0,0 +1,170 @@
//! 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.
pub const HARMONIC_MAX: u32 = 8;
pub const LOW_CUT_MAX: f32 = 400.;
/// A low cut below this is off: no filter on either path.
pub const LOW_CUT_OFF: f32 = 20.;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Source {
/// Pitch and note starts are detected from the incoming bass.
#[default]
Audio,
/// Notes come from MIDI note input.
Midi,
/// 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.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Rate {
Whole,
Half,
Quarter,
#[default]
Eighth,
Sixteenth,
ThirtySecond,
QuarterTriplet,
EighthTriplet,
SixteenthTriplet,
}
impl Rate {
pub const ALL: [Rate; 9] = [
Rate::Whole,
Rate::Half,
Rate::Quarter,
Rate::Eighth,
Rate::Sixteenth,
Rate::ThirtySecond,
Rate::QuarterTriplet,
Rate::EighthTriplet,
Rate::SixteenthTriplet,
];
/// Length of one beat cycle in quarter notes.
pub fn quarters(self) -> f64 {
match self {
Rate::Whole => 4.,
Rate::Half => 2.,
Rate::Quarter => 1.,
Rate::Eighth => 0.5,
Rate::Sixteenth => 0.25,
Rate::ThirtySecond => 0.125,
Rate::QuarterTriplet => 2. / 3.,
Rate::EighthTriplet => 1. / 3.,
Rate::SixteenthTriplet => 1. / 6.,
}
}
pub fn label(self) -> &'static str {
match self {
Rate::Whole => "1/1",
Rate::Half => "1/2",
Rate::Quarter => "1/4",
Rate::Eighth => "1/8",
Rate::Sixteenth => "1/16",
Rate::ThirtySecond => "1/32",
Rate::QuarterTriplet => "1/4T",
Rate::EighthTriplet => "1/8T",
Rate::SixteenthTriplet => "1/16T",
}
}
}
#[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,
/// 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.
pub harmonic: u32,
/// Level of the detuned copy against the bass. 100 % gives full nulls.
pub depth: f32,
/// Below this frequency the copy is removed, so the sub stays steady and mono-safe.
/// Under `LOW_CUT_OFF` Hz the filter is off.
pub low_cut: f32,
/// Moves the right channel's beat against the left, up to half a cycle.
pub width: f32,
/// Where in the cycle the beat peaks, in degrees. 0 puts the peak on the beat.
pub phase: f32,
/// Detection threshold in dBFS for note starts (Audio source).
pub gate: f32,
/// Round detected pitch to the nearest semitone. Off by default: the copy jumps by one
/// period of the pitch in use, and a period that does not fit the note is heard.
pub snap: bool,
pub output: f32,
pub bypass: bool,
}
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.
harmonic: 4,
depth: 70.,
low_cut: 100.,
width: 0.,
phase: 0.,
gate: -48.,
snap: false,
output: 0.,
bypass: false,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
self.pitch.validate()?;
let finite = [
self.depth,
self.low_cut,
self.width,
self.phase,
self.gate,
self.output,
]
.iter()
.all(|v| v.is_finite());
if !finite {
return Err("non-finite setting");
}
let ok = (1..=HARMONIC_MAX).contains(&self.harmonic)
&& (0.0..=100.).contains(&self.depth)
&& (0.0..=LOW_CUT_MAX).contains(&self.low_cut)
&& (0.0..=100.).contains(&self.width)
&& (0.0..=360.).contains(&self.phase)
&& (-60.0..=-20.).contains(&self.gate)
&& (-12.0..=12.).contains(&self.output);
if ok {
Ok(())
} else {
Err("setting out of range")
}
}
}
+278
View File
@@ -0,0 +1,278 @@
// Sample indices address several signals at once.
#![allow(clippy::needless_range_loop)]
use braze_core::{cycles, envelope, Engine, Rate, Settings};
use common_dsp::Saw;
use std::f32::consts::TAU;
const RATE: f32 = 48000.;
const TEMPO: f64 = 120.;
fn engine(s: Settings) -> (Engine, usize) {
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(s).unwrap();
e.set_transport(Some(TEMPO), Some(0.));
let l = e.latency_samples() as usize;
(e, l)
}
fn seconds(t: f32) -> usize {
(t * RATE) as usize
}
fn render(e: &mut Engine, x: &[f32]) -> Vec<[f32; 2]> {
x.iter().map(|&s| e.process_sample([s, s])).collect()
}
/// A saw at `hz` from 0.05 s on, with a 1 ms fade-in.
fn saw(hz: f32, total: f32) -> Vec<f32> {
let mut osc = Saw::new(RATE, hz);
(0..seconds(total))
.map(|i| {
let start = seconds(0.05);
if i < start {
return 0.;
}
0.5 * osc.sample() * ((i - start) as f32 / seconds(0.001) as f32).min(1.)
})
.collect()
}
/// Amplitude of harmonic `n` of `hz` over one period centred on sample `at`.
fn harmonic(x: &[f32], at: usize, hz: f32, n: u32) -> f32 {
let half = (RATE / hz / 2.).round() as usize;
let w = TAU * hz * n as f32 / RATE;
let (mut re, mut im) = (0f32, 0f32);
for i in at - half..at + half {
re += x[i] * (w * i as f32).cos();
im -= x[i] * (w * i as f32).sin();
}
2. * (re * re + im * im).sqrt() / (2 * half) as f32
}
/// Beat position (quarter notes) of output sample `i`.
fn beats(i: usize, latency: usize) -> f64 {
(i as f64 - latency as f64) / RATE as f64 * TEMPO / 60.
}
/// Harmonics 1–4 of A1 at falling levels, a periodic note the tracker reads exactly.
fn partials(t: f64) -> f64 {
(1..=4)
.map(|n| 0.4 / n as f64 * (std::f64::consts::TAU * 55. * n as f64 * t).sin())
.sum()
}
#[test]
fn every_harmonic_beats_on_the_tempo_grid() {
for (harmonic, rate) in [(1, Rate::Eighth), (2, Rate::Sixteenth), (3, Rate::Quarter)] {
let s = Settings {
harmonic,
rate,
depth: 100.,
snap: true,
low_cut: 0.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x: Vec<f32> = (0..seconds(1.5))
.map(|i| partials(i as f64 / RATE as f64) as f32)
.collect();
let y = render(&mut e, &x);
let mut worst = 0f32;
for i in seconds(0.5)..seconds(1.4) {
// Cycles of the chosen harmonic, from the grid alone: peaks on every multiple
// of `rate`. The copy lags the bass by (0.5 − frac(u)) periods.
let u = beats(i, l) / rate.quarters() / harmonic as f64 + 0.5;
let t = (i - l) as f64 / RATE as f64;
let lag = (0.5 - u.fract()) / 55.;
let ideal = (partials(t) + partials(t - lag)) / 2f64.sqrt();
worst = worst.max((y[i][0] - ideal as f32).abs());
}
assert!(worst < 3e-3, "harmonic {harmonic}, {rate:?}: {worst}");
}
}
#[test]
fn envelope_peaks_on_the_grid_and_harmonic_n_beats_n_over_k_as_fast() {
let s = Settings {
harmonic: 2,
..Settings::default()
};
// At cycle 0 every harmonic peaks: twice the bass, less the loudness correction.
for n in 1..=8 {
assert!((envelope(&s, n, 0., 1.) - 2f32.sqrt()).abs() < 1e-5);
}
// Harmonic 2 nulls half a cycle later; harmonic 1, beating half as fast, a cycle later.
assert!(envelope(&s, 2, 0.5, 1.) < 1e-5);
assert!(envelope(&s, 1, 1., 1.) < 1e-5);
assert!(envelope(&s, 4, 0.25, 1.) < 1e-5);
}
#[test]
fn the_copy_is_seamless_across_its_jumps() {
// A pure sine at exactly A1: the ideal copy is a smooth sine whose phase follows the beat.
let s = Settings {
harmonic: 1,
rate: Rate::Sixteenth,
depth: 100.,
snap: true,
low_cut: 0.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x: Vec<f32> = (0..seconds(1.5))
.map(|i| 0.5 * (TAU * 55. * i as f32 / RATE).sin())
.collect();
let y = render(&mut e, &x);
let mut worst = 0f32;
for i in seconds(0.5)..seconds(1.4) {
let u = cycles(&s, beats(i, l), 0) + 0.5;
let dry = x[i - l];
let phase = TAU as f64 * (55. * (i - l) as f64 / RATE as f64 + u.fract());
let copy = -0.5 * phase.sin() as f32;
let ideal = (dry + copy) / 2f32.sqrt();
worst = worst.max((y[i][0] - ideal).abs());
}
assert!(worst < 2e-3, "{worst}");
}
#[test]
fn depth_zero_is_the_delayed_input() {
let (mut e, l) = engine(Settings {
depth: 0.,
low_cut: 0.,
..Settings::default()
});
let x = saw(55., 0.6);
let y = render(&mut e, &x);
for i in l..x.len() {
assert_eq!(y[i][0], x[i - l]);
}
}
#[test]
fn width_moves_the_right_channel_half_a_cycle() {
let s = Settings {
harmonic: 1,
width: 100.,
depth: 100.,
rate: Rate::Quarter,
low_cut: 0.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x = saw(55., 1.5);
let y = render(&mut e, &x);
let left: Vec<f32> = y.iter().map(|v| v[0]).collect();
let right: Vec<f32> = y.iter().map(|v| v[1]).collect();
// On the beat the left fundamental peaks and the right one nulls; half a beat later
// they swap.
for (beat, loud, quiet) in [
(1., &left, &right),
(1.5, &right, &left),
(2., &left, &right),
] {
let at = l + (beat / (TEMPO / 60.) * RATE as f64) as usize;
let (a, b) = (harmonic(loud, at, 55., 1), harmonic(quiet, at, 55., 1));
assert!(20. * (a / b).log10() > 20., "beat {beat}: {a} vs {b}");
}
}
#[test]
fn note_changes_and_transport_jumps_do_not_click() {
let s = Settings {
depth: 100.,
..Settings::default()
};
let (mut e, _) = engine(s);
// Phase-continuous sine from A1 to D2, no gap.
let mut phase = 0f32;
let x: Vec<f32> = (0..seconds(1.2))
.map(|i| {
let hz = if i < seconds(0.6) { 55. } else { 73.42 };
phase = (phase + hz / RATE).fract();
0.5 * (TAU * phase).sin()
})
.collect();
let mut y = Vec::new();
for (i, &v) in x.iter().enumerate() {
if i == seconds(0.9) {
// A locate to another bar.
e.set_transport(Some(TEMPO), Some(37.3));
}
y.push(e.process_sample([v, v])[0]);
}
// Two sines of peak 0.5 at up to 79 Hz move at most this much per sample (after the
// loudness correction), plus some room for the fades.
let bound = 2. * 0.5 * TAU * 79. / RATE / 2f32.sqrt() * 1.5;
for i in seconds(0.2)..y.len() - 1 {
assert!(
(y[i + 1] - y[i]).abs() < bound,
"{i}: {}",
(y[i + 1] - y[i]).abs()
);
}
}
#[test]
fn low_cut_keeps_the_sub_steady_and_the_beat_on_the_grid() {
let s = Settings {
harmonic: 1,
depth: 100.,
rate: Rate::Half,
low_cut: 150.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x = saw(55., 2.4);
let y: Vec<f32> = render(&mut e, &x).iter().map(|v| v[0]).collect();
// One cycle (a half note, 1 s at 120 BPM): the fundamental, under the cut, does not beat.
let sub: Vec<f32> = (0..50)
.map(|k| harmonic(&y, seconds(1.) + k * seconds(0.02), 55., 1))
.collect();
let (lo, hi) = sub
.iter()
.fold((f32::MAX, 0f32), |(a, b), &v| (a.min(v), b.max(v)));
// Without the cut it would null fully; what remains is leakage of the beating
// harmonics into the one-period window.
assert!(20. * (hi / lo).log10() < 1.5, "{lo} .. {hi}");
// And it keeps its level: the loudness correction for the copy applies above the cut.
let dry = harmonic(&x, seconds(1.) - l, 55., 1);
let mean = sub.iter().sum::<f32>() / sub.len() as f32;
assert!((20. * (mean / dry).log10()).abs() < 0.5, "{mean} vs {dry}");
// Harmonic 6 (330 Hz) beats 6 times per cycle: it peaks on the grid, the high-pass's
// phase is matched on the bass, and nulls 1/12 cycle later.
for beat in [2., 4.] {
let peak = l + (beat / (TEMPO / 60.) * RATE as f64) as usize;
let null = peak + (RATE / 12.) as usize;
let (a, b) = (harmonic(&y, peak, 55., 6), harmonic(&y, null, 55., 6));
assert!(20. * (a / b).log10() > 15., "beat {beat}: {a} vs {b}");
}
}
#[test]
fn the_ratio_follows_the_note() {
let (mut e, _) = engine(Settings {
harmonic: 1,
..Settings::default()
});
for hz in [55., 110.] {
let x = saw(hz, 0.5);
render(&mut e, &x);
let f = e.take_analyzer();
// An eighth at 120 BPM beats 4 times per second: r = 1 + 4 / f0.
assert!((f.ratio - (1. + 4. / hz)).abs() < 2e-3, "{hz}: {}", f.ratio);
}
}
#[test]
fn settings_are_validated_and_serialise() {
let mut e = Engine::new(RATE, 1).unwrap();
assert!(e
.set_settings(Settings {
harmonic: 0,
..Settings::default()
})
.is_err());
let json = serde_json::to_string(&Settings::default()).unwrap();
assert_eq!(
serde_json::from_str::<Settings>(&json).unwrap(),
Settings::default()
);
assert!(Engine::new(1000., 2).is_err());
assert!(Engine::new(48000., 3).is_err());
}
+79
View File
@@ -0,0 +1,79 @@
use braze_core::{Engine, Rate, Settings, Source};
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
};
struct CheckedAllocator;
thread_local! {
static CHECK:Cell<bool>=const {Cell::new(false)};
static OPERATIONS:Cell<usize>=const {Cell::new(0)};
}
fn record() {
if CHECK.try_with(Cell::get).unwrap_or(false) {
let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1));
}
}
unsafe impl GlobalAlloc for CheckedAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
record();
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
record();
unsafe { System.dealloc(pointer, layout) }
}
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
record();
unsafe { System.realloc(pointer, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CheckedAllocator = CheckedAllocator;
#[test]
fn processing_notes_settings_transport_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings {
width: 50.,
..Settings::default()
};
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..96000 {
if i % 512 == 0 {
let playing = (i / 24000) % 2 == 0;
engine.set_transport(
Some(140.),
playing.then_some(i as f64 / 48000. * 140. / 60.),
);
}
if i % 4800 == 0 {
settings.rate = Rate::ALL[(i / 4800) % Rate::ALL.len()];
settings.harmonic = 1 + (i / 4800 % 4) as u32;
settings.source = if i > 48000 {
Source::Midi
} else {
Source::Audio
};
engine.set_settings(settings).unwrap();
engine.note_on(30 + (i / 4800 % 12) as u8);
}
if i % 4800 == 2400 {
engine.note_off(30 + (i / 4800 % 12) as u8);
}
let t = i as f32 / 48000.;
let x = if (i / 2400) % 2 == 0 {
(std::f32::consts::TAU * 55. * t).sin() * 0.5
} else {
0.
};
std::hint::black_box(engine.process_sample([x, x]));
if i % 1600 == 0 {
std::hint::black_box(engine.take_analyzer());
}
}
engine.reset();
CHECK.with(|v| v.set(false));
assert_eq!(OPERATIONS.with(Cell::get), 0);
}
+2394
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "braze"
version = "0.2.0"
edition = "2021"
license = "ISC"
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
braze-core = { path = "../core" }
common-params = { path = "../../common/params" }
braze-ui = { path = "../ui" }
crossbeam-queue = "0.3"
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+193
View File
@@ -0,0 +1,193 @@
//! CLAP audio effect. All DSP state is owned by the processing thread.
mod params;
use braze_core::{AnalyzerFrame, Engine};
use crossbeam_queue::ArrayQueue;
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::{BrazeParams, FLOATS};
use std::sync::Arc;
struct EditorData {
ui: braze_ui::State,
gestures: [bool; FLOATS],
}
pub struct Braze {
params: Arc<BrazeParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
engine: Option<Engine>,
publish_count: usize,
publish_interval: usize,
}
impl Default for Braze {
fn default() -> Self {
Self {
params: Arc::new(BrazeParams::default()),
analyzer: Arc::new(ArrayQueue::new(2)),
engine: None,
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for Braze {
const NAME: &'static str = "Braze";
const VENDOR: &'static str = "Bass Foundry";
const URL: &'static str = "";
const EMAIL: &'static str = "";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
AudioIOLayout {
main_input_channels: NonZeroU32::new(2),
main_output_channels: NonZeroU32::new(2),
..AudioIOLayout::const_default()
},
AudioIOLayout {
main_input_channels: NonZeroU32::new(1),
main_output_channels: NonZeroU32::new(1),
..AudioIOLayout::const_default()
},
];
const MIDI_INPUT: MidiConfig = MidiConfig::Basic;
const SAMPLE_ACCURATE_AUTOMATION: bool = false;
type SysExMessage = ();
type BackgroundTask = ();
fn params(&self) -> Arc<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
let params = self.params.clone();
let analyzer = self.analyzer.clone();
create_egui_editor(
params.editor.clone(),
EditorData {
ui: braze_ui::State::default(),
gestures: [false; FLOATS],
},
|ctx, _| braze_ui::style(ctx),
move |ctx, setter, data| {
while let Some(frame) = analyzer.pop() {
data.ui.accept_frame(frame);
}
let before = params.settings();
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("braze-editor")
.min_size(egui::vec2(
braze_ui::WIDTH as f32 - 80.,
braze_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
braze_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
// The playhead follows the transport.
ctx.request_repaint_after(std::time::Duration::from_millis(40));
},
)
}
fn initialize(
&mut self,
layout: &AudioIOLayout,
config: &BufferConfig,
context: &mut impl InitContext<Self>,
) -> bool {
let Ok(mut engine) = Engine::new(
config.sample_rate,
layout.main_input_channels.map_or(0, |v| v.get() as usize),
) else {
return false;
};
let settings = self.params.settings();
if engine.set_settings(settings).is_err() {
return false;
}
context.set_latency_samples(engine.latency_samples());
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
true
}
fn reset(&mut self) {
if let Some(e) = self.engine.as_mut() {
e.reset();
}
self.publish_count = 0;
}
fn process(
&mut self,
buffer: &mut Buffer,
_: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let settings = self.params.settings();
let _ = engine.set_settings(settings);
let transport = context.transport();
engine.set_transport(
transport.tempo,
if transport.playing {
transport.pos_beats()
} else {
None
},
);
let mut next = context.next_event();
for (index, mut sample) in buffer.iter_samples().enumerate() {
while let Some(event) = next {
if event.timing() as usize > index {
break;
}
match event {
NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => {
engine.note_on(note)
}
NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => {
engine.note_off(note)
}
NoteEvent::Choke { note, .. } => engine.note_off(note),
_ => {}
}
next = context.next_event();
}
let x = [
*sample.get_mut(0).unwrap(),
if engine.channels() == 2 {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let out = engine.process_sample(x);
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
self.analyzer.force_push(engine.take_analyzer());
self.publish_count = 0;
}
}
// The lookahead delay must drain after input stops.
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for Braze {
const CLAP_ID: &'static str = "io.narl.bass-foundry.braze";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Adds a detuned copy whose beating stays on the tempo grid on every note");
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Chorus,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Braze);
+238
View File
@@ -0,0 +1,238 @@
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;
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SourceParam {
#[id = "audio"]
Audio,
#[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 {
#[id = "1-1"]
#[name = "1/1"]
Whole,
#[id = "1-2"]
#[name = "1/2"]
Half,
#[id = "1-4"]
#[name = "1/4"]
Quarter,
#[id = "1-8"]
#[name = "1/8"]
Eighth,
#[id = "1-16"]
#[name = "1/16"]
Sixteenth,
#[id = "1-32"]
#[name = "1/32"]
ThirtySecond,
#[id = "1-4t"]
#[name = "1/4T"]
QuarterTriplet,
#[id = "1-8t"]
#[name = "1/8T"]
EighthTriplet,
#[id = "1-16t"]
#[name = "1/16T"]
SixteenthTriplet,
}
const RATES: [(RateParam, Rate); 9] = [
(RateParam::Whole, Rate::Whole),
(RateParam::Half, Rate::Half),
(RateParam::Quarter, Rate::Quarter),
(RateParam::Eighth, Rate::Eighth),
(RateParam::Sixteenth, Rate::Sixteenth),
(RateParam::ThirtySecond, Rate::ThirtySecond),
(RateParam::QuarterTriplet, Rate::QuarterTriplet),
(RateParam::EighthTriplet, Rate::EighthTriplet),
(RateParam::SixteenthTriplet, Rate::SixteenthTriplet),
];
fn to_param(rate: Rate) -> RateParam {
RATES
.iter()
.find(|(_, r)| *r == rate)
.map_or(RateParam::Eighth, |(p, _)| *p)
}
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
}
/// Continuous controls, in the order of the gesture slots.
pub const FLOATS: usize = 6;
#[derive(Params)]
pub struct BrazeParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[nested(group = "Pitch")]
pub pitch: PitchParams,
#[id = "rate"]
pub rate: EnumParam<RateParam>,
#[id = "harmonic"]
pub harmonic: IntParam,
#[id = "depth"]
pub depth: FloatParam,
#[id = "low_cut"]
pub low_cut: FloatParam,
#[id = "width"]
pub width: FloatParam,
#[id = "phase"]
pub phase: FloatParam,
#[id = "gate"]
pub gate: FloatParam,
#[id = "snap"]
pub snap: BoolParam,
#[id = "output"]
pub output: FloatParam,
#[id = "bypass"]
pub bypass: BoolParam,
}
impl Default for BrazeParams {
fn default() -> Self {
let d = Settings::default();
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",
d.harmonic as i32,
IntRange::Linear {
min: 1,
max: HARMONIC_MAX as i32,
},
),
depth: linear("Depth", d.depth, 0., 100., " %"),
low_cut: FloatParam::new(
"Low cut",
d.low_cut,
FloatRange::Skewed {
min: 0.,
max: LOW_CUT_MAX,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(" Hz"),
width: linear("Width", d.width, 0., 100., " %"),
phase: linear("Phase", d.phase, 0., 360., "°"),
gate: linear("Gate", d.gate, -60., -20., " dB"),
snap: BoolParam::new("Snap to semitone", d.snap),
output: linear("Output", d.output, -12., 12., " dB"),
bypass: BoolParam::new("Bypass", false).make_bypass(),
}
}
}
impl BrazeParams {
pub fn settings(&self) -> Settings {
let rate = self.rate.value();
Settings {
source: self.source.value().into(),
pitch: self.pitch.pitch(),
rate: RATES
.iter()
.find(|(p, _)| *p == rate)
.map_or(Rate::Eighth, |(_, r)| *r),
harmonic: self.harmonic.value() as u32,
depth: self.depth.value(),
low_cut: self.low_cut.value(),
width: self.width.value(),
phase: self.phase.value(),
gate: self.gate.value(),
snap: self.snap.value(),
output: self.output.value(),
bypass: self.bypass.value(),
}
}
fn floats(&self) -> [&FloatParam; FLOATS] {
[
&self.depth,
&self.low_cut,
&self.width,
&self.phase,
&self.gate,
&self.output,
]
}
fn values(s: &Settings) -> [f32; FLOATS] {
[s.depth, s.low_cut, s.width, s.phase, s.gate, s.output]
}
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
/// while the pointer is down; switches are set in a single gesture.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; FLOATS],
) {
let pairs = Self::values(old).into_iter().zip(Self::values(new));
for (i, (p, (a, b))) in self.floats().into_iter().zip(pairs).enumerate() {
if a != b {
if !open[i] {
open[i] = true;
setter.begin_set_parameter(p);
}
setter.set_parameter(p, b);
}
if !dragging && open[i] {
setter.end_set_parameter(p);
open[i] = false;
}
}
if new.harmonic != old.harmonic {
setter.begin_set_parameter(&self.harmonic);
setter.set_parameter(&self.harmonic, new.harmonic as i32);
setter.end_set_parameter(&self.harmonic);
}
if new.rate != old.rate {
setter.begin_set_parameter(&self.rate);
setter.set_parameter(&self.rate, to_param(new.rate));
setter.end_set_parameter(&self.rate);
}
for (p, a, b) in [
(&self.snap, old.snap, new.snap),
(&self.bypass, old.bypass, new.bypass),
] {
if a != b {
setter.begin_set_parameter(p);
setter.set_parameter(p, b);
setter.end_set_parameter(p);
}
}
if new.source != old.source {
setter.begin_set_parameter(&self.source);
setter.set_parameter(&self.source, new.source.into());
setter.end_set_parameter(&self.source);
}
self.pitch.apply(setter, &old.pitch, &new.pitch);
}
}
+216
View File
@@ -0,0 +1,216 @@
//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW.
use clap_sys::{
audio_buffer::*,
events::*,
ext::{audio_ports::*, latency::*, state::*},
factory::plugin_factory::*,
host::*,
process::*,
stream::*,
version::*,
};
use std::{
ffi::{c_char, c_void, CStr},
io::{Cursor, Read},
ptr,
};
unsafe extern "C" fn extension(_: *const clap_host, _: *const c_char) -> *const c_void {
ptr::null()
}
unsafe extern "C" fn request(_: *const clap_host) {}
unsafe extern "C" fn count(_: *const clap_input_events) -> u32 {
0
}
unsafe extern "C" fn event(_: *const clap_input_events, _: u32) -> *const clap_event_header {
ptr::null()
}
unsafe extern "C" fn push(_: *const clap_output_events, _: *const clap_event_header) -> bool {
true
}
unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 {
// SAFETY: The test owns the Vec for the entire synchronous save call.
let bytes = unsafe { &mut *((*stream).ctx as *mut Vec<u8>) };
bytes.extend_from_slice(unsafe {
std::slice::from_raw_parts(buffer as *const u8, size as usize)
});
size as i64
}
unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
// SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage.
let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor<Vec<u8>>) };
cursor
.read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) })
.unwrap() as i64
}
#[test]
fn effect_ports_state_latency_and_audio_lifecycle() {
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
unsafe {
let entry = &braze::clap_entry;
assert!(entry.init.unwrap()(c"braze.clap".as_ptr()));
let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr())
as *const clap_plugin_factory;
assert!(!factory.is_null());
let factory = &*factory;
assert_eq!(factory.get_plugin_count.unwrap()(factory), 1);
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(CStr::from_ptr((*descriptor).name), c"Braze");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.narl.bass-foundry.braze"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
clap_version: CLAP_VERSION,
host_data: ptr::null_mut(),
name: c"Storyboard test host".as_ptr(),
vendor: c"Tests".as_ptr(),
url: c"".as_ptr(),
version: c"1.0".as_ptr(),
get_extension: Some(extension),
request_restart: Some(request),
request_process: Some(request),
request_callback: Some(request),
};
let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id);
assert!(!plugin.is_null());
let p = &*plugin;
assert!(p.init.unwrap()(plugin));
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
as *const clap_plugin_audio_ports;
assert!(!ports.is_null());
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
as *const clap_plugin_latency;
assert!(!latency.is_null());
let state =
p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state;
assert!(!state.is_null());
let state = &*state;
let save = || {
let mut bytes = Vec::new();
let stream = clap_ostream {
ctx: &mut bytes as *mut Vec<u8> as *mut c_void,
write: Some(write),
};
assert!(state.save.unwrap()(plugin, &stream));
bytes
};
let mut envelope: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
use nih_plug::wrapper::state::ParamValue;
// Depth 0 passes the input through the lookahead delay unchanged.
envelope.params.insert("depth".into(), ParamValue::F32(0.));
envelope.params.insert("width".into(), ParamValue::F32(40.));
envelope
.params
.insert("harmonic".into(), ParamValue::I32(3));
envelope
.params
.insert("rate".into(), ParamValue::String("1-16t".into()));
envelope
.params
.insert("snap".into(), ParamValue::Bool(true));
envelope
.params
.insert("source".into(), ParamValue::String("midi".into()));
let encoded = serde_json::to_vec(&envelope).unwrap();
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
framed.extend_from_slice(&encoded);
let mut bytes = Cursor::new(framed);
let stream = clap_istream {
ctx: &mut bytes as *mut Cursor<Vec<u8>> as *mut c_void,
read: Some(read),
};
assert!(state.load.unwrap()(plugin, &stream));
let restored: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
assert_eq!(
serde_json::to_value(&restored.params).unwrap(),
serde_json::to_value(&envelope.params).unwrap()
);
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
// About 67 ms lookahead: two periods of the lowest detected note (30 Hz).
assert!((delay as f64 / rate - 0.067).abs() < 0.01, "{delay}");
assert!(p.start_processing.unwrap()(plugin));
let total = delay + 9600;
let mut position = 0;
let (mut energy_in, mut energy_out) = (0f64, 0f64);
while position < total {
let frames = [1, 127, 512, 31][position % 4].min(total - position);
// A 1 kHz tone at depth 0.
let tone = |n: usize| {
0.25 * (std::f64::consts::TAU * 1000. * n as f64 / rate).sin() as f32
};
let mut input_left = [0f32; 512];
let mut input_right = [0f32; 512];
for i in 0..frames {
input_left[i] = tone(position + i);
input_right[i] = -tone(position + i);
}
let mut left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
let incoming = clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let mut outgoing_audio = clap_audio_buffer {
data32: out_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let input = clap_input_events {
ctx: ptr::null_mut(),
size: Some(count),
get: Some(event),
};
let outgoing = clap_output_events {
ctx: ptr::null_mut(),
try_push: Some(push),
};
let process = clap_process {
steady_time: position as i64,
frames_count: frames as u32,
transport: ptr::null(),
audio_inputs: &incoming,
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 1,
audio_outputs_count: 1,
in_events: &input,
out_events: &outgoing,
};
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
for i in 0..frames {
let n = position + i;
if n < delay {
assert!(left[i].abs() < 1e-6, "output before the lookahead");
} else if n > delay + 4800 {
energy_in += (tone(n - delay) as f64).powi(2);
energy_out += (left[i] as f64).powi(2) + (right[i] as f64).powi(2);
}
}
position += frames;
}
let ratio_db = 10. * (energy_out / (2. * energy_in)).log10();
assert!(ratio_db.abs() < 0.2, "{ratio_db}");
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}
p.destroy.unwrap()(plugin);
entry.deinit.unwrap()();
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "braze-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
braze-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
common-dsp = { path = "../../common/dsp" }
+422
View File
@@ -0,0 +1,422 @@
//! Braze editor, shared by the CLAP wrapper and the headless renderer.
use braze_core::{
cycles, envelope, AnalyzerFrame, Rate, Settings, Source, HARMONIC_MAX, LOW_CUT_MAX, LOW_CUT_OFF,
};
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
pub use common_ui::style;
pub const WIDTH: u32 = 980;
pub const HEIGHT: u32 = 716;
pub struct State {
pub analyzer: AnalyzerFrame,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
/// 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(),
}
}
}
const DEFAULT_HELP: &str =
"Put Braze on a reese or a layered bass. Pick the note value the beating should follow.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
self.analyzer = frame;
}
}
fn hint(r: &egui::Response, state: &mut State, text: &str) {
if r.hovered() || r.has_focus() {
state.help = text.into();
}
}
#[allow(clippy::too_many_arguments)]
fn knob(
ui: &mut egui::Ui,
state: &mut State,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
size: f32,
help: &str,
) {
ui.vertical_centered(|ui| {
let r = rotary(ui, name, value, range, default, unit, size, ACCENT);
hint(&r, state, help);
});
}
/// Note value of one beat of harmonic `n`, as a label: "1/8", "1/16T", "1/8D", or "1/40".
pub fn beat_label(s: &Settings, n: u32) -> String {
let quarters = s.rate.quarters() * s.harmonic as f64 / n as f64;
let per_whole = 4. / quarters;
for den in [1u32, 2, 4, 8, 16, 32, 64, 128] {
let d = den as f64;
if (per_whole - d).abs() < 1e-6 {
return format!("1/{den}");
}
if (per_whole - d * 1.5).abs() < 1e-6 {
return format!("1/{den}T");
}
if (per_whole - d / 1.5).abs() < 1e-6 {
return format!("1/{den}D");
}
}
if (per_whole - per_whole.round()).abs() < 1e-6 {
format!("1/{}", per_whole.round())
} else {
format!("{:.2} beats", quarters)
}
}
/// One bar of the beat of harmonics 1–8, with the beat grid and a playhead.
fn beats(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame, height: f32) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, 2., SCREEN);
let label_w = 86.;
let area = Rect::from_min_max(
rect.left_top() + Vec2::new(label_w, 26.),
rect.right_bottom() - Vec2::new(12., 10.),
);
p.text(
rect.left_top() + Vec2::new(10., 8.),
egui::Align2::LEFT_TOP,
"BEAT PER HARMONIC · ONE BAR",
FontId::proportional(11.),
MUTED,
);
p.text(
rect.right_top() + Vec2::new(-12., 8.),
egui::Align2::RIGHT_TOP,
if frame.synced {
format!("{:.1} BPM · host transport", frame.tempo)
} else {
format!("{:.1} BPM · free running", frame.tempo)
},
FontId::proportional(11.),
MUTED,
);
// Grid: every beat of the chosen rate, quarters brighter.
let step = settings.rate.quarters();
let mut q = 0f64;
while q < 4. - 1e-9 {
let x = area.left() + area.width() * (q / 4.) as f32;
let on_quarter = (q - q.round()).abs() < 1e-6;
let color = if on_quarter {
Color32::from_rgb(58, 62, 70)
} else {
Color32::from_rgb(32, 35, 40)
};
p.line_segment(
[Pos2::new(x, area.top()), Pos2::new(x, area.bottom())],
Stroke::new(1.0_f32, color),
);
q += step;
}
let rows = HARMONIC_MAX as usize;
let row_h = area.height() / rows as f32;
let depth = settings.depth * 0.01;
let points = 240;
let bar_start = (frame.beats / 4.).floor() * 4.;
for row in 0..rows {
let n = row as u32 + 1;
let top = area.top() + row_h * row as f32;
let chosen = n == settings.harmonic;
// Under the low cut the copy is removed: that harmonic keeps the bass's level.
let cut = settings.low_cut >= LOW_CUT_OFF
&& frame.hz > 0.
&& n as f32 * frame.hz < settings.low_cut;
let color = match (cut, chosen) {
(true, _) => DIM,
(false, true) => ACCENT,
(false, false) => STEEL,
};
p.text(
Pos2::new(rect.left() + 10., top + row_h * 0.5),
egui::Align2::LEFT_CENTER,
format!("H{n}"),
FontId::proportional(11.),
if chosen { ACCENT } else { MUTED },
);
p.text(
Pos2::new(rect.left() + 36., top + row_h * 0.5),
egui::Align2::LEFT_CENTER,
if cut {
"cut".to_string()
} else {
beat_label(settings, n)
},
FontId::monospace(11.),
if chosen { TEXT } else { DIM },
);
let line: Vec<Pos2> = (0..=points)
.map(|i| {
let t = i as f64 / points as f64;
let level = if cut {
1.
} else {
envelope(settings, n, cycles(settings, bar_start + 4. * t, 0), depth)
};
// 0 at the bottom of the row, twice the bass (√2 after correction) at the top.
let y = top + row_h * (0.92 - 0.8 * level / 2f32.sqrt());
Pos2::new(area.left() + area.width() * t as f32, y)
})
.collect();
p.add(egui::Shape::line(
line,
Stroke::new(if chosen { 2.0_f32 } else { 1.2_f32 }, color),
));
}
let head = area.left() + area.width() * ((frame.beats - bar_start) / 4.) as f32;
p.line_segment(
[Pos2::new(head, area.top()), Pos2::new(head, area.bottom())],
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 90)),
);
p.rect_stroke(
rect,
2.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
backdrop(ui);
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
ui.add_space(2.);
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.bypass = !settings.bypass;
}
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| {
panel(ui, |ui| {
ui.set_width(240.);
ui.set_min_height(TOP);
ui.vertical(|ui| note_panel(ui, settings, &frame));
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
beats(ui, settings, &frame, TOP);
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
const TOP: f32 = 300.;
fn note_panel(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame) {
caption(ui, "NOTE");
let (name, hz) = match frame.note {
Some(n) if frame.hz > 0. => (common_dsp::note_name(n), format!("{:.1} Hz", frame.hz)),
_ => ("—".into(), "no note".into()),
};
ui.label(
RichText::new(name)
.font(FontId::new(54., egui::FontFamily::Name("medium".into())))
.color(if frame.note.is_some() { TEXT } else { DIM }),
);
ui.label(RichText::new(hz).monospace().size(14.).color(MUTED));
ui.add_space(14.);
let beat_hz = frame.tempo / 60. / settings.rate.quarters();
readout(
ui,
&format!("HARMONIC {} BEATS", settings.harmonic),
format!("{:.2} Hz, every {}", beat_hz, settings.rate.label()),
ACCENT,
);
if settings.low_cut >= LOW_CUT_OFF
&& frame.hz > 0.
&& settings.harmonic as f32 * frame.hz < settings.low_cut
{
ui.label(
RichText::new("Under Low cut on this note: it does not beat. Pick a higher harmonic.")
.size(12.)
.color(ACCENT),
);
ui.add_space(6.);
}
let cents = 1200. * frame.ratio.max(1e-6).log2();
readout(
ui,
"COPY DETUNE ON THIS NOTE",
if frame.hz > 0. {
format!("+{cents:.1} cents")
} else {
"waiting for a note".into()
},
TEXT,
);
ui.label(
RichText::new(format!(
"{:.0} ms lookahead · {}",
frame.latency as f32 / frame.sample_rate * 1000.,
pitch_status(settings.source.mode(), &settings.pitch)
))
.size(12.)
.color(MUTED),
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let d = Settings::default();
ui.horizontal(|ui| {
caption(ui, "RATE");
ui.add_space(6.);
for rate in Rate::ALL {
let r =
ui.selectable_label(settings.rate == rate, RichText::new(rate.label()).size(13.));
if r.clicked() {
settings.rate = rate;
}
hint(
&r,
state,
"One beat cycle of the chosen harmonic lasts this note value, on every note.",
);
}
ui.add_space(18.);
caption(ui, "ON HARMONIC");
ui.add_space(6.);
for n in 1..=HARMONIC_MAX {
let r = ui.selectable_label(
settings.harmonic == n,
RichText::new(format!("{n}")).size(13.),
);
if r.clicked() {
settings.harmonic = n;
}
hint(
&r,
state,
"This harmonic beats at Rate. Harmonic n beats n/this times as fast. \
Pick the harmonic you hear the movement on.",
);
}
});
ui.add_space(8.);
ui.separator();
let advanced = state.advanced;
ui.columns(if advanced { 6 } else { 4 }, |c| {
knob(
&mut c[0],
state,
"Depth",
&mut settings.depth,
0. ..=100.,
d.depth,
" %",
72.,
"Level of the detuned copy. 100 % beats down to full nulls; 0 % is the dry bass.",
);
knob(
&mut c[1],
state,
"Low cut",
&mut settings.low_cut,
0. ..=LOW_CUT_MAX,
d.low_cut,
" Hz",
72.,
"No copy below this frequency: the sub does not beat and keeps its level. \
Under 20 Hz the cut is off.",
);
knob(
&mut c[2],
state,
"Width",
&mut settings.width,
0. ..=100.,
d.width,
" %",
72.,
"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[at],
state,
"Output",
&mut settings.output,
-12. ..=12.,
d.output,
" dB",
72.,
"Output trim.",
);
});
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.",
);
}
}
+115 -7
View File
@@ -6,7 +6,7 @@
"library": "rivet",
"documentation": "docs/rivet",
"standalone": "rivet",
"clap_id": "io.storyboard.motif",
"clap_id": "io.narl.bass-foundry.rivet",
"packages": [
"rivet-core",
"rivet-midi",
@@ -20,12 +20,13 @@
"manifest": "plugins/patina/plugin/Cargo.toml",
"library": "patina",
"documentation": "docs/patina",
"clap_id": "io.storyboard.resonance-compressor",
"clap_id": "io.narl.bass-foundry.patina",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"common-dsp",
"patina-core",
"patina-ui",
"patina-render"
@@ -37,7 +38,7 @@
"manifest": "plugins/anvil/plugin/Cargo.toml",
"library": "anvil",
"documentation": "docs/anvil",
"clap_id": "io.storyboard.sub-anchor",
"clap_id": "io.narl.bass-foundry.anvil",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
@@ -54,7 +55,7 @@
"manifest": "plugins/crucible/plugin/Cargo.toml",
"library": "crucible",
"documentation": "docs/crucible",
"clap_id": "io.storyboard.resampler",
"clap_id": "io.narl.bass-foundry.crucible",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
@@ -71,7 +72,7 @@
"manifest": "plugins/lathe/plugin/Cargo.toml",
"library": "lathe",
"documentation": "docs/lathe",
"clap_id": "io.storyboard.harmonic-eq",
"clap_id": "io.narl.bass-foundry.lathe",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
@@ -88,7 +89,7 @@
"manifest": "plugins/temper/plugin/Cargo.toml",
"library": "temper",
"documentation": "docs/temper",
"clap_id": "io.storyboard.tracked-distortion",
"clap_id": "io.narl.bass-foundry.temper",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
@@ -105,7 +106,7 @@
"manifest": "plugins/weld/plugin/Cargo.toml",
"library": "weld",
"documentation": "docs/weld",
"clap_id": "io.storyboard.layer-align",
"clap_id": "io.narl.bass-foundry.weld",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
@@ -114,5 +115,112 @@
"weld-core",
"weld-ui"
]
},
"knurl": {
"name": "Knurl",
"description": "Give every cycle of the note its own level, timing and grit",
"manifest": "plugins/knurl/plugin/Cargo.toml",
"library": "knurl",
"documentation": "docs/knurl",
"clap_id": "io.narl.bass-foundry.knurl",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"knurl-core",
"knurl-ui"
]
},
"planish": {
"name": "Planish",
"description": "Upward and downward compression in bands set by harmonic number, the same on every note",
"manifest": "plugins/planish/plugin/Cargo.toml",
"library": "planish",
"documentation": "docs/planish",
"clap_id": "io.narl.bass-foundry.planish",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"common-dsp",
"planish-core",
"planish-ui"
]
},
"braze": {
"name": "Braze",
"description": "Adds a detuned copy whose beating stays on the tempo grid on every note",
"manifest": "plugins/braze/plugin/Cargo.toml",
"library": "braze",
"documentation": "docs/braze",
"clap_id": "io.narl.bass-foundry.braze",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"common-dsp",
"braze-core",
"braze-ui"
]
},
"quench": {
"name": "Quench",
"description": "Clips the top as hard as you like while the sub passes clean",
"manifest": "plugins/quench/plugin/Cargo.toml",
"library": "quench",
"documentation": "docs/quench",
"clap_id": "io.narl.bass-foundry.quench",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"common-dsp",
"quench-core",
"quench-ui"
]
},
"ballast": {
"name": "Ballast",
"description": "Levels every note or switch-up to one loudness with a single gain each",
"manifest": "plugins/ballast/plugin/Cargo.toml",
"library": "ballast",
"documentation": "docs/ballast",
"clap_id": "io.narl.bass-foundry.ballast",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"common-dsp",
"ballast-core",
"ballast-ui"
]
},
"forge": {
"name": "Forge",
"description": "One tracked channel strip: Lathe, Temper and Knurl behind one note tracker, in any order",
"manifest": "plugins/forge/plugin/Cargo.toml",
"library": "forge",
"documentation": "docs/forge",
"clap_id": "io.narl.bass-foundry.forge",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"common-dsp",
"forge-core",
"forge-ui",
"lathe-core",
"lathe-ui",
"temper-core",
"temper-ui",
"knurl-core",
"knurl-ui"
]
}
}
+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),
);
}
}
+34 -2
View File
@@ -1,6 +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;
@@ -215,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.)
+71
View File
@@ -0,0 +1,71 @@
//! ITU-R BS.1770 K-weighting: the pre-filter (a high shelf for the head) and the RLB high-pass.
//! Mean square of the weighted signal, summed over channels, gives the loudness in LUFS as
//! −0.691 + 10·log10(sum). Coefficients come from the standard's analog prototypes, so any
//! sample rate is exact, not only 48 kHz.
/// Biquad in transposed direct form II, in f64: the RLB pole sits close to 1 at high rates.
#[derive(Clone, Copy, Debug, Default)]
struct Stage {
b: [f64; 3],
a: [f64; 2],
z: [f64; 2],
}
impl Stage {
#[inline]
fn process(&mut self, x: f64) -> f64 {
let y = self.b[0] * x + self.z[0];
self.z[0] = self.b[1] * x - self.a[0] * y + self.z[1];
self.z[1] = self.b[2] * x - self.a[1] * y;
y
}
}
/// One channel of K-weighting. Allocation-free.
#[derive(Clone, Copy, Debug)]
pub struct KWeighting {
shelf: Stage,
high: Stage,
}
impl KWeighting {
pub fn new(rate: f32) -> Self {
let rate = rate as f64;
// Pre-filter: high shelf of about +4 dB above 1.7 kHz.
let (f0, g, q) = (1681.974450955533, 3.999843853973347, 0.7071752369554196);
let k = (std::f64::consts::PI * f0 / rate).tan();
let vh = 10f64.powf(g / 20.);
let vb = vh.powf(0.4996667741545416);
let a0 = 1. + k / q + k * k;
let shelf = Stage {
b: [
(vh + vb * k / q + k * k) / a0,
2. * (k * k - vh) / a0,
(vh - vb * k / q + k * k) / a0,
],
a: [2. * (k * k - 1.) / a0, (1. - k / q + k * k) / a0],
z: [0.; 2],
};
// RLB: second-order high-pass at 38 Hz.
let (f0, q) = (38.13547087602444, 0.5003270373238773);
let k = (std::f64::consts::PI * f0 / rate).tan();
let a0 = 1. + k / q + k * k;
let high = Stage {
b: [1., -2., 1.],
a: [2. * (k * k - 1.) / a0, (1. - k / q + k * k) / a0],
z: [0.; 2],
};
Self { shelf, high }
}
#[inline]
pub fn process(&mut self, x: f32) -> f32 {
self.high.process(self.shelf.process(x as f64)) as f32
}
pub fn reset(&mut self) {
self.shelf.z = [0.; 2];
self.high.z = [0.; 2];
}
}
/// Loudness in LUFS of a mean square summed over channels.
pub fn lufs(mean_square: f64) -> f32 {
(-0.691 + 10. * mean_square.max(1e-20).log10()) as f32
}
+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
}
+15
View File
@@ -70,6 +70,21 @@ impl SvfCoef {
m2: -1.,
}
}
/// Second-order allpass. With `q` = 1/√2 it has the phase of a Linkwitz–Riley crossover
/// built from two lowpass and two highpass stages at the same frequency, so it keeps
/// the bands of a crossover tree in phase.
pub fn allpass(rate: f32, hz: f32, q: f32) -> Self {
let k = 1. / q.max(1e-3);
let (a1, a2, a3) = Self::base(rate, hz, k, 1.);
Self {
a1,
a2,
a3,
m0: 1.,
m1: -2. * k,
m2: 0.,
}
}
pub fn high_shelf(rate: f32, hz: f32, q: f32, gain_db: f32) -> Self {
let a = 10f32.powf(gain_db / 40.);
let k = 1. / q.max(1e-3);
+496 -63
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);
@@ -397,3 +777,56 @@ impl PitchFollower {
self.hz
}
}
/// A processor driven by note events that are aligned with its input audio. Lathe, Temper and
/// Knurl are built from one each, so the same code runs standalone (after its own tracker) and
/// inside a strip that shares one tracker between several of them.
pub trait Tracked {
/// Delay of the output behind the input, in samples.
fn latency(&self) -> usize;
/// The undelayed input, one tracker lookahead ahead of the audio passed to `process`.
/// Called before the sample's events.
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 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.
pub struct Lookahead {
pub tracker: Tracker,
delay: Vec<[f32; 2]>,
at: usize,
}
impl Lookahead {
pub fn new(rate: f32, cfg: TrackerConfig) -> Self {
let tracker = Tracker::new(rate, cfg);
let len = tracker.latency() + 1;
Self {
tracker,
delay: vec![[0.; 2]; len],
at: 0,
}
}
/// The tracker's lookahead, in samples.
pub fn latency(&self) -> usize {
self.tracker.latency()
}
pub fn reset(&mut self) {
self.tracker.reset();
self.delay.fill([0.; 2]);
}
/// Feeds one input sample (`mono` is its channel average) and returns `module`'s output.
#[inline]
pub fn process(&mut self, x: [f32; 2], mono: f32, module: &mut impl Tracked) -> [f32; 2] {
module.ahead(mono);
self.tracker.process(mono, |e| module.event(e));
let len = self.delay.len();
self.delay[self.at] = x;
self.at = (self.at + 1) % len;
let y = self.delay[self.at];
module.process(y, self.tracker.state())
}
}
+158 -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;
@@ -46,6 +46,31 @@ fn svf_bell_at_zero_db_is_an_exact_identity() {
}
}
#[test]
fn svf_allpass_matches_a_linkwitz_riley_split() {
let q = std::f32::consts::FRAC_1_SQRT_2;
let (low, high, all) = (
SvfCoef::lowpass(RATE, 300., q),
SvfCoef::highpass(RATE, 300., q),
SvfCoef::allpass(RATE, 300., q),
);
let mut s = [Svf::default(); 5];
let mut saw = Saw::new(RATE, 55.);
for _ in 0..9600 {
let x = saw.sample();
let l = s[0].process(&low, x);
let l = s[1].process(&low, l);
let h = s[2].process(&high, x);
let h = s[3].process(&high, h);
let a = s[4].process(&all, x);
assert!((l + h - a).abs() < 1e-4, "{} vs {a}", l + h);
}
let mut s = Svf::default();
for hz in [30., 300., 3000.] {
assert!(sine_gain(|x| s.process(&all, x), hz).abs() < 0.01);
}
}
#[test]
fn svf_survives_retuning_every_sample() {
let mut s = Svf::default();
@@ -149,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;
@@ -185,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 {
@@ -223,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);
}
+26
View File
@@ -80,3 +80,29 @@ fn wave_round_trip_keeps_root_and_wavetable_chunks() {
assert_eq!(wav::decode(&bytes).unwrap(), w);
assert!(wav::decode(&bytes[..40]).is_err());
}
#[test]
fn k_weighting_reads_a_full_scale_997_hz_sine_at_minus_3_lufs() {
use common_dsp::loudness::{lufs, KWeighting};
let level = |rate: f32, hz: f64| {
let mut k = KWeighting::new(rate);
let n = rate as usize;
let mut sum = 0f64;
for i in 0..2 * n {
let x = (std::f64::consts::TAU * hz * i as f64 / rate as f64).sin() as f32;
let y = k.process(x) as f64;
if i >= n {
sum += y * y;
}
}
lufs(sum / n as f64)
};
// BS.1770-4: a 0 dBFS 997 Hz sine in one channel reads −3.01 LKFS, at every rate.
for rate in [44100., 48000., 96000., 192000.] {
let got = level(rate, 997.);
// BS.1770 allows meters 0.1 LU; the prototypes land within 0.03 up to 192 kHz.
assert!((got + 3.01).abs() < 0.05, "{rate}: {got}");
}
// The RLB high-pass counts a 30 Hz sub much less than the mids.
assert!(level(48000., 30.) < level(48000., 997.) - 5.);
}
+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" }
+284 -11
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.
@@ -113,6 +115,58 @@ pub fn rotary(
unit: &str,
size: f32,
color: Color32,
) -> egui::Response {
let response = dial(ui, value, range.clone(), default, size, color);
dial_caption(ui, name, size);
ui.add(
egui::DragValue::new(value)
.range(range)
.speed(0.1)
.suffix(unit),
);
response
}
/// A rotary whose value field shows custom text, for values that are not a plain number
/// with a unit (a ratio, a mapped knob position).
#[allow(clippy::too_many_arguments)]
pub fn rotary_text(
ui: &mut egui::Ui,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
size: f32,
color: Color32,
format: impl Fn(f32) -> String,
parse: impl Fn(&str) -> Option<f32>,
) -> egui::Response {
let response = dial(ui, value, range.clone(), default, size, color);
dial_caption(ui, name, size);
let speed = (*range.end() - *range.start()) as f64 / 200.;
ui.add(
egui::DragValue::new(value)
.range(range)
.speed(speed)
.custom_formatter(|v, _| format(v as f32))
.custom_parser(|s| parse(s).map(f64::from)),
);
response
}
fn dial_caption(ui: &mut egui::Ui, name: &str, size: f32) {
ui.label(
RichText::new(name.to_uppercase())
.size(if size > 100. { 14. } else { 12. })
.color(MUTED),
);
}
/// The knob itself: drag, keys, double-click reset, and drawing.
fn dial(
ui: &mut egui::Ui,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
size: f32,
color: Color32,
) -> egui::Response {
let low = *range.start();
let high = *range.end();
@@ -212,17 +266,6 @@ pub fn rotary(
if response.has_focus() {
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, color));
}
ui.label(
RichText::new(name.to_uppercase())
.size(if size > 100. { 14. } else { 12. })
.color(MUTED),
);
ui.add(
egui::DragValue::new(value)
.range(range)
.speed(0.1)
.suffix(unit),
);
response
}
/// Vertical gradient rectangle, used for panel faces.
@@ -269,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);
@@ -278,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);
});
}
@@ -318,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
}
+48 -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)]
@@ -34,6 +34,52 @@ pub fn stamp(unix: u64) -> String {
)
}
/// Writes a captured take's loop to `root/takes/` right away, so it survives a reload or a
/// crash. Never overwrites: a name already taken gets a counter. Returns the file written.
pub fn save_take(take: &Take, root: &Path, unix: u64) -> std::io::Result<PathBuf> {
let folder = root.join("takes");
std::fs::create_dir_all(&folder)?;
let base = format!(
"{}_{:.0}bpm_{}bar{}",
stamp(unix),
take.tempo(),
take.bars,
if take.bars == 1 { "" } else { "s" }
);
let wave = wav::Wave {
channels: 2,
rate: take.rate.round() as u32,
samples: take.audio.clone(),
root: None,
frame: None,
};
let bytes = wav::encode(&wave);
for n in 1..1000 {
let name = if n == 1 {
format!("{base}.wav")
} else {
format!("{base}_{n}.wav")
};
let path = folder.join(name);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
std::io::Write::write_all(&mut file, &bytes)?;
return Ok(path);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"too many takes with the same name",
))
}
pub fn export(
take: &Take,
settings: &Settings,
@@ -55,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(
+2 -2
View File
@@ -5,10 +5,10 @@ mod player;
mod ring;
mod settings;
mod take;
pub use export::{export, stamp, Exported};
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,
+41
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 {
@@ -64,8 +92,12 @@ pub struct Settings {
pub trim: bool,
/// Also write a wavetable from the selected slice.
pub wavetable: bool,
/// Save every captured take's loop to the `takes` subfolder at once.
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 {
@@ -78,7 +110,9 @@ impl Default for Settings {
normalize: true,
trim: true,
wavetable: false,
keep_takes: true,
folder: String::new(),
key: None,
}
}
}
@@ -99,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.
Loaded 100 of 242 files, more files were not shown because too many files have changed in this diff. Show more