Compare commits

..
Author SHA1 Message Date
nvrl 340ff13342 chore: release Crucible 0.3.0, Patina 0.4.0 and Planish 0.1.0
Check and Windows preview / check (anvil) (push) Successful in 2m22s
Check and Windows preview / check (crucible) (push) Successful in 1m55s
Check and Windows preview / check (knurl) (push) Successful in 1m42s
Check and Windows preview / check (lathe) (push) Successful in 1m41s
Check and Windows preview / check (patina) (push) Successful in 2m43s
Check and Windows preview / check (planish) (push) Successful in 2m20s
Check and Windows preview / check (rivet) (push) Successful in 1m42s
Check and Windows preview / check (temper) (push) Successful in 1m39s
Check and Windows preview / check (weld) (push) Successful in 2m40s
Check and Windows preview / previews (push) Successful in 1m37s
Check and Windows preview / windows (push) Successful in 2m34s
Independent plugin release / publish (push) Successful in 9s
Independent plugin release / plan (push) Successful in 7s
Independent plugin release / build (push) Successful in 3m8s
- Crucible 0.3.0: Keep takes.
- Patina 0.4.0: Tone guard and the Bass line preset.
- Planish 0.1.0 is already at its first version.
- Update the current version in every plugin guide; several still named an
  older release.
2026-11-06 12:58:53 +01:00
nvrl c06d193b18 feat(planish): add Planish harmonic OTT
Upward and downward compression in four bands whose edges are harmonic
numbers of the tracked note (default 1, 2-4, 5-16, 17 and up), so the
same setting squashes the same harmonics on every note. A fixed-band
OTT puts harmonic 2 of F1 and of C2 in different bands.

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

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

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

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

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

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

- Cycle clock from the shared note tracker, restarted at each note; the
  audio is delayed by the lookahead plus 4.2 ms of timing-jitter headroom.
- Per-cycle randomness from a hash of the cycle number: Same every note
  or Free.
- Patterns Off, Double, Triple, Fry (default), Gallop, Stutter or drawn;
  grit switchable per step. Pattern persisted as knurl-pattern.
- Editor: last eight cycles overlaid, pattern bars with the playing step.
- Tests match the prototype: Double gives f0/2 at -8.19 dB, Triple f0/3
  at -10.68 dB, equal on F1 and C2, no added clicks, bit-exact when off,
  no allocation on the audio thread.
2026-11-06 08:49:11 +01:00
nvrl f4c0a833e6 ci: check editor-check formatting in the product check
Check and Windows preview / check (anvil) (push) Successful in 39s
Check and Windows preview / check (crucible) (push) Successful in 44s
Check and Windows preview / check (lathe) (push) Successful in 40s
Check and Windows preview / check (patina) (push) Successful in 55s
Check and Windows preview / check (rivet) (push) Successful in 46s
Check and Windows preview / check (temper) (push) Successful in 44s
Check and Windows preview / check (weld) (push) Successful in 1m18s
Check and Windows preview / previews (push) Successful in 55s
Check and Windows preview / windows (push) Successful in 1m24s
Every product adds a fixture to tools/editor-check, but only the CI
previews job checked its formatting, so an unformatted fixture passed
the local product check and failed CI.
2026-11-06 08:34:14 +01:00
nvrl 9b3ddeaf56 feat(weld): phase alignment for layered bass, release 0.1.0
Check and Windows preview / check (anvil) (push) Successful in 2m0s
Check and Windows preview / check (crucible) (push) Successful in 1m29s
Check and Windows preview / check (lathe) (push) Successful in 1m27s
Check and Windows preview / check (patina) (push) Successful in 1m53s
Check and Windows preview / check (rivet) (push) Successful in 1m29s
Check and Windows preview / check (temper) (push) Successful in 1m29s
Check and Windows preview / check (weld) (push) Successful in 2m38s
Check and Windows preview / previews (push) Successful in 1m28s
Independent plugin release / plan (push) Successful in 8s
Check and Windows preview / windows (push) Successful in 3m14s
Independent plugin release / build (push) Successful in 3m50s
Independent plugin release / publish (push) Successful in 9s
Weld hears the other bass layer through the sidechain and fits polarity
plus up to four RBJ allpass sections to the measured cross-spectrum, so
the layers sum instead of cancelling. No latency; the layer on its own
keeps its sound.

- Worker thread: Welch cross-spectrum and coherence, Nelder-Mead fit with
  1/f weighting, a per-band loss penalty and model selection by share of
  the achievable gain; GCC-PHAT lag detection flags a late layer.
- Audio thread: allocation-free chain with a 40 ms warm-up and 40 ms
  crossfade per new fit; mono blocks go to the worker through a lock-free
  queue.
- Follow / Learn / Hold; the fit is persisted and restored without a
  crossfade.
- Editor: per-band loss and recovery against a perfect alignment, phase
  before and after, recovered dB, time offset and status.
- Tests for flat magnitude, crossfade, fit targets, Follow hysteresis,
  Learn, silence, realtime allocation and the persisted fit in the host.
2026-11-06 08:31:41 +01:00
nvrl 0a0917ac56 chore: rename the suite to Bass Foundry
Check and Windows preview / check (anvil) (push) Successful in 2m5s
Check and Windows preview / check (crucible) (push) Successful in 2m11s
Check and Windows preview / check (lathe) (push) Successful in 2m22s
Check and Windows preview / check (patina) (push) Successful in 2m50s
Check and Windows preview / check (rivet) (push) Successful in 46s
Check and Windows preview / check (temper) (push) Successful in 41s
Check and Windows preview / check (weld) (push) Successful in 39s
Check and Windows preview / previews (push) Failing after 24s
Check and Windows preview / windows (push) Skipped
Set the CLAP vendor of all seven plugins to "Bass Foundry" and name the
combined preview ZIP bass-foundry-windows-x86_64.zip. CLAP IDs and saved
state stay unchanged.

Bring development.md and releases.md up to date with all seven products,
the shared crates, the test types, the steps to add a product and the
published releases. Add texture problems and seven texture plugin ideas
to ideas.md.
2026-11-06 08:01:02 +01:00
nvrl 6a4ec3d372 docs: rework the main README
Check and Windows preview / check (patina) (push) Successful in 2m8s
Check and Windows preview / check (anvil) (push) Successful in 2m1s
Check and Windows preview / check (crucible) (push) Successful in 1m32s
Check and Windows preview / check (lathe) (push) Successful in 1m26s
Check and Windows preview / check (rivet) (push) Successful in 2m40s
Check and Windows preview / check (temper) (push) Successful in 2m7s
Check and Windows preview / check (weld) (push) Successful in 2m4s
Check and Windows preview / previews (push) Failing after 24s
Check and Windows preview / windows (push) Skipped
Name the suite, add a plugin table with placement and latency, an editor
gallery, install and build steps, the CI quality checks, the repository
layout and the release flow. List Weld as in development.
2026-11-06 07:53:35 +01:00
nvrl e3286f9094 feat(weld): add sidechain test build
Weld will phase-align a bass layer to another layer it hears through the
sidechain. This first build only checks that FL Studio delivers the
sidechain to a CLAP aux port: it passes the main layer through unchanged,
meters the sidechain and offers Layer/Sum/Sum dry listening. No latency.
2026-11-06 07:53:20 +01:00
nvrl b3ebe4d952 feat: add Lathe harmonic EQ and Temper tracked distortion
Check and Windows preview / check (anvil) (push) Successful in 2m8s
Check and Windows preview / check (crucible) (push) Successful in 1m32s
Check and Windows preview / check (lathe) (push) Successful in 2m13s
Check and Windows preview / check (patina) (push) Successful in 2m5s
Check and Windows preview / check (rivet) (push) Successful in 1m34s
Check and Windows preview / check (temper) (push) Successful in 2m12s
Check and Windows preview / previews (push) Successful in 1m19s
Check and Windows preview / windows (push) Successful in 2m13s
Independent plugin release / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 6s
Independent plugin release / build (push) Successful in 2m57s
Lathe places EQ bands on harmonics 1-32 of the played note, so a cut on
harmonic 3 stays on harmonic 3 on every note. Temper normalises each
note's level before an oversampled shaper and tunes its floor, push and
bright filters to the note, so every note distorts the same.

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

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

No files matched your search

