Compare commits

...
Author SHA1 Message Date
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
163 changed files with 20380 additions and 167 deletions

No files matched your search

+5 -1
View File
@@ -22,7 +22,7 @@ jobs:
strategy:
fail-fast: false
matrix:
plugin: [rivet, patina, anvil, crucible, lathe, temper, weld, knurl]
plugin: [rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish, braze]
steps:
- uses: https://github.com/actions/checkout@v4
with:
@@ -62,6 +62,8 @@ jobs:
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
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
name: editor-previews
@@ -93,6 +95,8 @@ jobs:
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/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*", "knurl-v*"]
tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*", "lathe-v*", "temper-v*", "weld-v*", "knurl-v*", "planish-v*", "braze-v*"]
permissions:
contents: read
Generated
+39
View File
@@ -61,6 +61,25 @@ 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"
@@ -304,6 +323,7 @@ dependencies = [
name = "patina-core"
version = "0.2.0"
dependencies = [
"common-dsp",
"rustfft",
"serde",
"serde_json",
@@ -326,6 +346,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"
+4 -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/knurl/core", "plugins/knurl/ui", "plugins/crucible/core", "plugins/crucible/ui", "plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"]
exclude = ["plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin", "plugins/anvil/plugin", "plugins/crucible/plugin", "plugins/lathe/plugin", "plugins/temper/plugin", "plugins/weld/plugin", "plugins/knurl/plugin"]
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/crucible/core", "plugins/crucible/ui", "plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"]
exclude = ["plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin", "plugins/anvil/plugin", "plugins/crucible/plugin", "plugins/lathe/plugin", "plugins/temper/plugin", "plugins/weld/plugin", "plugins/knurl/plugin", "plugins/planish/plugin"]
[workspace.package]
edition = "2021"
@@ -26,6 +26,8 @@ opt-level = 2
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
+22 -10
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,23 +22,26 @@ 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) |
Plugins with latency report it to the host, so FL Studio compensates the delay.
Anvil, Lathe, Temper and Knurl detect pitch from the audio. They can also take the
pitch from MIDI.
Anvil, Lathe, Temper, Knurl, Planish and Braze detect pitch 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;
**knurling** presses a ridged grip pattern into a surface.
**knurling** presses a ridged grip pattern into a surface; **planishing**
hammers a surface smooth and even; **brazing** joins two metals with a filler.
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*.
@@ -59,6 +64,10 @@ finished. Hosts list the plugins under the vendor name *Bass Foundry*.
<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><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>
</table>
The previews are rendered headless from the real editor code by
@@ -85,11 +94,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
@@ -117,7 +126,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`, `weld` and `knurl`. Builds go to `dist/<target>/<Name>.clap`. No
`lathe`, `temper`, `weld`, `knurl`, `planish` and `braze`. 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.
@@ -155,6 +164,8 @@ plugins/
temper/ core · ui · plugin
weld/ core · ui · plugin
knurl/ core · ui · plugin
planish/ core · ui · plugin
braze/ core · ui · plugin
common/
ui/ Shared look: palette, embedded fonts, knobs, panels, meters
dsp/ Shared filters, pitch tracking, oversampling, meters, WAV files
@@ -163,6 +174,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:
+21
View File
@@ -1,5 +1,26 @@
# Anvil changelog
## 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.
+67 -2
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.3.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
@@ -28,12 +28,73 @@ redistributed packages.
**Retrigger** on, every note starts at this phase.
5. Turn on **Solo sub** to hear only the new sub; the scope shows the original
low band (grey) against the new sub (accent).
6. Turn on **Phone** to check the drop the way phones and laptop speakers play
it: mono, nothing below 400 Hz. A clean sub vanishes there. If the note
disappears with it, raise **Ghost**.
### 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.
@@ -42,6 +103,10 @@ Other controls:
- **Keep** blends some of the original low band back under the new sub.
- **Snap to semitone** rounds detected pitch. Turn it off for pitch bends and slides.
If Knurl is on the bass before the bus, use **MIDI**: Knurl's patterns make
detection read notes from C2 upwards an octave low. See
[Signal chain](../chain.md).
### Pitch from MIDI
Detection follows the audio by default. For heavy formant or FM basses where the
Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 304 KiB

+18
View File
@@ -0,0 +1,18 @@
# Braze changelog
## 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.
+76
View File
@@ -0,0 +1,76 @@
# Braze
Braze makes a reese's movement play in time. Current version **0.1.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
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. 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.
### 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. See
[Signal chain](../chain.md).
Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

+60
View File
@@ -0,0 +1,60 @@
# Signal chain
The order of the Bass Foundry plugins in a chain matters. Anvil, Lathe, Temper,
Knurl, Planish and Braze 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 → Weld
bass bus: Anvil → (bus compression, clipping) → Crucible
```
- **Lathe and Temper before Knurl.** They hear the plain synth, where the pitch
is clear.
- **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.
- **Knurl last among the tracked plugins.** See below.
- **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.
- **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.
## 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. 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.
+13
View File
@@ -1,5 +1,18 @@
# Crucible changelog
## 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.
+5 -2
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.3.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
@@ -52,7 +52,10 @@ 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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 223 KiB

+20 -15
View File
@@ -25,7 +25,7 @@ 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, Temper and Knurl, 4× oversampling, meters and WAV files.
Lathe, Temper, Knurl, Planish and Braze, 4× oversampling, 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
@@ -45,7 +45,7 @@ python3 -m unittest discover -s scripts/tests -v
```
Products: `rivet`, `patina`, `anvil`, `crucible`, `lathe`, `temper`, `weld`,
`knurl`.
`knurl`, `planish`, `braze`.
`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
@@ -136,20 +136,25 @@ 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` |
| Knurl | (new) | `io.storyboard.cycle-texture` |
| 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` |
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.
+283
View File
@@ -35,6 +35,126 @@ 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.
## 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
@@ -83,8 +203,148 @@ 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.
- **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: 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, when enough modules exist.
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.
### 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.
### 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
@@ -113,6 +373,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
@@ -196,7 +465,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.
+6
View File
@@ -1,5 +1,11 @@
# Knurl changelog
## 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.
+6 -1
View File
@@ -1,7 +1,7 @@
# Knurl
Knurl gives every cycle of the note its own level, timing and grit. Current
version **0.1.0**.
version **0.2.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
@@ -72,6 +72,11 @@ 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
+6
View File
@@ -1,5 +1,11 @@
# Lathe changelog
## 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.
+1 -1
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.2.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
+16
View File
@@ -1,5 +1,21 @@
# Patina changelog
## 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
+30 -8
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.4.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
@@ -33,6 +33,27 @@ cuts, then use Delta to hear what is removed. Restrict Low/High frequency to kee
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
on the plot drag the processing limits.
@@ -76,7 +97,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 +138,12 @@ 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. 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: 420 KiB

+14
View File
@@ -0,0 +1,14 @@
# Planish changelog
## 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.
+97
View File
@@ -0,0 +1,97 @@
# Planish
Planish is upward and downward compression (OTT) in four bands whose edges are
harmonic numbers of the played note. Current version **0.1.0**.
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
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. **Band 1–4** set each band's gain after its compressor. Like the
compression, they are scaled by Depth.
**Output** trims the level. **Bypass** crossfades to the delayed dry signal.
### Pitch from MIDI
For basses whose fundamental is unclear (heavy FM, formant or noise patches),
and when Planish comes after Knurl, select **MIDI** and route the bass's MIDI
notes to Planish. In FL Studio, set the bass channel's MIDI output port and
Planish's input port to the same number. **Gate** sets the input level that
counts as a note in AUDIO mode. **Snap to semitone** rounds the detected pitch;
it is off by default, because the level is measured over one period of the
pitch in use, and a detuned patch fits its real period better.
## 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.
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: 307 KiB

+10 -8
View File
@@ -7,14 +7,16 @@ 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` |
| Knurl | `knurl-v0.1.0` | `knurl-windows-x86_64-0.1.0.zip` |
| Rivet | `rivet-v0.5.0` | `rivet-windows-x86_64-0.5.0.zip` |
| Patina | `patina-v0.4.0` | `patina-windows-x86_64-0.4.0.zip` |
| Anvil | `anvil-v0.3.0` | `anvil-windows-x86_64-0.3.0.zip` |
| Crucible | `crucible-v0.3.0` | `crucible-windows-x86_64-0.3.0.zip` |
| Lathe | `lathe-v0.2.0` | `lathe-windows-x86_64-0.2.0.zip` |
| Temper | `temper-v0.2.0` | `temper-windows-x86_64-0.2.0.zip` |
| Weld | `weld-v0.3.0` | `weld-windows-x86_64-0.3.0.zip` |
| Knurl | `knurl-v0.2.0` | `knurl-windows-x86_64-0.2.0.zip` |
| Planish | `planish-v0.1.0` | `planish-windows-x86_64-0.1.0.zip` |
| Braze | `braze-v0.1.0` | `braze-windows-x86_64-0.1.0.zip` |
## 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.
+6
View File
@@ -1,5 +1,11 @@
# Temper changelog
## 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.
+1 -1
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.2.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
+13
View File
@@ -1,5 +1,18 @@
# Weld changelog
## 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.
+6 -1
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.3.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
@@ -50,6 +50,11 @@ plugins. Keep the font license with redistributed packages.
- 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: 266 KiB

+427 -14
View File
@@ -4,10 +4,23 @@
//! 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.
//!
//! 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::meter::HarmonicMeter;
use common_dsp::svf::{Svf, SvfCoef};
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::{coefficient, db_to_gain, hz_to_midi, midi_to_hz, Biquad, Crossover};
pub use settings::{Settings, Source, KICK_OFFSET_MAX};
use std::f32::consts::TAU;
/// Detectable fundamental range. The lowest sets the lookahead (two periods).
@@ -17,11 +30,44 @@ pub const SCOPE: usize = 256;
const ATTACK_MS: f32 = 2.;
const RELEASE_MS: f32 = 15.;
const CROSSFADE_MS: f32 = 3.;
/// 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)]
@@ -32,6 +78,21 @@ pub struct AnalyzerFrame {
pub note: Option<u8>,
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],
@@ -47,6 +108,13 @@ impl Default for AnalyzerFrame {
note: None,
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 +140,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 +181,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);
}
}
}
@@ -140,6 +316,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 {
@@ -179,6 +363,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)
@@ -226,6 +421,49 @@ impl Engine {
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 +481,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 +492,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 {
@@ -288,6 +651,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 +680,30 @@ 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();
let sub = sub * self.level * shape * duck;
let generated = sub + ghost * self.level * shape * duck;
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);
}
let wet = |l: f32, h: f32| {
if monitor.solo {
sub
generated
} else {
h + self.keep * l + sub
h + self.keep * l + generated
}
};
let w = [wet(l0, h0), wet(l1, h1)];
@@ -326,6 +711,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,8 +731,25 @@ 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,
..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 {
f.hz = v.hz;
+50 -1
View File
@@ -22,6 +22,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,6 +44,32 @@ 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 {
@@ -46,6 +79,8 @@ impl Default for Settings {
split: 90.,
dynamics: 0.,
drive: 0.,
ghost: 0.,
ghost_from: GHOST_FROM,
octave: 0,
glide: 0.,
phase: 0.,
@@ -55,6 +90,10 @@ impl Default for Settings {
retrigger: true,
snap: true,
bypass: false,
kick_lock: false,
kick_duck: KICK_DUCK,
kick_release: KICK_RELEASE,
kick_offset: 0.,
}
}
}
@@ -65,11 +104,16 @@ impl Settings {
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 +124,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 {
+279 -2
View File
@@ -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,264 @@ 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]
);
}
}
+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());
}
+1 -1
View File
@@ -72,7 +72,7 @@ checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
[[package]]
name = "anvil"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"anvil-core",
"anvil-ui",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "anvil"
version = "0.1.0"
version = "0.3.0"
edition = "2021"
license = "ISC"
+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;
+54 -4
View File
@@ -1,4 +1,4 @@
use anvil_core::{Settings, Source};
use anvil_core::{Settings, Source, KICK_OFFSET_MAX};
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::Arc;
@@ -28,6 +28,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 +50,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 {
@@ -66,6 +78,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,6 +98,16 @@ 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",
),
}
}
}
@@ -89,6 +122,8 @@ impl AnvilParams {
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 +133,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 +147,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 +158,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 +180,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 +197,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);
@@ -180,6 +225,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,
+258 -7
View File
@@ -1,14 +1,21 @@
//! Anvil editor, shared by the CLAP wrapper and the headless renderer.
use anvil_core::{AnalyzerFrame, Settings, Source, SCOPE};
use anvil_core::{
AnalyzerFrame, 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,
@@ -18,6 +25,8 @@ impl Default for State {
fn default() -> Self {
Self {
solo: false,
phone: false,
audition: false,
analyzer: AnalyzerFrame::default(),
gesture_active: false,
help: DEFAULT_HELP.into(),
@@ -64,7 +73,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 +188,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.,
@@ -145,10 +215,18 @@ 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;
}
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;
}
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.));
@@ -169,7 +247,7 @@ 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.);
@@ -177,6 +255,11 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
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);
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
@@ -212,7 +295,7 @@ fn note_panel(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame) {
}
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 +338,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.);
@@ -348,3 +454,148 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
);
});
}
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 = "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 }
+356
View File
@@ -0,0 +1,356 @@
//! 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()?;
self.tracker.set(s.source == Source::Midi, 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.sounding() && 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;
}
}
+146
View File
@@ -0,0 +1,146 @@
//! Host-independent controls. Levels in dB, amounts in percent, angles in degrees.
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,
}
/// 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,
/// 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,
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> {
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);
}
+2381
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "braze"
version = "0.1.0"
edition = "2021"
license = "ISC"
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
braze-core = { path = "../core" }
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);
+219
View File
@@ -0,0 +1,219 @@
use braze_core::{Rate, Settings, Source, HARMONIC_MAX, LOW_CUT_MAX};
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 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>,
#[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),
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: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
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 {
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);
}
}
}
+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" }
+410
View File
@@ -0,0 +1,410 @@
//! 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,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
gesture_active: 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.);
header(ui, "BRAZE", "TEMPO-LOCKED BEATING", |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.");
if r.clicked() {
settings.bypass = !settings.bypass;
}
ui.separator();
for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] {
let r = ui.selectable_label(settings.source == source, RichText::new(label).size(13.));
if r.clicked() {
settings.source = source;
}
}
caption(ui, "PITCH FROM");
});
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(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.,
match settings.source {
Source::Audio => "detecting pitch",
Source::Midi => "following MIDI notes",
}
))
.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();
ui.columns(6, |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.",
);
knob(
&mut c[3],
state,
"Phase",
&mut settings.phase,
0. ..=360.,
d.phase,
"°",
72.,
"Where the beat peaks. 0° puts the peak of the chosen harmonic on the beat.",
);
knob(
&mut c[4],
state,
"Gate",
&mut settings.gate,
-60. ..=-20.,
d.gate,
" dB",
72.,
"Input level that counts as a note. Raise it if tails retrigger.",
);
knob(
&mut c[5],
state,
"Output",
&mut settings.output,
-12. ..=12.,
d.output,
" dB",
72.,
"Output trim.",
);
});
ui.add_space(4.);
let r = ui.selectable_label(settings.snap, RichText::new("SNAP TO SEMITONE").size(13.));
if r.clicked() {
settings.snap = !settings.snap;
}
hint(
&r,
state,
"Round the detected pitch. Off keeps the copy's jumps on the real period, which is \
seamless on detuned patches.",
);
}
+43 -8
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"
},
@@ -121,7 +122,7 @@
"manifest": "plugins/knurl/plugin/Cargo.toml",
"library": "knurl",
"documentation": "docs/knurl",
"clap_id": "io.storyboard.cycle-texture",
"clap_id": "io.narl.bass-foundry.knurl",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
@@ -130,5 +131,39 @@
"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"
]
}
}
+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);
+25
View File
@@ -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();
+46
View File
@@ -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,
+1 -1
View File
@@ -5,7 +5,7 @@ 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};
+3
View File
@@ -64,6 +64,8 @@ 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,
}
@@ -78,6 +80,7 @@ impl Default for Settings {
normalize: true,
trim: true,
wavetable: false,
keep_takes: true,
folder: String::new(),
}
}
+15
View File
@@ -231,6 +231,21 @@ fn export_writes_loop_slices_and_wavetable() {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn save_take_writes_the_loop_and_never_overwrites() {
let dir = std::env::temp_dir().join(format!("crucible-takes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut saw = Saw::new(RATE, 55.);
let take = take_of((0..96000).map(|_| saw.sample() * 0.5).collect(), 120., 1);
let first = save_take(&take, &dir, 1_700_000_000).unwrap();
let second = save_take(&take, &dir, 1_700_000_000).unwrap();
assert!(first.ends_with("takes/2023-11-14_221320_120bpm_1bar.wav"));
assert!(second.ends_with("takes/2023-11-14_221320_120bpm_1bar_2.wav"));
let w = wav::decode(&std::fs::read(&first).unwrap()).unwrap();
assert_eq!(w.samples, take.audio);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn audition_plays_and_returns_clips_for_freeing() {
let q = Queues::default();
+1 -1
View File
@@ -450,7 +450,7 @@ checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crucible"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"clap-sys",
"crucible-core",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "crucible"
version = "0.1.0"
version = "0.3.0"
edition = "2021"
license = "ISC"
+1 -1
View File
@@ -185,7 +185,7 @@ impl Plugin for Crucible {
}
}
impl ClapPlugin for Crucible {
const CLAP_ID: &'static str = "io.storyboard.resampler";
const CLAP_ID: &'static str = "io.narl.bass-foundry.crucible";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Capture the last bars, slice them and export clean, key-labelled samples");
const CLAP_MANUAL_URL: Option<&'static str> = None;
+4 -1
View File
@@ -59,7 +59,10 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(CStr::from_ptr((*descriptor).name), c"Crucible");
assert_eq!(CStr::from_ptr((*descriptor).id), c"io.storyboard.resampler");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.narl.bass-foundry.crucible"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
clap_version: CLAP_VERSION,
+38 -2
View File
@@ -1,8 +1,8 @@
//! Crucible editor, shared by the CLAP wrapper and the headless renderer.
use common_ui::*;
use crucible_core::{
capture, export, render, Division, Exported, Mode, Queues, Ring, Settings, Take, Transport,
OVERVIEW,
capture, export, render, save_take, Division, Exported, Mode, Queues, Ring, Settings, Take,
Transport, OVERVIEW,
};
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use std::sync::{Arc, Mutex};
@@ -33,6 +33,8 @@ pub struct State {
names: Vec<String>,
/// Filled by the export thread when it finishes.
job: Option<Arc<Mutex<Option<std::io::Result<Exported>>>>>,
/// Filled by the take-saving thread when it finishes.
saving: Option<Arc<Mutex<Option<std::io::Result<std::path::PathBuf>>>>>,
last_export: Option<std::path::PathBuf>,
}
impl Default for State {
@@ -49,6 +51,7 @@ impl Default for State {
counter: 0,
names: Vec::new(),
job: None,
saving: None,
last_export: None,
}
}
@@ -136,6 +139,17 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link:
Err(e) => format!("Export failed: {e}"),
};
}
let saved = state
.saving
.as_ref()
.and_then(|job| job.lock().ok().and_then(|mut slot| slot.take()));
if let Some(result) = saved {
state.saving = None;
state.status += &match result {
Ok(path) => format!(" Saved {}", path.display()),
Err(e) => format!(" Saving the take failed: {e}"),
};
}
ui.add_space(2.);
header(ui, "CRUCIBLE", "RESAMPLER", |ui| {
let db = 20. * state.peak.max(1e-6).log10();
@@ -204,6 +218,19 @@ fn capture_row(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, li
match capture(ring, settings.bars) {
Ok(take) => {
let synced = take.synced;
if settings.keep_takes {
let slot = Arc::new(Mutex::new(None));
let done = slot.clone();
let take = take.clone();
let folder = settings.folder();
std::thread::spawn(move || {
let result = save_take(&take, &folder, unix_now());
if let Ok(mut slot) = done.lock() {
*slot = Some(result);
}
});
state.saving = Some(slot);
}
state.add(take, settings);
state.status = if synced {
format!("Captured {} bars on the bar grid.", settings.bars)
@@ -481,6 +508,15 @@ fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link:
state,
"Also write wavetable.wav from the selected slice: 2048-sample frames for Serum or Vital.",
);
let r = chip(ui, settings.keep_takes, "KEEP TAKES");
if r.clicked() {
settings.keep_takes = !settings.keep_takes;
}
hint(
&r,
state,
"Save every capture's loop to the takes folder at once, so a reload or crash loses nothing.",
);
ui.separator();
let take = state.take;
let slice = state.slice;
+1 -1
View File
@@ -861,7 +861,7 @@ dependencies = [
[[package]]
name = "knurl"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"clap-sys",
"crossbeam-queue",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "knurl"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "ISC"
+1 -1
View File
@@ -180,7 +180,7 @@ impl Plugin for Knurl {
}
}
impl ClapPlugin for Knurl {
const CLAP_ID: &'static str = "io.storyboard.cycle-texture";
const CLAP_ID: &'static str = "io.narl.bass-foundry.knurl";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Texture per cycle of the played note: patterns, jitter and grit that fit every note");
const CLAP_MANUAL_URL: Option<&'static str> = None;
+1 -1
View File
@@ -61,7 +61,7 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
assert_eq!(CStr::from_ptr((*descriptor).name), c"Knurl");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.storyboard.cycle-texture"
c"io.narl.bass-foundry.knurl"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
+1 -1
View File
@@ -861,7 +861,7 @@ dependencies = [
[[package]]
name = "lathe"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"clap-sys",
"crossbeam-queue",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "lathe"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "ISC"
+1 -1
View File
@@ -174,7 +174,7 @@ impl Plugin for Lathe {
}
}
impl ClapPlugin for Lathe {
const CLAP_ID: &'static str = "io.storyboard.harmonic-eq";
const CLAP_ID: &'static str = "io.narl.bass-foundry.lathe";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("EQ the harmonics of the played note: a cut on harmonic 3 stays on harmonic 3");
const CLAP_MANUAL_URL: Option<&'static str> = None;
+1 -1
View File
@@ -61,7 +61,7 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
assert_eq!(CStr::from_ptr((*descriptor).name), c"Lathe");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.storyboard.harmonic-eq"
c"io.narl.bass-foundry.lathe"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
+1
View File
@@ -5,6 +5,7 @@ edition = "2021"
license = "ISC"
[dependencies]
common-dsp = { path = "../../common/dsp" }
rustfft = "6.4"
serde = { version = "1", features = ["derive"] }
+78 -2
View File
@@ -1,5 +1,7 @@
//! Independently designed spectral attenuation. No host or GUI dependencies.
mod pitch;
mod settings;
use pitch::PitchProbe;
use rustfft::{num_complex::Complex32, Fft, FftPlanner};
pub use settings::{focus_offset, knee, Focus, Settings};
use std::{f32::consts::PI, sync::Arc};
@@ -22,6 +24,14 @@ const CLIP_DB: f32 = 2.;
const EMPTY_DB: f32 = 24.;
/// Steepest spectral tilt the reference follows, in dB per octave.
const MAX_TILT: f32 = 12.;
/// Tone guard memory: bump heights are averaged over about this many seconds.
const TONE_MEMORY: f32 = 4.;
/// Tone guard starts to protect a bump this much (as a share of its height) more consistent
/// relative to the note than in Hz, and protects it fully at `TONE_FULL`.
const TONE_START: f32 = 0.2;
const TONE_FULL: f32 = 0.6;
/// Bumps lower than this (dB) do not count for the tone guard comparison.
const TONE_MIN_DB: f32 = 1.;
fn grid_step() -> f32 {
1000f32.log2() / (GRID - 1) as f32
}
@@ -112,6 +122,10 @@ pub struct AnalyzerFrame {
pub reference: [f32; GRID],
pub reduction: [f32; GRID],
pub focus: [f32; GRID],
/// Share of the cut that Tone guard holds back, 0–1.
pub tone: [f32; GRID],
/// Fundamental the Tone guard follows, Hz; 0 when none.
pub pitch: f32,
pub input_peak: f32,
pub output_peak: f32,
pub guard: f32,
@@ -127,6 +141,8 @@ impl Default for AnalyzerFrame {
reference: [-120.; GRID],
reduction: [0.; GRID],
focus: [0.; GRID],
tone: [0.; GRID],
pitch: 0.,
input_peak: 0.,
output_peak: 0.,
guard: 0.,
@@ -139,6 +155,10 @@ impl Default for AnalyzerFrame {
pub fn frequency(index: usize) -> f32 {
20. * 1000f32.powf(index as f32 / (GRID - 1) as f32)
}
/// Grid position of a frequency, fractional.
pub fn cell(hz: f32) -> f32 {
(hz.max(20.) / 20.).log2() / 1000f32.log2() * (GRID - 1) as f32
}
#[derive(Clone, Copy, Default)]
pub struct Monitor {
pub delta: bool,
@@ -173,6 +193,12 @@ pub struct Engine {
envelope_radius: usize,
reference_radius: usize,
settings: Settings,
/// Tone guard: pitch of the analysis window and two slow maps of bump height, one fixed
/// in Hz and one relative to the fundamental (index = cell + GRID − fundamental cell).
probe: PitchProbe,
fixed: [f32; GRID],
relative: [f32; 2 * GRID],
tone: [f32; GRID],
frame: AnalyzerFrame,
fresh: bool,
fast: f32,
@@ -276,6 +302,10 @@ impl Engine {
envelope_radius: 0,
reference_radius: 0,
settings: Settings::default(),
probe: PitchProbe::new(rate),
fixed: [0.; GRID],
relative: [0.; 2 * GRID],
tone: [0.; GRID],
frame: AnalyzerFrame::default(),
fresh: false,
fast: 0.,
@@ -344,6 +374,10 @@ impl Engine {
}
self.reduction.fill(0.);
self.power = [[0.; GRID]; 2];
self.probe.reset();
self.fixed.fill(0.);
self.relative.fill(0.);
self.tone.fill(0.);
self.pos = 0;
self.hop_pos = 0;
self.fast = 0.;
@@ -403,6 +437,14 @@ impl Engine {
((10. * ((self.fast + 1e-20) / (self.slow + 1e-20)).log10() - 3.) / 9.).clamp(0., 1.);
self.protect = onset.max(self.protect * (-1. / (self.rate * 0.03)).exp());
self.sample_power += energy;
if self.settings.tone_guard > 0. {
let mono = if self.channels == 2 {
0.5 * (self.input[0][self.pos] + self.input[1][self.pos])
} else {
self.input[0][self.pos]
};
self.probe.push(mono);
}
self.pos = (self.pos + 1) % self.n;
self.hop_pos += 1;
if self.hop_pos == self.hop {
@@ -433,6 +475,26 @@ impl Engine {
let mut clipped = [0f32; GRID];
let mut weight = [0f32; GRID];
let mut content = [false; GRID];
let mut bumps = [0f32; GRID];
// Tone guard: protect bumps that have followed the note more than they stayed in Hz.
let guard = self.settings.tone_guard / 100.;
let pitch = if guard > 0. && awake {
self.probe.estimate()
} else {
0.
};
let shift = cell(pitch).round() as usize;
for i in 0..GRID {
self.tone[i] = if pitch > 0. {
let rel = self.relative[i + GRID - shift];
let fixed = self.fixed[i];
let score = (rel - fixed) / rel.max(fixed).max(TONE_MIN_DB);
let t = ((score - TONE_START) / (TONE_FULL - TONE_START)).clamp(0., 1.);
t * t * (3. - 2. * t) * guard
} else {
0.
};
}
for c in 0..self.channels {
for i in 0..self.n {
self.spectra[c][i] =
@@ -524,15 +586,27 @@ impl Engine {
let excess = level - reference[i] + self.curve[i] - threshold;
// Only real content can ring: leakage far below nearby
// material is never cut.
let target = if awake && content[i] && level > -130. {
(knee(excess, KNEE) * SLOPE).min(self.settings.depth)
let live = awake && content[i] && level > -130.;
let target = if live {
(knee(excess, KNEE) * SLOPE).min(self.settings.depth) * (1. - self.tone[i])
} else {
0.
};
if live {
bumps[i] = bumps[i].max(level - reference[i]);
}
// Linked stereo: the stronger channel decides.
self.target[i] = self.target[i].max(target);
}
}
if pitch > 0. {
let alpha = 1. - (-frame_time / TONE_MEMORY).exp();
for i in 0..GRID {
self.fixed[i] += (bumps[i] - self.fixed[i]) * alpha;
let r = &mut self.relative[i + GRID - shift];
*r += (bumps[i] - *r) * alpha;
}
}
let cut = self.target;
smooth(&cut, &mut self.target, (self.envelope_radius / 2).max(1), 1);
let attack = (-(self.hop as f32) / (self.rate * self.settings.attack * 0.001)).exp();
@@ -553,6 +627,7 @@ impl Engine {
* (1. - self.protect * self.settings.transient_guard / 100.))
.clamp(0., self.settings.depth);
self.frame.focus[i] = self.curve[i];
self.frame.tone[i] = self.tone[i];
}
for c in 0..self.channels {
for k in 0..=self.n / 2 {
@@ -581,6 +656,7 @@ impl Engine {
self.frame.input_peak = self.in_peak;
self.frame.output_peak = self.out_peak;
self.frame.guard = self.protect;
self.frame.pitch = pitch;
self.frame.sample_rate = self.rate;
self.frame.latency = self.n as u32;
self.frame.channels = self.channels;
+80
View File
@@ -0,0 +1,80 @@
//! Fundamental of the newest input, for Tone guard. Allocation-free after `new`.
use common_dsp::{hz_to_midi, Biquad, Yin};
const MIN_HZ: f32 = 30.;
const MAX_HZ: f32 = 500.;
/// Lowpass in front of detection, removing upper harmonics that confuse YIN.
const DETECT_LOWPASS: f32 = 1000.;
const DETECT_RATE: f32 = 6000.;
const CLARITY: f32 = 0.6;
/// Two estimates in a row must agree this closely (semitones) to count.
const AGREE: f32 = 0.5;
pub(crate) struct PitchProbe {
lowpass: [Biquad; 2],
decimate: usize,
count: usize,
rate: f32,
ring: Vec<f32>,
at: usize,
scratch: Vec<f32>,
yin: Yin,
last: f32,
}
impl PitchProbe {
pub fn new(rate: f32) -> Self {
let decimate = (rate / DETECT_RATE).round().max(1.) as usize;
let detect_rate = rate / decimate as f32;
let max_lag = (detect_rate / MIN_HZ).ceil() as usize;
let window = 2 * max_lag + 3;
let q = std::f32::consts::FRAC_1_SQRT_2;
Self {
lowpass: [Biquad::lowpass(rate, DETECT_LOWPASS, q); 2],
decimate,
count: 0,
rate: detect_rate,
ring: vec![0.; window],
at: 0,
scratch: vec![0.; window],
yin: Yin::new(max_lag + 2),
last: 0.,
}
}
pub fn push(&mut self, x: f32) {
let d = self.lowpass[0].process(x);
let d = self.lowpass[1].process(d);
self.count += 1;
if self.count >= self.decimate {
self.count = 0;
self.ring[self.at] = d;
self.at = (self.at + 1) % self.ring.len();
}
}
/// Fundamental in Hz of the newest input, or 0 when it is unclear or still changing.
pub fn estimate(&mut self) -> f32 {
let n = self.ring.len();
for i in 0..n {
self.scratch[i] = self.ring[(self.at + i) % n];
}
let hz = match self.yin.estimate(&self.scratch, self.rate, MIN_HZ, MAX_HZ) {
Some((hz, clarity)) if clarity >= CLARITY => hz,
_ => 0.,
};
let stable =
hz > 0. && self.last > 0. && (hz_to_midi(hz) - hz_to_midi(self.last)).abs() < AGREE;
self.last = hz;
if stable {
hz
} else {
0.
}
}
pub fn reset(&mut self) {
for f in &mut self.lowpass {
f.reset();
}
self.ring.fill(0.);
self.count = 0;
self.last = 0.;
}
}
+15 -1
View File
@@ -37,6 +37,10 @@ pub struct Settings {
pub output: f32,
pub bypass: bool,
pub focus: [Focus; 4],
/// 0 % cuts every bump. Higher values hold back cuts on bumps that follow the played note
/// (a harmonic the sound is built on) while bumps fixed in Hz are still cut.
#[serde(default)]
pub tone_guard: f32,
}
impl Default for Settings {
fn default() -> Self {
@@ -53,6 +57,7 @@ impl Default for Settings {
output: 0.,
bypass: false,
focus: [Focus::default(); 4],
tone_guard: 0.,
}
}
}
@@ -69,6 +74,7 @@ impl Settings {
(self.high, 20., 20000.),
(self.mix, 0., 100.),
(self.output, -12., 12.),
(self.tone_guard, 0., 100.),
] {
if !v.is_finite() || !(low..=high).contains(&v) {
return Err("Invalid resonance control value");
@@ -102,11 +108,12 @@ impl Settings {
pub fn threshold(&self) -> f32 {
0.5 + self.selectivity * 0.05
}
pub const PRESETS: [&'static str; 4] = [
pub const PRESETS: [&'static str; 5] = [
"Gentle cleanup",
"Vocal harshness",
"Snare ring",
"Bright synth",
"Bass line",
];
pub fn preset(index: usize) -> Self {
let mut s = Self::default();
@@ -136,6 +143,13 @@ impl Settings {
s.sharpness = 25.;
s.selectivity = 45.;
}
4 => {
s.low = 150.;
s.high = 12000.;
s.sharpness = 50.;
s.selectivity = 45.;
s.tone_guard = 80.;
}
_ => {}
}
s
+110
View File
@@ -359,3 +359,113 @@ fn high_sample_rates_detect_the_same_bump() {
assert!(near >= 4. && away < 1., "{rate}: {near} / {away}");
}
}
/// Notes of a riddim-style line, in Hz (F1, G#1, C2, D#2, F2).
const LINE: [f32; 5] = [43.65, 51.91, 65.41, 77.78, 87.31];
/// Additive bass at `f0`: harmonics 1/k up to 16 kHz, each scaled by `shape(k, Hz)`.
fn bass(f0: f32, seconds: f32, shape: &dyn Fn(usize, f32) -> f32) -> Vec<f32> {
let rate = 48000.;
let n = (rate * seconds) as usize;
let mut x = vec![0f32; n];
for k in 1..=(16000. / f0) as usize {
let f = k as f32 * f0;
let a = 0.08 * shape(k, f) / k as f32;
let w = TAU * f / rate;
for (i, v) in x.iter_mut().enumerate() {
*v += a * (w * i as f32 + k as f32).sin();
}
}
x
}
/// Plays `notes` in turn (0.4 s each, `rounds` times) and returns the mean cut near `hz`
/// (largest within ±3 grid cells) over the second half of the last note.
fn tone_cut(
guard: f32,
notes: &[f32],
rounds: usize,
shape: &dyn Fn(usize, f32) -> f32,
hz: &dyn Fn(f32) -> f32,
) -> f32 {
let mut e = Engine::new(48000., 1).unwrap();
e.set_settings(Settings {
tone_guard: guard,
low: 150.,
..Default::default()
})
.unwrap();
let last = *notes.last().unwrap();
let at = patina_core::cell(hz(last)).round() as usize;
let (mut sum, mut count) = (0., 0);
for round in 0..rounds {
for &f0 in notes {
let x = bass(f0, 0.4, shape);
let measure = round + 1 == rounds && f0 == last;
for (i, v) in x.iter().enumerate() {
e.process_sample([*v, 0.], Monitor::default());
if let Some(frame) = e.take_analyzer() {
if measure && i > x.len() / 2 {
sum += frame.reduction[at - 3..=at + 3]
.iter()
.fold(0f32, |m, v| m.max(*v));
count += 1;
}
}
}
}
}
sum / count as f32
}
fn harmonic_six(k: usize, _: f32) -> f32 {
if k == 6 {
4.
} else {
1.
}
}
fn formant(_: usize, f: f32) -> f32 {
let z = (f / 1500.).log2() / 0.15;
1. + 3. * (-z * z).exp()
}
#[test]
fn tone_guard_holds_back_cuts_on_bumps_that_follow_the_note() {
let h6 = |f0: f32| 6. * f0;
let plain = tone_cut(0., &LINE, 3, &harmonic_six, &h6);
let guarded = tone_cut(100., &LINE, 3, &harmonic_six, &h6);
assert!(
plain > 4.,
"without guard the boosted harmonic is cut: {plain}"
);
assert!(
guarded < 0.25 * plain,
"guard keeps the harmonic: {guarded} vs {plain}"
);
}
#[test]
fn tone_guard_still_cuts_resonances_fixed_in_hz() {
let at = |_: f32| 1500.;
let plain = tone_cut(0., &LINE, 3, &formant, &at);
let guarded = tone_cut(100., &LINE, 3, &formant, &at);
assert!(plain > 4., "the formant is cut: {plain}");
assert!(
guarded > 0.85 * plain,
"guard still cuts it: {guarded} vs {plain}"
);
}
#[test]
fn tone_guard_needs_different_notes_to_tell_bumps_apart() {
let h6 = |f0: f32| 6. * f0;
let one = [LINE[2]];
let plain = tone_cut(0., &one, 10, &harmonic_six, &h6);
let guarded = tone_cut(100., &one, 10, &harmonic_six, &h6);
assert!(
(guarded - plain).abs() < 0.5,
"one held note gives no evidence: {guarded} vs {plain}"
);
}
#[test]
fn old_settings_without_tone_guard_still_load() {
let mut json = serde_json::to_value(Settings::default()).unwrap();
json.as_object_mut().unwrap().remove("tone_guard");
let s: Settings = serde_json::from_value(json).unwrap();
assert_eq!(s.tone_guard, 0.);
}
+7 -1
View File
@@ -35,6 +35,7 @@ static ALLOCATOR: CheckedAllocator = CheckedAllocator;
fn processing_automation_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings::preset(1);
settings.tone_guard = 80.;
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..48000 {
@@ -45,7 +46,12 @@ fn processing_automation_and_analysis_do_not_allocate_or_free() {
settings.low = 120. + (i % 100) as f32;
engine.set_settings(settings).unwrap();
}
std::hint::black_box(engine.process_sample([0.3, -0.2], Monitor { delta: i > 24000 }));
// A pitched input, so Tone guard estimates the pitch and updates its maps.
let x = 0.3
* (i as f32 * std::f32::consts::TAU * 55. / 48000.)
.sin()
.signum();
std::hint::black_box(engine.process_sample([x, -x], Monitor { delta: i > 24000 }));
std::hint::black_box(engine.take_analyzer());
}
engine.reset();
+6 -1
View File
@@ -306,6 +306,10 @@ dependencies = [
"memchr",
]
[[package]]
name = "common-dsp"
version = "0.1.0"
[[package]]
name = "common-ui"
version = "0.1.0"
@@ -1419,7 +1423,7 @@ dependencies = [
[[package]]
name = "patina"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"clap-sys",
"crossbeam-queue",
@@ -1434,6 +1438,7 @@ dependencies = [
name = "patina-core"
version = "0.2.0"
dependencies = [
"common-dsp",
"rustfft",
"serde",
]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "patina"
version = "0.2.0"
version = "0.4.0"
edition = "2021"
license = "ISC"
+1 -1
View File
@@ -187,7 +187,7 @@ fn store_epoch(ctx: &egui::Context, e: u64) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("reset-epoch"), e));
}
impl ClapPlugin for Patina {
const CLAP_ID: &'static str = "io.storyboard.resonance-compressor";
const CLAP_ID: &'static str = "io.narl.bass-foundry.patina";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Dynamic resonance attenuation with explicit tone protection");
const CLAP_MANUAL_URL: Option<&'static str> = None;
+8 -2
View File
@@ -62,6 +62,8 @@ pub struct PatinaParams {
pub mix: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "tone-guard"]
pub tone_guard: FloatParam,
#[id = "bypass"]
pub bypass: BoolParam,
#[nested(array, group = "Focus")]
@@ -82,6 +84,7 @@ impl Default for PatinaParams {
high: log("High frequency", d.high, 21., 20000., " Hz"),
mix: linear("Mix", 100., 0., 100., " %"),
output: linear("Output", 0., -12., 12., " dB"),
tone_guard: linear("Tone guard", d.tone_guard, 0., 100., " %"),
bypass: BoolParam::new("Bypass", false).make_bypass(),
focus: std::array::from_fn(|_| FocusParams::default()),
}
@@ -116,6 +119,7 @@ impl PatinaParams {
&self.focus[3].frequency,
&self.focus[3].width,
&self.focus[3].sensitivity,
&self.tone_guard,
]
}
pub fn settings(&self) -> Settings {
@@ -137,6 +141,7 @@ impl PatinaParams {
width: self.focus[i].width.value(),
sensitivity: self.focus[i].sensitivity.value(),
}),
tone_guard: self.tone_guard.value(),
};
// Independent automation of bounds may cross them; preserve a valid effective range.
if s.low >= s.high {
@@ -145,8 +150,8 @@ impl PatinaParams {
s
}
}
/// Host float parameters: ten main controls, then four per focus point.
pub const COUNT: usize = 26;
/// Host float parameters: ten main controls, four per focus point, then Tone guard.
pub const COUNT: usize = 27;
pub fn values(s: &Settings) -> [f32; COUNT] {
let mut v = [0.; COUNT];
v[..10].copy_from_slice(&[
@@ -169,5 +174,6 @@ pub fn values(s: &Settings) -> [f32; COUNT] {
s.focus[i].sensitivity,
]);
}
v[26] = s.tone_guard;
v
}
+1 -1
View File
@@ -61,7 +61,7 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
assert_eq!(CStr::from_ptr((*descriptor).name), c"Patina");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.storyboard.resonance-compressor"
c"io.narl.bass-foundry.patina"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
+51 -3
View File
@@ -43,7 +43,9 @@ impl State {
self.analyzer.envelope[i] += 0.45 * (frame.envelope[i] - self.analyzer.envelope[i]);
self.analyzer.reference[i] += 0.45 * (frame.reference[i] - self.analyzer.reference[i]);
self.analyzer.reduction[i] += 0.5 * (frame.reduction[i] - self.analyzer.reduction[i]);
self.analyzer.tone[i] += 0.5 * (frame.tone[i] - self.analyzer.tone[i]);
}
self.analyzer.pitch = frame.pitch;
self.analyzer.focus = frame.focus;
self.analyzer.input_peak = frame.input_peak;
self.analyzer.output_peak = frame.output_peak;
@@ -59,6 +61,21 @@ impl State {
}
}
}
/// Note name of a frequency, or a dash when there is none.
fn note_name(hz: f32) -> String {
if hz <= 0. {
return "—".into();
}
const NAMES: [&str; 12] = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
let n = (69. + 12. * (hz / 440.).log2()).round() as i32;
format!(
"{}{}",
NAMES[n.rem_euclid(12) as usize],
n.div_euclid(12) - 1
)
}
fn db(x: f32) -> f32 {
20. * x.max(1e-6).log10()
}
@@ -99,6 +116,7 @@ fn control(
let defaults = Settings::default();
*value = match name {
"Transient guard" => defaults.transient_guard,
"Tone guard" => defaults.tone_guard,
"Attack" => defaults.attack,
"Release" => defaults.release,
"Low frequency" => defaults.low,
@@ -170,7 +188,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.separator();
egui::ComboBox::from_id_salt("resonance-preset")
.selected_text(
(0..4)
(0..Settings::PRESETS.len())
.find(|i| *settings == Settings::preset(*i))
.map_or("Custom", |i| Settings::PRESETS[i]),
)
@@ -209,6 +227,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
format!("{:>3.0} %", state.analyzer.guard * 100.),
MUTED,
);
if settings.tone_guard > 0. {
readout(ui, "NOTE", note_name(state.analyzer.pitch), FOCUS);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
caption(
ui,
@@ -368,9 +389,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
panel(ui, |ui| {
ui.set_width(ui.available_width());
egui::Grid::new("engine-controls")
.num_columns(3)
.num_columns(4)
.spacing([16., 12.])
.min_col_width((ui.available_width() - 32.) / 3.)
.min_col_width((ui.available_width() - 48.) / 4.)
.show(ui, |ui| {
control(
ui,
@@ -402,6 +423,16 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
"Relaxes cuts on attacks to keep punch. Turn down to catch short rings.",
state,
);
control(
ui,
"Tone guard",
&mut settings.tone_guard,
0. ..=100.,
" %",
false,
"Keeps bumps that follow the played note (the harmonics a bass is built on) and still cuts rings fixed in Hz. Needs a few different notes to tell them apart.",
state,
);
ui.end_row();
});
ui.add_space(6.);
@@ -622,6 +653,23 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
fill[1..=GRID].to_vec(),
Stroke::new(1.6_f32, ACCENT),
));
// Tone guard: a strip along the top where cuts are held back because the bump follows the note.
if settings.tone_guard > 0. {
let mut strip = egui::Mesh::default();
for i in 0..GRID {
let xx = x(frequency(i));
let alpha = (state.analyzer.tone[i].clamp(0., 1.) * 170.) as u8;
let color = Color32::from_rgba_unmultiplied(FOCUS.r(), FOCUS.g(), FOCUS.b(), alpha);
strip.colored_vertex(Pos2::new(xx, r.top()), color);
strip.colored_vertex(Pos2::new(xx, r.top() + 8.), color);
if i > 0 {
let j = (i * 2) as u32;
strip.add_triangle(j - 2, j - 1, j);
strip.add_triangle(j - 1, j, j + 1);
}
}
p.add(egui::Shape::mesh(strip));
}
let center = r.center().y;
let focus_y = |offset: f32| center - offset / 36. * r.height() * 0.65;
let points: Vec<_> = (0..GRID)
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "planish-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 }
+412
View File
@@ -0,0 +1,412 @@
//! Upward and downward compression in four bands whose edges are harmonic numbers of the
//! played note, so the same setting squashes the same harmonics on every note.
//!
//! The crossovers are Linkwitz–Riley splits retuned to the tracked fundamental, with allpass
//! compensation, so the bands sum flat. An edge sits halfway (in log frequency) between two
//! harmonics. It is a slope, not a wall: neighbours of an edge partly reach the next band, by
//! the same amount on every note. Each band's level is measured over exactly one period
//! of the note: a steady note gives a steady level, and the gain does not ripple at the
//! fundamental. The audio path is delayed by the tracker's lookahead, so the edges are on the
//! new note when its attack leaves the plugin.
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::{Settings, Source, BANDS, GAIN_MAX, GAIN_MIN, TOP_MAX};
pub const MIN_HZ: f32 = TrackerConfig::BASS.min_hz;
pub const MAX_HZ: f32 = TrackerConfig::BASS.max_hz;
/// Fundamental the edges sit on before the first note.
pub const FALLBACK_HZ: f32 = 55.;
/// Band levels are RMS over one period: a sine with peak 1 reads −3 dB.
pub const DOWN_THRESHOLD: f32 = -30.;
pub const UP_THRESHOLD: f32 = -42.;
/// Gain change per dB beyond the threshold at 100 %: about 20:1 downward, 4:1 upward.
const DOWN_SLOPE: f32 = 0.95;
const UP_SLOPE: f32 = 0.75;
/// Most lift a band can get.
pub const UP_MAX: f32 = 24.;
/// Below this level the lift fades out, so silence and noise floors are not raised.
pub const FLOOR: f32 = -84.;
const FLOOR_FADE: f32 = 12.;
/// Attack and release at Time 100 %, bottom band first: lower bands move slower.
pub const ATTACK_MS: [f32; BANDS] = [30., 15., 8., 4.];
pub const RELEASE_MS: [f32; BANDS] = [200., 160., 120., 90.];
/// Filter coefficients are recomputed this often, in samples.
const RETUNE: usize = 16;
const Q: f32 = std::f32::consts::FRAC_1_SQRT_2;
const SILENT: f32 = -120.;
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
/// Fundamental the edges are tuned to, 0 before the first note.
pub hz: f32,
/// Sounding note, rounded; None between notes.
pub note: Option<u8>,
/// Crossover frequencies in Hz between the bands.
pub edges: [f32; BANDS - 1],
/// Band level plus Drive, in dB RMS, as the compressors see it.
pub levels: [f32; BANDS],
/// Gain of each compressor in dB at full depth, before the band gains.
pub gains: [f32; BANDS],
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
hz: 0.,
note: None,
edges: [0.; BANDS - 1],
levels: [SILENT; BANDS],
gains: [0.; BANDS],
latency: 0,
sample_rate: 48000.,
}
}
}
/// Gain in dB for a band level in dB, at the given upward and downward amounts (0–1).
pub fn compressor_gain(level: f32, upward: f32, downward: f32) -> f32 {
let down = if level > DOWN_THRESHOLD {
-(level - DOWN_THRESHOLD) * DOWN_SLOPE * downward
} else {
0.
};
let up = if level < UP_THRESHOLD {
let fade = ((level - FLOOR) / FLOOR_FADE).clamp(0., 1.);
let fade = fade * fade * (3. - 2. * fade);
((UP_THRESHOLD - level) * UP_SLOPE * upward).min(UP_MAX) * fade
} else {
0.
};
down + up
}
/// Mean of the last `n` values, with `n` changed only after a full window so the running sum
/// can be refreshed in the same step. Allocation-free after `new`.
struct Window {
ring: Vec<f32>,
at: usize,
n: usize,
next: usize,
count: usize,
sum: f32,
}
impl Window {
fn new(capacity: usize) -> Self {
Self {
ring: vec![0.; capacity + 1],
at: 0,
n: 1,
next: 1,
count: 0,
sum: 0.,
}
}
fn set_len(&mut self, n: usize) {
self.next = n.clamp(1, self.ring.len() - 1);
}
fn push(&mut self, v: f32) -> f32 {
let cap = self.ring.len();
self.sum += v - self.ring[(self.at + cap - self.n) % cap];
self.ring[self.at] = v;
self.at = (self.at + 1) % cap;
self.count += 1;
if self.count >= self.n {
// Exact sum over the new length: no drift, and a length change takes effect here.
self.count = 0;
self.n = self.next;
self.sum = (1..=self.n)
.map(|i| self.ring[(self.at + cap - i) % cap])
.sum();
}
self.sum.max(0.) / self.n as f32
}
fn reset(&mut self) {
self.ring.fill(0.);
self.sum = 0.;
self.count = 0;
}
}
/// Filter state of one channel: three LR4 splits and the allpasses that keep the lower bands
/// in phase with the upper ones.
#[derive(Clone, Copy, Default)]
struct Channel {
low: [[Svf; 2]; BANDS - 1],
high: [[Svf; 2]; BANDS - 1],
/// Band 1 through the allpasses of splits 2 and 3, band 2 through split 3.
phase: [Svf; 3],
}
impl Channel {
// Bands, splits and channels index several parallel arrays at once.
#[allow(clippy::needless_range_loop)]
fn split(&mut self, c: &Coefs, x: f32) -> [f32; BANDS] {
let mut rest = x;
let mut bands = [0.; BANDS];
for s in 0..BANDS - 1 {
let l = self.low[s][0].process(&c.low[s], rest);
bands[s] = self.low[s][1].process(&c.low[s], l);
let h = self.high[s][0].process(&c.high[s], rest);
rest = self.high[s][1].process(&c.high[s], h);
}
bands[BANDS - 1] = rest;
let b0 = self.phase[0].process(&c.all[1], bands[0]);
bands[0] = self.phase[1].process(&c.all[2], b0);
bands[1] = self.phase[2].process(&c.all[2], bands[1]);
bands
}
}
#[derive(Clone, Copy)]
struct Coefs {
low: [SvfCoef; BANDS - 1],
high: [SvfCoef; BANDS - 1],
all: [SvfCoef; BANDS - 1],
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
latency: usize,
delay: Vec<[f32; 2]>,
delay_at: usize,
tracker: Tracker,
pitch: PitchFollower,
hz: f32,
hz_coef: f32,
countdown: usize,
edges: [f32; BANDS - 1],
coefs: Coefs,
state: [Channel; 2],
windows: [Window; BANDS],
levels: [f32; BANDS],
gains: [f32; BANDS],
attack: [f32; BANDS],
release: [f32; BANDS],
smooth: f32,
depth: f32,
drive: f32,
band_gains: [f32; BANDS],
output: f32,
bypass: f32,
/// Smoothed controls start at the next settings instead of gliding from defaults.
fresh: bool,
}
/// 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;
}
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(8000.0..=768000.).contains(&rate) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let latency = tracker.latency();
let period = (rate / MIN_HZ.min(FALLBACK_HZ)).ceil() as usize + 1;
let identity = SvfCoef::IDENTITY;
let mut e = Self {
rate,
channels,
settings: Settings::default(),
latency,
delay: vec![[0.; 2]; latency + 1],
delay_at: 0,
tracker,
pitch: PitchFollower::default(),
hz: 0.,
hz_coef: 1.,
countdown: 0,
edges: [0.; BANDS - 1],
coefs: Coefs {
low: [identity; BANDS - 1],
high: [identity; BANDS - 1],
all: [identity; BANDS - 1],
},
state: [Channel::default(); 2],
windows: std::array::from_fn(|_| Window::new(period)),
levels: [SILENT; BANDS],
gains: [0.; BANDS],
attack: [1.; BANDS],
release: [1.; BANDS],
smooth: coefficient(rate, 10.),
depth: 0.,
drive: 0.,
band_gains: [1.; BANDS],
output: 1.,
bypass: 0.,
fresh: true,
};
e.set_settings(Settings::default())?;
e.fresh = true;
e.retune();
Ok(e)
}
pub fn latency_samples(&self) -> u32 {
self.latency as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.settings
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
self.tracker
.set(s.source == Source::Midi, s.gate, s.smooth > 0.);
self.hz_coef = coefficient(self.rate, s.smooth / 3.);
if s.time != self.settings.time || self.fresh {
let scale = s.time * 0.01;
for b in 0..BANDS {
self.attack[b] = coefficient(self.rate, ATTACK_MS[b] * scale);
self.release[b] = coefficient(self.rate, RELEASE_MS[b] * scale);
}
}
if s.tops() != self.settings.tops() {
self.countdown = 0;
}
self.settings = s;
if std::mem::take(&mut self.fresh) {
self.depth = s.depth * 0.01;
self.drive = s.drive;
self.band_gains = s.gains.map(db_to_gain);
self.output = db_to_gain(s.output);
self.bypass = f32::from(s.bypass);
}
Ok(())
}
pub fn reset(&mut self) {
self.delay.fill([0.; 2]);
self.tracker.reset();
self.state = [Channel::default(); 2];
for w in &mut self.windows {
w.reset();
}
self.levels = [SILENT; BANDS];
self.gains = [0.; BANDS];
self.countdown = 0;
self.fresh = true;
}
pub fn note_on(&mut self, note: u8) {
self.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.tracker.note_off(note);
}
/// Fundamental the edges follow: the tracked note, or the fallback before the first one.
fn root(&self) -> f32 {
if self.hz > 0. {
self.hz
} else {
FALLBACK_HZ
}
}
// Bands, splits and channels index several parallel arrays at once.
#[allow(clippy::needless_range_loop)]
fn retune(&mut self) {
let root = self.root();
let tops = self.settings.tops();
for s in 0..BANDS - 1 {
// Geometric middle: the harmonics on both sides of an edge lose the same amount.
let k = tops[s] as f32;
let hz = ((k * (k + 1.)).sqrt() * root).clamp(10., 0.45 * self.rate);
self.edges[s] = hz;
self.coefs.low[s] = SvfCoef::lowpass(self.rate, hz, Q);
self.coefs.high[s] = SvfCoef::highpass(self.rate, hz, Q);
self.coefs.all[s] = SvfCoef::allpass(self.rate, hz, Q);
}
let period = (self.rate / root).round() as usize;
for w in &mut self.windows {
w.set_len(period);
}
}
// Bands, splits and channels index several parallel arrays 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 len = self.delay.len();
self.delay[self.delay_at] = x;
self.delay_at = (self.delay_at + 1) % len;
let y = self.delay[self.delay_at];
let (hz, jump) = self.pitch.step(self.hz_coef);
self.hz = hz;
if jump || self.countdown == 0 {
self.retune();
self.countdown = RETUNE;
}
self.countdown -= 1;
let s = self.settings;
approach(&mut self.depth, s.depth * 0.01, self.smooth);
approach(&mut self.drive, s.drive, self.smooth);
for b in 0..BANDS {
approach(&mut self.band_gains[b], db_to_gain(s.gains[b]), self.smooth);
}
approach(&mut self.output, db_to_gain(s.output), self.smooth);
approach(&mut self.bypass, f32::from(s.bypass), self.smooth);
let mut bands = [[0.; BANDS]; 2];
for c in 0..self.channels {
bands[c] = self.state[c].split(&self.coefs, y[c]);
}
let (up, down) = (s.upward * 0.01, s.downward * 0.01);
let mut wet = [0.; 2];
for b in 0..BANDS {
let power = if self.channels == 2 {
0.5 * (bands[0][b] * bands[0][b] + bands[1][b] * bands[1][b])
} else {
bands[0][b] * bands[0][b]
};
let level = 10. * (self.windows[b].push(power) + 1e-12).log10() + self.drive;
let coef = if level > self.levels[b] {
self.attack[b]
} else {
self.release[b]
};
self.levels[b] += (level - self.levels[b]) * coef;
self.gains[b] = compressor_gain(self.levels[b], up, down);
let g = 1. + self.depth * (db_to_gain(self.gains[b]) * self.band_gains[b] - 1.);
for c in 0..self.channels {
wet[c] += bands[c][b] * g;
}
}
let mut out = [0.; 2];
for c in 0..self.channels {
out[c] = wet[c] * self.output * (1. - self.bypass) + y[c] * self.bypass;
}
out
}
pub fn take_analyzer(&self) -> AnalyzerFrame {
let mut f = AnalyzerFrame {
hz: self.hz,
edges: self.edges,
levels: self.levels,
gains: self.gains,
latency: self.latency as u32,
sample_rate: self.rate,
..AnalyzerFrame::default()
};
if self.tracker.sounding() && self.hz > 0. {
f.note = Some(hz_to_midi(self.hz).round().clamp(0., 127.) as u8);
}
f
}
}
+128
View File
@@ -0,0 +1,128 @@
//! Host-independent controls. Levels in dB, times in milliseconds, amounts in percent.
use serde::{Deserialize, Serialize};
/// Bands: the fundamental, low harmonics, mid harmonics and the top.
pub const BANDS: usize = 4;
pub const GAIN_MIN: f32 = -24.;
pub const GAIN_MAX: f32 = 24.;
/// Highest harmonic a band edge can be set to.
pub const TOP_MAX: u32 = 48;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Source {
/// Pitch and note starts are detected from the incoming bass.
#[default]
Audio,
/// Notes come from MIDI note input.
Midi,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// Highest harmonic in the bottom band. 1 keeps the fundamental on its own.
pub low: u32,
/// Highest harmonic in the second band.
pub mid: u32,
/// Highest harmonic in the third band; everything above is the top band.
pub high: u32,
/// Share of the compressed signal, per band, against the dry band.
pub depth: f32,
/// Scales every attack and release time.
pub time: f32,
/// Amount of upward compression: quiet bands are lifted.
pub upward: f32,
/// Amount of downward compression: loud bands are pushed down.
pub downward: f32,
/// Gain on the level the compressors see, not on the audio: moves the band levels against
/// the thresholds. More drive means more squash; at Depth 0 it changes nothing.
pub drive: f32,
/// Gain of each band after its compressor, bottom band first.
pub gains: [f32; BANDS],
/// Retune time when the pitch slides or bends.
pub smooth: f32,
/// Detection threshold in dBFS for note starts (Audio source).
pub gate: f32,
/// Round detected pitch to the nearest semitone. Off by default: the level window is one
/// period of the pitch in use, and a detuned note then gets a window that does not fit.
pub snap: bool,
pub output: f32,
pub bypass: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
low: 1,
mid: 4,
high: 16,
depth: 50.,
time: 100.,
upward: 100.,
downward: 100.,
drive: 0.,
gains: [0.; BANDS],
smooth: 10.,
gate: -42.,
snap: false,
output: 0.,
bypass: false,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
let finite = [
self.depth,
self.time,
self.upward,
self.downward,
self.drive,
self.smooth,
self.gate,
self.output,
]
.iter()
.chain(self.gains.iter())
.all(|v| v.is_finite());
if !finite {
return Err("non-finite setting");
}
let ok = (1..=8).contains(&self.low)
&& (2..=16).contains(&self.mid)
&& (3..=TOP_MAX).contains(&self.high)
&& self.gains.iter().all(|g| (GAIN_MIN..=GAIN_MAX).contains(g))
&& (0.0..=100.).contains(&self.depth)
&& (10.0..=1000.).contains(&self.time)
&& (0.0..=100.).contains(&self.upward)
&& (0.0..=100.).contains(&self.downward)
&& (-24.0..=24.).contains(&self.drive)
&& (0.0..=100.).contains(&self.smooth)
&& (-60.0..=-20.).contains(&self.gate)
&& (-24.0..=24.).contains(&self.output);
if ok {
Ok(())
} else {
Err("setting out of range")
}
}
/// Highest harmonic of the bottom three bands, in rising order. Host automation can set
/// the edges in any order, so a lower edge pushes the next one up instead of failing.
pub fn tops(&self) -> [u32; BANDS - 1] {
let low = self.low.clamp(1, 8);
let mid = self.mid.max(low + 1);
let high = self.high.max(mid + 1);
[low, mid, high]
}
/// Text for the harmonics of `band`, like "1", "2–4" or "17+".
pub fn band_label(&self, band: usize) -> String {
let t = self.tops();
let first = if band == 0 { 1 } else { t[band - 1] + 1 };
match band {
3 => format!("{first}+"),
_ if first == t[band] => format!("{first}"),
_ => format!("{first}–{}", t[band]),
}
}
}
+309
View File
@@ -0,0 +1,309 @@
use common_dsp::meter::HarmonicMeter;
use common_dsp::Saw;
use planish_core::{compressor_gain, Engine, Settings, Source, BANDS};
use std::f32::consts::{PI, TAU};
const RATE: f32 = 48000.;
fn engine(s: Settings) -> (Engine, usize) {
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(s).unwrap();
let l = e.latency_samples() as usize;
(e, l)
}
fn saw(hz: f32, seconds: f32, amp: f32) -> Vec<f32> {
let mut s = Saw::new(RATE, hz);
(0..(seconds * RATE) as usize)
.map(|_| s.sample() * amp)
.collect()
}
/// Sum of sines `(harmonic, amplitude)` on `hz`, with the phase in f64 so long signals stay
/// clean.
fn tones(hz: f32, seconds: f32, parts: &[(usize, f32)]) -> Vec<f32> {
(0..(seconds * RATE) as usize)
.map(|i| {
let t = i as f64 / RATE as f64;
parts
.iter()
.map(|&(k, a)| {
let phase = (TAU as f64 * hz as f64 * k as f64 * t) % (TAU as f64);
a * (phase as f32).sin()
})
.sum()
})
.collect()
}
fn sine(hz: f32, seconds: f32, amp: f32) -> Vec<f32> {
tones(hz, seconds, &[(1, amp)])
}
fn render(e: &mut Engine, x: &[f32]) -> Vec<f32> {
x.iter().map(|&v| e.process_sample([v, v])[0]).collect()
}
/// Harmonic levels of the last complete window of `x`.
fn harmonics(x: &[f32], hz: f32) -> [f32; 32] {
let mut m = HarmonicMeter::new(RATE, 85.);
for &v in x {
m.push(v, hz);
}
*m.levels()
}
fn rms_db(x: &[f32]) -> f32 {
let p: f32 = x.iter().map(|v| v * v).sum::<f32>() / x.len() as f32;
10. * (p + 1e-20).log10()
}
fn uncompressed() -> Settings {
Settings {
depth: 0.,
..Settings::default()
}
}
#[test]
fn bands_sum_flat_without_compression() {
for hz in [35., 80., 250., 1000., 4000., 12000.] {
let x = sine(hz, 0.6, 0.5);
let (mut e, l) = engine(uncompressed());
let y = render(&mut e, &x);
let tail = y.len() / 2;
let d = rms_db(&y[tail..]) - rms_db(&x[tail - l..x.len() - l]);
assert!(d.abs() < 0.05, "{hz} Hz: {d:+.3} dB");
}
}
#[test]
fn band_edges_sit_between_the_same_harmonics_on_every_note() {
// A saw with amplitude a has harmonic k at 2a/(πk). Band 1 holds harmonic 1 minus what
// its slope gives away; the split of the series between the bands is the same on every
// note.
let a = 0.5;
let fundamental = 10. * ((2. * a / PI).powi(2) / 2.).log10();
let mut first: Option<[f32; BANDS]> = None;
for hz in [43.65, 65.41, 130.81] {
let (mut e, _) = engine(uncompressed());
render(&mut e, &saw(hz, 1., a));
let f = e.take_analyzer();
assert!((f.hz - hz).abs() < 0.5, "{hz}: tracked {}", f.hz);
assert!((f.edges[0] - 2f32.sqrt() * hz).abs() < 1., "{:?}", f.edges);
assert!((f.edges[1] - 20f32.sqrt() * hz).abs() < 2., "{:?}", f.edges);
assert!(
(f.levels[0] - fundamental).abs() < 2.5,
"{hz}: {:?}",
f.levels
);
let levels = *first.get_or_insert(f.levels);
// The top band also holds the saw's own roll-off near Nyquist, which is not harmonic.
for (b, tolerance) in [0.4, 0.4, 0.4, 1.].into_iter().enumerate() {
let d = f.levels[b] - levels[b];
assert!(d.abs() < tolerance, "{hz}: {:?} vs {levels:?}", f.levels);
}
}
}
#[test]
fn the_same_setting_changes_the_same_harmonics_on_every_note() {
let s = Settings {
depth: 100.,
..Settings::default()
};
let notes = [43.65, 65.41, 98.];
let mut changes = Vec::new();
for hz in notes {
let x = saw(hz, 1.5, 0.5);
let (mut e, l) = engine(s);
let y = render(&mut e, &x);
let a = harmonics(&x[..x.len() - l], hz);
let b = harmonics(&y[l..], hz);
changes.push((0..12).map(|k| b[k] - a[k]).collect::<Vec<f32>>());
}
// Compression must actually happen for the comparison to mean anything.
assert!(changes[0][0] < -6., "{:?}", changes[0]);
for (n, c) in changes.iter().enumerate().skip(1) {
for k in 0..12 {
let d = c[k] - changes[0][k];
assert!(
d.abs() < 1.5,
"note {n} harmonic {}: {c:?} vs {:?}",
k + 1,
changes[0]
);
}
}
}
#[test]
fn upward_lifts_a_quiet_band_and_downward_pushes_a_loud_one() {
// Loud fundamental in band 1, a quiet 8th harmonic in band 3.
let hz = 55.;
let x = tones(hz, 1.5, &[(1, 0.7), (8, 0.003)]);
let a = harmonics(&x, hz);
let run = |upward: f32, downward: f32| {
let s = Settings {
depth: 100.,
upward,
downward,
..Settings::default()
};
let (mut e, l) = engine(s);
let y = render(&mut e, &x);
let b = harmonics(&y[l..], hz);
[b[0] - a[0], b[7] - a[7]]
};
let [body, top] = run(100., 0.);
assert!(body.abs() < 0.5 && top > 5., "upward: {body:+.2} {top:+.2}");
let [body, top] = run(0., 100.);
assert!(
body < -10. && top.abs() < 1.,
"downward: {body:+.2} {top:+.2}"
);
}
/// Level in dB of harmonic `k` of `hz` in `x`, over whole periods (Goertzel).
fn partial(x: &[f32], hz: f32, k: usize) -> f32 {
let w = TAU as f64 * hz as f64 * k as f64 / RATE as f64;
let (mut re, mut im) = (0f64, 0f64);
for (i, &v) in x.iter().enumerate() {
re += v as f64 * (w * i as f64).cos();
im += v as f64 * (w * i as f64).sin();
}
let amp = 2. * (re * re + im * im).sqrt() / x.len() as f64;
20. * (amp + 1e-12).log10() as f32
}
#[test]
fn heavy_compression_does_not_ripple_at_the_fundamental() {
// A per-period level is steady on a steady note, so even fast, hard compression adds no
// harmonics to a sine. 40 Hz has a period of exactly 1200 samples; Snap is off so the
// window is the measured period, not the nearest semitone's.
let hz = 40.;
let x = sine(hz, 1.5, 0.5);
let distortion = |depth: f32| {
let s = Settings {
depth,
drive: 12.,
time: 10.,
snap: false,
..Settings::default()
};
let (mut e, _) = engine(s);
let y = render(&mut e, &x);
let tail = &y[y.len() - 24000..];
let fundamental = partial(tail, hz, 1);
(2..6)
.map(|k| partial(tail, hz, k) - fundamental)
.fold(f32::MIN, f32::max)
};
let (dry, squashed) = (distortion(0.), distortion(100.));
assert!(squashed < -80., "{squashed:.1} dB (dry {dry:.1} dB)");
}
#[test]
fn silence_and_noise_floors_are_not_lifted() {
assert_eq!(compressor_gain(-120., 1., 1.), 0.);
assert!(compressor_gain(-60., 1., 1.) > 10.);
let s = Settings {
depth: 100.,
..Settings::default()
};
let x = saw(55., 1., 2e-5);
let (mut e, l) = engine(s);
let y = render(&mut e, &x);
let tail = y.len() / 2;
let d = rms_db(&y[tail..]) - rms_db(&x[tail - l..x.len() - l]);
assert!(d < 2., "{d:+.2} dB");
}
#[test]
fn bypass_passes_the_delayed_input_exactly() {
let s = Settings {
depth: 100.,
bypass: true,
..Settings::default()
};
let x = saw(55., 0.5, 0.5);
let (mut e, l) = engine(s);
let y = render(&mut e, &x);
for i in l..x.len() {
assert_eq!(y[i], x[i - l]);
}
}
#[test]
fn note_changes_do_not_click() {
let s = Settings {
depth: 100.,
..Settings::default()
};
let mut x = saw(43.65, 0.5, 0.5);
x.extend(saw(58.27, 0.5, 0.5));
x.extend(saw(87.31, 0.5, 0.5));
let (mut e, _) = engine(s);
let y = render(&mut e, &x);
let step = y
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.fold(0f32, f32::max);
let in_step = x
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.fold(0f32, f32::max);
assert!(y.iter().all(|v| v.is_finite()));
assert!(step < 2. * in_step, "{step} vs {in_step}");
}
#[test]
fn midi_source_moves_the_edges_at_the_delayed_note_time() {
let s = Settings {
source: Source::Midi,
..uncompressed()
};
let (mut e, l) = engine(s);
// 43.65 Hz is MIDI 29.
e.note_on(29);
render(&mut e, &saw(43.65, l as f32 / RATE - 0.002, 0.5));
assert_eq!(e.take_analyzer().hz, 0.);
render(&mut e, &saw(43.65, 0.01, 0.5));
let f = e.take_analyzer();
assert_eq!(f.note, Some(29));
assert!(
(f.edges[0] - 2f32.sqrt() * 43.65).abs() < 0.2,
"{:?}",
f.edges
);
}
#[test]
fn edges_in_the_wrong_order_are_pushed_up() {
let s = Settings {
low: 4,
mid: 3,
high: 3,
..Settings::default()
};
assert!(s.validate().is_ok());
assert_eq!(s.tops(), [4, 5, 6]);
assert_eq!(s.band_label(0), "1–4");
assert_eq!(s.band_label(1), "5");
assert_eq!(s.band_label(3), "7+");
let d = Settings::default();
let labels: Vec<String> = (0..BANDS).map(|b| d.band_label(b)).collect();
assert_eq!(labels, ["1", "2–4", "5–16", "17+"]);
}
#[test]
fn settings_are_validated_and_serialise() {
let s = Settings::default();
let json = serde_json::to_string(&s).unwrap();
assert_eq!(serde_json::from_str::<Settings>(&json).unwrap(), s);
let mut bad = s;
bad.gains[2] = 40.;
assert!(bad.validate().is_err());
bad = s;
bad.low = 0;
assert!(bad.validate().is_err());
bad = s;
bad.time = f32::NAN;
assert!(bad.validate().is_err());
let mut e = Engine::new(RATE, 2).unwrap();
assert!(e.set_settings(bad).is_err());
assert_eq!(e.settings(), s);
}
+71
View File
@@ -0,0 +1,71 @@
use planish_core::{Engine, Settings, Source};
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
};
struct CheckedAllocator;
thread_local! {
static CHECK:Cell<bool>=const {Cell::new(false)};
static OPERATIONS:Cell<usize>=const {Cell::new(0)};
}
fn record() {
if CHECK.try_with(Cell::get).unwrap_or(false) {
let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1));
}
}
unsafe impl GlobalAlloc for CheckedAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
record();
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
record();
unsafe { System.dealloc(pointer, layout) }
}
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
record();
unsafe { System.realloc(pointer, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CheckedAllocator = CheckedAllocator;
#[test]
fn processing_notes_settings_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings::default();
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..96000 {
if i % 4800 == 0 {
settings.gains[(i / 4800) % 4] = -6.;
settings.low = 1 + ((i / 4800) % 3) as u32;
settings.time = 10. + ((i / 4800) % 5) as f32 * 100.;
settings.depth = 100.;
settings.source = if i > 48000 {
Source::Midi
} else {
Source::Audio
};
engine.set_settings(settings).unwrap();
engine.note_on(30 + (i / 4800 % 12) as u8);
}
if i % 4800 == 2400 {
engine.note_off(30 + (i / 4800 % 12) as u8);
}
let t = i as f32 / 48000.;
let x = if (i / 2400) % 2 == 0 {
(std::f32::consts::TAU * 55. * t).sin() * 0.5
} else {
0.
};
std::hint::black_box(engine.process_sample([x, x]));
if i % 1600 == 0 {
std::hint::black_box(engine.take_analyzer());
}
}
engine.reset();
CHECK.with(|v| v.set(false));
assert_eq!(OPERATIONS.with(Cell::get), 0);
}
+2381
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "planish"
version = "0.1.0"
edition = "2021"
license = "ISC"
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
planish-core = { path = "../core" }
planish-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"
+185
View File
@@ -0,0 +1,185 @@
//! CLAP audio effect. All DSP state is owned by the processing thread.
mod params;
use crossbeam_queue::ArrayQueue;
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::{PlanishParams, FLOATS};
use planish_core::{AnalyzerFrame, Engine};
use std::sync::Arc;
struct EditorData {
ui: planish_ui::State,
gestures: [bool; FLOATS],
}
pub struct Planish {
params: Arc<PlanishParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
engine: Option<Engine>,
publish_count: usize,
publish_interval: usize,
}
impl Default for Planish {
fn default() -> Self {
Self {
params: Arc::new(PlanishParams::default()),
analyzer: Arc::new(ArrayQueue::new(2)),
engine: None,
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for Planish {
const NAME: &'static str = "Planish";
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: planish_ui::State::default(),
gestures: [false; FLOATS],
},
|ctx, _| planish_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("planish-editor")
.min_size(egui::vec2(
planish_ui::WIDTH as f32 - 80.,
planish_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
planish_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
// Band meters follow the audio.
ctx.request_repaint_after(std::time::Duration::from_millis(40));
},
)
}
fn initialize(
&mut self,
layout: &AudioIOLayout,
config: &BufferConfig,
context: &mut impl InitContext<Self>,
) -> bool {
let Ok(mut engine) = Engine::new(
config.sample_rate,
layout.main_input_channels.map_or(0, |v| v.get() as usize),
) else {
return false;
};
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 mut next = context.next_event();
for (index, mut sample) in buffer.iter_samples().enumerate() {
while let Some(event) = next {
if event.timing() as usize > index {
break;
}
match event {
NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => {
engine.note_on(note)
}
NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => {
engine.note_off(note)
}
NoteEvent::Choke { note, .. } => engine.note_off(note),
_ => {}
}
next = context.next_event();
}
let x = [
*sample.get_mut(0).unwrap(),
if engine.channels() == 2 {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let out = engine.process_sample(x);
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
self.analyzer.force_push(engine.take_analyzer());
self.publish_count = 0;
}
}
// The lookahead delay and compressor release must drain after input stops.
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for Planish {
const CLAP_ID: &'static str = "io.narl.bass-foundry.planish";
const CLAP_DESCRIPTION: Option<&'static str> = Some(
"Upward and downward compression in bands set by harmonic number, the same 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::Compressor,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Planish);
+192
View File
@@ -0,0 +1,192 @@
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use planish_core::{Settings, Source, BANDS, GAIN_MAX, GAIN_MIN, TOP_MAX};
use std::sync::Arc;
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SourceParam {
#[id = "audio"]
Audio,
#[id = "midi"]
#[name = "MIDI"]
Midi,
}
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
}
fn edge(name: &str, v: u32, min: i32, max: i32) -> IntParam {
IntParam::new(name, v as i32, IntRange::Linear { min, max })
}
/// Continuous controls, in the order of the gesture slots.
pub const FLOATS: usize = 12;
#[derive(Params)]
pub struct PlanishParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[id = "depth"]
pub depth: FloatParam,
#[id = "time"]
pub time: FloatParam,
#[id = "upward"]
pub upward: FloatParam,
#[id = "downward"]
pub downward: FloatParam,
#[id = "drive"]
pub drive: FloatParam,
#[id = "gain-1"]
pub gain_1: FloatParam,
#[id = "gain-2"]
pub gain_2: FloatParam,
#[id = "gain-3"]
pub gain_3: FloatParam,
#[id = "gain-4"]
pub gain_4: FloatParam,
#[id = "smooth"]
pub smooth: FloatParam,
#[id = "gate"]
pub gate: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "low"]
pub low: IntParam,
#[id = "mid"]
pub mid: IntParam,
#[id = "high"]
pub high: IntParam,
#[id = "snap"]
pub snap: BoolParam,
#[id = "bypass"]
pub bypass: BoolParam,
}
impl Default for PlanishParams {
fn default() -> Self {
let d = Settings::default();
let gain = |n: usize| linear(&format!("Band {n} gain"), 0., GAIN_MIN, GAIN_MAX, " dB");
Self {
editor: EguiState::from_size(planish_ui::WIDTH, planish_ui::HEIGHT),
source: EnumParam::new("Pitch source", SourceParam::Audio),
depth: linear("Depth", d.depth, 0., 100., " %"),
time: linear("Time", d.time, 10., 1000., " %"),
upward: linear("Upward", d.upward, 0., 100., " %"),
downward: linear("Downward", d.downward, 0., 100., " %"),
drive: linear("Drive", d.drive, -24., 24., " dB"),
gain_1: gain(1),
gain_2: gain(2),
gain_3: gain(3),
gain_4: gain(4),
smooth: linear("Smooth", d.smooth, 0., 100., " ms"),
gate: linear("Gate", d.gate, -60., -20., " dB"),
output: linear("Output", d.output, -24., 24., " dB"),
low: edge("Low edge", d.low, 1, 8),
mid: edge("Mid edge", d.mid, 2, 16),
high: edge("High edge", d.high, 3, TOP_MAX as i32),
snap: BoolParam::new("Snap to semitone", d.snap),
bypass: BoolParam::new("Bypass", false).make_bypass(),
}
}
}
impl PlanishParams {
pub fn settings(&self) -> Settings {
let gains = [&self.gain_1, &self.gain_2, &self.gain_3, &self.gain_4];
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
low: self.low.value() as u32,
mid: self.mid.value() as u32,
high: self.high.value() as u32,
depth: self.depth.value(),
time: self.time.value(),
upward: self.upward.value(),
downward: self.downward.value(),
drive: self.drive.value(),
gains: std::array::from_fn::<f32, BANDS, _>(|b| gains[b].value()),
smooth: self.smooth.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.time,
&self.upward,
&self.downward,
&self.drive,
&self.gain_1,
&self.gain_2,
&self.gain_3,
&self.gain_4,
&self.smooth,
&self.gate,
&self.output,
]
}
fn values(s: &Settings) -> [f32; FLOATS] {
[
s.depth, s.time, s.upward, s.downward, s.drive, s.gains[0], s.gains[1], s.gains[2],
s.gains[3], s.smooth, s.gate, s.output,
]
}
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
/// while the pointer is down; switches and edges 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;
}
}
for (p, a, b) in [
(&self.low, old.low, new.low),
(&self.mid, old.mid, new.mid),
(&self.high, old.high, new.high),
] {
if a != b {
setter.begin_set_parameter(p);
setter.set_parameter(p, b as i32);
setter.end_set_parameter(p);
}
}
for (p, a, b) in [
(&self.snap, old.snap, new.snap),
(&self.bypass, old.bypass, new.bypass),
] {
if a != b {
setter.begin_set_parameter(p);
setter.set_parameter(p, b);
setter.end_set_parameter(p);
}
}
if new.source != old.source {
let v = match new.source {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
setter.begin_set_parameter(&self.source);
setter.set_parameter(&self.source, v);
setter.end_set_parameter(&self.source);
}
}
}
+214
View File
@@ -0,0 +1,214 @@
//! 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 = &planish::clap_entry;
assert!(entry.init.unwrap()(c"planish.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"Planish");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.narl.bass-foundry.planish"
);
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 makes the processed signal a pure crossover sum: the tone must pass flat.
envelope.params.insert("depth".into(), ParamValue::F32(0.));
envelope
.params
.insert("gain-2".into(), ParamValue::F32(-6.));
envelope.params.insert("low".into(), ParamValue::I32(2));
envelope.params.insert("high".into(), ParamValue::I32(24));
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 through the crossovers, 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 = "planish-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
planish-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
common-dsp = { path = "../../common/dsp" }
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more