+14 -4
View File
@@ -22,7 +22,7 @@ jobs:
strategy:
fail-fast: false
matrix:
plugin: [rivet, patina, anvil, crucible]
plugin: [rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish]
steps:
- uses: https://github.com/actions/checkout@v4
with:
@@ -58,6 +58,11 @@ jobs:
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/patina-compact.png --plugin patina --compact
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/anvil.png --plugin anvil
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/crucible.png --plugin crucible
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/lathe.png --plugin lathe
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/temper.png --plugin temper
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
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
name: editor-previews
@@ -85,11 +90,16 @@ jobs:
python3 scripts/build-plugin.py --plugin patina --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin anvil --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin crucible --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin lathe --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin temper --target x86_64-pc-windows-gnu
python3 scripts/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/package-windows.py
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
name: rivet-patina-windows-x86_64
name: bass-foundry-windows-x86_64
path: |
dist/rivet-patina-windows-x86_64.zip
dist/rivet-patina-windows-x86_64.zip.sha256
dist/bass-foundry-windows-x86_64.zip
dist/bass-foundry-windows-x86_64.zip.sha256
retention-days: 30
+1 -1
View File
@@ -1,7 +1,7 @@
name: Independent plugin release
on:
push:
tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*"]
tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*", "lathe-v*", "temper-v*", "weld-v*", "knurl-v*", "planish-v*"]
permissions:
contents: read
Generated
+96
View File
@@ -170,6 +170,44 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "knurl-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
"serde_json",
]
[[package]]
name = "knurl-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"egui",
"knurl-core",
]
[[package]]
name = "lathe-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
"serde_json",
]
[[package]]
name = "lathe-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"egui",
"lathe-core",
]
[[package]]
name = "libc"
version = "0.2.191"
@@ -266,6 +304,7 @@ dependencies = [
name = "patina-core"
version = "0.2.0"
dependencies = [
"common-dsp",
"rustfft",
"serde",
"serde_json",
@@ -288,6 +327,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"
@@ -460,6 +518,25 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "temper-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
"serde_json",
]
[[package]]
name = "temper-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"egui",
"temper-core",
]
[[package]]
name = "transpose"
version = "0.2.3"
@@ -488,6 +565,25 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "weld-core"
version = "0.1.0"
dependencies = [
"crossbeam-queue",
"rustfft",
"serde",
"serde_json",
]
[[package]]
name = "weld-ui"
version = "0.1.0"
dependencies = [
"common-ui",
"egui",
"weld-core",
]
[[package]]
name = "windows-link"
version = "0.2.1"
+17 -2
View File
@@ -1,7 +1,7 @@
[workspace]
resolver = "2"
members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/crucible/core", "plugins/crucible/ui", "plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"]
exclude = ["plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin", "plugins/anvil/plugin", "plugins/crucible/plugin"]
members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/lathe/core", "plugins/lathe/ui", "plugins/temper/core", "plugins/temper/ui", "plugins/weld/core", "plugins/weld/ui", "plugins/knurl/core", "plugins/knurl/ui", "plugins/planish/core", "plugins/planish/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"
@@ -16,3 +16,18 @@ rivet-midi = { path = "plugins/rivet/midi" }
[profile.release]
lto = "thin"
strip = "symbols"
# Signal-level tests render seconds of audio; unoptimised DSP makes them crawl.
[profile.dev.package.common-dsp]
opt-level = 2
[profile.dev.package.lathe-core]
opt-level = 2
[profile.dev.package.temper-core]
opt-level = 2
[profile.dev.package.knurl-core]
opt-level = 2
[profile.dev.package.planish-core]
opt-level = 2
[profile.dev.package.weld-core]
opt-level = 2
+184 -64
View File
@@ -1,86 +1,206 @@
# Rivet & Patina
# Bass Foundry
Four Rust CLAP plugins for writing, designing and mixing bass music.
**Rust CLAP plugins for writing, designing and mixing bass music in FL Studio.**
| Plugin | Purpose | Guide |
| --- | --- | --- |
| **Rivet** | Tell a MIDI motif as a story on a storyboard of roles, with joined seams | [Rivet](docs/rivet/README.md) |
| **Patina** | Smooth harsh resonances while retaining tone and transient character | [Patina](docs/patina/README.md) |
| **Anvil** | Give every bass note the same clean sub: one level, one start phase | [Anvil](docs/anvil/README.md) |
| **Crucible** | Capture the last bars, slice them and export clean, key-labelled samples | [Crucible](docs/crucible/README.md) |
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)
![Rust stable](https://img.shields.io/badge/Rust-stable-b7410e)
![License ISC](https://img.shields.io/badge/license-ISC-2ea44f)
Riddim and dubstep basses change patch on every switch-up. Fixed-frequency tools
then act differently on every note: the sub jumps, an EQ cut lands on another
harmonic, the distortion gets fizzy on one note and dull on the next. Most plugins
in this suite follow the played note, so a setting means the same thing on every
note. Each plugin is a separate product with its own version, guide and release.
## Plugins
| Plugin | What it does | Load it on | Latency | Docs |
| --- | --- | --- | --- | --- |
| **[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) |
| **[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) |
Plugins with latency report it to the host, so FL Studio compensates the delay.
Anvil, Lathe, Temper, Knurl and Planish 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 while leaving its character; an **anvil** is the solid base everything is
struck on; a **crucible** melts material down to be cast again. They share a Rust foundation and keep
their own musical purpose, code, documentation, and version.
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; **planishing**
hammers a surface smooth and even.
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*.
## Editors
<table>
<tr>
<td width="50%"><a href="docs/rivet/README.md"><img src="docs/rivet/assets/editor.png" alt="Rivet editor"></a><br><b>Rivet</b>: storyboard composition</td>
<td width="50%"><a href="docs/patina/README.md"><img src="docs/patina/assets/editor.png" alt="Patina editor"></a><br><b>Patina</b>: resonance smoothing</td>
</tr>
<tr>
<td><a href="docs/anvil/README.md"><img src="docs/anvil/assets/editor.png" alt="Anvil editor"></a><br><b>Anvil</b>: sub anchor</td>
<td><a href="docs/crucible/README.md"><img src="docs/crucible/assets/editor.png" alt="Crucible editor"></a><br><b>Crucible</b>: resampler</td>
</tr>
<tr>
<td><a href="docs/lathe/README.md"><img src="docs/lathe/assets/editor.png" alt="Lathe editor"></a><br><b>Lathe</b>: harmonic EQ</td>
<td><a href="docs/temper/README.md"><img src="docs/temper/assets/editor.png" alt="Temper editor"></a><br><b>Temper</b>: tracked distortion</td>
</tr>
<tr>
<td><a href="docs/weld/README.md"><img src="docs/weld/assets/editor.png" alt="Weld editor"></a><br><b>Weld</b>: layer phase alignment</td>
<td><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></td>
</tr>
</table>
The previews are rendered headless from the real editor code by
`tools/editor-check`, so they match what the plugin draws.
## Install
Download the desired plugin's Windows ZIP from its Gitea release. Extract it
and copy its `.clap` file (`Rivet.clap`, `Patina.clap`, `Anvil.clap` or
`Crucible.clap`) into:
1. Open the plugin's release on Gitea. Every plugin has its own release tag,
for example `lathe-v0.1.0`.
2. Download `<plugin>-windows-x86_64-<version>.zip`. You can check it against
the `.zip.sha256` file next to it.
3. Extract the ZIP and copy the `.clap` file into:
```text
C:\Program Files\Common Files\CLAP\
```text
C:\Program Files\Common Files\CLAP\
```
4. In FL Studio 2024.1 or newer, open **Options → Manage plugins**, enable
**Verify plugins**, and click **Find installed plugins**.
5. Load Rivet in the Channel Rack. Load the other plugins in mixer effect slots.
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 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
You need:
- Rust stable and Python 3.11 or newer.
- For the Windows target on Linux: the `x86_64-pc-windows-gnu` Rust target and
MinGW (`x86_64-w64-mingw32-gcc`).
- For the native wrapper tests on Linux: pkg-config and the X11/XCB and OpenGL
development headers. Rivet's optional standalone also needs ALSA/JACK headers.
```sh
# Tests for all engines, editors and tools
cargo test --workspace --locked
# Formatting, tests and lints for one product, including its CLAP wrapper
python3 scripts/check-plugin.py --plugin lathe
# Windows build, then the ZIPs (all plugins, or --plugin <name>)
python3 scripts/build-plugin.py --plugin lathe --target x86_64-pc-windows-gnu
python3 scripts/package-windows.py
# Headless editor preview
cargo run --manifest-path tools/editor-check/Cargo.toml -- target/editor/lathe.png --plugin lathe
```
In FL Studio 2024.1 or newer, open **Options → Manage plugins**, enable
**Verify plugins**, and **Find installed plugins**. Load Rivet in the Channel Rack;
load Patina, Anvil and Crucible in mixer effect slots.
Plugin names for the scripts are `rivet`, `patina`, `anvil`, `crucible`,
`lathe`, `temper`, `weld`, `knurl` and `planish`. 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.
Normal branch/PR pipelines also provide a combined Windows preview ZIP in the
**windows** job. Releases are separate: tags such as `rivet-v0.4.0`,
`patina-v0.2.0`, `anvil-v0.1.0` and `crucible-v0.1.0` publish independently. See [release workflow](docs/releases.md).
## Quality checks
These names replace Motif Storyboard and Resonance Compressor. Their CLAP IDs
and saved-state identifiers are preserved; replace old scanned copies and rescan.
Every push and pull request runs these checks in CI
(`.gitea/workflows/check.yml`):
- **Engine tests.** Every engine has tests for its signal behaviour and latency
against measured targets.
- **Realtime safety.** Every audio effect has a test that fails if the audio
thread allocates memory.
- **Host tests.** Each wrapper is loaded through the exported CLAP ABI, without
a DAW. The tests check the ports, the reported latency, state save and load,
and processing at 44.1, 48 and 96 kHz.
- **Lints.** `rustfmt` and `clippy -D warnings` for every package and wrapper.
- **Editor previews.** All editors are rendered headless and kept as CI
artifacts.
- **Windows builds.** Every `.clap` is cross-compiled and packaged.
Release ZIPs are deterministic and contain checksums for every file. Things CI
cannot check stay manual: FL Studio routing, native window behaviour, and
listening.
## Repository
```text
plugins/
catalog.json Product metadata used by build and release tools
rivet/
core/ Motifs, harmony, variation, history, performance
midi/ Standard MIDI import/export
app/ Worker jobs and real-time audition control
preview/ Offline MIDI/WAV development tool
plugin/ CLAP wrapper, editor, optional standalone
patina/
core/ Spectral detector and attenuation engine
ui/ Patina's native editor
render/ Offline WAV evaluation
plugin/ CLAP audio-effect wrapper
anvil/
core/ Pitch tracking, crossover and sub oscillator
ui/ Anvil's native editor
plugin/ CLAP audio-effect wrapper with MIDI input
crucible/
core/ Input history, capture, slicing, cleanup and export
ui/ Crucible's native editor
plugin/ CLAP audio-effect wrapper
catalog.json Product metadata for the build, check and release tools
rivet/ core · midi · app · preview · plugin
patina/ core · ui · render · plugin
anvil/ core · ui · plugin
crucible/ core · ui · plugin
lathe/ core · ui · plugin
temper/ core · ui · plugin
weld/ core · ui · plugin
knurl/ core · ui · plugin
planish/ core · ui · plugin
common/
ui/ Shared look: palette, embedded fonts, knobs and panels
dsp/ Shared filters, YIN pitch detection and WAV files
tools/editor-check/ Headless visual QA for all editors
scripts/ Shared build, packaging, release, and analysis tools
docs/<plugin>/ Guide, changelog and editor preview per plugin
references/ Local reference audio; excluded from distribution
ui/ Shared look: palette, embedded fonts, knobs, panels, meters
dsp/ Shared filters, pitch tracking, oversampling, meters, WAV files
tools/editor-check/ Headless editor renderer for visual QA
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
```
The root Cargo workspace contains the engines and supporting tools. Each CLAP
wrapper has an independent workspace and lockfile so SDK dependencies stay
separate. [Development and testing](docs/development.md) covers the commands.
Each product follows the same split:
```sh
cargo test --workspace --locked
python scripts/build-plugin.py --plugin rivet --target x86_64-pc-windows-gnu
python scripts/build-plugin.py --plugin patina --target x86_64-pc-windows-gnu
python scripts/build-plugin.py --plugin anvil --target x86_64-pc-windows-gnu
python scripts/build-plugin.py --plugin crucible --target x86_64-pc-windows-gnu
python scripts/package-windows.py
```
- `core` is the engine. It is plain Rust, does not depend on a plugin SDK, and
is tested on its own.
- `ui` is the egui editor, built on `common-ui`.
- `plugin` is the CLAP wrapper on a pinned NIH-plug revision.
Windows is the release target. Linux remains useful for tests, cross-compilation,
and headless editor previews. FL Studio routing, native window behavior, and
artistic listening still require manual checks in the target setup.
The root Cargo workspace holds the engines, editors and tools. Each wrapper is
its own workspace with its own lockfile, so SDK dependencies stay separate.
`plugins/catalog.json` is the single list of products. The scripts and CI read
it, so adding a plugin starts there.
## Releases
Each plugin is versioned and released on its own. Its version is the `version`
in `plugins/<plugin>/plugin/Cargo.toml`. A tag `<plugin>-v<version>`, for example
`temper-v0.1.0`, starts the release workflow. The workflow builds, checks and
publishes that plugin's Windows ZIP to a Gitea release. See
[docs/releases.md](docs/releases.md).
## Roadmap
[docs/ideas.md](docs/ideas.md) lists the production problems the suite targets,
the released plugins and the ideas for the next ones.
## License
ISC, see [LICENSE](LICENSE). The embedded Roboto Condensed fonts are under the
SIL Open Font License ([OFL.txt](plugins/common/ui/assets/OFL.txt)).
+6
View File
@@ -1,5 +1,11 @@
# Anvil changelog
## 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.
+5 -1
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.2.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
@@ -42,6 +42,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
+53
View File
@@ -0,0 +1,53 @@
# Signal chain
The order of the Bass Foundry plugins in a chain matters. Anvil, Lathe, Temper,
Knurl and Planish 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 → 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.
- **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.**
- **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 and
Planish 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

+122 -66
View File
@@ -1,103 +1,159 @@
# Development
Run commands from the repository root. Rust stable and Python **3.11 or newer**
are required. Lockfiles are committed for the core workspace, both CLAP wrappers,
and the headless editor tool.
Run commands from the repository root. You need Rust stable and Python **3.11 or
newer**. Lockfiles are committed for the root workspace, every CLAP wrapper and
the headless editor tool. Use `--locked` so a build never changes them silently.
## Product boundaries
The catalog in [plugins/catalog.json](../plugins/catalog.json) defines product
names, wrapper manifests, documentation, internal packages, and distribution
licenses. Shared build/check/release tools consume it. Each wrapper's Cargo
package version is the authoritative release version.
[plugins/catalog.json](../plugins/catalog.json) is the single list of products.
For each product it defines the display name, the wrapper manifest, the library
name, the documentation folder, the CLAP ID, the internal packages and the
licenses to ship. The build, check, package and release scripts and the script
tests read it. The version in each wrapper's `Cargo.toml` is the release version.
The root workspace contains Rivet's core, MIDI, application, and offline preview
crates; Patina's core, editor, and renderer; Anvil's and Crucible's core and
editor crates; and the shared `common-ui` (palette, fonts, knobs, panels) and
`common-dsp` (filters, YIN pitch detection, WAV files) crates. Wrappers under
`plugins/<product>/plugin/` remain independent workspaces because they depend on
a pinned NIH-plug Git revision. Their host identities and persistence stay separate.
Every product has the same split:
| Folder | Content | Workspace |
| --- | --- | --- |
| `plugins/<product>/core/` | Engine. Plain Rust, no plugin SDK, tested on its own | root |
| `plugins/<product>/ui/` | egui editor, built on `common-ui` | root |
| `plugins/<product>/plugin/` | CLAP wrapper on a pinned NIH-plug revision | own |
Rivet has more crates in the root workspace (`midi`, `app`, `preview`), and
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, Knurl and Planish, 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
saved state are separate for every product.
The engines run allocation-free on the audio thread. Analysis that is too heavy
for the audio thread (Rivet's generation jobs, Weld's cross-spectrum and fit) runs on a worker
thread. Data goes to the editor through lock-free queues.
## Checks
```sh
cargo test --workspace --locked
cargo clippy --workspace --all-targets --locked -- -D warnings
python scripts/check-plugin.py --plugin rivet
python scripts/check-plugin.py --plugin patina
python scripts/check-plugin.py --plugin anvil
python scripts/check-plugin.py --plugin crucible
python -m unittest discover -s scripts/tests -v
python3 scripts/check-plugin.py --plugin <product>
python3 -m unittest discover -s scripts/tests -v
```
The product check script checks formatting, tests, and lints that product's
packages and wrapper without compiling the other plugin. Offline builds work
once dependencies are cached; append `--offline` to manual Cargo commands.
Products: `rivet`, `patina`, `anvil`, `crucible`, `lathe`, `temper`, `weld`,
`knurl`, `planish`.
Native wrapper tests on Linux require pkg-config, X11/XCB, and OpenGL development
headers. Rivet's optional standalone also needs ALSA/JACK headers. Gitea installs
the headers needed by its check jobs.
`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
when you change a shared crate or the root workspace. Offline builds work once
the dependencies are cached; add `--offline` to manual Cargo commands.
The test types are:
- **Engine tests** (`core/tests/dsp.rs` and similar) measure signal behaviour
and latency against fixed targets.
- **Realtime tests** (`core/tests/realtime.rs`) install a counting allocator
and fail if the audio-thread path allocates.
- **Host tests** (`plugin/tests/host.rs`) load the wrapper through the exported
CLAP ABI without a DAW. They check the ports, latency, state save and load,
and processing at 44.1, 48 and 96 kHz with odd block sizes.
- **Script tests** (`scripts/tests/`) cover the catalog, release planning,
packaging and the publish client with fake transports. They also fail when a
catalog product is missing from a CI workflow.
The native wrapper tests on Linux need pkg-config and the X11/XCB and OpenGL
development headers. Rivet's optional standalone also needs ALSA/JACK headers.
The Gitea check jobs install these headers.
## Windows builds
On Windows, install Rust and the MSVC build tools. For Linux cross-compilation,
install the Rust `x86_64-pc-windows-gnu` target and MinGW's
`x86_64-w64-mingw32-gcc`.
On Windows, install Rust and the MSVC build tools. To cross-compile on Linux,
install the Rust `x86_64-pc-windows-gnu` target and MinGW
(`x86_64-w64-mingw32-gcc`).
```sh
python scripts/build-plugin.py --plugin rivet --target x86_64-pc-windows-gnu
python scripts/build-plugin.py --plugin patina --target x86_64-pc-windows-gnu
python scripts/build-plugin.py --plugin anvil --target x86_64-pc-windows-gnu
python scripts/build-plugin.py --plugin crucible --target x86_64-pc-windows-gnu
python scripts/package-windows.py
python scripts/package-windows.py --plugin rivet
python scripts/package-windows.py --plugin patina
python3 scripts/build-plugin.py --plugin <product> --target x86_64-pc-windows-gnu
python3 scripts/package-windows.py # combined preview ZIP
python3 scripts/package-windows.py --plugin <product> # one product's release ZIP
```
Build outputs use `dist/<target>/<Name>.clap`, with docs and
licenses retaining their directory structure. `CARGO_TARGET_DIR` is supported
for sharing compilation between wrappers. MSVC packaging accepts
Builds go to `dist/<target>/<Name>.clap`, with the docs and licenses in the same
folder structure. `CARGO_TARGET_DIR` is supported for sharing one build
directory between wrappers. MSVC packaging accepts
`--target x86_64-pc-windows-msvc`.
The combined ZIP is `dist/rivet-patina-windows-x86_64.zip`. Individual ZIPs use
`<product>-windows-x86_64-<version>.zip`. Each ZIP has internal file checksums and
an external `.zip.sha256`. ZIP metadata is deterministic; binaries still depend
on the toolchain and build environment. Packaging includes only selected catalog
entries, never old binaries left in `dist`.
- The combined ZIP is `dist/bass-foundry-windows-x86_64.zip`. It holds every
product that has been built.
- A product ZIP is `<product>-windows-x86_64-<version>.zip` and holds only that
product.
Linux builds remain available for local development; CI produces Windows release
binaries only. Neither build nor packaging installs plugins into a DAW.
Every ZIP has a `SHA256SUMS` file inside and a `.zip.sha256` file next to it.
ZIP metadata is deterministic; the binaries still depend on the toolchain and
the build environment. Packaging takes only the selected catalog entries, never
old binaries left in `dist`.
Linux builds are for local development; CI produces only Windows binaries. No
script installs a plugin into a DAW.
## Editor previews
```sh
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/rivet.png --plugin rivet
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/rivet-creative.png --plugin rivet --creative
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/patina.png --plugin patina
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/patina-compact.png --plugin patina --compact
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/anvil.png --plugin anvil
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/crucible.png --plugin crucible
mkdir -p target/editor
cargo run --manifest-path tools/editor-check/Cargo.toml -- target/editor/<product>.png --plugin <product>
cargo run --manifest-path tools/editor-check/Cargo.toml -- target/editor/rivet-creative.png --plugin rivet --creative
cargo run --manifest-path tools/editor-check/Cargo.toml -- target/editor/patina-compact.png --plugin patina --compact
```
Patina also accepts `--silent`, `--delta`, and `--bypass` as render fixtures.
The tool compiles actual editor code and software-renders without a display.
These checks do not establish native host embedding, keyboard routing, or audible
artistic quality. Use the product guides for FL Studio acceptance checks.
Patina also accepts `--silent`, `--delta` and `--bypass`. The tool compiles the
real editor code, feeds it synthetic analysis data and renders it in software
without a display. Copy a checked render to `docs/<product>/assets/editor.png`
to update the guide. These renders do not prove native host embedding, keyboard
routing or how the plugin sounds; use the FL Studio checks in each product guide.
## Add a product
1. Create `plugins/<product>/{core,ui,plugin}`. Copy the wrapper `Cargo.toml`
and `Cargo.lock` of a similar product so the NIH-plug revision matches.
2. Add `core` and `ui` to the root `members`, the wrapper to `exclude`, and a
`[profile.dev.package.<product>-core] opt-level = 2` entry if the tests run
heavy DSP.
3. Add the product to `plugins/catalog.json` with a new, unique CLAP ID.
4. Add it to `.gitea/workflows/check.yml` (check matrix, editor preview,
Windows build) and to the tag list in `release.yml`. The script tests fail
until you do.
5. Add a `--plugin <product>` fixture to `tools/editor-check`.
6. Write `docs/<product>/README.md`, `CHANGELOG.md` and `assets/editor.png`, and
add the product to the root README.
## Compatibility and naming
| Product | Former display name | Preserved CLAP ID |
The suite is called **Bass Foundry**; this is the vendor name hosts show. Hosts
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` |
| 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` |
Rivet retains `story-session-v1` and its session migration support; Patina retains
all parameter IDs. No JSON/state migration is introduced by the rename. These
historical identifiers and copyright notices intentionally remain in source.
Replace old scanned plugin files before rescanning, rather than keeping duplicate
CLAP IDs installed. Verify existing-project recall 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.
See [release workflow](releases.md) for tagged publishing. Actual Gitea runner
execution and FL Studio validation remain separate from local checks.
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.
+334
View File
@@ -0,0 +1,334 @@
# Plugin ideas
A running list of plugin ideas for riddim, dubstep and bass-music sound design.
Each entry names the problem first. Entries are grouped as released or idea. Names follow the metalwork theme of the Bass Foundry suite.
## Problems seen in bass-music production
1. Every bass patch brings its own sub level and phase, so the weight of a drop
jumps between switch-ups, and layered patches cancel in the sub.
2. Patches at the same peak level still differ in perceived loudness and
brightness. The usual fix is hand-set gain or clipping per patch.
3. Resampling is repetitive: bounce, chop, trim, declick, normalise, find the
root key, name, save, load into a sampler.
4. Writing wobbles: LFO shapes are drawn, synced, copied and edited for every
variation. Triplet and dotted patterns plus per-note changes take long.
5. Unison, OTT and stereo distortion widen the mids. Parts disappear in mono or
on club systems.
6. Kick and bass clash. Sidechain helps, but the sub phase at each note start
changes how the kick hits.
7. Distortion chains build up harsh resonances. (Patina covers this.)
8. Comparing a drop with a reference: the difference is usually sub/mid balance
over time, which generic meters do not show clearly.
9. Fixed-frequency tools act differently on every note. An EQ boost at 300 Hz
lifts the 4th harmonic of one note and the 7th of another; a distortion with
a fixed tone filter sounds brighter on low notes than on high notes.
10. Layered bass patches partly cancel in the mids, not only in the sub.
11. Texture makes a riddim patch: grit, rattle, metal, vowel, noise. It takes long
chains (distortion, comb, formant, OTT, resample, repeat), and the result
changes from note to note.
12. Grit and tone cannot be shaped separately. More distortion for more grit
also changes the body, the level and the low end.
13. Resampled textures sound printed: a loop of one moment, with no movement
inside the note.
14. Texture changes inside a note (the switch-up within one held note) need
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.
## 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.
## Released
### Anvil — sub anchor (problems 1, 6)
Replaces the band below a split frequency with a clean mono sine that follows
the bass pitch, at one level and one start phase for every note. Pitch comes
from detection with 80 ms lookahead or from MIDI. See [Anvil](anvil/README.md).
### Crucible — resampler (problem 3)
Records the input continuously. One click captures the last bars on the host
bar grid, slices on a grid or on hits, trims, declicks, normalises, detects the
root key and exports labelled WAVs, a full loop and an optional Serum/Vital
wavetable. See [Crucible](crucible/README.md).
### Lathe — harmonic EQ (problem 9)
EQ bands placed on harmonics 1–32 of the detected note instead of fixed
frequencies. "+4 dB on harmonic 3, −6 dB on harmonic 7" sounds the same on F1
and on C2. Odd/even balance, tilt over harmonic number, and a Focus control
that lifts or cuts everything between the harmonics. Uses Anvil's pitch
tracker, or MIDI. See [Lathe](lathe/README.md).
### Temper — note-tracked distortion (problem 9)
A waveshaper whose tone filters before and after the shaper follow the note, so
every note distorts with the same character and the same harmonic content.
Input level is normalised before the shaper, so drive does not depend on how
loud the patch is, and the output is gain-matched. Oversampled to keep aliasing
(which is never harmonic) out. See [Temper](temper/README.md).
### Weld — layer phase alignment (problem 10)
Takes a second bass layer through the sidechain. It fits polarity and a chain
of up to four allpass sections to the measured cross-spectrum (no added
delay), so the layers sum instead of cancel. A display shows how much level the
cancellation cost and how much Weld recovered. Anvil solves the sub band; Weld
solves the mids. See [Weld](weld/README.md).
### Knurl — cycle texture (problems 11, 13)
Follows the played note and treats every cycle on its own: a repeating level
pattern (in-tune subharmonics), random level and timing per cycle, and a noise
burst per cycle tuned to the note. Amounts are counted in cycles, so the texture
is the same on every note. Slow per-harmonic drift is postponed. See
[Knurl](knurl/README.md).
### Planish — harmonic OTT (problem 16)
Upward and downward compression in four bands whose edges are harmonic numbers
of the tracked note (default 1, 2–4, 5–16, 17 and up), so the same setting
squashes the same harmonics on every note. Each band's level is measured over
one period of the note, so heavy settings do not ripple at f0. Built, not yet
released. See [Planish](planish/README.md).
## Next for released plugins
- **Anvil**: *Kick lock*: the kick through the sidechain ducks the sub on each
hit and restarts its phase so kick and sub sum (problem 6). A sub shape from
sine to rounded triangle. A note attack counted in cycles.
- **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, unreleased). MIDI as its pitch source. M/S
processing.
- **Weld**: alignment slots per note or per switch-up. The External
sender/receiver fallback if FL's sidechain does not arrive.
- **Crucible**: 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** (unreleased): 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, unreleased), then Quench and Ballast: they fill the mix and
master gap.
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. 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.
### 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
target or a reference patch and adjusts gain only, so each switch-up lands at
the same loudness without compression changing its character. Earlier working
name: Hit Leveler. Pairs with Anvil: Anvil levels the sub, Ballast the whole
patch.
### Bellows — pitch-tracked resonator
A comb or Karplus-style resonator tuned to the detected note. It turns noise,
distortion or drums into pitched metallic tone that is in key on every note.
Damping, harmonic spread, single or multiple combs.
### Gauge — low-end mono check (problem 5)
Shows phase correlation and mono-sum loss per band and per note in the bass
range, and marks the notes where widening breaks the mono sum. Optional fixes:
mono below a chosen frequency and phase rotation on the mids. Display-first, so
it is a small build. Earlier working name: Bass Mono Check.
### Wobble sequencer (problem 4)
Step patterns for volume, filter, formant and pitch with triplet and dotted
grids built in. Each MIDI note selects a different pattern, so a switch-up is a
different note in the piano roll. ShaperBox overlaps, but it has no note-keyed
patterns. No name yet.
### Drop compare (problem 8)
Loudness-matched A/B of a drop against a reference, with sub, low-mid, mid and
high energy over time: for example "your sub is 3 dB under the reference in
bars 2–4". No name yet.
## Texture ideas
These ideas target problems 11–15; Knurl, the first of them, is released above.
Most of them reuse the note tracker in
`common-dsp`, so a texture setting means the same thing on every note, the same
way Lathe and Temper work.
### Anneal — tone and grit split (problem 12)
Annealing changes how a metal behaves without changing its shape. Anneal
splits the signal into three parts: the harmonic part (energy on the harmonics
of the tracked note), the residual noise and grit between the harmonics, and
the transients. Each part has a level fader, a drive and a tilt.
- Turn the grit up 6 dB and keep the body and the sub unchanged.
- Take all the grit out of a layer and leave a clean tone for further design.
- Listen to each part solo, or send the grit to a second output for its own
chain.
DSP: an STFT with the note tracker; harmonic masks at k·f0, with a width that
follows the pitch; a transient mask from spectral flux. Latency is one FFT
frame. The display shows the three parts in different colours on Lathe's
harmonic strip.
### Swarf — in-key granular shavings (problems 11, 13)
Swarf is the shavings that machining leaves. Swarf records the bass itself and
sprays short grains of it back as a layer. Grain lengths are whole pitch
periods, and every grain is retuned to the note playing now, so the shredded
layer stays in key across a riddim line. Controls are density, spray (position
spread back in time), grain size in cycles, reverse probability, and a
harmonic-only filter for the layer. It is useful for the "shredded metal" top
layer, and works on a resample from Crucible as well.
### Etch — texture imprint (problem 11)
Etching transfers a pattern onto metal with acid. Etch takes a texture source
through the sidechain or from a loaded sample (a metal scrape, a vocal, a field
recording) and imprints its fine structure on the bass. The bass keeps its
pitch and its harmonic levels; between and on the harmonics, the source's
noise, roughness and movement come through. A "harmonic lock" mode samples the
source's spectral envelope only at the bass harmonics, so the imprint is always
in key. It differs from a vocoder: a vocoder transfers the envelope, and Etch
transfers the surface.
### Chase — talking formants that hit harmonics (problems 11, 14)
Chasing shapes metal by hammering it from the front. Chase is a formant filter
with a 2D vowel pad (a, e, i, o, u, plus "yoi" and "wub" shapes). You draw a
path over the pad and sync it to the tempo, or let each MIDI note select a path.
The formants stay in Hz, so a vowel stays a vowel on every note. A **snap**
control moves each resonant peak onto the nearest harmonic of the tracked
note, so a narrow formant never falls into the gap between two harmonics and
vanishes on some notes. Gain-matched, so a vowel change does not change the
level.
### Damascus — texture step sequencer (problem 14)
Damascus steel is folded from layers into a visible pattern. Damascus holds up
to four texture slots (for example a formant, a comb, a sample-rate reducer and
a Knurl-type rattle) and a tempo-synced step grid that chooses a slot, or a mix
of two, per step. Steps have their own crossfade time, so changes can be hard
cuts or smears. Each slot is gain-matched, so the steps change texture and not
level. Each MIDI note can select its own pattern, which is a texture switch-up
inside one held note. This overlaps with the wobble sequencer above; both could
become one plugin.
### Loupe — texture meter (problem 15)
A loupe is the lens used to inspect engraving. Loupe measures texture per note
over time:
- roughness and fluctuation strength (psychoacoustic models);
- harmonic-to-noise ratio, which shows how gritty a sound is;
- inharmonicity, which shows how metallic a sound is;
- brightness relative to the note (harmonic centroid, not centroid in Hz).
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.
+20
View File
@@ -0,0 +1,20 @@
# 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.
- Step patterns over 1–16 cycles make subharmonics in tune with every note:
Off, Double, Triple, Fry, Gallop, Stutter, or a drawn pattern.
- Random level and timing per cycle, and a noise burst per cycle tuned to the
note (Tone, Position, Length), switchable per step.
- Same every note or Free random sequences.
- Detect pitch and note starts from the audio with about 71 ms of latency, or
follow MIDI notes sent to the effect.
- Editor shows the last eight cycles overlaid and the pattern with the playing
step.
+91
View File
@@ -0,0 +1,91 @@
# Knurl
Knurl gives every cycle of the note its own level, timing and grit. Current
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
that texture comes from long chains of distortion, comb filters, formants and
resampling, and it changes from note to note. Knurl works on single cycles of the
wave instead. It follows the played note and treats each cycle on its own, and
every amount is counted in cycles, not in Hz or tempo. So the texture has the
same grain on F1 and on C2.
![Knurl](assets/editor.png)
## Install on Windows
Copy `Knurl.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio 2024.1
or newer, open Options → Manage plugins, enable Verify plugins, and Find installed
plugins. Load Knurl in a mixer slot of the bass. Keep the font license with
redistributed packages.
## Work with it
1. Play the bass. The note readout shows what Knurl tracks; the scope overlays
the last eight cycles of the output, so the texture is visible cycle by cycle.
2. Choose a **Pattern**. The pattern sets the level of each cycle and repeats:
- **Double** (0, −9 dB): every second cycle quieter. This makes a sub-octave
growl, exactly in tune, about 8 dB under the fundamental.
- **Triple** (0, −12, −12 dB): a growl an octave and a fifth down.
- **Fry** (0, −14, −5, −14 dB, the default): an uneven vocal-fry rattle.
- **Gallop** and **Stutter**: rhythmic patterns over six and eight cycles.
- **Off**: every cycle at full level.
3. Draw your own pattern on the bars: drag a bar to set the level of that cycle,
double-click to reset it. **Steps** sets the length (1–16 cycles).
4. Add surface:
- **Level** gives every cycle a random level (standard deviation in dB).
- **Time** moves every cycle a little in time (percent of a period): rattle
and scrape.
- **Grit** adds a short noise burst inside every cycle. **Tone** sets its
lowest frequency in harmonics of the note, **Position** where in the cycle
it sits, and **Length** how long it is. The squares under the pattern bars
switch the burst per step.
5. **Edge** sets how soft each step change is. Low values are sharper and
buzzier; high values smoother.
**Random**: **Same every note** (default) restarts every random sequence at each
note, so identical notes sound identical, which keeps a riddim line tight.
**Free** lets the sequences run on, so every note differs.
**Mix** blends with the delayed dry signal; **Output** trims the level.
### Pitch from MIDI
For basses whose fundamental is unclear (heavy FM, formant or noise patches),
select **MIDI** and route the bass's MIDI notes to Knurl. In FL Studio, set the
bass channel's MIDI output port and Knurl's input port to the same number.
**Gate** sets the input level that counts as a note in AUDIO mode; **Snap to
semitone** rounds the detected pitch (turn it off for slides).
## Timing and limits
Knurl delays the audio by about 71 ms: 67 ms of pitch-detection lookahead plus
4 ms of room for the timing jitter. The delay is reported to the host for delay
compensation, so the cycle clock knows every note before its attack leaves the
plugin. The cycle clock restarts at every note start, so the pattern always
begins with step 1 on the attack. Pitch range is 30–500 Hz, one note at a time.
Between notes the effects fade out over 20 ms, so tails stay clean.
Knurl works per cycle of the tracked pitch; it does not look for cycle borders in
the waveform. With a stable pitch that is the same thing. With heavy pitch
modulation inside a note the pattern follows the tracked pitch, which can lag a
fast bend. Both channels get the same treatment, so the effect is mono-safe.
Knurl's patterns put a real sub-octave into the signal. A pitch detector after
Knurl (Anvil, Lathe or Temper in Audio mode) then reads notes from C2 upwards
an octave too low. Put Knurl after the other tracked plugins, or use MIDI as
their source. See [Signal chain](../chain.md).
## How it works
A note tracker (shared with Anvil, Lathe and Temper) detects pitch and note
starts ahead of the delayed audio. A cycle clock starts at 0 on every note and
advances by f0 / sample rate. Each cycle gets its step gain and its random level
and time offset from a hash of the cycle number, so the result is deterministic.
Step changes are raised-cosine crossfades over the last **Edge** of a cycle;
level jitter crossfades over half a cycle and time jitter over the whole cycle,
read from the delay line with cubic interpolation. The grit is white noise
through a state-variable highpass at **Tone** × f0, shaped by a sin² window and
scaled to the input level. There is no nonlinearity, so no oversampling is
needed.
Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

+19
View File
@@ -0,0 +1,19 @@
# 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.
- Bands sit on harmonics 1–32 of the played note instead of fixed frequencies,
so a cut on harmonic 3 stays on harmonic 3 on every note.
- Draw a gain per harmonic; Tilt, Odd/Even and Between shape the whole series.
- Detect pitch and note starts from the audio with 67 ms lookahead, or follow
MIDI notes sent to the effect. Filters retune at each note's attack.
- Controls: Tilt, Odd/Even, Between, Width, Harmonics, Smooth, Gate, Mix,
Output, Snap to semitone, Bypass. Shapes: Flat, Odd only, Even only, Hollow.
- Harmonic editor shows the measured level of each harmonic before and after.
+66
View File
@@ -0,0 +1,66 @@
# Lathe
Lathe is an EQ whose bands sit on the harmonics of the note being played.
Current version **0.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
same bass sounds different on every note of a riddim line. Lathe follows the
pitch and places one band on each harmonic: "−12 dB on harmonic 3" stays on
harmonic 3 whatever note plays.
![Lathe](assets/editor.png)
## Install on Windows
Copy `Lathe.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio 2024.1
or newer, open Options → Manage plugins, enable Verify plugins, and Find installed
plugins. Load Lathe in a mixer slot of the bass, after the synth and before bus
processing. Keep the font license with redistributed packages.
## Work with it
1. Play the bass. The note readout shows what Lathe tracks; the lower strip of the
editor shows each harmonic's level going in (grey) and coming out (pink).
2. Draw gains on the harmonic bars. Drag across bars to paint a shape;
Shift-drag for fine steps; double-click a bar to reset it.
3. Use the macros for the whole series:
- **Tilt** brightens or darkens in dB per octave of harmonic number. The
fundamental is the pivot.
- **Odd/Even** lifts odd harmonics and lowers even ones (positive, more
square-like) or the reverse (negative, more octave-like).
- **Between** sets the gain halfway between harmonics. Cut it to clean up noise,
beating and inharmonic grit; lift it for dirt.
4. **Width** sets every band's bandwidth as a share of the harmonic spacing. Narrow
bands touch only the harmonic; wide bands also move its surroundings.
Other controls:
- **Harmonics** is how many harmonics are shaped (8–32). Higher harmonics pass
unchanged.
- **Smooth** is the retune time for slides and pitch bends. A new note always
retunes at once, on its attack.
- **Gate** is the input level that counts as a note. Raise it if tails or reverb
start new notes.
- **Mix** blends with the delayed dry signal; **Output** trims the level.
- **Snap to semitone** rounds the detected pitch. Turn it off for slides.
- **Shapes** set the drawn gains: Flat, Odd only, Even only, or Hollow (harmonics
2–4 cut).
The drawn gains are saved with the project but are not automation parameters;
the macros are.
### Pitch from MIDI
For basses whose fundamental is unclear (heavy FM, formant or noise patches),
select **MIDI** and route the bass's MIDI notes to Lathe. In FL Studio, set the
bass channel's MIDI output port and Lathe's input port to the same number.
## Timing and limits
Detection uses about 67 ms of lookahead, reported to the host for delay
compensation, so the bands are tuned before each note's attack leaves the
plugin. Pitch range is 30–500 Hz, one note at a time; chords are not separated.
Before the first note Lathe passes audio unchanged; between notes it holds the
last tuning so tails keep their shape. Bands above 0.45 × the sample rate are
skipped.
Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

+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

+36 -33
View File
@@ -1,49 +1,52 @@
# Independent plugin releases on Gitea
Rivet and Patina have independent versions and releases. Workflows live under
`.gitea/workflows/`.
Every Bass Foundry plugin has its own version and its own release. The
workflows are in `.gitea/workflows/`. A plugin's version is the `version` in
`plugins/<plugin>/plugin/Cargo.toml`, and its release tag is
`<plugin>-v<version>`.
| Tag | Gitea release | Windows download |
| Plugin | Latest tag | Windows download |
| --- | --- | --- |
| `rivet-v0.3.0` | Rivet 0.3.0 | `rivet-windows-x86_64-0.3.0.zip` |
| `patina-v0.1.0` | Patina 0.1.0 | `patina-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.2.0` | `anvil-windows-x86_64-0.2.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.2.0` | `weld-windows-x86_64-0.2.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` |
## Make a release
1. Update the selected wrapper's `Cargo.toml` version and its
`docs/<product>/CHANGELOG.md`. The wrapper version determines the release
version; internal crate versions can evolve separately.
2. Refresh affected lockfiles, run the product check, and commit/push the change.
3. Tag the intended commit and push that product's tag.
1. Set the new version in the plugin's wrapper `Cargo.toml` and add a section
with that version to `docs/<plugin>/CHANGELOG.md`. Internal crate versions
can change independently.
2. Refresh the affected lockfiles, run the plugin's check, then commit and push.
3. Tag the commit and push only that plugin's tag.
For Rivet:
For example, Temper 0.2.0:
```sh
python scripts/check-plugin.py --plugin rivet
git tag -a rivet-v0.3.0 -m "Rivet 0.3.0"
git push origin rivet-v0.3.0
python3 scripts/check-plugin.py --plugin temper
git tag -a temper-v0.2.0 -m "Temper 0.2.0"
git push origin temper-v0.2.0
```
For Patina:
```sh
python scripts/check-plugin.py --plugin patina
git tag -a patina-v0.1.0 -m "Patina 0.1.0"
git push origin patina-v0.1.0
```
These are instructions; this change does not create tags or publish a release.
Tags must exactly match the selected wrapper version and a changelog section.
Wrong product/version tags fail before building or publishing. Cargo prerelease
versions such as `patina-v0.2.0-rc.1` are supported and marked as prereleases.
The tag must match the wrapper version and a changelog section exactly. A wrong
plugin name or version fails in the `plan` job, before anything is built or
published. Cargo prerelease versions such as `temper-v0.2.0-rc.1` are supported
and published as prereleases.
## Workflows
**Check and Windows preview** runs on branch pushes, pull requests, and manual
dispatch. It checks both products, renders editor previews, and provides a combined
Windows ZIP as an Actions artifact. It never publishes a release.
dispatch. It checks every product in a matrix, renders all editor previews, and
provides the combined `bass-foundry-windows-x86_64.zip` as an Actions artifact.
It never publishes a release.
**Independent plugin release** runs on `rivet-v*` or `patina-v*` tag pushes:
**Independent plugin release** runs when a `<plugin>-v*` tag is pushed, for any
plugin in the catalog:
1. `plan` validates the product/version/changelog and exposes the selected plugin.
2. `build` checks and cross-compiles only that plugin, then packages its Windows
@@ -106,7 +109,7 @@ The release tag must already exist in Gitea; the publisher does not create tags.
The ZIP must contain exactly the selected plugin. Its sidecar and internal file
checksums are verified before any release write.
Choose a plugin's named release/tag rather than assuming the repository's global
latest release refers to both products. Package and API behavior are tested with
fake transports; an actual runner/tag release on this Gitea instance remains to
be verified.
Link to a plugin's own release or tag. The repository's "latest release" is
only the most recent release of any plugin. Packaging and API behaviour are tested with
fake transports. Every tag in the table above was published by this workflow on
the project's Gitea instance.
+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.
+20
View File
@@ -0,0 +1,20 @@
# 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.
- Normalises each note's level before the shaper, so quiet and loud patches are
driven the same.
- Floor (highpass), Push (one harmonic boosted or cut) and Bright (lowpass) sit
at multiples of the played note, so every note keeps the same character.
- Curves: Soft, Hard, Fold and Tube, with Bias for even harmonics. The shaper runs
at 4× oversampling.
- Auto gain keeps the output at the input loudness while Drive changes.
- Detect pitch and note starts from the audio with 67 ms lookahead, or follow
MIDI notes sent to the effect.
+66
View File
@@ -0,0 +1,66 @@
# Temper
Temper is a distortion that drives every note the same. Current version **0.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
a patch that is 6 dB quieter is driven 6 dB less. Across a riddim line that makes
some notes fizzy and others dull. Temper evens out each note's level before the
shaper and moves its tone filters with the pitch, so the character stays the same
on every note.
![Temper](assets/editor.png)
## Install on Windows
Copy `Temper.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio
2024.1 or newer, open Options → Manage plugins, enable Verify plugins, and Find
installed plugins. Load Temper in a mixer slot of the bass. Keep the font license
with redistributed packages.
## Work with it
1. Choose a **Curve**:
- **Soft**: tanh, smooth and rounded.
- **Hard**: clipping with a narrow knee, dense and square-like.
- **Fold**: the wave folds back on itself; Drive moves the upper harmonics.
- **Tube**: the negative half clips earlier, adding even harmonics.
2. Set **Drive**. The transfer plot shows the curve over the range a normalised
note covers; the **Into shaper** meter shows how far past the knee it goes.
3. Add **Bias** for asymmetry and even harmonics.
4. Shape the tone around the shaper, all in multiples of the note:
- **Floor** is a highpass before the shaper (0.25–2 × the fundamental; 0 is
off). It keeps sub mud out of the distortion. Put Anvil after Temper to add a
clean sub back.
- **Push** boosts or cuts one harmonic (**Push at**, 1–16) before the shaper,
so the distortion bites in the same place on every note.
- **Bright** is a lowpass after the shaper at a harmonic number (4–64; 0 is
off), so the top end stops at the same harmonic on every note.
Other controls:
- **Normalize** is how far each note's level is evened out before Drive. At 100 %
a quiet and a loud patch distort the same; at 0 % Temper acts like a normal
distortion.
- **Auto gain** keeps the output at the input's loudness, so turning up Drive
changes the character, not the level.
- **Gate** is the input level that counts as a note.
- **Mix** blends with the delayed dry signal; **Output** trims the level.
### Pitch from MIDI
Select **MIDI** and route the bass's MIDI notes to Temper when detection
struggles. In FL Studio, set the bass channel's MIDI output port and Temper's
input port to the same number.
## Timing and limits
Detection uses about 67 ms of lookahead; the 4× oversampling filters add under
1 ms. Both are reported to the host for delay compensation. Pitch range is
30–500 Hz, one note at a time. Before the first note the tracked filters are off;
between notes they hold the last tuning.
Aliasing stays below −60 dB for bass material (tested with a 2 kHz tone at 24 dB
drive and a 440 Hz tone at full drive, on every curve). High-pitched content at
full drive aliases more: a 5 kHz tone at 36 dB on the Hard curve reaches about
−31 dB.
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

+21
View File
@@ -0,0 +1,21 @@
# Weld changelog
## 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.
- Hears the other layer through the sidechain input and fits polarity plus up to
four allpass sections, so the layers sum instead of cancelling. No latency;
the layer on its own keeps its sound.
- Modes: Follow (refits only when clearly better), Learn (measure one take,
apply on exit), Hold. The alignment is saved with the project.
- Display: level per band against a perfect alignment (lost and won back),
phase before and after, recovered dB, cancellation cost, time offset, and
warnings for a late layer, unrelated layers and a missing sidechain.
- Controls: Mode, Sections, Response, Low, High, Gate, Listen (Layer, Sum,
Sum dry), Output, Bypass.
+110
View File
@@ -0,0 +1,110 @@
# Weld
Weld fuses two bass layers so they add up instead of cancelling. Current version
**0.2.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
small time offset between the layers, and the phase shift of the filters and EQs
in one chain. The result is a thin, hollow sum that changes with every
switch-up. Anvil fixes this for the sub band; Weld fixes it for the mids.
Weld sits on one layer and hears the other one through the sidechain input. It
measures how the two layers line up, then turns the phase of its own layer with
a polarity flip and up to four allpass filters. An allpass changes only phase,
so the layer on its own sounds the same; only the sum changes. Weld adds no
latency.
![Weld](assets/editor.png)
## Install on Windows
Copy `Weld.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. Keep the font license with redistributed packages.
## Set up in FL Studio
1. Put each layer on its own mixer track. Load Weld on the track of one layer,
the main layer.
2. Select the other layer's track. Right-click the route arrow under the main
layer's track and choose **Sidechain to this track**. The other layer still
goes to the master on its own; the sidechain only lets Weld hear it.
3. In Weld's plugin wrapper, open the settings (gear icon) and set Weld's
sidechain input to that track.
4. Play both layers. The SIDECHAIN meter must move. If the display says "No
sidechain signal", check step 3.
## Work with it
1. Leave **Mode** on **FOLLOW** and play the part where both layers sound. After
a second or two, Weld shows its alignment: polarity, and the allpass sections
with their frequency and Q.
2. Read the display:
- **Recovered** is how much level the sum gains, as an average per band
between Low and High. **Cancellation cost** is how much a perfect alignment
would gain. When the two numbers are close, Weld got nearly everything.
- The upper plot shows each band against a perfectly aligned sum (0 dB). Red
is what the unprocessed sum loses to cancellation; pink is the part Weld
wins back. The white line is the level with Weld.
- The lower plot shows the phase of this layer against the other one, before
(grey) and after (pink), and Weld's own phase curve (blue). After alignment
the pink line sits near 0°.
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.
4. When the alignment is right, switch to **HOLD**. It is saved with the project
and comes back when the project opens.
For one drop or switch-up, use **LEARN**: press it, play the part, then press
**HOLD** (or LEARN again). Weld measures the whole take with equal weight and
applies the result when Learn ends.
## Controls
- **Mode**:
- **Follow** keeps measuring and refits only when a new alignment is clearly
better (at least 0.3 dB on average), so the filter does not wander.
- **Learn** starts a fresh measurement and applies its result when Learn ends.
- **Hold** keeps the current alignment. The display keeps measuring.
- **Sections** (1–4) is the largest number of allpass sections the fit may use.
Weld uses fewer when fewer are enough.
- **Response** (0.3–5 s) is the averaging time in Follow. Longer is steadier.
- **Low** and **High** set the range the fit listens to. Low defaults to 60 Hz,
leaving the sub to Anvil. The allpass still acts below Low, and the display
shows that honestly.
- **Gate** (−70 to −30 dB): moments where either layer is quieter are not
measured, so tails and silence do not pull the fit.
- **Listen**: **Layer** outputs this layer, aligned, for the mix. **Sum** outputs
this layer aligned plus the sidechain layer. **Sum dry** outputs both
unprocessed.
- **Output** trims the level; **Bypass** passes this layer unchanged.
## Limits
- **A late layer cannot be moved earlier.** An allpass only delays. If the layer
Weld sits on is the later one, the display shows "This layer is late" and the
offset in ms. Put Weld on the other layer instead.
- **Free-running layers cannot be aligned.** Without oscillator retrigger, or
with detuned unison, the phase between the layers drifts all the time. Weld
marks those bands as *loose* (hatched) and does not fit them. Enable retrigger
in the synth if you want Weld to align the layers.
- **Delays leave a comb.** A time offset between the layers cancels at regular
frequencies. A few allpass sections cannot follow a delay all the way up, so
Weld aligns the lower range and moves the higher notches rather than removing
them. For a large offset, move the layer in time first.
- **One alignment for the whole patch.** Weld does not change the alignment per
note. If switch-ups need different alignments, use Follow, or automate Mode.
- **The sidechain must arrive.** With no sidechain signal Weld changes nothing.
## How it works
Every 256 samples the audio thread hands mono copies of both layers to a worker
thread through a lock-free queue. The worker estimates their cross-spectrum
(Welch, Hann window, about 11.7 Hz per bin, 50 % overlap) and their coherence.
It fits polarity plus up to four second-order allpass sections so that
Σ w(f)·Re(S(f)·H(f)) is largest, with w = 1/f so every octave counts, ignoring
bins where the layers are not coherent, and with a penalty for any band that
would lose more than 1 dB. A new fit fades in over 40 ms after its filters have
settled for 40 ms, so there are no clicks. One fit takes about 15 ms on one
core, twice per second in Follow.
Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

+90 -322
View File
@@ -5,27 +5,18 @@
//! a note's pitch is known before its delayed attack leaves the plugin, and the sine starts
//! exactly on that attack.
mod settings;
use common_dsp::{
coefficient, db_to_gain, gain_to_db, hz_to_midi, midi_to_hz, Biquad, Crossover, Yin,
};
use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi, midi_to_hz, Crossover};
pub use settings::{Settings, Source};
use std::f32::consts::TAU;
/// Lowest detectable fundamental. Sets the lookahead (two periods).
pub const MIN_HZ: f32 = 25.;
pub const MAX_HZ: f32 = 250.;
/// Detectable fundamental range. The lowest sets the lookahead (two periods).
pub const MIN_HZ: f32 = TrackerConfig::SUB.min_hz;
pub const MAX_HZ: f32 = TrackerConfig::SUB.max_hz;
pub const SCOPE: usize = 256;
const EVENTS: usize = 64;
const DETECT_RATE: f32 = 3000.;
const HOP_MS: f32 = 5.;
const ATTACK_MS: f32 = 2.;
const RELEASE_MS: f32 = 15.;
const CROSSFADE_MS: f32 = 3.;
const GATE_HOLD_MS: f32 = 20.;
const GATE_HYSTERESIS_DB: f32 = 6.;
/// A pitch move this large (semitones) counts as a new note.
const JUMP: f32 = 0.7;
const CLARITY: f32 = 0.5;
#[derive(Clone, Copy, Debug, Default)]
pub struct Monitor {
@@ -65,19 +56,6 @@ impl Default for AnalyzerFrame {
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Kind {
Onset,
Release,
NoteOn(u8),
NoteOff(u8),
}
#[derive(Clone, Copy, Debug)]
struct Event {
time: u64,
kind: Kind,
}
#[derive(Clone, Copy, Debug, Default)]
struct Voice {
phase: f32,
@@ -87,6 +65,55 @@ struct Voice {
releasing: bool,
}
/// Sine voices and per-note state, driven by tracker events at output time.
#[derive(Clone, Copy, Debug, Default)]
struct Synth {
voice: Voice,
old: Voice,
old_gain: f32,
note_peak: f32,
}
impl Synth {
fn event(&mut self, s: &Settings, e: NoteEvent) {
match e {
NoteEvent::Start { pitch, glide } => self.start(s, pitch, glide),
NoteEvent::Move { pitch } => {
if !s.snap {
self.voice.target = midi_to_hz(pitch + 12. * s.octave as f32);
}
}
NoteEvent::Stop => self.voice.releasing = true,
}
}
fn start(&mut self, s: &Settings, pitch: f32, glide: bool) {
let note = if s.snap { pitch.round() } else { pitch };
let hz = midi_to_hz(note + 12. * s.octave as f32);
let audible = self.voice.amp > 1e-4 && !self.voice.releasing;
if glide && audible {
self.voice.target = hz;
return;
}
if s.retrigger || !audible {
if self.voice.amp > 1e-4 {
// Restarting the phase mid-cycle would click; the old cycle fades out instead.
self.old = self.voice;
self.old_gain = 1.;
}
self.voice = Voice {
phase: s.phase / 360.,
hz,
target: hz,
amp: 0.,
releasing: false,
};
} else {
self.voice.target = hz;
self.voice.hz = hz;
}
self.note_peak = 0.;
}
}
pub struct Engine {
rate: f32,
channels: usize,
@@ -95,37 +122,10 @@ pub struct Engine {
delay: Vec<[f32; 2]>,
delay_at: usize,
crossover: [Crossover; 2],
detect: [Biquad; 2],
decimate: usize,
decimate_count: usize,
detect_rate: f32,
ring: Vec<f32>,
ring_at: usize,
scratch: Vec<f32>,
yin: Yin,
hop: usize,
hop_count: usize,
gate_env: f32,
gate_release: f32,
gate_open: bool,
gate_hold: usize,
clock: u64,
events: [Event; EVENTS],
event_head: usize,
event_len: usize,
/// Output-time note state.
sounding: bool,
pending: bool,
tracked: f32,
candidate: Option<(f32, u8)>,
voice: Voice,
old: Voice,
old_gain: f32,
tracker: Tracker,
synth: Synth,
glide: f32,
held: [u8; 16],
held_len: usize,
low_env: f32,
note_peak: f32,
env_coef: f32,
smooth: f32,
level: f32,
@@ -150,12 +150,8 @@ impl Engine {
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let decimate = (rate / DETECT_RATE).round().max(1.) as usize;
let detect_rate = rate / decimate as f32;
let max_lag = (detect_rate / MIN_HZ).ceil() as usize;
// YIN needs one comparison window of at least the longest period after the lags.
let analysis = 2 * max_lag + 3;
let latency = analysis * decimate;
let tracker = Tracker::new(rate, TrackerConfig::SUB);
let latency = tracker.latency();
let settings = Settings::default();
let mut e = Self {
rate,
@@ -165,39 +161,10 @@ impl Engine {
delay: vec![[0.; 2]; latency + 1],
delay_at: 0,
crossover: [Crossover::new(rate, settings.split); 2],
detect: [Biquad::lowpass(rate, 400., std::f32::consts::FRAC_1_SQRT_2); 2],
decimate,
decimate_count: 0,
detect_rate,
ring: vec![0.; analysis],
ring_at: 0,
scratch: vec![0.; analysis],
yin: Yin::new(max_lag + 2),
hop: ((detect_rate * HOP_MS * 0.001) as usize).max(1),
hop_count: 0,
gate_env: 0.,
gate_release: 1. - coefficient(rate, 30.),
gate_open: false,
gate_hold: 0,
clock: 0,
events: [Event {
time: 0,
kind: Kind::Release,
}; EVENTS],
event_head: 0,
event_len: 0,
sounding: false,
pending: false,
tracked: 0.,
candidate: None,
voice: Voice::default(),
old: Voice::default(),
old_gain: 0.,
tracker,
synth: Synth::default(),
glide: 1.,
held: [0; 16],
held_len: 0,
low_env: 0.,
note_peak: 0.,
env_coef: coefficient(rate, 10.),
smooth: coefficient(rate, 10.),
level: db_to_gain(settings.level),
@@ -233,8 +200,11 @@ impl Engine {
x.retune(self.rate, s.split);
}
}
if s.source != old.source {
self.release_all();
if self
.tracker
.set(s.source == Source::Midi, s.gate, s.glide > 0.)
{
self.synth.voice.releasing = true;
}
self.glide = if s.glide <= 0. {
1.
@@ -244,34 +214,14 @@ impl Engine {
self.settings = s;
Ok(())
}
fn release_all(&mut self) {
self.voice.releasing = true;
self.sounding = false;
self.pending = false;
self.held_len = 0;
self.event_len = 0;
self.candidate = None;
}
pub fn reset(&mut self) {
self.delay.fill([0.; 2]);
for x in &mut self.crossover {
x.reset();
}
for f in &mut self.detect {
f.reset();
}
self.ring.fill(0.);
self.decimate_count = 0;
self.hop_count = 0;
self.gate_env = 0.;
self.gate_open = false;
self.gate_hold = 0;
self.release_all();
self.voice = Voice::default();
self.old = Voice::default();
self.old_gain = 0.;
self.tracker.reset();
self.synth = Synth::default();
self.low_env = 0.;
self.note_peak = 0.;
self.low_power = 0.;
self.sub_power = 0.;
self.scope_low = [0.; 2 * SCOPE];
@@ -279,190 +229,10 @@ impl Engine {
}
/// MIDI note start at the current input sample. Used when the source is MIDI.
pub fn note_on(&mut self, note: u8) {
self.push(Kind::NoteOn(note.min(127)));
self.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.push(Kind::NoteOff(note.min(127)));
}
fn push(&mut self, kind: Kind) {
if self.event_len == EVENTS {
// Dropping the oldest keeps the newest note state; overflow only happens on floods.
self.event_head = (self.event_head + 1) % EVENTS;
self.event_len -= 1;
}
let at = (self.event_head + self.event_len) % EVENTS;
self.events[at] = Event {
time: self.clock,
kind,
};
self.event_len += 1;
}
/// Detected pitch of the most recent analysis window, in fractional MIDI notes.
fn estimate(&mut self) -> Option<f32> {
let n = self.ring.len();
for i in 0..n {
self.scratch[i] = self.ring[(self.ring_at + i) % n];
}
let (hz, clarity) = self
.yin
.estimate(&self.scratch, self.detect_rate, MIN_HZ, MAX_HZ)?;
(clarity >= CLARITY).then(|| hz_to_midi(hz))
}
fn start(&mut self, pitch: f32, glide: bool) {
let s = self.settings;
let note = if s.snap { pitch.round() } else { pitch };
let hz = midi_to_hz(note + 12. * s.octave as f32);
self.tracked = pitch;
self.candidate = None;
self.pending = false;
self.sounding = true;
let audible = self.voice.amp > 1e-4 && !self.voice.releasing;
if glide && audible {
self.voice.target = hz;
return;
}
if s.retrigger || !audible {
if self.voice.amp > 1e-4 {
// Restarting the phase mid-cycle would click; the old cycle fades out instead.
self.old = self.voice;
self.old_gain = 1.;
}
self.voice = Voice {
phase: s.phase / 360.,
hz,
target: hz,
amp: 0.,
releasing: false,
};
} else {
self.voice.target = hz;
self.voice.hz = hz;
}
self.note_peak = 0.;
}
fn stop(&mut self) {
self.voice.releasing = true;
self.sounding = false;
self.pending = false;
self.candidate = None;
}
fn handle(&mut self, kind: Kind) {
let midi = self.settings.source == Source::Midi;
match kind {
Kind::Onset if !midi => match self.estimate() {
Some(p) => self.start(p, false),
None => {
self.pending = true;
self.sounding = true;
}
},
Kind::Release if !midi => self.stop(),
Kind::NoteOn(n) if midi => {
let len = self.held_len;
if let Some(i) = self.held[..len].iter().position(|h| *h == n) {
self.held.copy_within(i + 1..len, i);
self.held_len -= 1;
}
if self.held_len == self.held.len() {
self.held.copy_within(1.., 0);
self.held_len -= 1;
}
let legato = self.held_len > 0;
self.held[self.held_len] = n;
self.held_len += 1;
self.start(n as f32, legato && self.settings.glide > 0.);
}
Kind::NoteOff(n) if midi => {
let len = self.held_len;
let Some(i) = self.held[..len].iter().position(|h| *h == n) else {
return;
};
let top = i + 1 == len;
self.held.copy_within(i + 1..len, i);
self.held_len -= 1;
if self.held_len == 0 {
self.stop();
} else if top {
let next = self.held[self.held_len - 1];
self.start(next as f32, self.settings.glide > 0.);
}
}
_ => {}
}
}
/// Regular analysis; its window starts at the current output time.
fn hop_analysis(&mut self) {
if self.settings.source != Source::Audio || !self.sounding {
return;
}
let Some(p) = self.estimate() else {
return;
};
if self.pending {
self.start(p, false);
return;
}
if (p - self.tracked).abs() >= JUMP {
match self.candidate {
Some((c, count)) if (c - p).abs() < 0.5 => {
if count + 1 >= 2 {
self.start(p, self.settings.glide > 0.);
} else {
self.candidate = Some((p, count + 1));
}
}
_ => self.candidate = Some((p, 1)),
}
} else {
self.candidate = None;
self.tracked += 0.3 * (p - self.tracked);
if !self.settings.snap {
self.voice.target = midi_to_hz(self.tracked + 12. * self.settings.octave as f32);
}
}
}
fn detect(&mut self, x: [f32; 2]) {
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
x[0]
};
let d = self.detect[0].process(mono);
let d = self.detect[1].process(d);
if self.settings.source == Source::Audio {
let a = d.abs();
self.gate_env = if a > self.gate_env {
a
} else {
self.gate_env * self.gate_release
};
let level = gain_to_db(self.gate_env);
if level > self.settings.gate {
self.gate_hold = (self.rate * GATE_HOLD_MS * 0.001) as usize;
if !self.gate_open {
self.gate_open = true;
self.push(Kind::Onset);
}
} else if self.gate_open && level < self.settings.gate - GATE_HYSTERESIS_DB {
if self.gate_hold == 0 {
self.gate_open = false;
self.push(Kind::Release);
} else {
self.gate_hold -= 1;
}
}
}
self.decimate_count += 1;
if self.decimate_count >= self.decimate {
self.decimate_count = 0;
self.ring[self.ring_at] = d;
self.ring_at = (self.ring_at + 1) % self.ring.len();
self.hop_count += 1;
if self.hop_count >= self.hop {
self.hop_count = 0;
self.hop_analysis();
}
}
self.tracker.note_off(note);
}
#[inline]
fn shape(&self, x: f32) -> f32 {
@@ -476,7 +246,7 @@ impl Engine {
fn oscillator(&mut self) -> f32 {
let attack = 1. / (self.rate * ATTACK_MS * 0.001);
let release = 1. / (self.rate * RELEASE_MS * 0.001);
let v = &mut self.voice;
let v = &mut self.synth.voice;
v.amp = if v.releasing {
(v.amp - release).max(0.)
} else {
@@ -488,31 +258,28 @@ impl Engine {
out += (TAU * v.phase).sin() * v.amp;
v.phase = (v.phase + v.hz / self.rate).fract();
}
if self.old_gain > 0. {
let o = &mut self.old;
out += (TAU * o.phase).sin() * o.amp * self.old_gain;
let s = &mut self.synth;
if s.old_gain > 0. {
let o = &mut s.old;
out += (TAU * o.phase).sin() * o.amp * s.old_gain;
o.phase = (o.phase + o.hz / self.rate).fract();
self.old_gain = (self.old_gain - 1. / (self.rate * CROSSFADE_MS * 0.001)).max(0.);
s.old_gain = (s.old_gain - 1. / (self.rate * CROSSFADE_MS * 0.001)).max(0.);
}
self.shape(out)
}
pub fn process_sample(&mut self, x: [f32; 2], monitor: Monitor) -> [f32; 2] {
self.detect(x);
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
x[0]
};
let (synth, settings) = (&mut self.synth, &self.settings);
self.tracker.process(mono, |e| synth.event(settings, e));
let len = self.delay.len();
self.delay[self.delay_at] = x;
self.delay_at = (self.delay_at + 1) % len;
// The slot after the newest write is the oldest: exactly `latency` samples ago.
let y = self.delay[self.delay_at];
while self.event_len > 0 {
let e = self.events[self.event_head];
if e.time + self.latency as u64 > self.clock {
break;
}
self.event_head = (self.event_head + 1) % EVENTS;
self.event_len -= 1;
self.handle(e.kind);
}
self.clock += 1;
let s = self.settings;
let (l0, h0) = self.crossover[0].process(y[0]);
let (l1, h1) = if self.channels == 2 {
@@ -523,11 +290,11 @@ impl Engine {
let low = 0.5 * (l0 + l1);
self.low_env += (low * low - self.low_env) * self.env_coef;
let env = self.low_env.sqrt();
if !self.voice.releasing {
self.note_peak = self.note_peak.max(env);
if !self.synth.voice.releasing {
self.synth.note_peak = self.synth.note_peak.max(env);
}
let follow = if self.note_peak > 1e-6 {
(env / self.note_peak).min(1.)
let follow = if self.synth.note_peak > 1e-6 {
(env / self.synth.note_peak).min(1.)
} else {
1.
};
@@ -570,9 +337,10 @@ impl Engine {
scope_ms: (self.scope_stride * SCOPE) as f32 / self.rate * 1000.,
..AnalyzerFrame::default()
};
if self.voice.amp > 0. && !self.voice.releasing {
f.hz = self.voice.hz;
f.note = Some(hz_to_midi(self.voice.hz).round().clamp(0., 127.) as u8);
let v = self.synth.voice;
if v.amp > 0. && !v.releasing {
f.hz = v.hz;
f.note = Some(hz_to_midi(v.hz).round().clamp(0., 127.) as u8);
}
// Trigger on the latest rising zero crossing that still leaves a full window behind it.
let n = 2 * SCOPE;
+1 -1
View File
@@ -72,7 +72,7 @@ checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
[[package]]
name = "anvil"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"anvil-core",
"anvil-ui",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "anvil"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "ISC"
+2 -2
View File
@@ -41,7 +41,7 @@ impl Default for Anvil {
}
impl Plugin for Anvil {
const NAME: &'static str = "Anvil";
const VENDOR: &'static str = "Rivet & Patina";
const VENDOR: &'static str = "Bass Foundry";
const URL: &'static str = "";
const EMAIL: &'static str = "";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
@@ -185,7 +185,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;
+1 -1
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 {
+88 -4
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"
},
@@ -64,5 +65,88 @@
"crucible-core",
"crucible-ui"
]
},
"lathe": {
"name": "Lathe",
"description": "EQ the harmonics of the played note: a cut on harmonic 3 stays on harmonic 3",
"manifest": "plugins/lathe/plugin/Cargo.toml",
"library": "lathe",
"documentation": "docs/lathe",
"clap_id": "io.narl.bass-foundry.lathe",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"common-dsp",
"lathe-core",
"lathe-ui"
]
},
"temper": {
"name": "Temper",
"description": "Distortion whose drive and tone filters follow the note, so every note distorts the same",
"manifest": "plugins/temper/plugin/Cargo.toml",
"library": "temper",
"documentation": "docs/temper",
"clap_id": "io.narl.bass-foundry.temper",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"common-dsp",
"temper-core",
"temper-ui"
]
},
"weld": {
"name": "Weld",
"description": "Align the phase of a bass layer to a sidechain layer so the two sum instead of cancel",
"manifest": "plugins/weld/plugin/Cargo.toml",
"library": "weld",
"documentation": "docs/weld",
"clap_id": "io.narl.bass-foundry.weld",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"weld-core",
"weld-ui"
]
},
"knurl": {
"name": "Knurl",
"description": "Give every cycle of the note its own level, timing and grit",
"manifest": "plugins/knurl/plugin/Cargo.toml",
"library": "knurl",
"documentation": "docs/knurl",
"clap_id": "io.narl.bass-foundry.knurl",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"knurl-core",
"knurl-ui"
]
},
"planish": {
"name": "Planish",
"description": "Upward and downward compression in bands set by harmonic number, the same on every note",
"manifest": "plugins/planish/plugin/Cargo.toml",
"library": "planish",
"documentation": "docs/planish",
"clap_id": "io.narl.bass-foundry.planish",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"common-dsp",
"planish-core",
"planish-ui"
]
}
}
+4
View File
@@ -1,4 +1,8 @@
//! Shared allocation-free DSP building blocks: filters, pitch detection, notes, WAV files.
pub mod meter;
pub mod oversample;
pub mod svf;
pub mod tracker;
pub mod wav;
use std::f32::consts::TAU;
+91
View File
@@ -0,0 +1,91 @@
//! Levels of the harmonics of a known fundamental, measured with Hann-windowed Goertzel
//! filters. Work is spread evenly over the samples, so there is no per-window spike.
use std::f32::consts::TAU;
pub const HARMONICS: usize = 32;
pub struct HarmonicMeter {
rate: f32,
window: Vec<f32>,
count: usize,
hz: f32,
coef: [f32; HARMONICS],
s1: [f32; HARMONICS],
s2: [f32; HARMONICS],
levels: [f32; HARMONICS],
}
impl HarmonicMeter {
/// Window of about `ms`; 85 ms resolves harmonics of fundamentals down to 30 Hz.
pub fn new(rate: f32, ms: f32) -> Self {
let n = ((rate * ms * 0.001) as usize).max(64);
Self {
rate,
window: (0..n)
.map(|i| 0.5 - 0.5 * (TAU * i as f32 / n as f32).cos())
.collect(),
count: 0,
hz: 0.,
coef: [0.; HARMONICS],
s1: [0.; HARMONICS],
s2: [0.; HARMONICS],
levels: [-120.; HARMONICS],
}
}
/// Peak level in dBFS of each harmonic (index 0 is the fundamental) from the last
/// complete window. Harmonics above 0.45 × the sample rate read −120.
pub fn levels(&self) -> &[f32; HARMONICS] {
&self.levels
}
/// Fundamental of the last complete window, 0 if none.
pub fn hz(&self) -> f32 {
self.hz
}
/// Feeds one sample. `hz` is read at the start of each window (0 = no note: skip it).
/// Returns true when a window completed and `levels` changed.
#[inline]
pub fn push(&mut self, x: f32, hz: f32) -> bool {
if self.count == 0 {
self.hz = hz;
for k in 0..HARMONICS {
let f = hz * (k + 1) as f32;
self.coef[k] = if hz > 0. && f < 0.45 * self.rate {
2. * (TAU * f / self.rate).cos()
} else {
0.
};
}
self.s1 = [0.; HARMONICS];
self.s2 = [0.; HARMONICS];
}
let w = x * self.window[self.count];
for k in 0..HARMONICS {
let s = w + self.coef[k] * self.s1[k] - self.s2[k];
self.s2[k] = self.s1[k];
self.s1[k] = s;
}
self.count += 1;
if self.count < self.window.len() {
return false;
}
self.count = 0;
let n = self.window.len() as f32;
for k in 0..HARMONICS {
let f = self.hz * (k + 1) as f32;
self.levels[k] = if self.hz > 0. && f < 0.45 * self.rate {
let c = self.coef[k];
let p =
self.s1[k] * self.s1[k] + self.s2[k] * self.s2[k] - c * self.s1[k] * self.s2[k];
// Hann coherent gain is 0.5: a sine of peak A gives |X| = A·N/4.
let amp = 4. * p.max(0.).sqrt() / n;
20. * amp.max(1e-6).log10()
} else {
-120.
};
}
true
}
pub fn reset(&mut self) {
self.count = 0;
self.levels = [-120.; HARMONICS];
}
}
+131
View File
@@ -0,0 +1,131 @@
//! 4× oversampling with two linear-phase half-band FIR stages. The round trip (up, then
//! down) delays the signal by a whole number of base-rate samples, `latency()`.
const STAGE1_HALF: usize = 31; // 63 taps at 2×: steep, carries the audio band edge
const STAGE2_HALF: usize = 11; // 23 taps at 4×: the band edge is far from its images
const BETA: f32 = 9.;
/// Kaiser-windowed half-band lowpass with gain `gain`, `2 * half + 1` taps.
fn halfband(half: usize, gain: f32) -> Vec<f32> {
fn i0(x: f32) -> f32 {
let (mut sum, mut term) = (1f64, 1f64);
let y = (x as f64 / 2.).powi(2);
for k in 1..50 {
term *= y / (k * k) as f64;
sum += term;
}
sum as f32
}
let n = 2 * half + 1;
let norm = i0(BETA);
let mut h: Vec<f32> = (0..n)
.map(|i| {
let m = i as f32 - half as f32;
let sinc = if m == 0. {
0.5
} else {
(std::f32::consts::FRAC_PI_2 * m).sin() / (std::f32::consts::PI * m)
};
let r = m / half as f32;
sinc * i0(BETA * (1. - r * r).max(0.).sqrt()) / norm
})
.collect();
let sum: f32 = h.iter().sum();
for v in &mut h {
*v *= gain / sum;
}
h
}
/// FIR with a doubled circular buffer so every convolution reads one contiguous slice.
struct Fir {
taps: Vec<f32>,
buf: Vec<f32>,
at: usize,
}
impl Fir {
fn new(taps: Vec<f32>) -> Self {
let n = taps.len();
Self {
taps,
buf: vec![0.; 2 * n],
at: 0,
}
}
#[inline]
fn process(&mut self, x: f32) -> f32 {
let n = self.taps.len();
self.at = if self.at == 0 { n - 1 } else { self.at - 1 };
self.buf[self.at] = x;
self.buf[self.at + n] = x;
let window = &self.buf[self.at..self.at + n];
window.iter().zip(&self.taps).map(|(a, b)| a * b).sum()
}
fn reset(&mut self) {
self.buf.fill(0.);
self.at = 0;
}
}
/// One channel of 4× oversampling. Allocation-free after `new`.
pub struct Oversampler {
up1: Fir,
up2: Fir,
down2: Fir,
down1: Fir,
/// Two samples at 4× make the round trip a whole number of base samples.
pad: [f32; 2],
}
impl Default for Oversampler {
fn default() -> Self {
Self::new()
}
}
impl Oversampler {
pub const FACTOR: usize = 4;
pub fn new() -> Self {
Self {
up1: Fir::new(halfband(STAGE1_HALF, 2.)),
up2: Fir::new(halfband(STAGE2_HALF, 2.)),
down2: Fir::new(halfband(STAGE2_HALF, 1.)),
down1: Fir::new(halfband(STAGE1_HALF, 1.)),
pad: [0.; 2],
}
}
/// Round-trip delay in base-rate samples.
pub const fn latency() -> usize {
STAGE1_HALF + STAGE2_HALF.div_ceil(2)
}
#[inline]
pub fn up(&mut self, x: f32) -> [f32; 4] {
let a = self.up1.process(x);
let b = self.up1.process(0.);
let mut out = [
self.up2.process(a),
self.up2.process(0.),
self.up2.process(b),
self.up2.process(0.),
];
// Delay by two 4× samples.
let [p0, p1] = self.pad;
self.pad = [out[2], out[3]];
out = [p0, p1, out[0], out[1]];
out
}
#[inline]
pub fn down(&mut self, y: [f32; 4]) -> f32 {
let a = self.down2.process(y[0]);
self.down2.process(y[1]);
let b = self.down2.process(y[2]);
self.down2.process(y[3]);
let out = self.down1.process(a);
self.down1.process(b);
out
}
pub fn reset(&mut self) {
self.up1.reset();
self.up2.reset();
self.down2.reset();
self.down1.reset();
self.pad = [0.; 2];
}
}
+121
View File
@@ -0,0 +1,121 @@
//! Trapezoidal state-variable filter (Simper/Cytomic). Stable and click-free under fast
//! retuning, so it can follow a gliding note. Coefficients are separate from state, so one
//! set can drive both channels.
use std::f32::consts::PI;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SvfCoef {
a1: f32,
a2: f32,
a3: f32,
m0: f32,
m1: f32,
m2: f32,
}
impl Default for SvfCoef {
/// Identity: passes the input unchanged.
fn default() -> Self {
Self::IDENTITY
}
}
impl SvfCoef {
pub const IDENTITY: Self = Self {
a1: 1.,
a2: 0.,
a3: 0.,
m0: 1.,
m1: 0.,
m2: 0.,
};
fn base(rate: f32, hz: f32, k: f32, warp: f32) -> (f32, f32, f32) {
let g = (PI * (hz / rate).clamp(1e-6, 0.49)).tan() * warp;
let a1 = 1. / (1. + g * (g + k));
let a2 = g * a1;
(a1, a2, g * a2)
}
/// Peaking filter. At 0 dB it is an exact identity.
pub fn bell(rate: f32, hz: f32, q: f32, gain_db: f32) -> Self {
let a = 10f32.powf(gain_db / 40.);
let k = 1. / (q.max(1e-3) * a);
let (a1, a2, a3) = Self::base(rate, hz, k, 1.);
Self {
a1,
a2,
a3,
m0: 1.,
m1: k * (a * a - 1.),
m2: 0.,
}
}
pub fn lowpass(rate: f32, hz: f32, q: f32) -> Self {
let (a1, a2, a3) = Self::base(rate, hz, 1. / q.max(1e-3), 1.);
Self {
a1,
a2,
a3,
m0: 0.,
m1: 0.,
m2: 1.,
}
}
pub fn highpass(rate: f32, hz: f32, q: f32) -> Self {
let k = 1. / q.max(1e-3);
let (a1, a2, a3) = Self::base(rate, hz, k, 1.);
Self {
a1,
a2,
a3,
m0: 1.,
m1: -k,
m2: -1.,
}
}
/// 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);
let (a1, a2, a3) = Self::base(rate, hz, k, a.sqrt());
Self {
a1,
a2,
a3,
m0: a * a,
m1: k * (1. - a) * a,
m2: 1. - a * a,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Svf {
ic1: f32,
ic2: f32,
}
impl Svf {
#[inline]
pub fn process(&mut self, c: &SvfCoef, v0: f32) -> f32 {
let v3 = v0 - self.ic2;
let v1 = c.a1 * self.ic1 + c.a2 * v3;
let v2 = self.ic2 + c.a2 * self.ic1 + c.a3 * v3;
self.ic1 = 2. * v1 - self.ic1;
self.ic2 = 2. * v2 - self.ic2;
c.m0 * v0 + c.m1 * v1 + c.m2 * v2
}
pub fn reset(&mut self) {
*self = Self::default();
}
}
+399
View File
@@ -0,0 +1,399 @@
//! Note tracker with lookahead: pitch and note starts of a monophonic bass, from audio or MIDI.
//!
//! Detection runs on the undelayed input. Events are reported at *output* time, `latency()`
//! samples later, so a host that delays its audio path by the same amount sees each note start
//! exactly on its attack, with the pitch already known.
use crate::{coefficient, gain_to_db, hz_to_midi, Biquad, Yin};
const EVENTS: usize = 64;
const HOP_MS: f32 = 5.;
const GATE_HOLD_MS: f32 = 20.;
const GATE_HYSTERESIS_DB: f32 = 6.;
/// A pitch move this large (semitones) counts as a new note.
const JUMP: f32 = 0.7;
const CLARITY: f32 = 0.5;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TrackerConfig {
/// Lowest detectable fundamental. Sets the lookahead (two periods plus a margin).
pub min_hz: f32,
pub max_hz: f32,
/// Lowpass in front of detection, removing upper harmonics that confuse YIN.
pub detect_lowpass: f32,
/// Approximate decimated analysis rate.
pub detect_rate: f32,
}
impl TrackerConfig {
/// Sub range: 25–250 Hz.
pub const SUB: Self = Self {
min_hz: 25.,
max_hz: 250.,
detect_lowpass: 400.,
detect_rate: 3000.,
};
/// Bass range for harmonic tools: 30–500 Hz.
pub const BASS: Self = Self {
min_hz: 30.,
max_hz: 500.,
detect_lowpass: 1000.,
detect_rate: 6000.,
};
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum NoteEvent {
/// A new note. `glide` asks to slide from the previous pitch instead of restarting.
Start {
pitch: f32,
glide: bool,
},
/// The sounding note's smoothed pitch moved within a semitone (bends, vibrato).
Move {
pitch: f32,
},
Stop,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Kind {
Onset,
Release,
NoteOn(u8),
NoteOff(u8),
}
#[derive(Clone, Copy, Debug)]
struct Event {
time: u64,
kind: Kind,
}
/// Allocation-free after `new`. Pitches are fractional MIDI notes.
pub struct Tracker {
rate: f32,
cfg: TrackerConfig,
latency: usize,
midi: bool,
gate_db: f32,
glide: bool,
detect: [Biquad; 2],
decimate: usize,
decimate_count: usize,
detect_rate: f32,
ring: Vec<f32>,
ring_at: usize,
scratch: Vec<f32>,
yin: Yin,
hop: usize,
hop_count: usize,
gate_env: f32,
gate_release: f32,
gate_open: bool,
gate_hold: usize,
clock: u64,
events: [Event; EVENTS],
event_head: usize,
event_len: usize,
/// Output-time note state.
sounding: bool,
pending: bool,
tracked: f32,
candidate: Option<(f32, u8)>,
held: [u8; 16],
held_len: usize,
}
impl Tracker {
pub fn new(rate: f32, cfg: TrackerConfig) -> Self {
let decimate = (rate / cfg.detect_rate).round().max(1.) as usize;
let detect_rate = rate / decimate as f32;
let max_lag = (detect_rate / cfg.min_hz).ceil() as usize;
// YIN needs one comparison window of at least the longest period after the lags.
let analysis = 2 * max_lag + 3;
let q = std::f32::consts::FRAC_1_SQRT_2;
Self {
rate,
cfg,
latency: analysis * decimate,
midi: false,
gate_db: -42.,
glide: false,
detect: [Biquad::lowpass(rate, cfg.detect_lowpass, q); 2],
decimate,
decimate_count: 0,
detect_rate,
ring: vec![0.; analysis],
ring_at: 0,
scratch: vec![0.; analysis],
yin: Yin::new(max_lag + 2),
hop: ((detect_rate * HOP_MS * 0.001) as usize).max(1),
hop_count: 0,
gate_env: 0.,
gate_release: 1. - coefficient(rate, 30.),
gate_open: false,
gate_hold: 0,
clock: 0,
events: [Event {
time: 0,
kind: Kind::Release,
}; EVENTS],
event_head: 0,
event_len: 0,
sounding: false,
pending: false,
tracked: 0.,
candidate: None,
held: [0; 16],
held_len: 0,
}
}
/// Lookahead in samples. Delay the audio path by exactly this much.
pub fn latency(&self) -> usize {
self.latency
}
pub fn config(&self) -> TrackerConfig {
self.cfg
}
/// `midi` selects note input over detection; `gate_db` is the detection threshold;
/// `glide` lets legato notes slide. Returns true when the source changed, which drops
/// every pending and held note: the caller should release its sound.
pub fn set(&mut self, midi: bool, gate_db: f32, glide: bool) -> bool {
let changed = midi != self.midi;
self.midi = midi;
self.gate_db = gate_db;
self.glide = glide;
if changed {
self.release_all();
}
changed
}
fn release_all(&mut self) {
self.sounding = false;
self.pending = false;
self.held_len = 0;
self.event_len = 0;
self.candidate = None;
}
pub fn reset(&mut self) {
for f in &mut self.detect {
f.reset();
}
self.ring.fill(0.);
self.decimate_count = 0;
self.hop_count = 0;
self.gate_env = 0.;
self.gate_open = false;
self.gate_hold = 0;
self.release_all();
}
/// MIDI note start at the current input sample. Used when the source is MIDI.
pub fn note_on(&mut self, note: u8) {
self.push(Kind::NoteOn(note.min(127)));
}
pub fn note_off(&mut self, note: u8) {
self.push(Kind::NoteOff(note.min(127)));
}
/// True while a note sounds at output time.
pub fn sounding(&self) -> bool {
self.sounding
}
fn push(&mut self, kind: Kind) {
if self.event_len == EVENTS {
// Dropping the oldest keeps the newest note state; overflow only happens on floods.
self.event_head = (self.event_head + 1) % EVENTS;
self.event_len -= 1;
}
let at = (self.event_head + self.event_len) % EVENTS;
self.events[at] = Event {
time: self.clock,
kind,
};
self.event_len += 1;
}
/// Detected pitch of the most recent analysis window, in fractional MIDI notes.
fn estimate(&mut self) -> Option<f32> {
let n = self.ring.len();
for i in 0..n {
self.scratch[i] = self.ring[(self.ring_at + i) % n];
}
let (hz, clarity) = self.yin.estimate(
&self.scratch,
self.detect_rate,
self.cfg.min_hz,
self.cfg.max_hz,
)?;
(clarity >= CLARITY).then(|| hz_to_midi(hz))
}
fn start(&mut self, pitch: f32, glide: bool, emit: &mut impl FnMut(NoteEvent)) {
self.tracked = pitch;
self.candidate = None;
self.pending = false;
self.sounding = true;
emit(NoteEvent::Start { pitch, glide });
}
fn stop(&mut self, emit: &mut impl FnMut(NoteEvent)) {
self.sounding = false;
self.pending = false;
self.candidate = None;
emit(NoteEvent::Stop);
}
fn handle(&mut self, kind: Kind, emit: &mut impl FnMut(NoteEvent)) {
let midi = self.midi;
match kind {
Kind::Onset if !midi => match self.estimate() {
Some(p) => self.start(p, false, emit),
None => {
self.pending = true;
self.sounding = true;
}
},
Kind::Release if !midi => self.stop(emit),
Kind::NoteOn(n) if midi => {
let len = self.held_len;
if let Some(i) = self.held[..len].iter().position(|h| *h == n) {
self.held.copy_within(i + 1..len, i);
self.held_len -= 1;
}
if self.held_len == self.held.len() {
self.held.copy_within(1.., 0);
self.held_len -= 1;
}
let legato = self.held_len > 0;
self.held[self.held_len] = n;
self.held_len += 1;
self.start(n as f32, legato && self.glide, emit);
}
Kind::NoteOff(n) if midi => {
let len = self.held_len;
let Some(i) = self.held[..len].iter().position(|h| *h == n) else {
return;
};
let top = i + 1 == len;
self.held.copy_within(i + 1..len, i);
self.held_len -= 1;
if self.held_len == 0 {
self.stop(emit);
} else if top {
let next = self.held[self.held_len - 1];
self.start(next as f32, self.glide, emit);
}
}
_ => {}
}
}
/// Regular analysis; its window starts at the current output time.
fn hop_analysis(&mut self, emit: &mut impl FnMut(NoteEvent)) {
if self.midi || !self.sounding {
return;
}
let Some(p) = self.estimate() else {
return;
};
if self.pending {
self.start(p, false, emit);
return;
}
if (p - self.tracked).abs() >= JUMP {
match self.candidate {
Some((c, count)) if (c - p).abs() < 0.5 => {
if count + 1 >= 2 {
self.start(p, self.glide, emit);
} else {
self.candidate = Some((p, count + 1));
}
}
_ => self.candidate = Some((p, 1)),
}
} else {
self.candidate = None;
self.tracked += 0.3 * (p - self.tracked);
emit(NoteEvent::Move {
pitch: self.tracked,
});
}
}
/// Feeds one undelayed mono input sample. Events due at output time go to `emit`, in order.
pub fn process(&mut self, mono: f32, mut emit: impl FnMut(NoteEvent)) {
let d = self.detect[0].process(mono);
let d = self.detect[1].process(d);
if !self.midi {
let a = d.abs();
self.gate_env = if a > self.gate_env {
a
} else {
self.gate_env * self.gate_release
};
let level = gain_to_db(self.gate_env);
if level > self.gate_db {
self.gate_hold = (self.rate * GATE_HOLD_MS * 0.001) as usize;
if !self.gate_open {
self.gate_open = true;
self.push(Kind::Onset);
}
} else if self.gate_open && level < self.gate_db - GATE_HYSTERESIS_DB {
if self.gate_hold == 0 {
self.gate_open = false;
self.push(Kind::Release);
} else {
self.gate_hold -= 1;
}
}
}
self.decimate_count += 1;
if self.decimate_count >= self.decimate {
self.decimate_count = 0;
self.ring[self.ring_at] = d;
self.ring_at = (self.ring_at + 1) % self.ring.len();
self.hop_count += 1;
if self.hop_count >= self.hop {
self.hop_count = 0;
self.hop_analysis(&mut emit);
}
}
while self.event_len > 0 {
let e = self.events[self.event_head];
if e.time + self.latency as u64 > self.clock {
break;
}
self.event_head = (self.event_head + 1) % EVENTS;
self.event_len -= 1;
self.handle(e.kind, &mut emit);
}
self.clock += 1;
}
}
/// Fundamental that follows tracker events: jumps on new notes, slides on bends and legato.
#[derive(Clone, Copy, Debug, Default)]
pub struct PitchFollower {
target: f32,
hz: f32,
jump: bool,
}
impl PitchFollower {
pub fn event(&mut self, snap: bool, e: NoteEvent) {
match e {
NoteEvent::Start { pitch, glide } => {
let p = if snap { pitch.round() } else { pitch };
self.target = crate::midi_to_hz(p);
self.jump |= !glide;
}
NoteEvent::Move { pitch } if !snap => self.target = crate::midi_to_hz(pitch),
// The last pitch is held so tails keep their tuning.
_ => {}
}
}
/// Advances one sample with one-pole coefficient `coef`. Returns the frequency, 0 before the
/// first note, and whether it jumped this sample (a new note, or the first one).
pub fn step(&mut self, coef: f32) -> (f32, bool) {
let jump = std::mem::take(&mut self.jump) || (self.hz == 0. && self.target > 0.);
if jump {
self.hz = self.target;
} else {
self.hz += (self.target - self.hz) * coef;
}
(self.hz, jump)
}
pub fn hz(&self) -> f32 {
self.hz
}
}
+250
View File
@@ -0,0 +1,250 @@
use common_dsp::meter::HarmonicMeter;
use common_dsp::oversample::Oversampler;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig};
use common_dsp::{hz_to_midi, Saw};
use std::f32::consts::TAU;
const RATE: f32 = 48000.;
/// Steady-state gain in dB of a sine at `hz` through `f`.
fn sine_gain(mut f: impl FnMut(f32) -> f32, hz: f32) -> f32 {
let n = (RATE * 0.5) as usize;
let (mut pin, mut pout) = (0., 0.);
for i in 0..n {
let x = (TAU * hz * i as f32 / RATE).sin();
let y = f(x);
if i > n / 2 {
pin += x * x;
pout += y * y;
}
}
10. * (pout / pin).log10()
}
#[test]
fn svf_bell_hits_its_gain_and_leaves_the_rest() {
for (hz, db) in [(130., -12.), (400., 6.), (2000., -24.)] {
let c = SvfCoef::bell(RATE, hz, 4., db);
let mut s = Svf::default();
let g = sine_gain(|x| s.process(&c, x), hz);
assert!((g - db).abs() < 0.1, "{hz}: {g}");
let mut s = Svf::default();
let g = sine_gain(|x| s.process(&c, x), hz * 4.);
assert!(g.abs() < 0.3, "{hz} away: {g}");
}
}
#[test]
fn svf_bell_at_zero_db_is_an_exact_identity() {
let c = SvfCoef::bell(RATE, 100., 2., 0.);
let mut s = Svf::default();
let mut saw = Saw::new(RATE, 55.);
for _ in 0..4800 {
let x = saw.sample();
assert_eq!(s.process(&c, x), x);
}
}
#[test]
fn svf_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();
let mut saw = Saw::new(RATE, 55.);
let mut peak: f32 = 0.;
for i in 0..48000 {
let hz = 40. + 4000. * (0.5 + 0.5 * (i as f32 * 0.003).sin());
let c = SvfCoef::bell(RATE, hz, 8., 12.);
let y = s.process(&c, saw.sample());
assert!(y.is_finite());
peak = peak.max(y.abs());
}
assert!(peak < 8., "{peak}");
let c = SvfCoef::lowpass(RATE, 200., 0.707);
let mut s = Svf::default();
assert!(sine_gain(|x| s.process(&c, x), 2000.) < -35.);
let c = SvfCoef::highpass(RATE, 200., 0.707);
let mut s = Svf::default();
assert!(sine_gain(|x| s.process(&c, x), 20.) < -35.);
}
#[test]
fn oversampler_round_trip_is_a_whole_sample_delay() {
let mut o = Oversampler::new();
let l = Oversampler::latency();
let mut saw = Saw::new(RATE, 97.);
let x: Vec<f32> = (0..9600).map(|_| saw.sample() * 0.5).collect();
let y: Vec<f32> = x
.iter()
.map(|&v| {
let u = o.up(v);
o.down(u)
})
.collect();
// Band-limited content passes; compare after the delay, away from the start.
let mut err: f32 = 0.;
let mut sig: f32 = 0.;
for i in 2000..9600 {
let d = y[i] - x[i - l];
err += d * d;
sig += x[i - l] * x[i - l];
}
let null = 10. * (err / sig).log10();
// The saw has energy above the passband edge, so the null is limited by that, not by timing.
assert!(null < -40., "{null}");
}
#[test]
fn oversampler_passband_is_flat_and_images_are_removed() {
for hz in [50., 1000., 10000., 18000.] {
let mut o = Oversampler::new();
let g = sine_gain(
|x| {
let u = o.up(x);
o.down(u)
},
hz,
);
assert!(g.abs() < 0.1, "{hz}: {g}");
}
// An image of a 1 kHz tone at 4× would sit at 47 kHz; check the 4× signal there.
let mut o = Oversampler::new();
let n = 48000;
let (mut a, mut b) = (0f64, 0f64);
let w = TAU as f64 * 47000. / (4. * RATE as f64);
for i in 0..n {
let x = (TAU * 1000. * i as f32 / RATE).sin();
for (k, v) in o.up(x).into_iter().enumerate() {
let t = (4 * i + k) as f64;
a += v as f64 * (w * t).cos();
b += v as f64 * (w * t).sin();
}
}
let image = 20.
* ((a * a + b * b).sqrt() / (2. * n as f64))
.max(1e-12)
.log10();
assert!(image < -85., "{image}");
}
#[test]
fn harmonic_meter_reads_saw_harmonics() {
let hz = 55.;
let mut m = HarmonicMeter::new(RATE, 85.);
let mut saw = Saw::new(RATE, hz);
let mut done = 0;
for _ in 0..48000 {
if m.push(saw.sample(), hz) {
done += 1;
}
}
assert!(done >= 5);
// A saw's k-th harmonic has peak 2/(πk).
for k in 1..=16 {
let want = 20. * (2. / (std::f32::consts::PI * k as f32)).log10();
let got = m.levels()[k - 1];
assert!((got - want).abs() < 0.5, "harmonic {k}: {got} vs {want}");
}
}
#[test]
fn tracker_reports_notes_at_output_time() {
let mut t = Tracker::new(RATE, TrackerConfig::BASS);
t.set(false, -42., false);
let l = t.latency();
assert!(l < (0.08 * RATE) as usize, "{l}");
let onset = 4800;
let mut events = vec![];
let mut saw_a = Saw::new(RATE, 87.31);
let mut saw_b = Saw::new(RATE, 220.);
for i in 0..onset + 48000 {
let x = if i < onset {
0.
} else if i < onset + 24000 {
0.5 * saw_a.sample()
} else {
0.5 * saw_b.sample()
};
t.process(x, |e| events.push((i, e)));
}
let starts: Vec<_> = events
.iter()
.filter_map(|(i, e)| match e {
NoteEvent::Start { pitch, .. } => Some((*i, *pitch)),
_ => None,
})
.collect();
assert_eq!(starts.len(), 2, "{starts:?}");
assert!(
(starts[0].0 as i64 - (onset + l) as i64).abs() <= 24,
"{starts:?}"
);
assert!((starts[0].1 - hz_to_midi(87.31)).abs() < 0.2);
assert!((starts[1].1 - hz_to_midi(220.)).abs() < 0.2);
}
#[test]
fn tracker_delays_midi_by_the_latency() {
let mut t = Tracker::new(RATE, TrackerConfig::BASS);
assert!(t.set(true, -42., true));
let l = t.latency();
let mut events = vec![];
for i in 0..l + 2000 {
if i == 100 {
t.note_on(41);
}
if i == 600 {
t.note_on(43);
}
if i == 900 {
// Releasing the lower held note first leaves the top note; then the top ends it.
t.note_off(41);
t.note_off(43);
}
t.process(0., |e| events.push((i, e)));
}
assert_eq!(
events,
vec![
(
100 + l,
NoteEvent::Start {
pitch: 41.,
glide: false
}
),
(
600 + l,
NoteEvent::Start {
pitch: 43.,
glide: true
}
),
(900 + l, NoteEvent::Stop),
]
);
}
+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"
+2 -2
View File
@@ -51,7 +51,7 @@ impl Default for Crucible {
}
impl Plugin for Crucible {
const NAME: &'static str = "Crucible";
const VENDOR: &'static str = "Rivet & Patina";
const VENDOR: &'static str = "Bass Foundry";
const URL: &'static str = "";
const EMAIL: &'static str = "";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
@@ -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;
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "knurl-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 }
+406
View File
@@ -0,0 +1,406 @@
//! Knurl: texture per cycle of the played note. A cycle clock follows the tracked pitch and
//! restarts at every note; each cycle gets its own step gain (a repeating pattern that
//! makes subharmonics), a random level and timing, and a noise burst tuned to the note.
//! Every amount is counted in cycles, so the texture has the same grain on every note.
//!
//! The audio path is delayed by the tracker lookahead plus the timing-jitter headroom, so
//! the clock already knows each note when its attack leaves the plugin. Allocation-free
//! after `new`; mono-safe (both channels get the same treatment).
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::{Seed, Settings, Source, GRIT_OFF, PATTERNS, STEPS, STEP_MIN};
/// Cycles kept for the editor's scope, and points per cycle.
pub const SCOPE_CYCLES: usize = 8;
pub const SCOPE_POINTS: usize = 64;
/// Timing jitter can move a cycle by at most this share of the lowest period.
const JITTER_HEADROOM: f32 = 0.125;
/// Effects fade in and out with the note over this time.
const FADE_MS: f32 = 20.;
/// The grit filter is retuned this often, in samples.
const RETUNE: usize = 16;
/// Level follower for scaling the grit to the input.
const LEVEL_MS: f32 = 30.;
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
/// Fundamental the clock follows, 0 before the first note.
pub hz: f32,
/// Sounding note, rounded; None between notes.
pub note: Option<u8>,
/// Pattern step of the newest cycle.
pub step: usize,
/// Output of the last `SCOPE_CYCLES` cycles, oldest first, each resampled to
/// `SCOPE_POINTS` points.
pub scope: [[f32; SCOPE_POINTS]; SCOPE_CYCLES],
/// True when the scope holds cycles of a sounding note.
pub scope_valid: bool,
pub input_db: f32,
pub output_db: f32,
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
hz: 0.,
note: None,
step: 0,
scope: [[0.; SCOPE_POINTS]; SCOPE_CYCLES],
scope_valid: false,
input_db: -120.,
output_db: -120.,
latency: 0,
sample_rate: 48000.,
}
}
}
/// Deterministic per-cycle randomness: a hash of (stream, note seed, cycle).
fn hash(stream: u64, seed: u64, cycle: u64) -> u64 {
let mut z = stream
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(seed.wrapping_mul(0xBF58_476D_1CE4_E5B9))
.wrapping_add(cycle.wrapping_mul(0x94D0_49BB_1331_11EB));
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn uniform(stream: u64, seed: u64, cycle: u64) -> f32 {
(hash(stream, seed, cycle) >> 40) as f32 / (1u64 << 24) as f32
}
/// Approximately normal, clamped to ±3 (sum of four uniforms, unit variance).
fn normal(stream: u64, seed: u64, cycle: u64) -> f32 {
let s: f32 = (0..4).map(|i| uniform(stream, seed, cycle * 4 + i)).sum();
((s - 2.) * 3f32.sqrt()).clamp(-3., 3.)
}
/// Raised-cosine weight of the next cycle's value: 0 until `1 − width` of the cycle, then
/// rising to 1 at the boundary.
fn edge(frac: f32, width: f32) -> f32 {
let t = ((frac - (1. - width)) / width).clamp(0., 1.);
0.5 - 0.5 * (std::f32::consts::PI * t).cos()
}
const LEVEL: u64 = 1;
const TIME: u64 = 2;
const NOISE: u64 = 3;
/// What the clock needs, delayed by the jitter headroom so it lines up with the audio.
#[derive(Clone, Copy, Default)]
struct Pitch {
hz: f32,
start: bool,
sounding: bool,
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
/// Tracker lookahead.
lookahead: usize,
/// Largest timing offset in samples, either way.
headroom: usize,
tracker: Tracker,
pitch: PitchFollower,
pitch_line: Vec<Pitch>,
pitch_at: usize,
audio: Vec<[f32; 2]>,
audio_at: usize,
// Cycle clock.
phase: f64,
cycle: u64,
note_seed: u64,
notes: u64,
noise_at: u64,
amount: f32,
fade: f32,
// Grit.
grit_coef: SvfCoef,
grit_filter: Svf,
countdown: usize,
level: f32,
level_coef: f32,
// Smoothed controls.
smooth: f32,
mix: f32,
output: f32,
bypass: f32,
fresh: bool,
// Editor.
scope: [[f32; SCOPE_POINTS]; SCOPE_CYCLES],
scope_valid: bool,
meter_in: f64,
meter_out: f64,
meter_count: usize,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(rate.is_finite() && (8000.0..=768000.).contains(&rate)) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let lookahead = tracker.latency();
let headroom = (JITTER_HEADROOM * rate / TrackerConfig::BASS.min_hz).ceil() as usize;
let settings = Settings::default();
let mut e = Self {
rate,
channels,
settings,
lookahead,
headroom,
tracker,
pitch: PitchFollower::default(),
pitch_line: vec![Pitch::default(); headroom + 1],
pitch_at: 0,
// Reads reach `lookahead + 2·headroom` back, plus the interpolation taps.
audio: vec![[0.; 2]; lookahead + 2 * headroom + 4],
audio_at: 0,
phase: 0.,
cycle: 0,
note_seed: 0,
notes: 0,
noise_at: 0,
amount: 0.,
fade: coefficient(rate, FADE_MS),
grit_coef: SvfCoef::IDENTITY,
grit_filter: Svf::default(),
countdown: 0,
level: 0.,
level_coef: coefficient(rate, LEVEL_MS),
smooth: coefficient(rate, 10.),
mix: 1.,
output: 1.,
bypass: 0.,
fresh: true,
scope: [[0.; SCOPE_POINTS]; SCOPE_CYCLES],
scope_valid: false,
meter_in: 0.,
meter_out: 0.,
meter_count: 0,
};
e.set_settings(settings)?;
e.fresh = true;
Ok(e)
}
pub fn latency_samples(&self) -> u32 {
(self.lookahead + self.headroom) 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, false);
self.settings = s;
if std::mem::take(&mut self.fresh) {
self.mix = s.mix * 0.01;
self.output = db_to_gain(s.output);
self.bypass = f32::from(s.bypass);
}
Ok(())
}
pub fn reset(&mut self) {
self.audio.fill([0.; 2]);
self.pitch_line.fill(Pitch::default());
self.pitch = PitchFollower::default();
self.tracker.reset();
self.grit_filter.reset();
self.phase = 0.;
self.cycle = 0;
self.notes = 0;
self.note_seed = 0;
self.noise_at = 0;
self.amount = 0.;
self.level = 0.;
self.countdown = 0;
self.scope_valid = false;
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) -> [f32; 2] {
let len = self.audio.len();
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) % len];
// y0 is one sample newer than y1; y2 and y3 are older.
let (newer, p1, p2, p3) = (at(base.max(1) - 1), at(base), at(base + 1), at(base + 2));
let mut out = [0.; 2];
for c in 0..2 {
let (y0, y1, y2, y3) = (newer[c], p1[c], p2[c], p3[c]);
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);
out[c] = ((c3 * t + c2) * t + c1) * t + y1;
}
out
}
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(1.);
// Tracker events are timed for `lookahead`; the audio leaves `headroom` later.
let len = self.pitch_line.len();
self.pitch_line[self.pitch_at] = Pitch {
hz,
start: jump,
sounding: self.tracker.sounding(),
};
self.pitch_at = (self.pitch_at + 1) % len;
let p = self.pitch_line[self.pitch_at];
let len = self.audio.len();
self.audio_at = (self.audio_at + 1) % len;
self.audio[self.audio_at] = x;
let centre = (self.lookahead + self.headroom) as f32;
let dry = self.read(centre);
// Cycle clock.
if p.start {
self.phase = 0.;
self.cycle = 0;
self.notes += 1;
self.noise_at = 0;
self.note_seed = match self.settings.seed {
Seed::Same => 0,
Seed::Free => self.notes,
};
self.countdown = 0;
}
let active = p.sounding && p.hz > 0.;
self.amount += (f32::from(active) - self.amount) * self.fade;
let s = self.settings;
let frac = self.phase.fract() as f32;
let c = self.cycle;
let seed = self.note_seed;
let step = |k: u64| (k % s.steps as u64) as usize;
// Pattern and level jitter, crossfaded into the next cycle.
let w = edge(frac, s.edge * 0.01);
let jitter_w = edge(frac, 0.5);
let pattern = |k: u64| db_to_gain(s.pattern[step(k)]);
let wobble = |k: u64| db_to_gain(s.level_jitter * normal(LEVEL, seed, k));
let gain = (pattern(c) * (1. - w) + pattern(c + 1) * w)
* (wobble(c) * (1. - jitter_w) + wobble(c + 1) * jitter_w);
let gain = 1. + self.amount * (gain - 1.);
// Timing jitter: an offset per cycle, blended across the whole cycle.
let period = if p.hz > 0. { self.rate / p.hz } else { 0. };
let shift = |k: u64| normal(TIME, seed, k) * s.time_jitter * 0.01 * period;
let tw = edge(frac, 1.);
let offset = (shift(c) * (1. - tw) + shift(c + 1) * tw) * self.amount;
let offset = offset.clamp(-(self.headroom as f32), self.headroom as f32);
let moved = if offset == 0. {
dry
} else {
self.read(centre + offset)
};
// Grit: noise above `tone` harmonics, in a sin² window at `position` of each cycle.
let mut grit = 0.;
if s.grit > GRIT_OFF && p.hz > 0. && s.grit_steps[step(c)] {
if self.countdown == 0 {
let f = (s.tone * p.hz).min(0.45 * self.rate);
self.grit_coef = SvfCoef::highpass(self.rate, f, std::f32::consts::FRAC_1_SQRT_2);
self.countdown = RETUNE;
}
self.countdown -= 1;
self.noise_at = self.noise_at.wrapping_add(1);
let n = 2. * uniform(NOISE, seed, self.noise_at) - 1.;
let hp = self.grit_filter.process(&self.grit_coef, n * 3f32.sqrt());
let d = (frac - s.position * 0.01).rem_euclid(1.);
let l = s.length * 0.01;
if d < l {
let window = (std::f32::consts::PI * d / l).sin().powi(2);
grit = hp * window * self.level.sqrt() * db_to_gain(s.grit) * self.amount;
}
}
let m = if self.channels == 2 {
0.5 * (dry[0] + dry[1])
} else {
dry[0]
};
self.level += (m * m - self.level) * self.level_coef;
// Advance the clock.
if p.hz > 0. {
self.phase += (p.hz / self.rate) as f64;
let whole = self.phase.floor() as u64;
if whole > 0 {
self.phase -= whole as f64;
self.cycle += whole;
self.scope
.rotate_left(whole.min(SCOPE_CYCLES as u64) as usize);
self.scope[SCOPE_CYCLES - 1] = [0.; SCOPE_POINTS];
}
}
self.mix += (s.mix * 0.01 - self.mix) * self.smooth;
self.output += (db_to_gain(s.output) - self.output) * self.smooth;
self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth;
let mut out = [0.; 2];
for ch in 0..self.channels {
let wet = moved[ch] * gain + grit;
let mixed = (self.mix * wet + (1. - self.mix) * dry[ch]) * self.output;
out[ch] = mixed * (1. - self.bypass) + dry[ch] * self.bypass;
}
let o = if self.channels == 2 {
0.5 * (out[0] + out[1])
} else {
out[0]
};
if active {
let i = ((frac * SCOPE_POINTS as f32) as usize).min(SCOPE_POINTS - 1);
self.scope[SCOPE_CYCLES - 1][i] = o;
self.scope_valid = true;
}
self.meter_in += (m * m) as f64;
self.meter_out += (o * o) as f64;
self.meter_count += 1;
out
}
/// Editor data; levels are averaged since the previous call.
pub fn take_analyzer(&mut self) -> AnalyzerFrame {
let n = self.meter_count.max(1) as f64;
let db = |p: f64| (10. * (p / n).max(1e-12).log10()).max(-120.) as f32;
let mut f = AnalyzerFrame {
hz: self.pitch.hz(),
note: None,
step: (self.cycle % self.settings.steps as u64) as usize,
scope: self.scope,
scope_valid: self.scope_valid,
input_db: db(self.meter_in),
output_db: db(self.meter_out),
latency: self.latency_samples(),
sample_rate: self.rate,
};
if self.tracker.sounding() && f.hz > 0. {
f.note = Some(hz_to_midi(f.hz).round().clamp(0., 127.) as u8);
}
self.meter_in = 0.;
self.meter_out = 0.;
self.meter_count = 0;
f
}
}
+166
View File
@@ -0,0 +1,166 @@
//! Host-independent controls. Gains in dB; amounts relative to one cycle in percent.
use serde::{Deserialize, Serialize};
/// Pattern steps: one per cycle, repeating.
pub const STEPS: usize = 16;
pub const STEP_MIN: f32 = -24.;
/// Grit at or below this level is off.
pub const GRIT_OFF: f32 = -60.;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Source {
/// Pitch and note starts are detected from the incoming bass.
#[default]
Audio,
/// Notes come from MIDI note input.
Midi,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Seed {
/// Every note gets the same random sequence, so identical notes sound identical.
#[default]
Same,
/// The random sequences keep running from note to note.
Free,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// Gain per pattern step, 0 down to `STEP_MIN`.
pub pattern: [f32; STEPS],
/// Steps that get a grit burst.
pub grit_steps: [bool; STEPS],
/// Pattern length in cycles, 1..=STEPS.
pub steps: usize,
/// Crossfade into the next step, as a share of a cycle.
pub edge: f32,
/// Random level per cycle, standard deviation in dB.
pub level_jitter: f32,
/// Random timing per cycle, standard deviation in percent of a period.
pub time_jitter: f32,
/// Grit burst level relative to the input, dB; `GRIT_OFF` or lower is off.
pub grit: f32,
/// Grit highpass, in harmonics of the note.
pub tone: f32,
/// Where in the cycle the grit burst sits, percent.
pub position: f32,
/// Grit burst length, percent of a cycle.
pub length: f32,
pub seed: Seed,
/// Detection threshold in dBFS for note starts (Audio source).
pub gate: f32,
/// Round detected pitch to the nearest semitone.
pub snap: bool,
pub mix: f32,
pub output: f32,
pub bypass: bool,
}
/// Named step patterns: (name, help, gains). Missing steps are 0 dB.
pub const PATTERNS: [(&str, &str, &[f32]); 6] = [
("Off", "Every cycle at full level.", &[0.]),
(
"Double",
"Every second cycle quieter: a sub-octave growl.",
&[0., -9.],
),
(
"Triple",
"One loud cycle, two quiet: a growl an octave and a fifth down.",
&[0., -12., -12.],
),
(
"Fry",
"Uneven four-cycle pattern: vocal-fry rattle.",
&[0., -14., -5., -14.],
),
(
"Gallop",
"Loud, quiet, loud, then three quiet: a rolling gallop.",
&[0., -18., 0., -18., -18., -18.],
),
(
"Stutter",
"Bursts of cycles with gaps: a gated stutter.",
&[0., 0., -24., -24., 0., -24., -24., -24.],
),
];
impl Settings {
/// Applies a named pattern: its gains, its length, grit on every step.
pub fn apply_pattern(&mut self, gains: &[f32]) {
self.pattern = [0.; STEPS];
for (p, g) in self.pattern.iter_mut().zip(gains) {
*p = *g;
}
self.steps = gains.len().clamp(1, STEPS);
}
}
impl Default for Settings {
fn default() -> Self {
let mut s = Self {
source: Source::Audio,
pattern: [0.; STEPS],
grit_steps: [true; STEPS],
steps: 1,
edge: 15.,
level_jitter: 1.,
time_jitter: 3.,
grit: -9.,
tone: 12.,
position: 10.,
length: 12.,
seed: Seed::Same,
gate: -42.,
snap: true,
mix: 100.,
output: 0.,
bypass: false,
};
s.apply_pattern(PATTERNS[3].2);
s
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
let finite = [
self.edge,
self.level_jitter,
self.time_jitter,
self.grit,
self.tone,
self.position,
self.length,
self.gate,
self.mix,
self.output,
]
.iter()
.chain(self.pattern.iter())
.all(|v| v.is_finite());
if !finite {
return Err("non-finite setting");
}
let ok = self.pattern.iter().all(|g| (STEP_MIN..=0.).contains(g))
&& (1..=STEPS).contains(&self.steps)
&& (5.0..=50.).contains(&self.edge)
&& (0.0..=6.).contains(&self.level_jitter)
&& (0.0..=10.).contains(&self.time_jitter)
&& (GRIT_OFF..=0.).contains(&self.grit)
&& (4.0..=64.).contains(&self.tone)
&& (0.0..=100.).contains(&self.position)
&& (2.0..=50.).contains(&self.length)
&& (-60.0..=-20.).contains(&self.gate)
&& (0.0..=100.).contains(&self.mix)
&& (-12.0..=12.).contains(&self.output);
if ok {
Ok(())
} else {
Err("setting out of range")
}
}
}
+232
View File
@@ -0,0 +1,232 @@
use common_dsp::{midi_to_hz, Saw};
use knurl_core::{Engine, Seed, Settings, Source, GRIT_OFF, PATTERNS};
const RATE: f32 = 48000.;
/// A tanh saw bass at `note`, as the prototype used.
fn bass(note: u8, n: usize) -> Vec<f32> {
let mut saw = Saw::new(RATE, midi_to_hz(note as f32));
(0..n).map(|_| (2.2 * saw.sample()).tanh() * 0.5).collect()
}
fn plain() -> Settings {
let mut s = Settings {
source: Source::Midi,
level_jitter: 0.,
time_jitter: 0.,
grit: GRIT_OFF,
..Settings::default()
};
s.apply_pattern(PATTERNS[0].2);
s
}
/// Runs one held note from sample 0 and returns the output, aligned to the input.
fn run(settings: Settings, note: u8, input: &[f32]) -> Vec<f32> {
let mut engine = Engine::new(RATE, 1).unwrap();
engine.set_settings(settings).unwrap();
let latency = engine.latency_samples() as usize;
engine.note_on(note);
let mut out: Vec<f32> = input
.iter()
.chain(std::iter::repeat_n(&0., latency))
.map(|&x| engine.process_sample([x, 0.])[0])
.collect();
out.drain(..latency);
out
}
/// Level of the component at `hz` (single-bin DFT with a Hann window), dB.
fn level(x: &[f32], hz: f32) -> f64 {
let n = x.len();
let (mut re, mut im) = (0f64, 0f64);
for (i, &v) in x.iter().enumerate() {
let w = 0.5 - 0.5 * (std::f64::consts::TAU * i as f64 / n as f64).cos();
let a = std::f64::consts::TAU * hz as f64 * i as f64 / RATE as f64;
re += w * v as f64 * a.cos();
im -= w * v as f64 * a.sin();
}
10. * (re * re + im * im).max(1e-30).log10()
}
fn subharmonic(out: &[f32], note: u8, divisor: f32) -> f64 {
let f = midi_to_hz(note as f32);
let seg = &out[4800..out.len() - 4800];
level(seg, f / divisor) - level(seg, f)
}
fn max_step(x: &[f32]) -> f32 {
x.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0., f32::max)
}
#[test]
fn everything_off_is_the_delayed_input() {
let input = bass(29, 24000);
let out = run(plain(), 29, &input);
assert_eq!(out, input);
}
#[test]
fn patterns_make_tuned_subharmonics_without_clicks() {
let input = bass(29, 48000);
let base = run(plain(), 29, &input);
assert!(subharmonic(&base, 29, 2.) < -50.);
for (pattern, divisor, expected) in [(1, 2., -8.2), (2, 3., -10.7)] {
let mut s = plain();
s.apply_pattern(PATTERNS[pattern].2);
let out = run(s, 29, &input);
let sub = subharmonic(&out, 29, divisor);
assert!(
(sub - expected).abs() < 0.5,
"{}: {sub}",
PATTERNS[pattern].0
);
assert!(
max_step(&out) <= 1.05 * max_step(&input),
"{}",
PATTERNS[pattern].0
);
}
}
#[test]
fn the_texture_is_the_same_on_every_note() {
let mut s = plain();
s.apply_pattern(PATTERNS[1].2);
let low = run(s, 29, &bass(29, 48000));
let high = run(s, 36, &bass(36, 48000));
let (a, b) = (subharmonic(&low, 29, 2.), subharmonic(&high, 36, 2.));
assert!((a - b).abs() < 0.3, "F1 {a} C2 {b}");
}
#[test]
fn same_seed_repeats_every_note_and_free_does_not() {
for (seed, identical) in [(Seed::Same, true), (Seed::Free, false)] {
let s = Settings {
source: Source::Midi,
seed,
..Settings::default()
};
let mut engine = Engine::new(RATE, 2).unwrap();
engine.set_settings(s).unwrap();
let latency = engine.latency_samples() as usize;
let note = bass(29, 12000);
let mut out = Vec::new();
let starts = [0, 36000];
for i in 0..72000 + latency {
if starts.contains(&i) {
engine.note_on(29);
}
if i == starts[0] + 12000 || i == starts[1] + 12000 {
engine.note_off(29);
}
let k = starts
.iter()
.filter(|&&s| i >= s)
.map(|&s| i - s)
.min()
.unwrap();
let x = note.get(k).copied().unwrap_or(0.);
out.push(engine.process_sample([x, x])[0]);
}
let first = &out[latency..latency + 12000];
let second = &out[starts[1] + latency..starts[1] + latency + 12000];
let largest = first
.iter()
.zip(second)
.map(|(a, b)| (a - b).abs())
.fold(0f32, f32::max);
if identical {
assert!(largest < 1e-4, "{seed:?}: {largest}");
} else {
assert!(largest > 1e-2, "{seed:?}: {largest}");
}
assert!(out.iter().all(|v| v.is_finite()));
}
}
#[test]
fn grit_follows_the_input_level_and_stops_with_the_note() {
let mut s = plain();
s.grit = -6.;
let input = bass(29, 24000);
let out = run(s, 29, &input);
let noise: f64 = out
.iter()
.zip(&input)
.skip(4800)
.map(|(o, i)| ((o - i) as f64).powi(2))
.sum::<f64>()
/ (out.len() - 4800) as f64;
let signal: f64 = input
.iter()
.skip(4800)
.map(|v| (*v as f64).powi(2))
.sum::<f64>()
/ (out.len() - 4800) as f64;
let ratio = 10. * (noise / signal).log10();
// −6 dB bursts over 12 % of each cycle; the sin² window keeps 3/8 of the power:
// −6 + 10·log10(0.12 · 0.375) = −19.5 dB.
assert!((ratio + 19.5).abs() < 1.5, "{ratio}");
// Silence in, silence out: no grit without a note, and no NaN.
let mut engine = Engine::new(RATE, 2).unwrap();
engine.set_settings(Settings::default()).unwrap();
for _ in 0..48000 {
let out = engine.process_sample([0., 0.]);
assert_eq!(out, [0., 0.]);
}
}
#[test]
fn audio_source_finds_the_note_and_reports_latency() {
let mut s = plain();
s.source = Source::Audio;
s.apply_pattern(PATTERNS[1].2);
let mut engine = Engine::new(RATE, 1).unwrap();
engine.set_settings(s).unwrap();
let latency = engine.latency_samples() as usize;
// About 67 ms of lookahead plus 4.2 ms of jitter headroom.
assert!((latency as f32 / RATE - 0.071).abs() < 0.005, "{latency}");
let input = bass(29, 48000);
let mut out: Vec<f32> = input
.iter()
.map(|&x| engine.process_sample([x, 0.])[0])
.collect();
out.drain(..latency);
let frame = engine.take_analyzer();
assert_eq!(frame.note, Some(29));
assert!(frame.scope_valid);
let sub = subharmonic(&out[..out.len() - 4800], 29, 2.);
assert!(sub > -14., "{sub}");
}
#[test]
fn invalid_settings_are_rejected() {
let mut engine = Engine::new(RATE, 2).unwrap();
for bad in [
Settings {
steps: 0,
..Settings::default()
},
Settings {
edge: 60.,
..Settings::default()
},
Settings {
grit: f32::NAN,
..Settings::default()
},
Settings {
time_jitter: 11.,
..Settings::default()
},
] {
assert!(engine.set_settings(bad).is_err());
}
let mut s = Settings::default();
s.pattern[3] = -30.;
assert!(engine.set_settings(s).is_err());
assert!(Engine::new(1000., 2).is_err());
assert!(Engine::new(48000., 3).is_err());
let json = serde_json::to_string(&Settings::default()).unwrap();
assert_eq!(
serde_json::from_str::<Settings>(&json).unwrap(),
Settings::default()
);
}
+70
View File
@@ -0,0 +1,70 @@
use knurl_core::{Engine, Seed, Settings, Source, PATTERNS};
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 notes_patterns_grit_and_jitter_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.apply_pattern(PATTERNS[(i / 4800) % PATTERNS.len()].2);
settings.seed = if i > 48000 { Seed::Free } else { Seed::Same };
settings.source = if (i / 4800) % 3 == 0 {
Source::Midi
} else {
Source::Audio
};
settings.time_jitter = ((i / 4800) % 10) as f32;
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);
}
+2382
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "knurl"
version = "0.2.0"
edition = "2021"
license = "ISC"
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
knurl-core = { path = "../core" }
knurl-ui = { path = "../ui" }
crossbeam-queue = "0.3"
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+195
View File
@@ -0,0 +1,195 @@
//! CLAP audio effect. All DSP state is owned by the processing thread.
mod params;
use crossbeam_queue::ArrayQueue;
use knurl_core::{AnalyzerFrame, Engine};
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::{KnurlParams, Pattern, GESTURES};
use std::sync::Arc;
struct EditorData {
ui: knurl_ui::State,
gestures: [bool; GESTURES],
}
pub struct Knurl {
params: Arc<KnurlParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
engine: Option<Engine>,
pattern: Pattern,
publish_count: usize,
publish_interval: usize,
}
impl Default for Knurl {
fn default() -> Self {
Self {
params: Arc::new(KnurlParams::default()),
analyzer: Arc::new(ArrayQueue::new(2)),
engine: None,
pattern: Pattern::default(),
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for Knurl {
const NAME: &'static str = "Knurl";
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: knurl_ui::State::default(),
gestures: [false; GESTURES],
},
|ctx, _| knurl_ui::style(ctx),
move |ctx, setter, data| {
while let Some(frame) = analyzer.pop() {
data.ui.accept_frame(frame);
}
let current = params.pattern();
let before = params.settings(&current);
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("knurl-editor")
.min_size(egui::vec2(
knurl_ui::WIDTH as f32 - 80.,
knurl_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
knurl_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
// The scope follows the audio.
ctx.request_repaint_after(std::time::Duration::from_millis(40));
},
)
}
fn initialize(
&mut self,
layout: &AudioIOLayout,
config: &BufferConfig,
context: &mut impl InitContext<Self>,
) -> bool {
let Ok(mut engine) = Engine::new(
config.sample_rate,
layout.main_input_channels.map_or(0, |v| v.get() as usize),
) else {
return false;
};
let settings = self.params.settings(&self.pattern);
self.pattern = Pattern {
gains: settings.pattern,
grit: settings.grit_steps,
};
if engine.set_settings(settings).is_err() {
return false;
}
context.set_latency_samples(engine.latency_samples());
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
true
}
fn reset(&mut self) {
if let Some(e) = self.engine.as_mut() {
e.reset();
}
self.publish_count = 0;
}
fn process(
&mut self,
buffer: &mut Buffer,
_: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let settings = self.params.settings(&self.pattern);
self.pattern = Pattern {
gains: settings.pattern,
grit: settings.grit_steps,
};
let _ = engine.set_settings(settings);
let mut next = context.next_event();
for (index, mut sample) in buffer.iter_samples().enumerate() {
while let Some(event) = next {
if event.timing() as usize > index {
break;
}
match event {
NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => {
engine.note_on(note)
}
NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => {
engine.note_off(note)
}
NoteEvent::Choke { note, .. } => engine.note_off(note),
_ => {}
}
next = context.next_event();
}
let x = [
*sample.get_mut(0).unwrap(),
if engine.channels() == 2 {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let out = engine.process_sample(x);
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
self.analyzer.force_push(engine.take_analyzer());
self.publish_count = 0;
}
}
// The lookahead delay and filter tails must drain after input stops.
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for Knurl {
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;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Distortion,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Knurl);
+255
View File
@@ -0,0 +1,255 @@
use knurl_core::{Seed, Settings, Source, GRIT_OFF, STEPS, STEP_MIN};
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::{Arc, RwLock};
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SourceParam {
#[id = "audio"]
Audio,
#[id = "midi"]
#[name = "MIDI"]
Midi,
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SeedParam {
#[id = "same"]
#[name = "Same every note"]
Same,
#[id = "free"]
Free,
}
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
}
/// Continuous controls, in the order of the gesture slots.
const FLOATS: usize = 10;
pub const GESTURES: usize = FLOATS;
/// The drawn pattern: step gains and grit switches. Saved with the project, not automation.
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Pattern {
pub gains: [f32; STEPS],
pub grit: [bool; STEPS],
}
impl Default for Pattern {
fn default() -> Self {
let d = Settings::default();
Self {
gains: d.pattern,
grit: d.grit_steps,
}
}
}
#[derive(Params)]
pub struct KnurlParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[persist = "knurl-pattern"]
pub pattern: RwLock<Pattern>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[id = "seed"]
pub seed: EnumParam<SeedParam>,
#[id = "steps"]
pub steps: IntParam,
#[id = "edge"]
pub edge: FloatParam,
#[id = "level-jitter"]
pub level_jitter: FloatParam,
#[id = "time-jitter"]
pub time_jitter: FloatParam,
#[id = "grit"]
pub grit: FloatParam,
#[id = "tone"]
pub tone: FloatParam,
#[id = "position"]
pub position: FloatParam,
#[id = "length"]
pub length: FloatParam,
#[id = "gate"]
pub gate: FloatParam,
#[id = "mix"]
pub mix: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "snap"]
pub snap: BoolParam,
#[id = "bypass"]
pub bypass: BoolParam,
}
impl Default for KnurlParams {
fn default() -> Self {
let d = Settings::default();
Self {
editor: EguiState::from_size(knurl_ui::WIDTH, knurl_ui::HEIGHT),
pattern: RwLock::new(Pattern::default()),
source: EnumParam::new("Pitch source", SourceParam::Audio),
seed: EnumParam::new("Random", SeedParam::Same),
steps: IntParam::new(
"Steps",
d.steps as i32,
IntRange::Linear {
min: 1,
max: STEPS as i32,
},
),
edge: linear("Edge", d.edge, 5., 50., " %"),
level_jitter: linear("Level jitter", d.level_jitter, 0., 6., " dB"),
time_jitter: linear("Time jitter", d.time_jitter, 0., 10., " %"),
grit: linear("Grit", d.grit, GRIT_OFF, 0., " dB").with_value_to_string(Arc::new(|v| {
if v <= GRIT_OFF {
"Off".into()
} else {
format!("{v:.1}")
}
})),
tone: FloatParam::new(
"Tone",
d.tone,
FloatRange::Skewed {
min: 4.,
max: 64.,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(" ×f0"),
position: linear("Position", d.position, 0., 100., " %"),
length: linear("Length", d.length, 2., 50., " %"),
gate: linear("Gate", d.gate, -60., -20., " dB"),
mix: linear("Mix", d.mix, 0., 100., " %"),
output: linear("Output", d.output, -12., 12., " dB"),
snap: BoolParam::new("Snap to semitone", d.snap),
bypass: BoolParam::new("Bypass", false).make_bypass(),
}
}
}
impl KnurlParams {
/// Current settings. The pattern falls back to `last` when the editor holds the lock,
/// so the audio thread never waits.
pub fn settings(&self, last: &Pattern) -> Settings {
let pattern = self.pattern.try_read().map(|p| *p).unwrap_or(*last);
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
pattern: pattern.gains.map(|g| {
if g.is_finite() {
g.clamp(STEP_MIN, 0.)
} else {
0.
}
}),
grit_steps: pattern.grit,
steps: self.steps.value().clamp(1, STEPS as i32) as usize,
edge: self.edge.value(),
level_jitter: self.level_jitter.value(),
time_jitter: self.time_jitter.value(),
grit: self.grit.value(),
tone: self.tone.value(),
position: self.position.value(),
length: self.length.value(),
seed: match self.seed.value() {
SeedParam::Same => Seed::Same,
SeedParam::Free => Seed::Free,
},
gate: self.gate.value(),
snap: self.snap.value(),
mix: self.mix.value(),
output: self.output.value(),
bypass: self.bypass.value(),
}
}
pub fn pattern(&self) -> Pattern {
self.pattern.read().map(|p| *p).unwrap_or_default()
}
fn floats(&self) -> [&FloatParam; FLOATS] {
[
&self.edge,
&self.level_jitter,
&self.time_jitter,
&self.grit,
&self.tone,
&self.position,
&self.length,
&self.gate,
&self.mix,
&self.output,
]
}
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
/// while the pointer is down; switches are set in a single gesture. The pattern is
/// stored directly.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; FLOATS],
) {
if new.pattern != old.pattern || new.grit_steps != old.grit_steps {
if let Ok(mut p) = self.pattern.write() {
*p = Pattern {
gains: new.pattern,
grit: new.grit_steps,
};
}
}
let values = [
(old.edge, new.edge),
(old.level_jitter, new.level_jitter),
(old.time_jitter, new.time_jitter),
(old.grit, new.grit),
(old.tone, new.tone),
(old.position, new.position),
(old.length, new.length),
(old.gate, new.gate),
(old.mix, new.mix),
(old.output, new.output),
];
for (i, (p, (a, b))) in self.floats().into_iter().zip(values).enumerate() {
if a != b {
if !open[i] {
open[i] = true;
setter.begin_set_parameter(p);
}
setter.set_parameter(p, b);
}
if !dragging && open[i] {
setter.end_set_parameter(p);
open[i] = false;
}
}
fn switch<P: Param>(setter: &ParamSetter, p: &P, v: P::Plain) {
setter.begin_set_parameter(p);
setter.set_parameter(p, v);
setter.end_set_parameter(p);
}
if new.steps != old.steps {
switch(setter, &self.steps, new.steps as i32);
}
if new.snap != old.snap {
switch(setter, &self.snap, new.snap);
}
if new.bypass != old.bypass {
switch(setter, &self.bypass, new.bypass);
}
if new.source != old.source {
let v = match new.source {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
switch(setter, &self.source, v);
}
if new.seed != old.seed {
let v = match new.seed {
Seed::Same => SeedParam::Same,
Seed::Free => SeedParam::Free,
};
switch(setter, &self.seed, v);
}
}
}
+227
View File
@@ -0,0 +1,227 @@
//! 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 = &knurl::clap_entry;
assert!(entry.init.unwrap()(c"knurl.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"Knurl");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.narl.bass-foundry.knurl"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
clap_version: CLAP_VERSION,
host_data: ptr::null_mut(),
name: c"Storyboard test host".as_ptr(),
vendor: c"Tests".as_ptr(),
url: c"".as_ptr(),
version: c"1.0".as_ptr(),
get_extension: Some(extension),
request_restart: Some(request),
request_process: Some(request),
request_callback: Some(request),
};
let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id);
assert!(!plugin.is_null());
let p = &*plugin;
assert!(p.init.unwrap()(plugin));
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
as *const clap_plugin_audio_ports;
assert!(!ports.is_null());
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
as *const clap_plugin_latency;
assert!(!latency.is_null());
let state =
p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state;
assert!(!state.is_null());
let state = &*state;
let save = || {
let mut bytes = Vec::new();
let stream = clap_ostream {
ctx: &mut bytes as *mut Vec<u8> as *mut c_void,
write: Some(write),
};
assert!(state.save.unwrap()(plugin, &stream));
bytes
};
let mut envelope: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
use nih_plug::wrapper::state::ParamValue;
envelope.params.insert("edge".into(), ParamValue::F32(30.));
envelope.params.insert("steps".into(), ParamValue::I32(3));
envelope
.params
.insert("seed".into(), ParamValue::String("free".into()));
envelope
.params
.insert("source".into(), ParamValue::String("midi".into()));
// Pitch from MIDI and no note arrives, so the audio check below sees the dry input
// even with a pattern set.
envelope
.params
.insert("level-jitter".into(), ParamValue::F32(0.));
envelope
.params
.insert("time-jitter".into(), ParamValue::F32(0.));
envelope.params.insert("grit".into(), ParamValue::F32(-60.));
let (gains, grit) = ([-6f32; 16], [true; 16]);
let mut pattern = serde_json::json!({ "gains": gains, "grit": grit });
pattern["grit"][5] = false.into();
envelope
.fields
.insert("knurl-pattern".into(), pattern.to_string());
let encoded = serde_json::to_vec(&envelope).unwrap();
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
framed.extend_from_slice(&encoded);
let mut bytes = Cursor::new(framed);
let stream = clap_istream {
ctx: &mut bytes as *mut Cursor<Vec<u8>> as *mut c_void,
read: Some(read),
};
assert!(state.load.unwrap()(plugin, &stream));
let restored: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
assert_eq!(
serde_json::to_value(&restored.params).unwrap(),
serde_json::to_value(&envelope.params).unwrap()
);
let saved: serde_json::Value =
serde_json::from_str(&restored.fields["knurl-pattern"]).unwrap();
assert_eq!(saved, pattern);
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
// About 67 ms lookahead plus 4.2 ms of timing-jitter headroom.
assert!((delay as f64 / rate - 0.071).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);
// With no MIDI note and every effect off, a tone passes through unchanged.
let tone = |n: usize| {
0.25 * (std::f64::consts::TAU * 1000. * n as f64 / rate).sin() as f32
};
let mut input_left = [0f32; 512];
let mut input_right = [0f32; 512];
for i in 0..frames {
input_left[i] = tone(position + i);
input_right[i] = -tone(position + i);
}
let mut left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
let incoming = clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let mut outgoing_audio = clap_audio_buffer {
data32: out_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let input = clap_input_events {
ctx: ptr::null_mut(),
size: Some(count),
get: Some(event),
};
let outgoing = clap_output_events {
ctx: ptr::null_mut(),
try_push: Some(push),
};
let process = clap_process {
steady_time: position as i64,
frames_count: frames as u32,
transport: ptr::null(),
audio_inputs: &incoming,
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 1,
audio_outputs_count: 1,
in_events: &input,
out_events: &outgoing,
};
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
for i in 0..frames {
let n = position + i;
if n < delay {
assert!(left[i].abs() < 1e-6, "output before the lookahead");
} else if n > delay + 4800 {
energy_in += (tone(n - delay) as f64).powi(2);
energy_out += (left[i] as f64).powi(2) + (right[i] as f64).powi(2);
}
}
position += frames;
}
let ratio_db = 10. * (energy_out / (2. * energy_in)).log10();
assert!(ratio_db.abs() < 0.2, "{ratio_db}");
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}
p.destroy.unwrap()(plugin);
entry.deinit.unwrap()();
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "knurl-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
knurl-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
common-dsp = { path = "../../common/dsp" }
+501
View File
@@ -0,0 +1,501 @@
//! Knurl editor, shared by the CLAP wrapper and the headless renderer.
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use knurl_core::{
AnalyzerFrame, Seed, Settings, Source, GRIT_OFF, PATTERNS, SCOPE_CYCLES, SCOPE_POINTS, STEPS,
STEP_MIN,
};
pub use common_ui::style;
pub const WIDTH: u32 = 1040;
pub const HEIGHT: u32 = 650;
const GRID: Color32 = Color32::from_rgb(24, 26, 30);
const TOP: f32 = 316.;
pub struct State {
pub analyzer: AnalyzerFrame,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
gesture_active: false,
help: DEFAULT_HELP.into(),
}
}
}
const DEFAULT_HELP: &str =
"Every cycle of the note gets its own level, timing and grit. Counted in cycles, so it fits every note.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
let old = self.analyzer;
self.analyzer = frame;
self.analyzer.input_db = frame.input_db.max(old.input_db - 3.);
self.analyzer.output_db = frame.output_db.max(old.output_db - 3.);
// Keep the last scope between notes.
if !frame.scope_valid && old.scope_valid {
self.analyzer.scope = old.scope;
self.analyzer.scope_valid = true;
}
}
}
fn hint(r: &egui::Response, state: &mut State, text: &str) {
if r.hovered() || r.has_focus() {
state.help = text.into();
}
}
#[allow(clippy::too_many_arguments)]
fn knob(
ui: &mut egui::Ui,
state: &mut State,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
help: &str,
) {
ui.vertical_centered(|ui| {
let r = rotary(ui, name, value, range, default, unit, 58., ACCENT);
hint(&r, state, help);
});
}
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, "KNURL", "CYCLE TEXTURE", |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.);
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(210.);
ui.set_min_height(TOP);
ui.vertical(|ui| note_panel(ui, settings, state));
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| {
let frame = state.analyzer;
scope(ui, &frame, 140.);
ui.add_space(4.);
pattern_editor(ui, settings, state, TOP - 144.);
});
});
});
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));
}
fn note_panel(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let frame = state.analyzer;
caption(ui, "NOTE");
let (name, hz) = match frame.note {
Some(n) if frame.hz > 0. => (common_dsp::note_name(n), format!("{:.1} Hz", frame.hz)),
_ if frame.hz > 0. => ("—".into(), format!("holding {:.1} Hz", frame.hz)),
_ => ("—".into(), "no note yet".into()),
};
ui.label(
RichText::new(name)
.font(FontId::new(54., egui::FontFamily::Name("medium".into())))
.color(if frame.note.is_some() { TEXT } else { DIM }),
);
ui.label(RichText::new(hz).size(13.).color(MUTED));
if frame.hz > 0. {
ui.label(
RichText::new(format!("one cycle = {:.1} ms", 1000. / frame.hz))
.size(12.)
.color(DIM),
);
}
ui.add_space(10.);
meter(ui, "IN", frame.input_db, STEEL, 110.);
meter(ui, "OUT", frame.output_db, ACCENT, 110.);
ui.add_space(10.);
caption(ui, "RANDOM");
ui.horizontal(|ui| {
for (seed, label, help) in [
(
Seed::Same,
"SAME EVERY NOTE",
"Every note gets the same random sequence: identical notes sound identical.",
),
(
Seed::Free,
"FREE",
"The random sequences keep running, so every note differs.",
),
] {
let r = ui.selectable_label(settings.seed == seed, RichText::new(label).size(12.));
if r.clicked() {
settings.seed = seed;
}
hint(&r, state, help);
}
});
ui.add_space(6.);
let r = ui.checkbox(
&mut settings.snap,
RichText::new("Snap to semitone").size(12.),
);
hint(
&r,
state,
"Round the detected pitch. Turn it off for slides.",
);
}
/// The last cycles of the output, overlaid; the newest is brightest.
fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32) {
let (outer, _) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
let p = ui.painter_at(outer);
p.rect_filled(outer, 2., SCREEN);
p.text(
outer.left_top() + Vec2::new(8., 5.),
egui::Align2::LEFT_TOP,
format!("LAST {SCOPE_CYCLES} CYCLES, OVERLAID"),
FontId::proportional(10.),
MUTED,
);
let plot = Rect::from_min_max(
outer.left_top() + Vec2::new(10., 20.),
outer.right_bottom() - Vec2::new(10., 8.),
);
for t in [0.25f32, 0.5, 0.75] {
let x = plot.left() + plot.width() * t;
p.line_segment(
[Pos2::new(x, plot.top()), Pos2::new(x, plot.bottom())],
Stroke::new(1.0_f32, GRID),
);
}
p.line_segment(
[
Pos2::new(plot.left(), plot.center().y),
Pos2::new(plot.right(), plot.center().y),
],
Stroke::new(1.0_f32, EDGE),
);
if !frame.scope_valid {
p.text(
plot.center(),
egui::Align2::CENTER_CENTER,
"play a note",
FontId::proportional(12.),
DIM,
);
return;
}
let peak = frame
.scope
.iter()
.flatten()
.fold(1e-6f32, |m, v| m.max(v.abs()));
// The newest cycle is still being written; draw the complete ones.
for (age, cycle) in frame.scope[..SCOPE_CYCLES - 1].iter().enumerate() {
let alpha = 0.2 + 0.8 * age as f32 / (SCOPE_CYCLES - 2) as f32;
let color = if age == SCOPE_CYCLES - 2 {
ACCENT
} else {
STEEL.gamma_multiply(alpha * 0.6)
};
let points: Vec<Pos2> = cycle
.iter()
.enumerate()
.map(|(i, v)| {
Pos2::new(
plot.left() + plot.width() * i as f32 / (SCOPE_POINTS - 1) as f32,
plot.center().y - plot.height() * 0.48 * v / peak,
)
})
.collect();
p.add(egui::Shape::line(
points,
Stroke::new(
if age == SCOPE_CYCLES - 2 {
2.0_f32
} else {
1.0
},
color,
),
));
}
}
fn step_y(rect: Rect, db: f32) -> f32 {
rect.top() + rect.height() * (db / STEP_MIN).clamp(0., 1.)
}
/// One bar per step: drag to set its level, double-click to reset; the square below a bar
/// switches its grit burst.
fn pattern_editor(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, height: f32) {
let (outer, response) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), height),
Sense::click_and_drag(),
);
let p = ui.painter_at(outer);
p.rect_filled(outer, 2., SCREEN);
p.text(
outer.left_top() + Vec2::new(8., 5.),
egui::Align2::LEFT_TOP,
"PATTERN: LEVEL PER CYCLE",
FontId::proportional(10.),
MUTED,
);
let bars = Rect::from_min_max(
outer.left_top() + Vec2::new(34., 22.),
Pos2::new(outer.right() - 10., outer.bottom() - 26.),
);
let toggles_y = outer.bottom() - 16.;
let col = bars.width() / STEPS as f32;
let column =
|x: f32| (((x - bars.left()) / col).floor() as isize).clamp(0, STEPS as isize - 1) as usize;
if response.hovered() {
state.help = "Drag a bar to set the level of that cycle; double-click resets it. The squares switch grit per step.".into();
}
if let Some(pos) = response.interact_pointer_pos() {
let k = column(pos.x);
if pos.y > bars.bottom() + 2. {
if response.clicked() {
settings.grit_steps[k] = !settings.grit_steps[k];
}
} else if response.double_clicked() {
settings.pattern[k] = 0.;
} else if response.dragged() || response.clicked() {
let t = ((pos.y - bars.top()) / bars.height()).clamp(0., 1.);
let db = t * STEP_MIN;
settings.pattern[k] = if db > -0.5 { 0. } else { db.round() };
if k >= settings.steps {
settings.steps = k + 1;
}
}
}
for db in [0f32, -6., -12., -18., -24.] {
let y = step_y(bars, db);
p.line_segment(
[Pos2::new(bars.left(), y), Pos2::new(bars.right(), y)],
Stroke::new(1.0_f32, GRID),
);
p.text(
Pos2::new(outer.left() + 6., y),
egui::Align2::LEFT_CENTER,
if db == 0. {
"0".into()
} else {
format!("{db:.0}")
},
FontId::proportional(10.),
DIM,
);
}
let current = state.analyzer.step;
let playing = state.analyzer.note.is_some();
for k in 0..STEPS {
let on = k < settings.steps;
let x0 = bars.left() + k as f32 * col + 3.;
let x1 = x0 + col - 6.;
let top = step_y(bars, settings.pattern[k]);
let color = if !on {
DIM.gamma_multiply(0.5)
} else if playing && k == current {
TEXT
} else {
ACCENT
};
p.rect_filled(
Rect::from_min_max(
Pos2::new(x0, top.min(bars.bottom() - 2.)),
Pos2::new(x1, bars.bottom()),
),
1.,
color.gamma_multiply(if on { 0.85 } else { 0.4 }),
);
let grit = settings.grit_steps[k] && settings.grit > GRIT_OFF;
let square = Rect::from_center_size(Pos2::new((x0 + x1) / 2., toggles_y), Vec2::splat(9.));
if settings.grit_steps[k] {
p.rect_filled(square, 1., if grit && on { FOCUS } else { DIM });
} else {
p.rect_stroke(
square,
1.,
Stroke::new(1.0_f32, DIM),
egui::StrokeKind::Inside,
);
}
}
p.text(
Pos2::new(outer.left() + 6., toggles_y),
egui::Align2::LEFT_CENTER,
"grit",
FontId::proportional(10.),
DIM,
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let d = Settings::default();
ui.horizontal(|ui| {
caption(ui, "PATTERN");
for (name, help, gains) in PATTERNS {
let selected = settings.steps == gains.len()
&& settings.pattern[..gains.len()]
.iter()
.zip(gains)
.all(|(a, b)| a == b);
let r = ui.selectable_label(selected, RichText::new(name.to_uppercase()).size(13.));
if r.clicked() {
settings.apply_pattern(gains);
}
hint(&r, state, help);
}
ui.add_space(24.);
caption(ui, "STEPS");
let minus = ui.button(RichText::new("−").size(14.));
if minus.clicked() && settings.steps > 1 {
settings.steps -= 1;
}
ui.label(
RichText::new(format!("{:>2}", settings.steps))
.monospace()
.size(14.)
.color(TEXT),
);
let plus = ui.button(RichText::new("+").size(14.));
if plus.clicked() && settings.steps < STEPS {
settings.steps += 1;
}
let help = "Pattern length in cycles. 2 makes a sub-octave, 3 an octave and a fifth down.";
hint(&minus, state, help);
hint(&plus, state, help);
});
ui.add_space(4.);
ui.columns(10, |c| {
knob(
&mut c[0],
state,
"Edge",
&mut settings.edge,
5. ..=50.,
d.edge,
" %",
"How soft each step change is, as a share of a cycle. Low is sharper and buzzier.",
);
knob(
&mut c[1],
state,
"Level",
&mut settings.level_jitter,
0. ..=6.,
d.level_jitter,
" dB",
"Random level per cycle: a rough, living surface.",
);
knob(
&mut c[2],
state,
"Time",
&mut settings.time_jitter,
0. ..=10.,
d.time_jitter,
" %",
"Random timing per cycle, in percent of a period: rattle and scrape.",
);
knob(
&mut c[3],
state,
"Grit",
&mut settings.grit,
GRIT_OFF..=0.,
d.grit,
" dB",
"Level of the noise burst in each cycle, relative to the input. Fully left is off.",
);
knob(
&mut c[4],
state,
"Tone",
&mut settings.tone,
4. ..=64.,
d.tone,
" ×f0",
"Lowest frequency of the grit, in harmonics of the note.",
);
knob(
&mut c[5],
state,
"Position",
&mut settings.position,
0. ..=100.,
d.position,
" %",
"Where in the cycle the grit burst sits.",
);
knob(
&mut c[6],
state,
"Length",
&mut settings.length,
2. ..=50.,
d.length,
" %",
"Length of the grit burst, as a share of a cycle.",
);
knob(
&mut c[7],
state,
"Gate",
&mut settings.gate,
-60. ..=-20.,
d.gate,
" dB",
"Input level that counts as a note (AUDIO). Raise it if tails retrigger.",
);
knob(
&mut c[8],
state,
"Mix",
&mut settings.mix,
0. ..=100.,
d.mix,
" %",
"Blend with the delayed dry signal.",
);
knob(
&mut c[9],
state,
"Output",
&mut settings.output,
-12. ..=12.,
d.output,
" dB",
"Output trim.",
);
});
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "lathe-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
common-dsp = { path = "../../common/dsp" }
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+258
View File
@@ -0,0 +1,258 @@
//! Harmonic EQ: bands sit on the harmonics of the played note instead of fixed frequencies,
//! so a cut on harmonic 3 stays on harmonic 3 on every note.
//!
//! The audio path is delayed by the tracker's lookahead, so the filters are already tuned to
//! a note when its attack leaves the plugin.
mod settings;
use common_dsp::meter::HarmonicMeter;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{PitchFollower, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi};
pub use settings::{Settings, Source, GAIN_MAX, GAIN_MIN, HARMONICS};
pub const MIN_HZ: f32 = TrackerConfig::BASS.min_hz;
pub const MAX_HZ: f32 = TrackerConfig::BASS.max_hz;
/// Harmonic bands plus the bands halfway between them.
const BANDS: usize = 2 * HARMONICS;
/// Filter coefficients are recomputed this often, in samples.
const RETUNE: usize = 16;
const GAIN_MS: f32 = 20.;
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
/// Fundamental the filters are tuned to, 0 before the first note.
pub hz: f32,
/// Sounding note, rounded; None between notes.
pub note: Option<u8>,
/// Harmonic levels in dBFS of the delayed input and of the output (index 0 = fundamental).
pub input: [f32; HARMONICS],
pub output: [f32; HARMONICS],
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
hz: 0.,
note: None,
input: [-120.; HARMONICS],
output: [-120.; HARMONICS],
latency: 0,
sample_rate: 48000.,
}
}
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
latency: usize,
delay: Vec<[f32; 2]>,
delay_at: usize,
tracker: Tracker,
pitch: PitchFollower,
hz: f32,
hz_coef: f32,
countdown: usize,
coefs: [SvfCoef; BANDS],
active: [bool; BANDS],
gains: [f32; BANDS],
state: [[Svf; BANDS]; 2],
gain_coef: f32,
smooth: f32,
mix: f32,
output: f32,
bypass: f32,
meter_in: HarmonicMeter,
meter_out: HarmonicMeter,
/// Smoothed controls start at the next settings instead of gliding from defaults.
fresh: bool,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(8000.0..=768000.).contains(&rate) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let latency = tracker.latency();
let settings = Settings::default();
let mut e = Self {
rate,
channels,
settings,
latency,
delay: vec![[0.; 2]; latency + 1],
delay_at: 0,
tracker,
pitch: PitchFollower::default(),
hz: 0.,
hz_coef: 1.,
countdown: 0,
coefs: [SvfCoef::IDENTITY; BANDS],
active: [false; BANDS],
gains: [0.; BANDS],
state: [[Svf::default(); BANDS]; 2],
gain_coef: coefficient(rate / RETUNE as f32, GAIN_MS),
smooth: coefficient(rate, 10.),
mix: 1.,
output: 1.,
bypass: 0.,
meter_in: HarmonicMeter::new(rate, 85.),
meter_out: HarmonicMeter::new(rate, 85.),
fresh: true,
};
e.set_settings(settings)?;
e.fresh = true;
Ok(e)
}
pub fn latency_samples(&self) -> u32 {
self.latency as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.settings
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
self.tracker
.set(s.source == Source::Midi, s.gate, s.smooth > 0.);
self.hz_coef = coefficient(self.rate, s.smooth / 3.);
self.settings = s;
if std::mem::take(&mut self.fresh) {
self.mix = s.mix * 0.01;
self.output = db_to_gain(s.output);
self.bypass = f32::from(s.bypass);
self.gains = self.targets();
}
Ok(())
}
pub fn reset(&mut self) {
self.delay.fill([0.; 2]);
self.tracker.reset();
for ch in &mut self.state {
for f in ch.iter_mut() {
f.reset();
}
}
self.meter_in.reset();
self.meter_out.reset();
self.countdown = 0;
self.fresh = true;
}
pub fn note_on(&mut self, note: u8) {
self.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.tracker.note_off(note);
}
/// Gain targets in dB of every band for the current settings.
fn targets(&self) -> [f32; BANDS] {
let s = &self.settings;
let mut t = [0.; BANDS];
for k in 1..=s.harmonics as usize {
t[k - 1] = s.effective(k);
t[HARMONICS + k - 1] = s.between;
}
t
}
fn retune(&mut self, jump: bool) {
let s = self.settings;
let targets = self.targets();
let width = s.width * 0.01;
for b in 0..BANDS {
self.gains[b] = if jump {
targets[b]
} else {
self.gains[b] + (targets[b] - self.gains[b]) * self.gain_coef
};
if (self.gains[b] - targets[b]).abs() < 1e-4 {
self.gains[b] = targets[b];
}
let order = if b < HARMONICS {
(b + 1) as f32
} else {
(b - HARMONICS + 1) as f32 + 0.5
};
let centre = order * self.hz;
let audible = self.hz > 0. && centre < 0.45 * self.rate;
if audible && !self.active[b] {
for ch in &mut self.state {
ch[b].reset();
}
}
self.active[b] = audible && self.gains[b] != 0.;
if self.active[b] {
self.coefs[b] = SvfCoef::bell(self.rate, centre, order / width, self.gains[b]);
}
}
}
pub fn process_sample(&mut self, x: [f32; 2]) -> [f32; 2] {
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
x[0]
};
let (pitch, snap) = (&mut self.pitch, self.settings.snap);
self.tracker.process(mono, |e| pitch.event(snap, e));
let len = self.delay.len();
self.delay[self.delay_at] = x;
self.delay_at = (self.delay_at + 1) % len;
let y = self.delay[self.delay_at];
let first = self.hz == 0.;
let (hz, jump) = self.pitch.step(self.hz_coef);
let first = first && jump;
self.hz = hz;
if jump || self.countdown == 0 {
self.retune(first);
self.countdown = RETUNE;
}
self.countdown -= 1;
let s = self.settings;
self.mix += (s.mix * 0.01 - self.mix) * self.smooth;
self.output += (db_to_gain(s.output) - self.output) * self.smooth;
self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth;
let mut out = [0.; 2];
for c in 0..self.channels {
let mut v = y[c];
for b in 0..BANDS {
if self.active[b] {
v = self.state[c][b].process(&self.coefs[b], v);
}
}
let wet = (self.mix * v + (1. - self.mix) * y[c]) * self.output;
out[c] = wet * (1. - self.bypass) + y[c] * self.bypass;
}
let hz = if self.tracker.sounding() { self.hz } else { 0. };
let (dry, wet) = if self.channels == 2 {
(0.5 * (y[0] + y[1]), 0.5 * (out[0] + out[1]))
} else {
(y[0], out[0])
};
self.meter_in.push(dry, hz);
self.meter_out.push(wet, hz);
out
}
pub fn take_analyzer(&self) -> AnalyzerFrame {
let mut f = AnalyzerFrame {
hz: self.hz,
latency: self.latency as u32,
sample_rate: self.rate,
input: *self.meter_in.levels(),
output: *self.meter_out.levels(),
..AnalyzerFrame::default()
};
if self.tracker.sounding() && self.hz > 0. {
f.note = Some(hz_to_midi(self.hz).round().clamp(0., 127.) as u8);
}
f
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Host-independent controls. Gains in dB, times in milliseconds.
use serde::{Deserialize, Serialize};
/// Editable harmonics: the fundamental is harmonic 1.
pub const HARMONICS: usize = 32;
pub const GAIN_MIN: f32 = -24.;
pub const GAIN_MAX: f32 = 12.;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Source {
/// Pitch and note starts are detected from the incoming bass.
#[default]
Audio,
/// Notes come from MIDI note input.
Midi,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// Gain per harmonic, index 0 = fundamental.
pub gains: [f32; HARMONICS],
/// dB per octave of harmonic number, added on top of `gains`. Harmonic 1 is the pivot.
pub tilt: f32,
/// Positive lifts odd harmonics and lowers even ones by half this amount each.
pub odd_even: f32,
/// Gain of the regions halfway between harmonics: noise, beating, inharmonic grit.
pub between: f32,
/// Bandwidth of every band as a percentage of the fundamental.
pub width: f32,
/// Number of harmonics that are shaped; higher ones pass unchanged.
pub harmonics: u32,
/// Retune time when the pitch slides or bends.
pub smooth: f32,
/// Detection threshold in dBFS for note starts (Audio source).
pub gate: f32,
/// Round detected pitch to the nearest semitone.
pub snap: bool,
pub mix: f32,
pub output: f32,
pub bypass: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
gains: [0.; HARMONICS],
tilt: 0.,
odd_even: 0.,
between: 0.,
width: 20.,
harmonics: 24,
smooth: 10.,
gate: -42.,
snap: true,
mix: 100.,
output: 0.,
bypass: false,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
let finite = [
self.tilt,
self.odd_even,
self.between,
self.width,
self.smooth,
self.gate,
self.mix,
self.output,
]
.iter()
.chain(self.gains.iter())
.all(|v| v.is_finite());
if !finite {
return Err("non-finite setting");
}
let ok = self.gains.iter().all(|g| (GAIN_MIN..=GAIN_MAX).contains(g))
&& (-12.0..=12.).contains(&self.tilt)
&& (-12.0..=12.).contains(&self.odd_even)
&& (-24.0..=12.).contains(&self.between)
&& (5.0..=50.).contains(&self.width)
&& (8..=HARMONICS as u32).contains(&self.harmonics)
&& (0.0..=100.).contains(&self.smooth)
&& (-60.0..=-20.).contains(&self.gate)
&& (0.0..=100.).contains(&self.mix)
&& (-12.0..=12.).contains(&self.output);
if ok {
Ok(())
} else {
Err("setting out of range")
}
}
/// Gain applied to harmonic `k` (1-based), with tilt and odd/even included.
pub fn effective(&self, k: usize) -> f32 {
if k == 0 || k > self.harmonics as usize || k > HARMONICS {
return 0.;
}
let parity = if k % 2 == 1 { 0.5 } else { -0.5 };
(self.gains[k - 1] + self.tilt * (k as f32).log2() + self.odd_even * parity)
.clamp(-30., 18.)
}
}
+139
View File
@@ -0,0 +1,139 @@
use common_dsp::meter::HarmonicMeter;
use common_dsp::Saw;
use lathe_core::{Engine, Settings, Source};
const RATE: f32 = 48000.;
fn engine(s: Settings) -> (Engine, usize) {
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(s).unwrap();
let l = e.latency_samples() as usize;
(e, l)
}
fn saw(hz: f32, seconds: f32, amp: f32) -> Vec<f32> {
let mut s = Saw::new(RATE, hz);
(0..(seconds * RATE) as usize)
.map(|_| s.sample() * amp)
.collect()
}
fn render(e: &mut Engine, x: &[f32]) -> Vec<f32> {
x.iter().map(|&v| e.process_sample([v, v])[0]).collect()
}
/// Harmonic levels of the last complete window of `x`.
fn harmonics(x: &[f32], hz: f32) -> [f32; 32] {
let mut m = HarmonicMeter::new(RATE, 85.);
for &v in x {
m.push(v, hz);
}
*m.levels()
}
#[test]
fn a_cut_on_harmonic_three_stays_on_harmonic_three_on_every_note() {
let mut s = Settings::default();
s.gains[2] = -12.;
for hz in [43.65, 65.41, 130.81] {
let x = saw(hz, 1., 0.5);
let (mut e, l) = engine(s);
let y = render(&mut e, &x);
let a = harmonics(&x[..x.len() - l], hz);
let b = harmonics(&y[l..], hz);
let d: Vec<f32> = (0..6).map(|k| b[k] - a[k]).collect();
assert!((d[2] + 12.).abs() < 0.7, "{hz}: {d:?}");
for k in [0, 1, 3, 4] {
assert!(d[k].abs() < 1., "{hz} harmonic {}: {d:?}", k + 1);
}
}
}
#[test]
fn a_flat_map_passes_the_delayed_input_exactly() {
let x = saw(55., 0.5, 0.5);
let (mut e, l) = engine(Settings::default());
let y = render(&mut e, &x);
for i in l..x.len() {
assert_eq!(y[i], x[i - l]);
}
}
#[test]
fn odd_even_balances_the_harmonic_series() {
let s = Settings {
odd_even: 6.,
..Settings::default()
};
let hz = 55.;
let x = saw(hz, 1., 0.5);
let (mut e, l) = engine(s);
let y = render(&mut e, &x);
let a = harmonics(&x[..x.len() - l], hz);
let b = harmonics(&y[l..], hz);
for k in 1..8 {
let want = if k % 2 == 1 { 3. } else { -3. };
let got = b[k - 1] - a[k - 1];
assert!((got - want).abs() < 0.8, "harmonic {k}: {got}");
}
}
#[test]
fn note_changes_do_not_click() {
let mut s = Settings::default();
s.gains[1] = 12.;
s.gains[3] = -24.;
s.tilt = -3.;
let mut x = saw(43.65, 0.5, 0.5);
x.extend(saw(58.27, 0.5, 0.5));
x.extend(saw(87.31, 0.5, 0.5));
let (mut e, _) = engine(s);
let y = render(&mut e, &x);
let step = y
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.fold(0f32, f32::max);
let in_step = x
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.fold(0f32, f32::max);
assert!(y.iter().all(|v| v.is_finite()));
// A saw has its own jumps; the filtered output must not jump much further than that.
assert!(step < 2. * in_step, "{step} vs {in_step}");
}
#[test]
fn midi_source_tunes_the_bands_at_the_delayed_note_time() {
let mut s = Settings {
source: Source::Midi,
..Settings::default()
};
s.gains[0] = -24.;
let (mut e, l) = engine(s);
// 43.65 Hz is MIDI 29. Notes arrive at sample 0; the bands must be tuned by sample l.
e.note_on(29);
let x = saw(43.65, 1., 0.5);
let y = render(&mut e, &x);
let a = harmonics(&x[..x.len() - l], 43.65);
let b = harmonics(&y[l..], 43.65);
assert!(b[0] - a[0] < -20., "{}", b[0] - a[0]);
let f = e.take_analyzer();
assert_eq!(f.note, Some(29));
assert!((f.hz - 43.65).abs() < 0.05);
}
#[test]
fn settings_are_validated_and_serialise() {
let s = Settings::default();
let json = serde_json::to_string(&s).unwrap();
assert_eq!(serde_json::from_str::<Settings>(&json).unwrap(), s);
let mut bad = s;
bad.gains[5] = 40.;
assert!(bad.validate().is_err());
bad = s;
bad.harmonics = 4;
assert!(bad.validate().is_err());
bad = s;
bad.width = f32::NAN;
assert!(bad.validate().is_err());
let mut e = Engine::new(RATE, 2).unwrap();
assert!(e.set_settings(bad).is_err());
assert_eq!(e.settings(), s);
}
+69
View File
@@ -0,0 +1,69 @@
use lathe_core::{Engine, Settings, Source};
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
};
struct CheckedAllocator;
thread_local! {
static CHECK:Cell<bool>=const {Cell::new(false)};
static OPERATIONS:Cell<usize>=const {Cell::new(0)};
}
fn record() {
if CHECK.try_with(Cell::get).unwrap_or(false) {
let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1));
}
}
unsafe impl GlobalAlloc for CheckedAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
record();
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
record();
unsafe { System.dealloc(pointer, layout) }
}
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
record();
unsafe { System.realloc(pointer, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CheckedAllocator = CheckedAllocator;
#[test]
fn processing_notes_settings_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings::default();
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..96000 {
if i % 4800 == 0 {
settings.gains[(i / 4800) % 32] = -6.;
settings.tilt = ((i / 4800) % 5) as f32 - 2.;
settings.source = if i > 48000 {
Source::Midi
} else {
Source::Audio
};
engine.set_settings(settings).unwrap();
engine.note_on(30 + (i / 4800 % 12) as u8);
}
if i % 4800 == 2400 {
engine.note_off(30 + (i / 4800 % 12) as u8);
}
let t = i as f32 / 48000.;
let x = if (i / 2400) % 2 == 0 {
(std::f32::consts::TAU * 55. * t).sin() * 0.5
} else {
0.
};
std::hint::black_box(engine.process_sample([x, x]));
if i % 1600 == 0 {
std::hint::black_box(engine.take_analyzer());
}
}
engine.reset();
CHECK.with(|v| v.set(false));
assert_eq!(OPERATIONS.with(Cell::get), 0);
}
+2381
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "lathe"
version = "0.2.0"
edition = "2021"
license = "ISC"
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
lathe-core = { path = "../core" }
lathe-ui = { path = "../ui" }
crossbeam-queue = "0.3"
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+189
View File
@@ -0,0 +1,189 @@
//! CLAP audio effect. All DSP state is owned by the processing thread.
mod params;
use crossbeam_queue::ArrayQueue;
use lathe_core::{AnalyzerFrame, Engine, HARMONICS};
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::LatheParams;
use std::sync::Arc;
struct EditorData {
ui: lathe_ui::State,
gestures: [bool; 8],
}
pub struct Lathe {
params: Arc<LatheParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
engine: Option<Engine>,
gains: [f32; HARMONICS],
publish_count: usize,
publish_interval: usize,
}
impl Default for Lathe {
fn default() -> Self {
Self {
params: Arc::new(LatheParams::default()),
analyzer: Arc::new(ArrayQueue::new(2)),
engine: None,
gains: [0.; HARMONICS],
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for Lathe {
const NAME: &'static str = "Lathe";
const VENDOR: &'static str = "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: lathe_ui::State::default(),
gestures: [false; 8],
},
|ctx, _| lathe_ui::style(ctx),
move |ctx, setter, data| {
while let Some(frame) = analyzer.pop() {
data.ui.accept_frame(frame);
}
let current = params.gains.read().map(|g| *g).unwrap_or([0.; HARMONICS]);
let before = params.settings(&current);
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("lathe-editor")
.min_size(egui::vec2(
lathe_ui::WIDTH as f32 - 80.,
lathe_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
lathe_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
// Harmonic levels follow the audio.
ctx.request_repaint_after(std::time::Duration::from_millis(40));
},
)
}
fn initialize(
&mut self,
layout: &AudioIOLayout,
config: &BufferConfig,
context: &mut impl InitContext<Self>,
) -> bool {
let Ok(mut engine) = Engine::new(
config.sample_rate,
layout.main_input_channels.map_or(0, |v| v.get() as usize),
) else {
return false;
};
let settings = self.params.settings(&self.gains);
self.gains = settings.gains;
if engine.set_settings(settings).is_err() {
return false;
}
context.set_latency_samples(engine.latency_samples());
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
true
}
fn reset(&mut self) {
if let Some(e) = self.engine.as_mut() {
e.reset();
}
self.publish_count = 0;
}
fn process(
&mut self,
buffer: &mut Buffer,
_: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let settings = self.params.settings(&self.gains);
self.gains = settings.gains;
let _ = engine.set_settings(settings);
let mut next = context.next_event();
for (index, mut sample) in buffer.iter_samples().enumerate() {
while let Some(event) = next {
if event.timing() as usize > index {
break;
}
match event {
NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => {
engine.note_on(note)
}
NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => {
engine.note_off(note)
}
NoteEvent::Choke { note, .. } => engine.note_off(note),
_ => {}
}
next = context.next_event();
}
let x = [
*sample.get_mut(0).unwrap(),
if engine.channels() == 2 {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let out = engine.process_sample(x);
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
self.analyzer.force_push(engine.take_analyzer());
self.publish_count = 0;
}
}
// The lookahead delay and filter tails must drain after input stops.
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for Lathe {
const CLAP_ID: &'static str = "io.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;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Equalizer,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Lathe);
+186
View File
@@ -0,0 +1,186 @@
use lathe_core::{Settings, Source, HARMONICS};
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::{Arc, RwLock};
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SourceParam {
#[id = "audio"]
Audio,
#[id = "midi"]
#[name = "MIDI"]
Midi,
}
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
}
/// Continuous controls, in the order of the gesture slots.
const FLOATS: usize = 8;
#[derive(Params)]
pub struct LatheParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
/// Drawn per-harmonic gains. Saved with the project, not exposed as automation lanes.
#[persist = "lathe-gains"]
pub gains: RwLock<[f32; HARMONICS]>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[id = "tilt"]
pub tilt: FloatParam,
#[id = "odd-even"]
pub odd_even: FloatParam,
#[id = "between"]
pub between: FloatParam,
#[id = "width"]
pub width: FloatParam,
#[id = "smooth"]
pub smooth: FloatParam,
#[id = "gate"]
pub gate: FloatParam,
#[id = "mix"]
pub mix: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "harmonics"]
pub harmonics: IntParam,
#[id = "snap"]
pub snap: BoolParam,
#[id = "bypass"]
pub bypass: BoolParam,
}
impl Default for LatheParams {
fn default() -> Self {
let d = Settings::default();
Self {
editor: EguiState::from_size(lathe_ui::WIDTH, lathe_ui::HEIGHT),
gains: RwLock::new(d.gains),
source: EnumParam::new("Pitch source", SourceParam::Audio),
tilt: linear("Tilt", d.tilt, -12., 12., " dB/oct"),
odd_even: linear("Odd/Even", d.odd_even, -12., 12., " dB"),
between: linear("Between", d.between, -24., 12., " dB"),
width: linear("Width", d.width, 5., 50., " %"),
smooth: linear("Smooth", d.smooth, 0., 100., " ms"),
gate: linear("Gate", d.gate, -60., -20., " dB"),
mix: linear("Mix", d.mix, 0., 100., " %"),
output: linear("Output", d.output, -12., 12., " dB"),
harmonics: IntParam::new(
"Harmonics",
d.harmonics as i32,
IntRange::Linear {
min: 8,
max: HARMONICS as i32,
},
),
snap: BoolParam::new("Snap to semitone", d.snap),
bypass: BoolParam::new("Bypass", false).make_bypass(),
}
}
}
impl LatheParams {
/// Current settings. `gains` falls back to `last` when the editor holds the lock, so the
/// audio thread never waits.
pub fn settings(&self, last: &[f32; HARMONICS]) -> Settings {
let gains = self.gains.try_read().map(|g| *g).unwrap_or(*last);
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
gains: gains.map(|g| {
if g.is_finite() {
g.clamp(lathe_core::GAIN_MIN, lathe_core::GAIN_MAX)
} else {
0.
}
}),
tilt: self.tilt.value(),
odd_even: self.odd_even.value(),
between: self.between.value(),
width: self.width.value(),
harmonics: self.harmonics.value() as u32,
smooth: self.smooth.value(),
gate: self.gate.value(),
snap: self.snap.value(),
mix: self.mix.value(),
output: self.output.value(),
bypass: self.bypass.value(),
}
}
fn floats(&self) -> [&FloatParam; FLOATS] {
[
&self.tilt,
&self.odd_even,
&self.between,
&self.width,
&self.smooth,
&self.gate,
&self.mix,
&self.output,
]
}
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
/// while the pointer is down; switches are set in a single gesture. Gains are stored
/// directly.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; FLOATS],
) {
if new.gains != old.gains {
if let Ok(mut g) = self.gains.write() {
*g = new.gains;
}
}
let values = [
(old.tilt, new.tilt),
(old.odd_even, new.odd_even),
(old.between, new.between),
(old.width, new.width),
(old.smooth, new.smooth),
(old.gate, new.gate),
(old.mix, new.mix),
(old.output, new.output),
];
for (i, (p, (a, b))) in self.floats().into_iter().zip(values).enumerate() {
if a != b {
if !open[i] {
open[i] = true;
setter.begin_set_parameter(p);
}
setter.set_parameter(p, b);
}
if !dragging && open[i] {
setter.end_set_parameter(p);
open[i] = false;
}
}
if new.harmonics != old.harmonics {
setter.begin_set_parameter(&self.harmonics);
setter.set_parameter(&self.harmonics, new.harmonics as i32);
setter.end_set_parameter(&self.harmonics);
}
for (p, a, b) in [
(&self.snap, old.snap, new.snap),
(&self.bypass, old.bypass, new.bypass),
] {
if a != b {
setter.begin_set_parameter(p);
setter.set_parameter(p, b);
setter.end_set_parameter(p);
}
}
if new.source != old.source {
let v = match new.source {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
setter.begin_set_parameter(&self.source);
setter.set_parameter(&self.source, v);
setter.end_set_parameter(&self.source);
}
}
}
+219
View File
@@ -0,0 +1,219 @@
//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW.
use clap_sys::{
audio_buffer::*,
events::*,
ext::{audio_ports::*, latency::*, state::*},
factory::plugin_factory::*,
host::*,
process::*,
stream::*,
version::*,
};
use std::{
ffi::{c_char, c_void, CStr},
io::{Cursor, Read},
ptr,
};
unsafe extern "C" fn extension(_: *const clap_host, _: *const c_char) -> *const c_void {
ptr::null()
}
unsafe extern "C" fn request(_: *const clap_host) {}
unsafe extern "C" fn count(_: *const clap_input_events) -> u32 {
0
}
unsafe extern "C" fn event(_: *const clap_input_events, _: u32) -> *const clap_event_header {
ptr::null()
}
unsafe extern "C" fn push(_: *const clap_output_events, _: *const clap_event_header) -> bool {
true
}
unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 {
// SAFETY: The test owns the Vec for the entire synchronous save call.
let bytes = unsafe { &mut *((*stream).ctx as *mut Vec<u8>) };
bytes.extend_from_slice(unsafe {
std::slice::from_raw_parts(buffer as *const u8, size as usize)
});
size as i64
}
unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
// SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage.
let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor<Vec<u8>>) };
cursor
.read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) })
.unwrap() as i64
}
#[test]
fn effect_ports_state_latency_and_audio_lifecycle() {
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
unsafe {
let entry = &lathe::clap_entry;
assert!(entry.init.unwrap()(c"lathe.clap".as_ptr()));
let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr())
as *const clap_plugin_factory;
assert!(!factory.is_null());
let factory = &*factory;
assert_eq!(factory.get_plugin_count.unwrap()(factory), 1);
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(CStr::from_ptr((*descriptor).name), c"Lathe");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.narl.bass-foundry.lathe"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
clap_version: CLAP_VERSION,
host_data: ptr::null_mut(),
name: c"Storyboard test host".as_ptr(),
vendor: c"Tests".as_ptr(),
url: c"".as_ptr(),
version: c"1.0".as_ptr(),
get_extension: Some(extension),
request_restart: Some(request),
request_process: Some(request),
request_callback: Some(request),
};
let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id);
assert!(!plugin.is_null());
let p = &*plugin;
assert!(p.init.unwrap()(plugin));
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
as *const clap_plugin_audio_ports;
assert!(!ports.is_null());
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
as *const clap_plugin_latency;
assert!(!latency.is_null());
let state =
p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state;
assert!(!state.is_null());
let state = &*state;
let save = || {
let mut bytes = Vec::new();
let stream = clap_ostream {
ctx: &mut bytes as *mut Vec<u8> as *mut c_void,
write: Some(write),
};
assert!(state.save.unwrap()(plugin, &stream));
bytes
};
let mut envelope: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
use nih_plug::wrapper::state::ParamValue;
envelope.params.insert("tilt".into(), ParamValue::F32(-3.));
envelope
.params
.insert("harmonics".into(), ParamValue::I32(16));
let mut gains = [0f32; 32];
gains[2] = -12.;
gains[4] = 6.;
envelope
.fields
.insert("lathe-gains".into(), serde_json::to_string(&gains).unwrap());
envelope
.params
.insert("snap".into(), ParamValue::Bool(false));
envelope
.params
.insert("source".into(), ParamValue::String("midi".into()));
let encoded = serde_json::to_vec(&envelope).unwrap();
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
framed.extend_from_slice(&encoded);
let mut bytes = Cursor::new(framed);
let stream = clap_istream {
ctx: &mut bytes as *mut Cursor<Vec<u8>> as *mut c_void,
read: Some(read),
};
assert!(state.load.unwrap()(plugin, &stream));
let restored: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
assert_eq!(
serde_json::to_value(&restored.params).unwrap(),
serde_json::to_value(&envelope.params).unwrap()
);
let saved: [f32; 32] = serde_json::from_str(&restored.fields["lathe-gains"]).unwrap();
assert_eq!(saved, gains);
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
// About 67 ms lookahead: two periods of the lowest detected note (30 Hz).
assert!((delay as f64 / rate - 0.067).abs() < 0.01, "{delay}");
assert!(p.start_processing.unwrap()(plugin));
let total = delay + 9600;
let mut position = 0;
let (mut energy_in, mut energy_out) = (0f64, 0f64);
while position < total {
let frames = [1, 127, 512, 31][position % 4].min(total - position);
// A 1 kHz tone sits above the split and passes through unchanged.
let tone = |n: usize| {
0.25 * (std::f64::consts::TAU * 1000. * n as f64 / rate).sin() as f32
};
let mut input_left = [0f32; 512];
let mut input_right = [0f32; 512];
for i in 0..frames {
input_left[i] = tone(position + i);
input_right[i] = -tone(position + i);
}
let mut left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
let incoming = clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let mut outgoing_audio = clap_audio_buffer {
data32: out_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let input = clap_input_events {
ctx: ptr::null_mut(),
size: Some(count),
get: Some(event),
};
let outgoing = clap_output_events {
ctx: ptr::null_mut(),
try_push: Some(push),
};
let process = clap_process {
steady_time: position as i64,
frames_count: frames as u32,
transport: ptr::null(),
audio_inputs: &incoming,
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 1,
audio_outputs_count: 1,
in_events: &input,
out_events: &outgoing,
};
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
for i in 0..frames {
let n = position + i;
if n < delay {
assert!(left[i].abs() < 1e-6, "output before the lookahead");
} else if n > delay + 4800 {
energy_in += (tone(n - delay) as f64).powi(2);
energy_out += (left[i] as f64).powi(2) + (right[i] as f64).powi(2);
}
}
position += frames;
}
let ratio_db = 10. * (energy_out / (2. * energy_in)).log10();
assert!(ratio_db.abs() < 0.2, "{ratio_db}");
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}
p.destroy.unwrap()(plugin);
entry.deinit.unwrap()();
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "lathe-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
lathe-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
common-dsp = { path = "../../common/dsp" }
+431
View File
@@ -0,0 +1,431 @@
//! Lathe editor, shared by the CLAP wrapper and the headless renderer.
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use lathe_core::{AnalyzerFrame, Settings, Source, GAIN_MAX, GAIN_MIN, HARMONICS};
pub use common_ui::style;
pub const WIDTH: u32 = 1040;
pub const HEIGHT: u32 = 650;
pub struct State {
pub analyzer: AnalyzerFrame,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
/// Last point of a draw stroke in the harmonic editor: (harmonic index, dB).
stroke: Option<(usize, f32)>,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
gesture_active: false,
help: DEFAULT_HELP.into(),
stroke: None,
}
}
}
const DEFAULT_HELP: &str =
"Draw gains on the harmonics. They follow the note: harmonic 3 stays harmonic 3 on every pitch.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
let old = self.analyzer;
self.analyzer = frame;
// Levels fall smoothly so bars stay readable between windows.
for k in 0..HARMONICS {
self.analyzer.input[k] = frame.input[k].max(old.input[k] - 3.);
self.analyzer.output[k] = frame.output[k].max(old.output[k] - 3.);
}
}
}
fn hint(r: &egui::Response, state: &mut State, text: &str) {
if r.hovered() || r.has_focus() {
state.help = text.into();
}
}
#[allow(clippy::too_many_arguments)]
fn knob(
ui: &mut egui::Ui,
state: &mut State,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
size: f32,
help: &str,
) {
ui.vertical_centered(|ui| {
let r = rotary(ui, name, value, range, default, unit, size, ACCENT);
hint(&r, state, help);
});
}
const GRID: Color32 = Color32::from_rgb(24, 26, 30);
const LEVEL_FLOOR: f32 = -72.;
fn gain_y(rect: Rect, db: f32) -> f32 {
let t = (db - GAIN_MIN) / (GAIN_MAX - GAIN_MIN);
rect.bottom() - rect.height() * t.clamp(0., 1.)
}
fn y_gain(rect: Rect, y: f32) -> f32 {
let t = (rect.bottom() - y) / rect.height();
// Snap near 0 dB so drawing a flat line is easy.
let db = GAIN_MIN + t * (GAIN_MAX - GAIN_MIN);
let db = if db.abs() < 0.4 { 0. } else { db };
db.clamp(GAIN_MIN, GAIN_MAX)
}
/// Bar editor of the per-harmonic gains, with the effective curve and measured levels.
fn harmonic_editor(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, height: f32) {
let size = Vec2::new(ui.available_width(), height);
let (outer, response) = ui.allocate_exact_size(size, Sense::click_and_drag());
let p = ui.painter_at(outer);
p.rect_filled(outer, 2., SCREEN);
let levels_h = 64.;
let labels_h = 18.;
let left = 34.;
let gains = Rect::from_min_max(
outer.left_top() + Vec2::new(left, 12.),
Pos2::new(
outer.right() - 10.,
outer.bottom() - levels_h - labels_h - 8.,
),
);
let levels = Rect::from_min_max(
Pos2::new(gains.left(), gains.bottom() + labels_h + 4.),
Pos2::new(gains.right(), outer.bottom() - 6.),
);
let col = gains.width() / HARMONICS as f32;
let column = |x: f32| {
(((x - gains.left()) / col).floor() as isize).clamp(0, HARMONICS as isize - 1) as usize
};
// Interaction: drag draws, Shift-drag nudges, double-click resets a bar.
if let Some(pos) = response.interact_pointer_pos() {
let k = column(pos.x);
if response.double_clicked() {
settings.gains[k] = 0.;
state.stroke = None;
} else if response.dragged() || response.drag_started() || response.clicked() {
if ui.input(|i| i.modifiers.shift) {
let dy = ui.input(|i| i.pointer.delta().y);
let step = dy * (GAIN_MAX - GAIN_MIN) / gains.height() * 0.15;
settings.gains[k] = (settings.gains[k] - step).clamp(GAIN_MIN, GAIN_MAX);
} else {
let db = y_gain(gains, pos.y);
// Fill every column the pointer skipped since the last frame.
let (from, from_db) = state.stroke.unwrap_or((k, db));
let (a, b) = (from.min(k), from.max(k));
for i in a..=b {
let t = if b == a {
1.
} else {
(i as f32 - from as f32) / (k as f32 - from as f32)
};
settings.gains[i] = from_db + (db - from_db) * t.clamp(0., 1.);
}
state.stroke = Some((k, db));
}
}
}
if !response.dragged() {
state.stroke = None;
}
// Grid: dB lines with labels.
for db in [12., 6., 0., -6., -12., -18., -24.] {
let y = gain_y(gains, db);
let zero = db == 0.;
p.line_segment(
[Pos2::new(gains.left(), y), Pos2::new(gains.right(), y)],
Stroke::new(1.0_f32, if zero { EDGE } else { GRID }),
);
p.text(
Pos2::new(outer.left() + 6., y),
egui::Align2::LEFT_CENTER,
if zero {
"0".into()
} else {
format!("{db:+.0}")
},
FontId::proportional(10.),
if zero { MUTED } else { DIM },
);
}
let zero = gain_y(gains, 0.);
let active = settings.harmonics as usize;
let hover = response.hover_pos().map(|pos| column(pos.x));
let mut curve = Vec::with_capacity(HARMONICS);
for k in 0..HARMONICS {
let x0 = gains.left() + col * k as f32;
let bar = Rect::from_min_max(
Pos2::new(x0 + col * 0.18, zero.min(gain_y(gains, settings.gains[k]))),
Pos2::new(x0 + col * 0.82, zero.max(gain_y(gains, settings.gains[k]))),
);
let on = k < active;
let fill = if !on {
Color32::from_rgb(40, 42, 47)
} else if hover == Some(k) {
Color32::from_rgb(255, 60, 130)
} else {
ACCENT
};
if bar.height() >= 1. {
p.rect_filled(bar, 1., fill);
} else {
p.line_segment(
[Pos2::new(bar.left(), zero), Pos2::new(bar.right(), zero)],
Stroke::new(2.0_f32, if on { DIM } else { GRID }),
);
}
if on {
curve.push(Pos2::new(
x0 + col * 0.5,
gain_y(gains, settings.effective(k + 1)),
));
}
let label_color = if hover == Some(k) {
TEXT
} else if on {
MUTED
} else {
DIM
};
p.text(
Pos2::new(x0 + col * 0.5, gains.bottom() + 4.),
egui::Align2::CENTER_TOP,
format!("{}", k + 1),
FontId::proportional(10.),
label_color,
);
// Measured levels: input (steel) and output (accent), side by side.
let f = &state.analyzer;
for (i, (db, color)) in [(f.input[k], STEEL), (f.output[k], ACCENT)]
.into_iter()
.enumerate()
{
let t = ((db - LEVEL_FLOOR) / -LEVEL_FLOOR).clamp(0., 1.);
if t <= 0. {
continue;
}
let w = col * 0.3;
let x = x0 + col * 0.18 + i as f32 * (w + col * 0.04);
let r = Rect::from_min_max(
Pos2::new(x, levels.bottom() - levels.height() * t),
Pos2::new(x + w, levels.bottom()),
);
p.rect_filled(r, 0., color.gamma_multiply(if on { 0.9 } else { 0.4 }));
}
}
if curve.len() > 1 {
p.add(egui::Shape::line(curve, Stroke::new(1.5_f32, FOCUS)));
}
p.line_segment(
[
Pos2::new(levels.left(), levels.top() - 2.),
Pos2::new(levels.right(), levels.top() - 2.),
],
Stroke::new(1.0_f32, GRID),
);
p.text(
gains.right_top() + Vec2::new(-4., 2.),
egui::Align2::RIGHT_TOP,
"GAIN · EFFECTIVE",
FontId::proportional(11.),
MUTED,
);
p.text(
Pos2::new(levels.right() - 4., levels.top() + 2.),
egui::Align2::RIGHT_TOP,
"LEVEL IN · OUT",
FontId::proportional(11.),
MUTED,
);
p.rect_stroke(
outer,
2.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
if let Some(k) = hover {
let f = &state.analyzer;
let at = if f.hz > 0. {
format!(" at {:.0} Hz", f.hz * (k + 1) as f32)
} else {
String::new()
};
state.help = format!(
"Harmonic {}{at}: drawn {:+.1} dB, effective {:+.1} dB. Drag to draw, Shift-drag fine, double-click resets.",
k + 1,
settings.gains[k],
settings.effective(k + 1),
);
}
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
backdrop(ui);
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
ui.add_space(2.);
header(ui, "LATHE", "HARMONIC EQ", |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.");
if r.clicked() {
settings.bypass = !settings.bypass;
}
ui.separator();
for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] {
let r = ui.selectable_label(settings.source == source, RichText::new(label).size(13.));
if r.clicked() {
settings.source = source;
}
}
caption(ui, "PITCH FROM");
});
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(210.);
ui.set_min_height(TOP);
ui.vertical(|ui| note_panel(ui, settings, state, &frame));
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
harmonic_editor(ui, settings, state, TOP);
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
const TOP: f32 = 330.;
/// Label, help text, and gain in dB for 1-based harmonic `k`.
type Preset = (&'static str, &'static str, fn(usize) -> f32);
fn note_panel(
ui: &mut egui::Ui,
settings: &mut Settings,
state: &mut State,
frame: &AnalyzerFrame,
) {
caption(ui, "NOTE");
let (name, hz) = match frame.note {
Some(n) if frame.hz > 0. => (common_dsp::note_name(n), format!("{:.1} Hz", frame.hz)),
_ if frame.hz > 0. => ("—".into(), format!("holding {:.1} Hz", frame.hz)),
_ => ("—".into(), "no note yet".into()),
};
ui.label(
RichText::new(name)
.font(FontId::new(54., egui::FontFamily::Name("medium".into())))
.color(if frame.note.is_some() { TEXT } else { DIM }),
);
ui.label(RichText::new(hz).monospace().size(14.).color(MUTED));
ui.add_space(6.);
ui.label(
RichText::new(format!(
"{:.0} ms lookahead · {}",
frame.latency as f32 / frame.sample_rate * 1000.,
match settings.source {
Source::Audio => "detecting pitch",
Source::Midi => "following MIDI",
}
))
.size(12.)
.color(MUTED),
);
ui.add_space(14.);
caption(ui, "SHAPES");
let presets: [Preset; 4] = [
("FLAT", "Reset every drawn gain to 0 dB.", |_| 0.),
(
"ODD ONLY",
"Remove even harmonics: hollow, square-like.",
|k| {
if k % 2 == 0 {
-24.
} else {
0.
}
},
),
(
"EVEN ONLY",
"Keep the fundamental and even harmonics: octave-like, smooth.",
|k| {
if k > 1 && k % 2 == 1 {
-24.
} else {
0.
}
},
),
(
"HOLLOW",
"Cut harmonics 2–4: scooped, vocal growl body.",
|k| {
if (2..=4).contains(&k) {
-12.
} else {
0.
}
},
),
];
ui.horizontal_wrapped(|ui| {
for (label, help, f) in presets {
let r = ui.button(RichText::new(label).size(12.));
if r.clicked() {
for k in 0..HARMONICS {
settings.gains[k] = f(k + 1);
}
}
hint(&r, state, help);
}
});
ui.add_space(10.);
let r = ui.selectable_label(settings.snap, RichText::new("SNAP TO SEMITONE").size(13.));
if r.clicked() {
settings.snap = !settings.snap;
}
hint(
&r,
state,
"Round the detected pitch. Turn off for slides and pitch bends.",
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let d = Settings::default();
ui.columns(9, |c| {
knob(&mut c[0], state, "Tilt", &mut settings.tilt, -12. ..=12., d.tilt, " dB/oct", 80.,
"Brighter or darker by harmonic number: dB per octave of harmonics, pivot on the fundamental.");
knob(&mut c[1], state, "Odd/Even", &mut settings.odd_even, -12. ..=12., d.odd_even, " dB", 80.,
"Positive: odd harmonics up, even down (square-like). Negative: the reverse.");
knob(&mut c[2], state, "Between", &mut settings.between, -24. ..=12., d.between, " dB", 80.,
"Gain between the harmonics. Cut to clean noise and beating, lift for grit.");
knob(&mut c[3], state, "Width", &mut settings.width, 5. ..=50., d.width, " %", 80.,
"Bandwidth of every band as a share of the harmonic spacing.");
let mut harmonics = settings.harmonics as f32;
knob(&mut c[4], state, "Harmonics", &mut harmonics, 8. ..=32., d.harmonics as f32, "", 54.,
"How many harmonics are shaped. Higher harmonics pass unchanged.");
settings.harmonics = harmonics.round() as u32;
knob(&mut c[5], state, "Smooth", &mut settings.smooth, 0. ..=100., d.smooth, " ms", 54.,
"Retune time for slides and bends. New notes always retune at once.");
knob(&mut c[6], state, "Gate", &mut settings.gate, -60. ..=-20., d.gate, " dB", 54.,
"Input level that counts as a note. Raise it if tails retrigger.");
knob(&mut c[7], state, "Mix", &mut settings.mix, 0. ..=100., d.mix, " %", 54.,
"Blend with the delayed dry signal.");
knob(&mut c[8], state, "Output", &mut settings.output, -12. ..=12., d.output, " dB", 54.,
"Output trim.");
});
}
+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"
+2 -2
View File
@@ -39,7 +39,7 @@ impl Default for Patina {
}
impl Plugin for Patina {
const NAME: &'static str = "Patina";
const VENDOR: &'static str = "Rivet & Patina";
const VENDOR: &'static str = "Bass Foundry";
const URL: &'static str = "";
const EMAIL: &'static str = "";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
@@ -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);
}
}
}
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more