Compare commits

...
Author SHA1 Message Date
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
nvrl f36ff0780c feat: add Anvil sub anchor and Crucible resampler
Check and Windows preview / check (anvil) (push) Successful in 2m41s
Check and Windows preview / check (crucible) (push) Successful in 2m7s
Check and Windows preview / check (patina) (push) Successful in 2m5s
Check and Windows preview / check (rivet) (push) Successful in 1m33s
Check and Windows preview / previews (push) Successful in 1m10s
Check and Windows preview / windows (push) Successful in 2m2s
Independent plugin release / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 7s
Independent plugin release / build (push) Successful in 3m3s
Two new CLAP effects aimed at riddim and dubstep production, plus shared
crates so all four plugins keep one look and reuse DSP.

Anvil 0.1.0 (io.storyboard.sub-anchor):
- Replaces the band below Split with a clean mono sine. Every note gets
  the same peak level and restarts at the same phase, so switch-ups keep
  their weight and kick hits line up.
- Pitch and note starts come from the audio (YIN, 80 ms lookahead
  reported to the host) or from MIDI notes sent to the effect.
- Dynamics, Drive, Octave, Glide, Phase, Gate and Keep; Solo sub monitor,
  note readout, level meters and a low-band oscilloscope.

Crucible 0.1.0 (io.storyboard.resampler):
- Records its input continuously in a lock-free ring with host transport
  markers. Capture takes the newest 1-16 complete bars on the bar grid,
  following tempo changes, or the newest audio when stopped.
- Grid (straight and triplet) or transient slicing, manual cuts, slice
  audition without audio-thread allocation.
- Export writes the loop, trimmed, declicked and normalised slices named
  and tagged with their root note, and an optional 2048-sample wavetable
  for Serum and Vital.

Shared:
- common-ui holds the palette, fonts, knobs and panels taken from Patina;
  Patina renders identically. Rotary knobs at their minimum no longer
  tessellate a zero-length arc into NaN vertices.
- common-dsp holds biquads, a Linkwitz-Riley crossover, YIN, note helpers
  and a float WAV writer with sampler root and Serum frame chunks.
- Catalog, CI matrix, release tags, editor previews and docs cover both
  plugins; a script test keeps the catalog and workflows in step.
2026-11-06 03:22:00 +01:00
118 changed files with 26486 additions and 416 deletions

No files matched your search

+14 -4
View File
@@ -22,7 +22,7 @@ jobs:
strategy:
fail-fast: false
matrix:
plugin: [rivet, patina]
plugin: [rivet, patina, anvil, crucible, lathe, temper, weld]
steps:
- uses: https://github.com/actions/checkout@v4
with:
@@ -56,6 +56,11 @@ jobs:
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/rivet.png --plugin rivet
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/patina.png --plugin patina
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
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
name: editor-previews
@@ -81,11 +86,16 @@ jobs:
- run: |
python3 scripts/build-plugin.py --plugin rivet --target x86_64-pc-windows-gnu
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/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*"]
tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*", "lathe-v*", "temper-v*", "weld-v*"]
permissions:
contents: read
Generated
+108
View File
@@ -30,6 +30,25 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "anvil-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
"serde_json",
]
[[package]]
name = "anvil-ui"
version = "0.1.0"
dependencies = [
"anvil-core",
"common-dsp",
"common-ui",
"egui",
]
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -48,6 +67,17 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "common-dsp"
version = "0.1.0"
[[package]]
name = "common-ui"
version = "0.1.0"
dependencies = [
"egui",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.14"
@@ -63,6 +93,26 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crucible-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"crossbeam-queue",
"serde",
"serde_json",
]
[[package]]
name = "crucible-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"crucible-core",
"egui",
]
[[package]]
name = "ecolor"
version = "0.31.1"
@@ -120,6 +170,25 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lathe-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
"serde_json",
]
[[package]]
name = "lathe-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"egui",
"lathe-core",
]
[[package]]
name = "libc"
version = "0.2.191"
@@ -233,6 +302,7 @@ dependencies = [
name = "patina-ui"
version = "0.2.0"
dependencies = [
"common-ui",
"egui",
"patina-core",
]
@@ -409,6 +479,25 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "temper-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
"serde_json",
]
[[package]]
name = "temper-ui"
version = "0.1.0"
dependencies = [
"common-dsp",
"common-ui",
"egui",
"temper-core",
]
[[package]]
name = "transpose"
version = "0.2.3"
@@ -437,6 +526,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"
+12 -2
View File
@@ -1,7 +1,7 @@
[workspace]
resolver = "2"
members = ["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"]
members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/lathe/core", "plugins/lathe/ui", "plugins/temper/core", "plugins/temper/ui", "plugins/weld/core", "plugins/weld/ui", "plugins/crucible/core", "plugins/crucible/ui", "plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"]
exclude = ["plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin", "plugins/anvil/plugin", "plugins/crucible/plugin", "plugins/lathe/plugin", "plugins/temper/plugin", "plugins/weld/plugin"]
[workspace.package]
edition = "2021"
@@ -16,3 +16,13 @@ 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.weld-core]
opt-level = 2
+172 -49
View File
@@ -1,70 +1,193 @@
# Rivet & Patina
# Bass Foundry
Two Rust CLAP plugins for writing and refining 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) |
![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) |
Plugins with latency report it to the host, so FL Studio compensates the delay.
Anvil, Lathe and Temper detect pitch from the audio. They can also take the
pitch from MIDI.
The names come from metalwork: a **rivet** joins pieces; **patina** changes a
surface while leaving its character. 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.
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></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 `Rivet.clap` or `Patina.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 Motif Storyboard or Resonance Compressor?** These are now
> Rivet and Patina. The CLAP IDs and saved state are unchanged, so projects
> still load. Delete the old `.clap` files and rescan. The vendor name changed
> from "Rivet & Patina" to "Bass Foundry", so FL Studio may list the plugins
> in a new vendor folder after the rescan.
## 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 in a mixer effect slot.
Plugin names for the scripts are `rivet`, `patina`, `anvil`, `crucible`,
`lathe`, `temper` and `weld`. 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: `rivet-v0.3.0` and `patina-v0.2.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/ Shared native editor and embedded fonts
render/ Offline WAV evaluation
plugin/ CLAP audio-effect wrapper
tools/editor-check/ Headless visual QA for both editors
scripts/ Shared build, packaging, release, and analysis tools
docs/rivet/ Rivet guide, theory, research, and editor previews
docs/patina/ Patina guide, research, and editor preview
references/ Local reference audio; excluded from distribution
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
common/
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
```
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/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)).
+13
View File
@@ -0,0 +1,13 @@
# Anvil changelog
## 0.1.0
- Introduce Anvil, a sub anchor for bass music.
- Replace the band below Split with a clean mono sine. Every note gets the same
peak level and starts at the same phase, so switch-ups and kick hits line up.
- Detect pitch and note starts from the audio with 80 ms lookahead, or follow
MIDI notes sent to the effect.
- Controls: Sub level, Split, Dynamics, Drive, Octave, Glide, Phase, Gate, Keep,
Output, Retrigger, Snap to semitone, Bypass. Solo sub monitors only the new sub.
- Note readout, level meters and an oscilloscope of the original low band against
the new sub.
+62
View File
@@ -0,0 +1,62 @@
# Anvil
Anvil gives every bass note the same solid sub. Current version **0.1.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
note to note and the kick lands differently on each one. Anvil removes the
original band below **Split** and puts a clean mono sine in its place. The sine
follows the bass pitch, has the same peak level on every note, and starts at the
same phase on every note.
![Anvil](assets/editor.png)
## Install on Windows
Copy `Anvil.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 Anvil in the mixer slot of the bass bus. Keep the font license with
redistributed packages.
## Work with it
1. Put Anvil on the bass bus, after the sound design and before the bus
compression or clipping.
2. Set **Split** just above the sub. 80–100 Hz suits most riddim basses.
3. Set **Sub level** once. Every patch on the bus now gets this level.
4. Use **Phase** to line the start of each note up with your kick. With
**Retrigger** on, every note starts at this phase.
5. Turn on **Solo sub** to hear only the new sub; the scope shows the original
low band (grey) against the new sub (accent).
Other controls:
- **Dynamics** at 0 % holds the level for the whole note. Raise it to let the sub
follow the original's volume shape, for example a wobble.
- **Drive** adds upper harmonics so the sub reads on phones and laptops.
- **Octave** moves the sub against the detected note. Use −1 for a bass played
one octave up.
- **Glide** slides between overlapping notes; 0 jumps.
- **Gate** is the input level that counts as a note. Raise it if tails or reverb
start new notes.
- **Keep** blends some of the original low band back under the new sub.
- **Snap to semitone** rounds detected pitch. Turn it off for pitch bends and slides.
### Pitch from MIDI
Detection follows the audio by default. For heavy formant or FM basses where the
fundamental is unclear, select **MIDI** and route the bass's MIDI notes to Anvil.
In FL Studio, set the bass channel's MIDI output port and Anvil's input port to the
same number, or duplicate the notes into a pattern on a channel that feeds Anvil.
## Timing and limits
Detection uses 80 ms of lookahead, reported to the host for delay compensation,
so each note's pitch is known before its attack leaves the plugin. Pitch range is
25–250 Hz, one voice: chords and layered notes in the sub band are not separated.
Notes shorter than about 15 ms may not be recognised. A pitch move of more than
0.7 semitone that holds for two analysis hops (10 ms) starts a new note.
The crossover is a 4th-order Linkwitz–Riley pair, so with Keep at 100 % and no
note playing, the signal passes with flat magnitude. Stereo above the split is
kept; the new sub is mono.
Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

+13
View File
@@ -0,0 +1,13 @@
# Crucible changelog
## 0.1.0
- Introduce Crucible, a resampler that always records its input.
- Capture the newest 1–16 complete bars on the host's bar grid, or the newest
audio when the transport is stopped. Tempo changes inside a take are followed.
- Slice on a straight or triplet grid, or at transients. Add and remove cuts by
hand, include or exclude slices, and audition them in place.
- Clean every slice: trim leading silence, fade both ends, normalise to −0.3 dBFS.
The root note of each pitched slice is detected and written into the file.
- Export the loop, every slice, and optionally a 2048-sample wavetable for Serum
and Vital into a dated folder.
+60
View File
@@ -0,0 +1,60 @@
# Crucible
Crucible melts the bars you just played into ready-to-use samples. Current version
**0.1.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
does all of it from one button. It always records its input, so the take you
liked is already there when you press **Capture**.
![Crucible](assets/editor.png)
## Install on Windows
Copy `Crucible.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 Crucible in a mixer slot at the end of the chain you want
to resample. Keep the font license with redistributed packages.
## Work with it
1. Play the pattern or song section.
2. Choose **Bars** (1–16) and press **Capture**. Crucible takes the newest complete
bars on FL's bar grid. With the transport stopped, it takes the newest audio at
the last known tempo instead.
3. Choose how to slice: **Grid** (1/4, 1/8, 1/16, 1/8T, 1/16T) or **Transients**
with a Sensitivity. Double-click the waveform to add a cut, right-click to remove
the nearest one.
4. Click a slice to hear it. Untick **Export** to leave it out.
5. Press **Export**.
Each export creates a folder named after the date, time and tempo, holding:
- `loop.wav`: the whole take, for Slicex or Edison;
- `s01_F1.wav`, `s02_G#1.wav`, …: one file per slice, named by its detected root
note. The root is also stored in the file's sampler chunk;
- `wavetable.wav` when **Wavetable** is on: up to 256 single-cycle frames of 2048
samples from the selected slice. Each frame starts on a rising zero crossing, so
frames morph without phase jumps. Serum reads the frame size from the file; in
Vital, import it as a wavetable with 2048-sample frames.
Every slice is cleaned on export: **Trim silence** removes leading silence below
−60 dBFS, **Declick** fades both ends (default 2 ms), **Normalize** peaks it at
−0.3 dBFS. Files are 32-bit float WAV at the session rate.
Add the export folder to FL's browser once (Options → File settings → Browser extra
search folders). New exports then appear there and can be dragged into channels,
the playlist or Edison. **Open folder** shows the latest export in Explorer.
## Timing and limits
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.
Bar alignment follows the host's song position and time signature. Root notes are
detected between 35 Hz and 1 kHz. Unpitched slices, such as drums or noise, get
no note in their name. Transient slicing finds hits that rise out of quieter
audio; for legato basses, slice on the grid.
Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

+114 -54
View File
@@ -1,93 +1,153 @@
# 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, plus Patina's core, editor, and renderer. 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 and Temper, 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 -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`.
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/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>/Rivet.clap` and `Patina.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
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` |
| Lathe | (new) | `io.storyboard.harmonic-eq` |
| Temper | (new) | `io.storyboard.tracked-distortion` |
| Weld | (new) | `io.storyboard.layer-align` |
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.
The `io.storyboard.` prefix is historical and stays, because changing a CLAP ID
breaks existing projects. Rivet keeps `story-session-v1` and its session
migration. Patina keeps all parameter IDs. The copyright notice keeps its
original holder name. Replace old plugin files before rescanning, so no CLAP ID
is installed twice, and check that existing projects still load in FL Studio.
See [release workflow](releases.md) for tagged publishing. Actual Gitea runner
execution and FL Studio validation remain separate from local checks.
See [releases](releases.md) for tagged publishing. Runs on the real Gitea runner
and checks in FL Studio are separate from the local checks.
+210
View File
@@ -0,0 +1,210 @@
# 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.
## 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).
## Ideas
### 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. Most of them reuse the note tracker in
`common-dsp`, so a texture setting means the same thing on every note, the same
way Lathe and Temper work.
### Knurl — pitch-synchronous grit (problems 11, 13)
Knurling presses a ridged pattern into metal. Knurl cuts the signal into single
pitch periods with the note tracker and changes each period on its own:
- level and timing jitter per cycle;
- short noise bursts locked to the cycle;
- period doubling and tripling (a cycle pattern such as "loud, quiet, quiet"),
which gives the subharmonic rattle and growl of riddim basses;
- slow random drift of each harmonic's level and phase, which brings movement
into a printed resample without widening it.
Rates are in cycles, not in Hz or tempo, so the grit has the same grain on F1
and on C2. A pattern editor sets 1–16 cycles. Everything is mono-safe. Latency
is the tracker lookahead, about 67 ms. Recommended first texture build: it is
new, small, and reuses Lathe's tracking.
### Anneal — tone and grit split (problem 12)
Annealing changes how a metal behaves without changing its shape. Anneal
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.
+13
View File
@@ -0,0 +1,13 @@
# Lathe changelog
## 0.1.0
- Introduce Lathe, a harmonic EQ for bass sound design.
- Bands sit on harmonics 1–32 of the played note instead of fixed frequencies,
so a cut on harmonic 3 stays on harmonic 3 on every note.
- Draw a gain per harmonic; Tilt, Odd/Even and Between shape the whole series.
- Detect pitch and note starts from the audio with 67 ms lookahead, or follow
MIDI notes sent to the effect. Filters retune at each note's attack.
- Controls: Tilt, Odd/Even, Between, Width, Harmonics, Smooth, Gate, Mix,
Output, Snap to semitone, Bypass. Shapes: Flat, Odd only, Even only, Hollow.
- Harmonic editor shows the measured level of each harmonic before and after.
+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.1.0**.
A normal EQ works in fixed frequencies. A cut at 130 Hz takes out the 3rd
harmonic of an F1 bass, the 2nd of a C2, and the fundamental of a C3, so the
same bass sounds different on every note of a riddim line. Lathe follows the
pitch and places one band on each harmonic: "−12 dB on harmonic 3" stays on
harmonic 3 whatever note plays.
![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

+34 -33
View File
@@ -1,49 +1,50 @@
# 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.4.0` | `rivet-windows-x86_64-0.4.0.zip` |
| Patina | `patina-v0.2.0` | `patina-windows-x86_64-0.2.0.zip` |
| Anvil | `anvil-v0.1.0` | `anvil-windows-x86_64-0.1.0.zip` |
| Crucible | `crucible-v0.1.0` | `crucible-windows-x86_64-0.1.0.zip` |
| Lathe | `lathe-v0.1.0` | `lathe-windows-x86_64-0.1.0.zip` |
| Temper | `temper-v0.1.0` | `temper-windows-x86_64-0.1.0.zip` |
| Weld | `weld-v0.1.0` | `weld-windows-x86_64-0.1.0.zip` |
## 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 +107,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.
+14
View File
@@ -0,0 +1,14 @@
# Temper changelog
## 0.1.0
- Introduce Temper, a note-tracked distortion for bass sound design.
- Normalises each note's level before the shaper, so quiet and loud patches are
driven the same.
- Floor (highpass), Push (one harmonic boosted or cut) and Bright (lowpass) sit
at multiples of the played note, so every note keeps the same character.
- Curves: Soft, Hard, Fold and Tube, with Bias for even harmonics. The shaper runs
at 4× oversampling.
- Auto gain keeps the output at the input loudness while Drive changes.
- Detect pitch and note starts from the audio with 67 ms lookahead, or follow
MIDI notes sent to the effect.
+66
View File
@@ -0,0 +1,66 @@
# Temper
Temper is a distortion that drives every note the same. Current version **0.1.0**.
A distortion with a fixed tone filter acts differently on each note: a lowpass
at 2 kHz lets through 45 harmonics of an F1 but only 22 of an F2, and
a patch that is 6 dB quieter is driven 6 dB less. Across a riddim line that makes
some notes fizzy and others dull. Temper evens out each note's level before the
shaper and moves its tone filters with the pitch, so the character stays the same
on every note.
![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

+15
View File
@@ -0,0 +1,15 @@
# Weld changelog
## 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.1.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

+12
View File
@@ -0,0 +1,12 @@
[package]
name = "anvil-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 }
+364
View File
@@ -0,0 +1,364 @@
//! Sub anchor: replaces the band below the split with a clean mono sine that follows the
//! bass pitch, holds one level for every note and restarts at the same phase on each note.
//!
//! The audio path is delayed by a fixed lookahead. Detection runs on the undelayed input, so
//! a note's pitch is known before its delayed attack leaves the plugin, and the sine starts
//! exactly on that attack.
mod settings;
use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi, midi_to_hz, Crossover};
pub use settings::{Settings, Source};
use std::f32::consts::TAU;
/// 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 ATTACK_MS: f32 = 2.;
const RELEASE_MS: f32 = 15.;
const CROSSFADE_MS: f32 = 3.;
#[derive(Clone, Copy, Debug, Default)]
pub struct Monitor {
/// Hear only the generated sub.
pub solo: bool,
}
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
/// Current sub frequency in Hz, 0 when silent.
pub hz: f32,
/// MIDI note of the current sub, rounded.
pub note: Option<u8>,
pub low_db: f32,
pub sub_db: f32,
/// Original low band and new sub, triggered on a rising zero crossing of the sub.
pub scope_low: [f32; SCOPE],
pub scope_sub: [f32; SCOPE],
/// Duration of the scope window in milliseconds.
pub scope_ms: f32,
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
hz: 0.,
note: None,
low_db: -120.,
sub_db: -120.,
scope_low: [0.; SCOPE],
scope_sub: [0.; SCOPE],
scope_ms: 60.,
latency: 0,
sample_rate: 48000.,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
struct Voice {
phase: f32,
hz: f32,
target: f32,
amp: f32,
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,
settings: Settings,
latency: usize,
delay: Vec<[f32; 2]>,
delay_at: usize,
crossover: [Crossover; 2],
tracker: Tracker,
synth: Synth,
glide: f32,
low_env: f32,
env_coef: f32,
smooth: f32,
level: f32,
keep: f32,
output: f32,
bypass: f32,
meter_coef: f32,
low_power: f32,
sub_power: f32,
scope_low: [f32; 2 * SCOPE],
scope_sub: [f32; 2 * SCOPE],
scope_at: usize,
scope_stride: usize,
scope_count: usize,
}
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::SUB);
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,
crossover: [Crossover::new(rate, settings.split); 2],
tracker,
synth: Synth::default(),
glide: 1.,
low_env: 0.,
env_coef: coefficient(rate, 10.),
smooth: coefficient(rate, 10.),
level: db_to_gain(settings.level),
keep: 0.,
output: 1.,
bypass: 0.,
meter_coef: coefficient(rate, 150.),
low_power: 0.,
sub_power: 0.,
scope_low: [0.; 2 * SCOPE],
scope_sub: [0.; 2 * SCOPE],
scope_at: 0,
scope_stride: ((rate * 0.06) / SCOPE as f32).round().max(1.) as usize,
scope_count: 0,
};
e.set_settings(settings)?;
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()?;
let old = self.settings;
if (s.split - old.split).abs() > 1e-3 {
for x in &mut self.crossover {
x.retune(self.rate, s.split);
}
}
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.
} else {
coefficient(self.rate, s.glide / 3.)
};
self.settings = s;
Ok(())
}
pub fn reset(&mut self) {
self.delay.fill([0.; 2]);
for x in &mut self.crossover {
x.reset();
}
self.tracker.reset();
self.synth = Synth::default();
self.low_env = 0.;
self.low_power = 0.;
self.sub_power = 0.;
self.scope_low = [0.; 2 * SCOPE];
self.scope_sub = [0.; 2 * SCOPE];
}
/// MIDI note start at the current input sample. Used when the source is MIDI.
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);
}
#[inline]
fn shape(&self, x: f32) -> f32 {
let drive = self.settings.drive * 0.01;
if drive <= 0. {
return x;
}
let k = 1. + 6. * drive;
(k * x).tanh() / k.tanh()
}
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.synth.voice;
v.amp = if v.releasing {
(v.amp - release).max(0.)
} else {
(v.amp + attack).min(1.)
};
v.hz += (v.target - v.hz) * self.glide;
let mut out = 0.;
if v.amp > 0. {
out += (TAU * v.phase).sin() * v.amp;
v.phase = (v.phase + v.hz / self.rate).fract();
}
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();
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] {
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];
let s = self.settings;
let (l0, h0) = self.crossover[0].process(y[0]);
let (l1, h1) = if self.channels == 2 {
self.crossover[1].process(y[1])
} else {
(l0, h0)
};
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.synth.voice.releasing {
self.synth.note_peak = self.synth.note_peak.max(env);
}
let follow = if self.synth.note_peak > 1e-6 {
(env / self.synth.note_peak).min(1.)
} else {
1.
};
let dynamics = s.dynamics * 0.01;
let shape = 1. - dynamics + dynamics * follow;
self.level += (db_to_gain(s.level) - self.level) * self.smooth;
self.keep += (s.keep * 0.01 - self.keep) * self.smooth;
self.output += (db_to_gain(s.output) - self.output) * self.smooth;
self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth;
let sub = self.oscillator() * self.level * shape;
self.low_power += (low * low - self.low_power) * self.meter_coef;
self.sub_power += (sub * sub - self.sub_power) * self.meter_coef;
self.scope_count += 1;
if self.scope_count >= self.scope_stride {
self.scope_count = 0;
self.scope_low[self.scope_at] = low;
self.scope_sub[self.scope_at] = sub;
self.scope_at = (self.scope_at + 1) % (2 * SCOPE);
}
let wet = |l: f32, h: f32| {
if monitor.solo {
sub
} else {
h + self.keep * l + sub
}
};
let w = [wet(l0, h0), wet(l1, h1)];
let mut out = [0.; 2];
for c in 0..self.channels {
out[c] = (w[c] * self.output) * (1. - self.bypass) + y[c] * self.bypass;
}
out
}
pub fn take_analyzer(&self) -> AnalyzerFrame {
let mut f = AnalyzerFrame {
latency: self.latency as u32,
sample_rate: self.rate,
low_db: 10. * self.low_power.max(1e-12).log10() + 3.01,
sub_db: 10. * self.sub_power.max(1e-12).log10() + 3.01,
scope_ms: (self.scope_stride * SCOPE) as f32 / self.rate * 1000.,
..AnalyzerFrame::default()
};
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;
let oldest = self.scope_at;
let mut start = oldest;
for back in 0..SCOPE {
let i = (oldest + SCOPE - back) % n;
let prev = (i + n - 1) % n;
if self.scope_sub[prev] < 0. && self.scope_sub[i] >= 0. {
start = i;
break;
}
}
for k in 0..SCOPE {
let i = (start + k) % n;
f.scope_low[k] = self.scope_low[i];
f.scope_sub[k] = self.scope_sub[i];
}
f
}
}
+95
View File
@@ -0,0 +1,95 @@
//! Host-independent controls. Levels in dB, times in milliseconds, frequencies in Hz.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Source {
/// Pitch and note starts are detected from the incoming bass.
#[default]
Audio,
/// Notes come from MIDI note input; the audio only supplies the band above the split.
Midi,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// Peak level of the new sub in dBFS, the same for every note.
pub level: f32,
/// Crossover frequency: below it the original sub is replaced.
pub split: f32,
/// 0 % holds a constant level per note; 100 % follows the original low band's shape.
pub dynamics: f32,
/// Soft saturation of the sine, adding upper harmonics for small speakers.
pub drive: f32,
pub octave: i32,
/// Pitch slide time between overlapping notes.
pub glide: f32,
/// Start phase in degrees on every note start.
pub phase: f32,
/// Detection threshold in dBFS for note starts (Audio source).
pub gate: f32,
/// Amount of the original low band that is kept under the new sub.
pub keep: f32,
pub output: f32,
/// Restart the sine at `phase` on every note, so each note and kick hit lines up the same.
pub retrigger: bool,
/// Round detected pitch to the nearest semitone.
pub snap: bool,
pub bypass: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
level: -9.,
split: 90.,
dynamics: 0.,
drive: 0.,
octave: 0,
glide: 0.,
phase: 0.,
gate: -42.,
keep: 0.,
output: 0.,
retrigger: true,
snap: true,
bypass: false,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
let finite = [
self.level,
self.split,
self.dynamics,
self.drive,
self.glide,
self.phase,
self.gate,
self.keep,
self.output,
]
.iter()
.all(|v| v.is_finite());
if !finite {
return Err("non-finite setting");
}
let ok = (-30.0..=0.).contains(&self.level)
&& (40.0..=200.).contains(&self.split)
&& (0.0..=100.).contains(&self.dynamics)
&& (0.0..=100.).contains(&self.drive)
&& (-2..=1).contains(&self.octave)
&& (0.0..=200.).contains(&self.glide)
&& (0.0..=360.).contains(&self.phase)
&& (-60.0..=-20.).contains(&self.gate)
&& (0.0..=100.).contains(&self.keep)
&& (-12.0..=12.).contains(&self.output);
if ok {
Ok(())
} else {
Err("setting out of range")
}
}
}
+213
View File
@@ -0,0 +1,213 @@
use anvil_core::{Engine, Monitor, Settings, Source};
use common_dsp::Saw;
const RATE: f32 = 48000.;
/// Saw notes (start s, end s, Hz, peak) with 1 ms fades.
fn notes(list: &[(f32, f32, f32, f32)], total: f32) -> Vec<f32> {
let mut x = vec![0.; (total * RATE) as usize];
for &(a, b, hz, amp) in list {
let (a, b) = ((a * RATE) as usize, (b * RATE) as usize);
let mut saw = Saw::new(RATE, hz);
let fade = (0.001 * RATE) as usize;
for i in a..b.min(x.len()) {
let edge = ((i - a).min(b - 1 - i) as f32 / fade as f32).min(1.);
x[i] += saw.sample() * amp * edge;
}
}
x
}
fn render(e: &mut Engine, x: &[f32], solo: bool) -> Vec<f32> {
x.iter()
.map(|&s| e.process_sample([s, s], Monitor { solo })[0])
.collect()
}
fn rms(x: &[f32]) -> f32 {
(x.iter().map(|v| v * v).sum::<f32>() / x.len() as f32).sqrt()
}
fn db(x: f32) -> f32 {
20. * x.max(1e-9).log10()
}
/// Frequency from interpolated rising zero crossings.
fn frequency(x: &[f32]) -> f32 {
let mut first = None;
let mut last = 0.;
let mut count = 0;
for i in 1..x.len() {
if x[i - 1] < 0. && x[i] >= 0. {
let t = (i - 1) as f32 + x[i - 1] / (x[i - 1] - x[i]);
if first.is_none() {
first = Some(t);
} else {
count += 1;
}
last = t;
}
}
count as f32 * RATE / (last - first.unwrap())
}
fn seconds(t: f32) -> usize {
(t * RATE) as usize
}
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)
}
#[test]
fn every_note_gets_the_same_sub_level() {
let (mut e, l) = engine(Settings::default());
let x = notes(&[(0.1, 0.6, 55., 0.5), (0.8, 1.3, 55., 0.1)], 1.6);
let y = render(&mut e, &x, true);
let loud = rms(&y[seconds(0.3) + l..seconds(0.5) + l]);
let quiet = rms(&y[seconds(1.0) + l..seconds(1.2) + l]);
assert!(
(db(loud) - db(quiet)).abs() < 0.5,
"{} vs {}",
db(loud),
db(quiet)
);
// −9 dBFS peak sine.
assert!((db(loud * 2f32.sqrt()) + 9.).abs() < 0.5, "{}", db(loud));
}
#[test]
fn sub_follows_the_detected_pitch_and_octave() {
for (octave, expect) in [(0, 43.654), (-1, 21.827)] {
let (mut e, l) = engine(Settings {
octave,
..Settings::default()
});
let x = notes(&[(0.1, 1.1, 43.65, 0.5)], 1.3);
let y = render(&mut e, &x, true);
let f = frequency(&y[seconds(0.3) + l..seconds(1.0) + l]);
assert!((f / expect - 1.).abs() < 0.005, "octave {octave}: {f}");
}
}
#[test]
fn a_pitch_change_without_a_gap_is_a_new_note() {
let (mut e, l) = engine(Settings::default());
let x = notes(&[(0.1, 0.6, 55., 0.5), (0.6, 1.1, 82.41, 0.5)], 1.3);
let y = render(&mut e, &x, true);
let f = frequency(&y[seconds(0.75) + l..seconds(1.05) + l]);
assert!((f / 82.41 - 1.).abs() < 0.005, "{f}");
}
#[test]
fn every_note_restarts_at_the_start_phase() {
let (mut e, l) = engine(Settings {
phase: 90.,
..Settings::default()
});
let x = notes(&[(0.1, 0.4, 55., 0.5), (0.6, 0.9, 55., 0.3)], 1.1);
let y = render(&mut e, &x, true);
let period = (RATE / 55.) as usize;
let a = &y[seconds(0.1) + l..seconds(0.1) + l + 2 * period];
let b = &y[seconds(0.6) + l..seconds(0.6) + l + 2 * period];
let peak = y.iter().fold(0f32, |m, v| m.max(v.abs()));
let worst = a.iter().zip(b).fold(0f32, |m, (p, q)| m.max((p - q).abs()));
assert!(worst < 0.1 * peak, "{worst} of {peak}");
// Fit the phase over the second period, after the 2 ms attack.
let w = std::f32::consts::TAU * 55. / RATE;
let (mut s, mut c) = (0., 0.);
for (k, v) in a.iter().enumerate().skip(period) {
s += v * (w * k as f32).sin();
c += v * (w * k as f32).cos();
}
let phase = c.atan2(s).to_degrees();
assert!((phase - 90.).abs() < 20., "{phase}");
}
#[test]
fn midi_notes_set_pitch_and_are_delayed_by_the_latency() {
let (mut e, l) = engine(Settings {
source: Source::Midi,
..Settings::default()
});
let mut y = Vec::new();
for i in 0..seconds(0.8) {
if i == 1000 {
e.note_on(33);
}
if i == seconds(0.6) {
e.note_off(33);
}
y.push(e.process_sample([0.; 2], Monitor { solo: true })[0]);
}
assert!(y[..1000 + l].iter().all(|v| *v == 0.));
assert!(y[1000 + l + 50].abs() > 0.);
let f = frequency(&y[seconds(0.2) + l..seconds(0.5)]);
assert!((f / 55. - 1.).abs() < 0.001, "{f}");
assert!(y[seconds(0.6) + l + seconds(0.05)..]
.iter()
.all(|v| v.abs() < 1e-6));
}
#[test]
fn note_changes_do_not_click() {
let (mut e, _) = engine(Settings::default());
let x = notes(
&[
(0.1, 0.4, 55., 0.5),
(0.4, 0.7, 73.42, 0.5),
(0.7, 1.0, 41.2, 0.5),
],
1.3,
);
let y = render(&mut e, &x, true);
let step = y.windows(2).fold(0f32, |m, w| m.max((w[1] - w[0]).abs()));
// A −9 dBFS sine at 73 Hz moves at most 0.0034 per sample.
assert!(step < 0.012, "{step}");
}
#[test]
fn keep_without_notes_is_an_allpass() {
let (mut e, _) = engine(Settings {
source: Source::Midi,
keep: 100.,
..Settings::default()
});
for hz in [40., 90., 300., 3000.] {
e.reset();
let x: Vec<f32> = (0..seconds(1.))
.map(|i| 0.3 * (std::f32::consts::TAU * hz * i as f32 / RATE).sin())
.collect();
let y = render(&mut e, &x, false);
let d = db(rms(&y[seconds(0.5)..])) - db(rms(&x[seconds(0.5)..]));
assert!(d.abs() < 0.1, "{hz}: {d}");
}
}
#[test]
fn split_removes_original_sub_and_keeps_mids() {
let (mut e, l) = engine(Settings {
source: Source::Midi,
..Settings::default()
});
let x: Vec<f32> = (0..seconds(1.))
.map(|i| 0.3 * (std::f32::consts::TAU * 40. * i as f32 / RATE).sin())
.collect();
let y = render(&mut e, &x, false);
assert!(db(rms(&y[seconds(0.5) + l..])) < db(0.3) - 18.);
}
#[test]
fn settings_are_validated_and_serialise() {
let mut e = Engine::new(RATE, 1).unwrap();
assert!(e
.set_settings(Settings {
level: 3.,
..Settings::default()
})
.is_err());
let json = serde_json::to_string(&Settings::default()).unwrap();
assert_eq!(
serde_json::from_str::<Settings>(&json).unwrap(),
Settings::default()
);
assert!(Engine::new(1000., 2).is_err());
assert!(Engine::new(48000., 3).is_err());
}
+69
View File
@@ -0,0 +1,69 @@
use anvil_core::{Engine, Monitor, 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.split = 60. + (i % 100) as f32;
settings.source = if i > 48000 {
Source::Midi
} else {
Source::Audio
};
settings.octave = -((i / 4800) % 2);
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], Monitor { solo: i % 3 == 0 }));
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
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "anvil"
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" }
anvil-core = { path = "../core" }
anvil-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"
+199
View File
@@ -0,0 +1,199 @@
//! CLAP audio effect. All DSP state is owned by the processing thread.
mod params;
use anvil_core::{AnalyzerFrame, Engine, Monitor};
use crossbeam_queue::ArrayQueue;
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::AnvilParams;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
struct Shared {
analyzer: ArrayQueue<AnalyzerFrame>,
solo: AtomicBool,
}
struct EditorData {
ui: anvil_ui::State,
gestures: [bool; 9],
}
pub struct Anvil {
params: Arc<AnvilParams>,
shared: Arc<Shared>,
engine: Option<Engine>,
publish_count: usize,
publish_interval: usize,
}
impl Default for Anvil {
fn default() -> Self {
Self {
params: Arc::new(AnvilParams::default()),
shared: Arc::new(Shared {
analyzer: ArrayQueue::new(2),
solo: AtomicBool::new(false),
}),
engine: None,
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for Anvil {
const NAME: &'static str = "Anvil";
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 = true;
type SysExMessage = ();
type BackgroundTask = ();
fn params(&self) -> Arc<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
let params = self.params.clone();
let shared = self.shared.clone();
create_egui_editor(
params.editor.clone(),
EditorData {
ui: anvil_ui::State::default(),
gestures: [false; 9],
},
|ctx, data| {
data.ui.solo = false;
anvil_ui::style(ctx);
},
move |ctx, setter, data| {
while let Some(frame) = shared.analyzer.pop() {
data.ui.accept_frame(frame);
}
let before = params.settings();
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("anvil-editor")
.min_size(egui::vec2(
anvil_ui::WIDTH as f32 - 80.,
anvil_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
anvil_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
// Solo is a monitor only: never saved and never left on after the editor closes.
shared.solo.store(data.ui.solo, Ordering::Release);
if data.ui.analyzer.note.is_some() {
ctx.request_repaint();
}
},
)
}
fn initialize(
&mut self,
layout: &AudioIOLayout,
config: &BufferConfig,
context: &mut impl InitContext<Self>,
) -> bool {
let Ok(mut engine) = Engine::new(
config.sample_rate,
layout.main_input_channels.map_or(0, |v| v.get() as usize),
) else {
return false;
};
if engine.set_settings(self.params.settings()).is_err() {
return false;
}
context.set_latency_samples(engine.latency_samples());
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
self.shared.solo.store(false, Ordering::Release);
true
}
fn reset(&mut self) {
if let Some(e) = self.engine.as_mut() {
e.reset();
}
self.publish_count = 0;
}
fn process(
&mut self,
buffer: &mut Buffer,
_: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let _ = engine.set_settings(self.params.settings());
let solo = self.shared.solo.load(Ordering::Acquire) && self.params.editor.is_open();
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, Monitor { solo });
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
self.shared.analyzer.force_push(engine.take_analyzer());
self.publish_count = 0;
}
}
// The lookahead delay and the sub release must drain after input stops.
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for Anvil {
const CLAP_ID: &'static str = "io.storyboard.sub-anchor";
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;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Anvil);
+192
View File
@@ -0,0 +1,192 @@
use anvil_core::{Settings, Source};
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::Arc;
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SourceParam {
#[id = "audio"]
Audio,
#[id = "midi"]
#[name = "MIDI"]
Midi,
}
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)
}
#[derive(Params)]
pub struct AnvilParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[id = "level"]
pub level: FloatParam,
#[id = "split"]
pub split: FloatParam,
#[id = "dynamics"]
pub dynamics: FloatParam,
#[id = "drive"]
pub drive: FloatParam,
#[id = "octave"]
pub octave: IntParam,
#[id = "glide"]
pub glide: FloatParam,
#[id = "phase"]
pub phase: FloatParam,
#[id = "gate"]
pub gate: FloatParam,
#[id = "keep"]
pub keep: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "retrigger"]
pub retrigger: BoolParam,
#[id = "snap"]
pub snap: BoolParam,
#[id = "bypass"]
pub bypass: BoolParam,
}
impl Default for AnvilParams {
fn default() -> Self {
let d = Settings::default();
Self {
editor: EguiState::from_size(anvil_ui::WIDTH, anvil_ui::HEIGHT),
source: EnumParam::new("Pitch source", SourceParam::Audio),
level: linear("Sub level", d.level, -30., 0., " dBFS"),
split: FloatParam::new(
"Split",
d.split,
FloatRange::Skewed {
min: 40.,
max: 200.,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(" Hz"),
dynamics: linear("Dynamics", d.dynamics, 0., 100., " %"),
drive: linear("Drive", d.drive, 0., 100., " %"),
octave: IntParam::new("Octave", d.octave, IntRange::Linear { min: -2, max: 1 }),
glide: linear("Glide", d.glide, 0., 200., " ms"),
phase: linear("Phase", d.phase, 0., 360., "°"),
gate: linear("Gate", d.gate, -60., -20., " dB"),
keep: linear("Keep", d.keep, 0., 100., " %"),
output: linear("Output", d.output, -12., 12., " dB"),
retrigger: BoolParam::new("Retrigger", d.retrigger),
snap: BoolParam::new("Snap to semitone", d.snap),
bypass: BoolParam::new("Bypass", false).make_bypass(),
}
}
}
impl AnvilParams {
pub fn settings(&self) -> Settings {
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
level: self.level.value(),
split: self.split.value(),
dynamics: self.dynamics.value(),
drive: self.drive.value(),
octave: self.octave.value(),
glide: self.glide.value(),
phase: self.phase.value(),
gate: self.gate.value(),
keep: self.keep.value(),
output: self.output.value(),
retrigger: self.retrigger.value(),
snap: self.snap.value(),
bypass: self.bypass.value(),
}
}
/// Pushes every changed editor value to the host. Continuous controls hold one
/// gesture while the pointer is down; switches are set in a single gesture.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; 9],
) {
let floats: [(&FloatParam, f32, f32); 8] = [
(&self.level, old.level, new.level),
(&self.split, old.split, new.split),
(&self.dynamics, old.dynamics, new.dynamics),
(&self.drive, old.drive, new.drive),
(&self.glide, old.glide, new.glide),
(&self.phase, old.phase, new.phase),
(&self.gate, old.gate, new.gate),
(&self.keep, old.keep, new.keep),
];
let mut continuous = |i: usize, changed: bool, set: &dyn Fn()| {
if changed {
if !open[i] {
open[i] = true;
set_begin(setter, self, i);
}
set();
}
if !dragging && open[i] {
set_end(setter, self, i);
open[i] = false;
}
};
for (i, (p, a, b)) in floats.into_iter().enumerate() {
continuous(i, a != b, &|| setter.set_parameter(p, b));
}
continuous(8, old.output != new.output, &|| {
setter.set_parameter(&self.output, new.output)
});
let once = |changed: bool, f: &dyn Fn()| {
if changed {
f();
}
};
once(new.octave != old.octave, &|| {
setter.begin_set_parameter(&self.octave);
setter.set_parameter(&self.octave, new.octave);
setter.end_set_parameter(&self.octave);
});
for (p, a, b) in [
(&self.retrigger, old.retrigger, new.retrigger),
(&self.snap, old.snap, new.snap),
(&self.bypass, old.bypass, new.bypass),
] {
once(a != b, &|| {
setter.begin_set_parameter(p);
setter.set_parameter(p, b);
setter.end_set_parameter(p);
});
}
once(new.source != old.source, &|| {
let v = match new.source {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
setter.begin_set_parameter(&self.source);
setter.set_parameter(&self.source, v);
setter.end_set_parameter(&self.source);
});
}
fn continuous(&self, i: usize) -> &FloatParam {
[
&self.level,
&self.split,
&self.dynamics,
&self.drive,
&self.glide,
&self.phase,
&self.gate,
&self.keep,
&self.output,
][i]
}
}
fn set_begin(setter: &ParamSetter, p: &AnvilParams, i: usize) {
setter.begin_set_parameter(p.continuous(i));
}
fn set_end(setter: &ParamSetter, p: &AnvilParams, i: usize) {
setter.end_set_parameter(p.continuous(i));
}
+212
View File
@@ -0,0 +1,212 @@
//! 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 = &anvil::clap_entry;
assert!(entry.init.unwrap()(c"anvil.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"Anvil");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.storyboard.sub-anchor"
);
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("level".into(), ParamValue::F32(-14.));
envelope.params.insert("octave".into(), ParamValue::I32(-1));
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()
);
assert!(!restored.fields.contains_key("solo"));
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
// About 80 ms of lookahead: two periods of the lowest detected note.
assert!((delay as f64 / rate - 0.08).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 = "anvil-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
anvil-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
common-dsp = { path = "../../common/dsp" }
+350
View File
@@ -0,0 +1,350 @@
//! Anvil editor, shared by the CLAP wrapper and the headless renderer.
use anvil_core::{AnalyzerFrame, Settings, Source, SCOPE};
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, RichText, Sense, Stroke, Vec2};
pub use common_ui::style;
pub const WIDTH: u32 = 980;
pub const HEIGHT: u32 = 704;
pub struct State {
pub solo: bool,
pub analyzer: AnalyzerFrame,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
}
impl Default for State {
fn default() -> Self {
Self {
solo: false,
analyzer: AnalyzerFrame::default(),
gesture_active: false,
help: DEFAULT_HELP.into(),
}
}
}
const DEFAULT_HELP: &str =
"Put Anvil on the bass, set Split just above the sub, then set Sub level once for every patch.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
let old = self.analyzer;
self.analyzer = frame;
// Meters fall smoothly; the readout and scope stay exact.
self.analyzer.low_db = frame.low_db.max(old.low_db - 1.5);
self.analyzer.sub_db = frame.sub_db.max(old.sub_db - 1.5);
}
}
fn hint(r: &egui::Response, state: &mut State, text: &str) {
if r.hovered() || r.has_focus() {
state.help = text.into();
}
}
#[allow(clippy::too_many_arguments)]
fn knob(
ui: &mut egui::Ui,
state: &mut State,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
size: f32,
help: &str,
) {
ui.vertical_centered(|ui| {
let r = rotary(ui, name, value, range, default, unit, size, ACCENT);
hint(&r, state, help);
});
}
fn toggle(ui: &mut egui::Ui, state: &mut State, on: &mut bool, label: &str, help: &str) {
let r = ui.selectable_label(*on, RichText::new(label).size(13.));
if r.clicked() {
*on = !*on;
}
hint(&r, state, help);
}
fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, 2., SCREEN);
let mid = rect.center().y;
for k in 1..6 {
let x = rect.left() + rect.width() * k as f32 / 6.;
p.line_segment(
[Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())],
Stroke::new(1.0_f32, Color32::from_rgb(24, 26, 30)),
);
}
p.line_segment(
[Pos2::new(rect.left(), mid), Pos2::new(rect.right(), mid)],
Stroke::new(1.0_f32, EDGE),
);
let peak = frame
.scope_low
.iter()
.chain(frame.scope_sub.iter())
.fold(0.05f32, |m, v| m.max(v.abs()));
let scale = rect.height() * 0.42 / peak;
for (data, color, width) in [
(&frame.scope_low, STEEL, 1.5_f32),
(&frame.scope_sub, ACCENT, 2.0_f32),
] {
let points: Vec<Pos2> = data
.iter()
.enumerate()
.map(|(i, v)| {
Pos2::new(
rect.left() + rect.width() * i as f32 / (SCOPE - 1) as f32,
mid - v * scale,
)
})
.collect();
p.add(egui::Shape::line(points, Stroke::new(width, color)));
}
p.text(
rect.left_top() + Vec2::new(10., 8.),
egui::Align2::LEFT_TOP,
"ORIGINAL LOW BAND",
FontId::proportional(11.),
STEEL,
);
p.text(
rect.left_top() + Vec2::new(130., 8.),
egui::Align2::LEFT_TOP,
"NEW SUB",
FontId::proportional(11.),
ACCENT,
);
p.text(
rect.right_top() + Vec2::new(-10., 8.),
egui::Align2::RIGHT_TOP,
format!("{:.0} ms", frame.scope_ms),
FontId::proportional(11.),
MUTED,
);
p.rect_stroke(
rect,
2.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
backdrop(ui);
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
ui.add_space(2.);
header(ui, "ANVIL", "SUB ANCHOR", |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.");
if r.clicked() {
settings.bypass = !settings.bypass;
}
let r = ui
.selectable_label(state.solo, RichText::new("SOLO SUB").size(13.))
.on_hover_text("Hear only the generated sub.");
if r.clicked() {
state.solo = !state.solo;
}
ui.separator();
for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] {
let r = ui.selectable_label(settings.source == source, RichText::new(label).size(13.));
if r.clicked() {
settings.source = source;
}
}
caption(ui, "PITCH FROM");
});
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(260.);
ui.set_min_height(TOP);
ui.vertical(|ui| note_panel(ui, settings, &frame));
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
scope(ui, &frame, TOP);
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
const TOP: f32 = 196.;
fn note_panel(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame) {
caption(ui, "NOTE");
let (name, hz) = match frame.note {
Some(n) if frame.hz > 0. => (common_dsp::note_name(n), format!("{:.1} Hz", frame.hz)),
_ => ("—".into(), "no note".into()),
};
ui.label(
RichText::new(name)
.font(FontId::new(54., egui::FontFamily::Name("medium".into())))
.color(if frame.note.is_some() { TEXT } else { DIM }),
);
ui.label(RichText::new(hz).monospace().size(14.).color(MUTED));
ui.add_space(10.);
meter(ui, "ORIGINAL", frame.low_db, STEEL, 100.);
meter(ui, "NEW SUB", frame.sub_db, ACCENT, 100.);
ui.add_space(10.);
ui.label(
RichText::new(format!(
"{:.0} ms lookahead · {}",
frame.latency as f32 / frame.sample_rate * 1000.,
match settings.source {
Source::Audio => "detecting pitch",
Source::Midi => "following MIDI notes",
}
))
.size(12.)
.color(MUTED),
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let defaults = Settings::default();
ui.columns(4, |c| {
knob(
&mut c[0],
state,
"Sub level",
&mut settings.level,
-30. ..=0.,
defaults.level,
" dBFS",
88.,
"Peak level of the new sub. Every note gets exactly this level.",
);
knob(
&mut c[1],
state,
"Split",
&mut settings.split,
40. ..=200.,
defaults.split,
" Hz",
88.,
"Below this frequency the original sub is replaced.",
);
knob(
&mut c[2],
state,
"Dynamics",
&mut settings.dynamics,
0. ..=100.,
defaults.dynamics,
" %",
88.,
"0 %: constant level per note. Higher: follow the original's wobble shape.",
);
knob(
&mut c[3],
state,
"Drive",
&mut settings.drive,
0. ..=100.,
defaults.drive,
" %",
88.,
"Adds upper harmonics so the sub reads on small speakers.",
);
});
ui.add_space(6.);
ui.separator();
ui.columns(6, |c| {
let mut octave = settings.octave as f32;
knob(
&mut c[0],
state,
"Octave",
&mut octave,
-2. ..=1.,
0.,
"",
54.,
"Shift the sub against the detected note. −1 for basses played high.",
);
settings.octave = octave.round() as i32;
knob(
&mut c[1],
state,
"Glide",
&mut settings.glide,
0. ..=200.,
defaults.glide,
" ms",
54.,
"Slide time when notes overlap. 0 jumps.",
);
knob(
&mut c[2],
state,
"Phase",
&mut settings.phase,
0. ..=360.,
defaults.phase,
"°",
54.,
"Start phase of every note. Line it up with your kick.",
);
knob(
&mut c[3],
state,
"Gate",
&mut settings.gate,
-60. ..=-20.,
defaults.gate,
" dB",
54.,
"Input level that counts as a note. Raise it if tails retrigger.",
);
knob(
&mut c[4],
state,
"Keep",
&mut settings.keep,
0. ..=100.,
defaults.keep,
" %",
54.,
"Blend some original low band back under the new sub.",
);
knob(
&mut c[5],
state,
"Output",
&mut settings.output,
-12. ..=12.,
defaults.output,
" dB",
54.,
"Output trim.",
);
});
ui.add_space(4.);
ui.horizontal(|ui| {
toggle(
ui,
state,
&mut settings.retrigger,
"RETRIGGER",
"Restart the sine at Phase on every note, so each note and kick hit lines up.",
);
toggle(
ui,
state,
&mut settings.snap,
"SNAP TO SEMITONE",
"Round the detected pitch. Turn off for slides and pitch bends.",
);
});
}
+86 -1
View File
@@ -22,12 +22,97 @@
"documentation": "docs/patina",
"clap_id": "io.storyboard.resonance-compressor",
"licenses": {
"plugins/patina/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"patina-core",
"patina-ui",
"patina-render"
]
},
"anvil": {
"name": "Anvil",
"description": "Replace the sub with a clean sine at one level and phase for every note",
"manifest": "plugins/anvil/plugin/Cargo.toml",
"library": "anvil",
"documentation": "docs/anvil",
"clap_id": "io.storyboard.sub-anchor",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"common-dsp",
"anvil-core",
"anvil-ui"
]
},
"crucible": {
"name": "Crucible",
"description": "Capture the last bars, slice them and export clean, key-labelled samples",
"manifest": "plugins/crucible/plugin/Cargo.toml",
"library": "crucible",
"documentation": "docs/crucible",
"clap_id": "io.storyboard.resampler",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"common-dsp",
"crucible-core",
"crucible-ui"
]
},
"lathe": {
"name": "Lathe",
"description": "EQ the harmonics of the played note: a cut on harmonic 3 stays on harmonic 3",
"manifest": "plugins/lathe/plugin/Cargo.toml",
"library": "lathe",
"documentation": "docs/lathe",
"clap_id": "io.storyboard.harmonic-eq",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"common-dsp",
"lathe-core",
"lathe-ui"
]
},
"temper": {
"name": "Temper",
"description": "Distortion whose drive and tone filters follow the note, so every note distorts the same",
"manifest": "plugins/temper/plugin/Cargo.toml",
"library": "temper",
"documentation": "docs/temper",
"clap_id": "io.storyboard.tracked-distortion",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"common-dsp",
"temper-core",
"temper-ui"
]
},
"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.storyboard.layer-align",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"weld-core",
"weld-ui"
]
}
}
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "common-dsp"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
+267
View File
@@ -0,0 +1,267 @@
//! 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;
/// Transposed direct form II biquad (RBJ cookbook coefficients).
#[derive(Clone, Copy, Debug, Default)]
pub struct Biquad {
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
z1: f32,
z2: f32,
}
impl Biquad {
fn from(b: [f32; 3], a: [f32; 3]) -> Self {
Self {
b0: b[0] / a[0],
b1: b[1] / a[0],
b2: b[2] / a[0],
a1: a[1] / a[0],
a2: a[2] / a[0],
z1: 0.,
z2: 0.,
}
}
pub fn lowpass(rate: f32, hz: f32, q: f32) -> Self {
let w = TAU * (hz / rate).clamp(1e-5, 0.49);
let (s, c) = w.sin_cos();
let alpha = s / (2. * q);
Self::from(
[(1. - c) / 2., 1. - c, (1. - c) / 2.],
[1. + alpha, -2. * c, 1. - alpha],
)
}
pub fn highpass(rate: f32, hz: f32, q: f32) -> Self {
let w = TAU * (hz / rate).clamp(1e-5, 0.49);
let (s, c) = w.sin_cos();
let alpha = s / (2. * q);
Self::from(
[(1. + c) / 2., -(1. + c), (1. + c) / 2.],
[1. + alpha, -2. * c, 1. - alpha],
)
}
/// Peaking EQ; used by tests to model resonant material.
pub fn peak(rate: f32, hz: f32, q: f32, gain_db: f32) -> Self {
let w = TAU * (hz / rate).clamp(1e-5, 0.49);
let (s, c) = w.sin_cos();
let a = 10f32.powf(gain_db / 40.);
let alpha = s / (2. * q);
Self::from(
[1. + alpha * a, -2. * c, 1. - alpha * a],
[1. + alpha / a, -2. * c, 1. - alpha / a],
)
}
/// Replace the coefficients and keep the filter state, for smooth parameter changes.
pub fn retune(&mut self, other: Self) {
let (z1, z2) = (self.z1, self.z2);
*self = other;
self.z1 = z1;
self.z2 = z2;
}
#[inline]
pub fn process(&mut self, x: f32) -> f32 {
let y = self.b0 * x + self.z1;
self.z1 = self.b1 * x - self.a1 * y + self.z2;
self.z2 = self.b2 * x - self.a2 * y;
y
}
pub fn reset(&mut self) {
self.z1 = 0.;
self.z2 = 0.;
}
}
/// Fourth-order Linkwitz–Riley crossover. Low plus high is an allpass: flat magnitude.
#[derive(Clone, Copy, Debug, Default)]
pub struct Crossover {
low: [Biquad; 2],
high: [Biquad; 2],
}
impl Crossover {
pub fn new(rate: f32, hz: f32) -> Self {
let q = std::f32::consts::FRAC_1_SQRT_2;
Self {
low: [Biquad::lowpass(rate, hz, q); 2],
high: [Biquad::highpass(rate, hz, q); 2],
}
}
pub fn retune(&mut self, rate: f32, hz: f32) {
let fresh = Self::new(rate, hz);
for i in 0..2 {
self.low[i].retune(fresh.low[i]);
self.high[i].retune(fresh.high[i]);
}
}
/// Returns (low, high).
#[inline]
pub fn process(&mut self, x: f32) -> (f32, f32) {
let low = self.low[0].process(x);
let low = self.low[1].process(low);
let high = self.high[0].process(x);
let high = self.high[1].process(high);
// LR4 bands are in phase at the crossover point, so they sum to an allpass.
(low, high)
}
pub fn reset(&mut self) {
for f in self.low.iter_mut().chain(self.high.iter_mut()) {
f.reset();
}
}
}
/// YIN fundamental-frequency estimator with preallocated scratch.
pub struct Yin {
diff: Vec<f32>,
pub threshold: f32,
}
impl Yin {
/// `max_lag` is the longest period in samples that will ever be requested.
pub fn new(max_lag: usize) -> Self {
Self {
diff: vec![0.; max_lag + 2],
threshold: 0.15,
}
}
pub fn max_lag(&self) -> usize {
self.diff.len() - 2
}
/// Estimates the fundamental of `x` in Hz with its clarity (1 = perfectly periodic).
/// `x` must hold at least twice the longest period. Never allocates.
pub fn estimate(
&mut self,
x: &[f32],
rate: f32,
min_hz: f32,
max_hz: f32,
) -> Option<(f32, f32)> {
let hi = ((rate / min_hz).ceil() as usize).min(self.max_lag());
let lo = ((rate / max_hz).floor() as usize).max(2);
if hi <= lo + 2 || x.len() < hi + 2 {
return None;
}
let window = x.len() - hi - 1;
if window < hi {
return None;
}
let energy: f32 = x[..window].iter().map(|v| v * v).sum();
if energy < 1e-9 * window as f32 {
return None;
}
let d = &mut self.diff;
d[0] = 1.;
let mut running = 0.;
for tau in 1..=hi + 1 {
let mut s = 0.;
for j in 0..window {
let e = x[j] - x[j + tau];
s += e * e;
}
running += s;
// Cumulative-mean normalisation: suppresses the zero-lag dip.
d[tau] = if running > 0. {
s * tau as f32 / running
} else {
1.
};
}
let mut best = None;
let mut tau = lo;
while tau < hi {
if d[tau] < self.threshold {
while tau + 1 < hi && d[tau + 1] < d[tau] {
tau += 1;
}
best = Some(tau);
break;
}
tau += 1;
}
let tau = match best {
Some(t) => t,
None => {
let (t, v) = (lo..hi)
.map(|t| (t, d[t]))
.min_by(|a, b| a.1.total_cmp(&b.1))?;
if v > 0.35 {
return None;
}
t
}
};
let (a, b, c) = (d[tau - 1], d[tau], d[tau + 1]);
let denom = a - 2. * b + c;
let shift = if denom.abs() > 1e-12 {
(0.5 * (a - c) / denom).clamp(-1., 1.)
} else {
0.
};
Some((rate / (tau as f32 + shift), (1. - b).clamp(0., 1.)))
}
}
pub fn midi_to_hz(note: f32) -> f32 {
440. * 2f32.powf((note - 69.) / 12.)
}
pub fn hz_to_midi(hz: f32) -> f32 {
69. + 12. * (hz.max(1e-3) / 440.).log2()
}
/// Scientific note name: MIDI 60 = C4, 43.65 Hz = F1. FL Studio labels MIDI 60 as C5.
pub fn note_name(note: u8) -> String {
const NAMES: [&str; 12] = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
format!("{}{}", NAMES[note as usize % 12], note as i32 / 12 - 1)
}
pub fn db_to_gain(db: f32) -> f32 {
10f32.powf(db / 20.)
}
pub fn gain_to_db(g: f32) -> f32 {
20. * g.abs().max(1e-9).log10()
}
/// One-pole smoothing coefficient for a time constant in milliseconds.
pub fn coefficient(rate: f32, ms: f32) -> f32 {
if ms <= 0. {
1.
} else {
1. - (-1. / (rate * ms * 0.001)).exp()
}
}
/// PolyBLEP sawtooth oscillator: cheap and close to band-limited, for tests and previews.
pub struct Saw {
phase: f32,
inc: f32,
}
impl Saw {
pub fn new(rate: f32, hz: f32) -> Self {
Self {
phase: 0.,
inc: hz / rate,
}
}
pub fn sample(&mut self) -> f32 {
let t = self.phase;
let dt = self.inc;
let blep = if t < dt {
let x = t / dt;
x + x - x * x - 1.
} else if t > 1. - dt {
let x = (t - 1.) / dt;
x * x + x + x + 1.
} else {
0.
};
self.phase += dt;
if self.phase >= 1. {
self.phase -= 1.;
}
2. * t - 1. - blep
}
}
+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];
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Trapezoidal state-variable filter (Simper/Cytomic). Stable and click-free under fast
//! retuning, so it can follow a gliding note. Coefficients are separate from state, so one
//! set can drive both channels.
use std::f32::consts::PI;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SvfCoef {
a1: f32,
a2: f32,
a3: f32,
m0: f32,
m1: f32,
m2: f32,
}
impl Default for SvfCoef {
/// Identity: passes the input unchanged.
fn default() -> Self {
Self::IDENTITY
}
}
impl SvfCoef {
pub const IDENTITY: Self = Self {
a1: 1.,
a2: 0.,
a3: 0.,
m0: 1.,
m1: 0.,
m2: 0.,
};
fn base(rate: f32, hz: f32, k: f32, warp: f32) -> (f32, f32, f32) {
let g = (PI * (hz / rate).clamp(1e-6, 0.49)).tan() * warp;
let a1 = 1. / (1. + g * (g + k));
let a2 = g * a1;
(a1, a2, g * a2)
}
/// Peaking filter. At 0 dB it is an exact identity.
pub fn bell(rate: f32, hz: f32, q: f32, gain_db: f32) -> Self {
let a = 10f32.powf(gain_db / 40.);
let k = 1. / (q.max(1e-3) * a);
let (a1, a2, a3) = Self::base(rate, hz, k, 1.);
Self {
a1,
a2,
a3,
m0: 1.,
m1: k * (a * a - 1.),
m2: 0.,
}
}
pub fn lowpass(rate: f32, hz: f32, q: f32) -> Self {
let (a1, a2, a3) = Self::base(rate, hz, 1. / q.max(1e-3), 1.);
Self {
a1,
a2,
a3,
m0: 0.,
m1: 0.,
m2: 1.,
}
}
pub fn highpass(rate: f32, hz: f32, q: f32) -> Self {
let k = 1. / q.max(1e-3);
let (a1, a2, a3) = Self::base(rate, hz, k, 1.);
Self {
a1,
a2,
a3,
m0: 1.,
m1: -k,
m2: -1.,
}
}
pub fn high_shelf(rate: f32, hz: f32, q: f32, gain_db: f32) -> Self {
let a = 10f32.powf(gain_db / 40.);
let k = 1. / q.max(1e-3);
let (a1, a2, a3) = Self::base(rate, hz, k, a.sqrt());
Self {
a1,
a2,
a3,
m0: a * a,
m1: k * (1. - a) * a,
m2: 1. - a * a,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Svf {
ic1: f32,
ic2: f32,
}
impl Svf {
#[inline]
pub fn process(&mut self, c: &SvfCoef, v0: f32) -> f32 {
let v3 = v0 - self.ic2;
let v1 = c.a1 * self.ic1 + c.a2 * v3;
let v2 = self.ic2 + c.a2 * self.ic1 + c.a3 * v3;
self.ic1 = 2. * v1 - self.ic1;
self.ic2 = 2. * v2 - self.ic2;
c.m0 * v0 + c.m1 * v1 + c.m2 * v2
}
pub fn reset(&mut self) {
*self = Self::default();
}
}
+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
}
}
+142
View File
@@ -0,0 +1,142 @@
//! Minimal RIFF/WAVE writer and reader: 32-bit float data, sampler root key, Serum wavetable tag.
use std::{io, path::Path};
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Wave {
pub channels: u16,
pub rate: u32,
/// Interleaved samples.
pub samples: Vec<f32>,
/// MIDI root key, written as a `smpl` chunk that samplers (FL included) read.
pub root: Option<u8>,
/// Wavetable frame size, written as Serum's `clm ` chunk.
pub frame: Option<u32>,
}
fn chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
out.extend_from_slice(id);
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(body);
if body.len() % 2 == 1 {
out.push(0);
}
}
pub fn encode(w: &Wave) -> Vec<u8> {
let channels = w.channels.max(1);
let frames = w.samples.len() / channels as usize;
let mut body = Vec::with_capacity(w.samples.len() * 4 + 256);
body.extend_from_slice(b"WAVE");
let mut fmt = Vec::new();
fmt.extend_from_slice(&3u16.to_le_bytes()); // IEEE float
fmt.extend_from_slice(&channels.to_le_bytes());
fmt.extend_from_slice(&w.rate.to_le_bytes());
fmt.extend_from_slice(&(w.rate * 4 * channels as u32).to_le_bytes());
fmt.extend_from_slice(&(4 * channels).to_le_bytes());
fmt.extend_from_slice(&32u16.to_le_bytes());
fmt.extend_from_slice(&0u16.to_le_bytes());
chunk(&mut body, b"fmt ", &fmt);
chunk(&mut body, b"fact", &(frames as u32).to_le_bytes());
if let Some(frame) = w.frame {
// Serum reads the frame size from the "<!>NNNN" prefix.
chunk(
&mut body,
b"clm ",
format!("<!>{frame} 00000000 wavetable (Crucible)").as_bytes(),
);
}
if let Some(root) = w.root {
let mut smpl = Vec::new();
let period = (1e9 / w.rate.max(1) as f64).round() as u32;
for v in [0, 0, period, root as u32, 0, 0, 0, 0, 0] {
smpl.extend_from_slice(&v.to_le_bytes());
}
chunk(&mut body, b"smpl", &smpl);
}
let mut data = Vec::with_capacity(w.samples.len() * 4);
for s in &w.samples[..frames * channels as usize] {
data.extend_from_slice(&s.to_le_bytes());
}
chunk(&mut body, b"data", &data);
let mut out = Vec::with_capacity(body.len() + 8);
out.extend_from_slice(b"RIFF");
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&body);
out
}
pub fn write(path: &Path, w: &Wave) -> io::Result<()> {
std::fs::write(path, encode(w))
}
/// Reads float32 or PCM16/24 files written by this module or common tools.
pub fn decode(b: &[u8]) -> Result<Wave, &'static str> {
if b.len() < 12 || &b[..4] != b"RIFF" || &b[8..12] != b"WAVE" {
return Err("expected RIFF/WAVE");
}
let mut w = Wave::default();
let (mut format, mut bits) = (0u16, 0u16);
let mut at = 12;
let mut data = false;
let u16_at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
let u32_at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
while at + 8 <= b.len() {
let id = &b[at..at + 4];
let len = u32_at(at + 4) as usize;
let start = at + 8;
let end = start
.checked_add(len)
.filter(|e| *e <= b.len())
.ok_or("truncated chunk")?;
match id {
b"fmt " if len >= 16 => {
format = u16_at(start);
w.channels = u16_at(start + 2);
w.rate = u32_at(start + 4);
bits = u16_at(start + 14);
}
b"smpl" if len >= 16 => w.root = Some(u32_at(start + 12) as u8),
b"clm " => {
let text = std::str::from_utf8(&b[start..end]).map_err(|_| "bad clm chunk")?;
w.frame = text
.strip_prefix("<!>")
.and_then(|t| t.split_whitespace().next())
.and_then(|n| n.parse().ok());
}
b"data" => {
data = true;
let d = &b[start..end];
w.samples = match (format, bits) {
(3, 32) => d
.as_chunks::<4>()
.0
.iter()
.map(|c| f32::from_le_bytes(*c))
.collect(),
(1, 16) => d
.as_chunks::<2>()
.0
.iter()
.map(|c| i16::from_le_bytes(*c) as f32 / 32768.)
.collect(),
(1, 24) => d
.as_chunks::<3>()
.0
.iter()
.map(|c| (i32::from_le_bytes([0, c[0], c[1], c[2]]) >> 8) as f32 / 8388608.)
.collect(),
_ => return Err("unsupported sample format"),
};
}
_ => {}
}
at = end + (len % 2);
}
if w.channels == 0 || w.rate == 0 {
return Err("missing fmt chunk");
}
if !data {
return Err("missing data chunk");
}
Ok(w)
}
+225
View File
@@ -0,0 +1,225 @@
use common_dsp::meter::HarmonicMeter;
use common_dsp::oversample::Oversampler;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig};
use common_dsp::{hz_to_midi, Saw};
use std::f32::consts::TAU;
const RATE: f32 = 48000.;
/// Steady-state gain in dB of a sine at `hz` through `f`.
fn sine_gain(mut f: impl FnMut(f32) -> f32, hz: f32) -> f32 {
let n = (RATE * 0.5) as usize;
let (mut pin, mut pout) = (0., 0.);
for i in 0..n {
let x = (TAU * hz * i as f32 / RATE).sin();
let y = f(x);
if i > n / 2 {
pin += x * x;
pout += y * y;
}
}
10. * (pout / pin).log10()
}
#[test]
fn svf_bell_hits_its_gain_and_leaves_the_rest() {
for (hz, db) in [(130., -12.), (400., 6.), (2000., -24.)] {
let c = SvfCoef::bell(RATE, hz, 4., db);
let mut s = Svf::default();
let g = sine_gain(|x| s.process(&c, x), hz);
assert!((g - db).abs() < 0.1, "{hz}: {g}");
let mut s = Svf::default();
let g = sine_gain(|x| s.process(&c, x), hz * 4.);
assert!(g.abs() < 0.3, "{hz} away: {g}");
}
}
#[test]
fn svf_bell_at_zero_db_is_an_exact_identity() {
let c = SvfCoef::bell(RATE, 100., 2., 0.);
let mut s = Svf::default();
let mut saw = Saw::new(RATE, 55.);
for _ in 0..4800 {
let x = saw.sample();
assert_eq!(s.process(&c, x), x);
}
}
#[test]
fn svf_survives_retuning_every_sample() {
let mut s = Svf::default();
let mut saw = Saw::new(RATE, 55.);
let mut peak: f32 = 0.;
for i in 0..48000 {
let hz = 40. + 4000. * (0.5 + 0.5 * (i as f32 * 0.003).sin());
let c = SvfCoef::bell(RATE, hz, 8., 12.);
let y = s.process(&c, saw.sample());
assert!(y.is_finite());
peak = peak.max(y.abs());
}
assert!(peak < 8., "{peak}");
let c = SvfCoef::lowpass(RATE, 200., 0.707);
let mut s = Svf::default();
assert!(sine_gain(|x| s.process(&c, x), 2000.) < -35.);
let c = SvfCoef::highpass(RATE, 200., 0.707);
let mut s = Svf::default();
assert!(sine_gain(|x| s.process(&c, x), 20.) < -35.);
}
#[test]
fn oversampler_round_trip_is_a_whole_sample_delay() {
let mut o = Oversampler::new();
let l = Oversampler::latency();
let mut saw = Saw::new(RATE, 97.);
let x: Vec<f32> = (0..9600).map(|_| saw.sample() * 0.5).collect();
let y: Vec<f32> = x
.iter()
.map(|&v| {
let u = o.up(v);
o.down(u)
})
.collect();
// Band-limited content passes; compare after the delay, away from the start.
let mut err: f32 = 0.;
let mut sig: f32 = 0.;
for i in 2000..9600 {
let d = y[i] - x[i - l];
err += d * d;
sig += x[i - l] * x[i - l];
}
let null = 10. * (err / sig).log10();
// The saw has energy above the passband edge, so the null is limited by that, not by timing.
assert!(null < -40., "{null}");
}
#[test]
fn oversampler_passband_is_flat_and_images_are_removed() {
for hz in [50., 1000., 10000., 18000.] {
let mut o = Oversampler::new();
let g = sine_gain(
|x| {
let u = o.up(x);
o.down(u)
},
hz,
);
assert!(g.abs() < 0.1, "{hz}: {g}");
}
// An image of a 1 kHz tone at 4× would sit at 47 kHz; check the 4× signal there.
let mut o = Oversampler::new();
let n = 48000;
let (mut a, mut b) = (0f64, 0f64);
let w = TAU as f64 * 47000. / (4. * RATE as f64);
for i in 0..n {
let x = (TAU * 1000. * i as f32 / RATE).sin();
for (k, v) in o.up(x).into_iter().enumerate() {
let t = (4 * i + k) as f64;
a += v as f64 * (w * t).cos();
b += v as f64 * (w * t).sin();
}
}
let image = 20.
* ((a * a + b * b).sqrt() / (2. * n as f64))
.max(1e-12)
.log10();
assert!(image < -85., "{image}");
}
#[test]
fn harmonic_meter_reads_saw_harmonics() {
let hz = 55.;
let mut m = HarmonicMeter::new(RATE, 85.);
let mut saw = Saw::new(RATE, hz);
let mut done = 0;
for _ in 0..48000 {
if m.push(saw.sample(), hz) {
done += 1;
}
}
assert!(done >= 5);
// A saw's k-th harmonic has peak 2/(πk).
for k in 1..=16 {
let want = 20. * (2. / (std::f32::consts::PI * k as f32)).log10();
let got = m.levels()[k - 1];
assert!((got - want).abs() < 0.5, "harmonic {k}: {got} vs {want}");
}
}
#[test]
fn tracker_reports_notes_at_output_time() {
let mut t = Tracker::new(RATE, TrackerConfig::BASS);
t.set(false, -42., false);
let l = t.latency();
assert!(l < (0.08 * RATE) as usize, "{l}");
let onset = 4800;
let mut events = vec![];
let mut saw_a = Saw::new(RATE, 87.31);
let mut saw_b = Saw::new(RATE, 220.);
for i in 0..onset + 48000 {
let x = if i < onset {
0.
} else if i < onset + 24000 {
0.5 * saw_a.sample()
} else {
0.5 * saw_b.sample()
};
t.process(x, |e| events.push((i, e)));
}
let starts: Vec<_> = events
.iter()
.filter_map(|(i, e)| match e {
NoteEvent::Start { pitch, .. } => Some((*i, *pitch)),
_ => None,
})
.collect();
assert_eq!(starts.len(), 2, "{starts:?}");
assert!(
(starts[0].0 as i64 - (onset + l) as i64).abs() <= 24,
"{starts:?}"
);
assert!((starts[0].1 - hz_to_midi(87.31)).abs() < 0.2);
assert!((starts[1].1 - hz_to_midi(220.)).abs() < 0.2);
}
#[test]
fn tracker_delays_midi_by_the_latency() {
let mut t = Tracker::new(RATE, TrackerConfig::BASS);
assert!(t.set(true, -42., true));
let l = t.latency();
let mut events = vec![];
for i in 0..l + 2000 {
if i == 100 {
t.note_on(41);
}
if i == 600 {
t.note_on(43);
}
if i == 900 {
// Releasing the lower held note first leaves the top note; then the top ends it.
t.note_off(41);
t.note_off(43);
}
t.process(0., |e| events.push((i, e)));
}
assert_eq!(
events,
vec![
(
100 + l,
NoteEvent::Start {
pitch: 41.,
glide: false
}
),
(
600 + l,
NoteEvent::Start {
pitch: 43.,
glide: true
}
),
(900 + l, NoteEvent::Stop),
]
);
}
+82
View File
@@ -0,0 +1,82 @@
use common_dsp::{hz_to_midi, midi_to_hz, note_name, wav, Crossover, Saw, Yin};
fn cents(a: f32, b: f32) -> f32 {
1200. * (a / b).log2()
}
#[test]
fn yin_tracks_bass_fundamentals_within_two_cents() {
let rate = 48000.;
let mut yin = Yin::new(2400);
for hz in [43.65, 55., 110., 220.] {
let mut saw = Saw::new(rate, hz);
let x: Vec<f32> = (0..6000).map(|_| saw.sample()).collect();
let (got, clarity) = yin.estimate(&x, rate, 25., 1000.).unwrap();
assert!(cents(got, hz).abs() < 2., "{hz}: {got}");
assert!(clarity > 0.8);
}
}
#[test]
fn yin_rejects_silence_and_noise() {
let mut yin = Yin::new(2400);
assert!(yin.estimate(&[0.; 6000], 48000., 25., 1000.).is_none());
let mut seed = 1u32;
let noise: Vec<f32> = (0..6000)
.map(|_| {
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
(seed >> 8) as f32 / (1u32 << 24) as f32 - 0.5
})
.collect();
assert!(yin.estimate(&noise, 48000., 25., 1000.).is_none());
}
#[test]
fn crossover_bands_sum_to_flat_magnitude() {
let rate = 48000.;
for hz in [20., 45., 90., 180., 1000., 8000., 18000.] {
let mut x = Crossover::new(rate, 90.);
let (mut a, mut b) = (0f64, 0f64);
let n = 96000;
for i in 0..n {
let s = (std::f32::consts::TAU * hz * i as f32 / rate).sin();
let (l, h) = x.process(s);
if i > n / 2 {
a += (s as f64).powi(2);
b += ((l + h) as f64).powi(2);
}
}
let db = 10. * (b / a).log10();
assert!(db.abs() < 0.1, "{hz} Hz: {db} dB");
}
}
#[test]
fn notes_round_trip() {
assert!((midi_to_hz(29.) - 43.65).abs() < 0.01);
assert!((hz_to_midi(440.) - 69.).abs() < 1e-4);
assert_eq!(note_name(29), "F1");
assert_eq!(note_name(61), "C#4");
}
#[test]
fn wave_round_trip_keeps_root_and_wavetable_chunks() {
let w = wav::Wave {
channels: 2,
rate: 44100,
samples: (0..999)
.map(|i| (i as f32 * 0.01).sin())
.collect::<Vec<_>>()[..998]
.to_vec(),
root: Some(29),
frame: Some(2048),
};
let bytes = wav::encode(&w);
assert_eq!(&bytes[..4], b"RIFF");
assert_eq!(
u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize,
bytes.len() - 8
);
assert_eq!(wav::decode(&bytes).unwrap(), w);
assert!(wav::decode(&bytes[..40]).is_err());
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "common-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
File renamed without changes.
+320
View File
@@ -0,0 +1,320 @@
//! Shared hardware look for every plugin editor: palette, fonts, knobs, panels.
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
// Dark anodized hardware: graphite surfaces, one signal colour, steel data traces.
pub const BG: Color32 = Color32::from_rgb(14, 15, 17);
pub const PANEL: Color32 = Color32::from_rgb(25, 27, 31);
pub const PANEL_TOP: Color32 = Color32::from_rgb(34, 36, 41);
pub const EDGE: Color32 = Color32::from_rgb(44, 47, 53);
pub const SCREEN: Color32 = Color32::from_rgb(9, 10, 11);
pub const TEXT: Color32 = Color32::from_rgb(232, 233, 236);
pub const MUTED: Color32 = Color32::from_rgb(140, 146, 156);
pub const DIM: Color32 = Color32::from_rgb(70, 74, 82);
/// Signal colour: cuts, active states, value arcs.
pub const ACCENT: Color32 = Color32::from_rgb(255, 1, 96);
/// Measured input and neutral data.
pub const STEEL: Color32 = Color32::from_rgb(184, 192, 204);
/// Focus / protect curve, distinct from cuts without competing with them.
pub const FOCUS: Color32 = Color32::from_rgb(126, 167, 216);
pub fn style(ctx: &egui::Context) {
let mut fonts = egui::FontDefinitions::default();
fonts.font_data.insert(
"regular".into(),
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Regular.ttf")).into(),
);
fonts.font_data.insert(
"medium".into(),
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Medium.ttf")).into(),
);
fonts
.families
.get_mut(&egui::FontFamily::Proportional)
.unwrap()
.insert(0, "regular".into());
fonts.families.insert(
egui::FontFamily::Name("medium".into()),
vec![
"medium".into(),
"Ubuntu-Light".into(),
"NotoEmoji-Regular".into(),
"emoji-icon-font".into(),
],
);
ctx.set_fonts(fonts);
let mut s = (*ctx.style()).clone();
let v = &mut s.visuals;
*v = egui::Visuals::dark();
v.panel_fill = BG;
v.window_fill = PANEL;
v.extreme_bg_color = SCREEN;
v.faint_bg_color = PANEL;
v.override_text_color = Some(TEXT);
v.selection.bg_fill = Color32::from_rgb(110, 6, 46);
v.selection.stroke = Stroke::new(1.0_f32, ACCENT);
v.hyperlink_color = ACCENT;
v.window_stroke = Stroke::new(1.0_f32, EDGE);
for (w, fill, stroke) in [
(&mut v.widgets.noninteractive, PANEL, EDGE),
(&mut v.widgets.inactive, Color32::from_rgb(35, 38, 43), EDGE),
(
&mut v.widgets.hovered,
Color32::from_rgb(45, 48, 54),
Color32::from_rgb(78, 82, 90),
),
(&mut v.widgets.active, Color32::from_rgb(58, 18, 34), ACCENT),
(&mut v.widgets.open, Color32::from_rgb(35, 38, 43), EDGE),
] {
w.bg_fill = fill;
w.weak_bg_fill = fill;
w.bg_stroke = Stroke::new(1.0_f32, stroke);
w.corner_radius = 3.into();
}
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, TEXT);
v.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, TEXT);
v.slider_trailing_fill = true;
s.spacing.item_spacing = Vec2::new(10., 8.);
s.spacing.button_padding = Vec2::new(12., 6.);
s.text_styles
.insert(egui::TextStyle::Body, FontId::proportional(16.));
s.text_styles
.insert(egui::TextStyle::Button, FontId::proportional(16.));
ctx.set_style(s);
}
/// Disc with a vertical light-to-dark gradient, like brushed metal lit from above.
pub fn metal_disc(p: &egui::Painter, center: Pos2, radius: f32, top: Color32, bottom: Color32) {
let mut mesh = egui::Mesh::default();
let mix = |t: f32| {
let l = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8;
Color32::from_rgb(
l(top.r(), bottom.r()),
l(top.g(), bottom.g()),
l(top.b(), bottom.b()),
)
};
mesh.colored_vertex(center, mix(0.5));
let n = 48;
for i in 0..=n {
let a = std::f32::consts::TAU * i as f32 / n as f32;
let point = center + Vec2::angled(a) * radius;
mesh.colored_vertex(point, mix((point.y - (center.y - radius)) / (2. * radius)));
if i > 0 {
mesh.add_triangle(0, i, i + 1);
}
}
p.add(egui::Shape::mesh(mesh));
}
#[allow(clippy::too_many_arguments)]
pub fn rotary(
ui: &mut egui::Ui,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
size: f32,
color: Color32,
) -> egui::Response {
let low = *range.start();
let high = *range.end();
let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click_and_drag());
if response.drag_started() {
response.request_focus();
}
if response.dragged() {
let delta = ui.input(|i| i.pointer.delta().y);
let fine = if ui.input(|i| i.modifiers.shift) {
0.1
} else {
1.
};
*value = (*value - delta * (high - low) / 180. * fine).clamp(low, high);
}
if response.double_clicked() {
*value = default;
}
if response.has_focus() {
let step = if ui.input(|i| i.modifiers.shift) {
0.1
} else {
1.
};
if ui.input(|i| i.key_pressed(egui::Key::ArrowUp) || i.key_pressed(egui::Key::ArrowRight)) {
*value = (*value + step).min(high);
}
if ui.input(|i| i.key_pressed(egui::Key::ArrowDown) || i.key_pressed(egui::Key::ArrowLeft))
{
*value = (*value - step).max(low);
}
}
let center = rect.center();
let radius = size * 0.46;
let p = ui.painter();
let start = std::f32::consts::PI * 0.75;
let span = std::f32::consts::PI * 1.5;
let normalized = ((*value - low) / (high - low)).clamp(0., 1.);
// Scale track and value arc around the knob.
for (fraction, stroke) in [
(1., Stroke::new(2.5_f32, DIM)),
(normalized, Stroke::new(2.5_f32, color)),
] {
// A zero-length polyline tessellates to NaN vertices.
if fraction < 1e-3 {
continue;
}
let points: Vec<_> = (0..=64)
.map(|i| {
let a = start + span * fraction * i as f32 / 64.;
center + Vec2::angled(a) * (radius - 1.5)
})
.collect();
p.add(egui::Shape::line(points, stroke));
}
// Machined body: drop shadow, brushed gradient skirt, flat cap.
let body = radius - 7.;
p.circle_filled(
center + Vec2::new(0., 2.),
body + 1.,
Color32::from_black_alpha(140),
);
metal_disc(
p,
center,
body,
Color32::from_rgb(78, 82, 90),
Color32::from_rgb(28, 30, 34),
);
p.circle_stroke(
center,
body,
Stroke::new(1.0_f32, Color32::from_rgb(10, 11, 12)),
);
let cap = body * 0.74;
metal_disc(
p,
center,
cap,
Color32::from_rgb(44, 47, 52),
Color32::from_rgb(33, 35, 40),
);
p.circle_stroke(
center,
cap,
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 22)),
);
let angle = start + span * normalized;
p.line_segment(
[
center + Vec2::angled(angle) * (cap * 0.35),
center + Vec2::angled(angle) * (cap - 2.),
],
Stroke::new(2.0_f32, TEXT),
);
if response.has_focus() {
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, color));
}
ui.label(
RichText::new(name.to_uppercase())
.size(if size > 100. { 14. } else { 12. })
.color(MUTED),
);
ui.add(
egui::DragValue::new(value)
.range(range)
.speed(0.1)
.suffix(unit),
);
response
}
/// Vertical gradient rectangle, used for panel faces.
pub fn gradient(rect: Rect, top: Color32, bottom: Color32) -> egui::Shape {
let mut mesh = egui::Mesh::default();
for (pos, color) in [
(rect.left_top(), top),
(rect.right_top(), top),
(rect.right_bottom(), bottom),
(rect.left_bottom(), bottom),
] {
mesh.colored_vertex(pos, color);
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
egui::Shape::mesh(mesh)
}
/// Raised hardware panel: graphite face, hairline edge, faint top highlight.
pub fn panel<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let face = ui.painter().add(egui::Shape::Noop);
let inner = egui::Frame::new()
.inner_margin(14.)
.stroke(Stroke::new(1.0_f32, EDGE))
.show(ui, add);
let r = inner.response.rect;
ui.painter().set(face, gradient(r, PANEL_TOP, PANEL));
ui.painter().line_segment(
[
r.left_top() + Vec2::new(1., 1.),
r.right_top() + Vec2::new(-1., 1.),
],
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 16)),
);
inner.inner
}
pub fn caption(ui: &mut egui::Ui, text: &str) {
ui.label(RichText::new(text).size(11.).color(MUTED));
}
/// Small labelled hardware-style readout.
pub fn readout(ui: &mut egui::Ui, label: &str, value: String, color: Color32) {
ui.label(RichText::new(label).size(11.).color(MUTED));
ui.label(RichText::new(value).monospace().size(13.).color(color));
ui.add_space(10.);
}
/// Product header: accent mark, product name, subtitle, then right-aligned controls.
pub fn header(ui: &mut egui::Ui, name: &str, subtitle: &str, right: impl FnOnce(&mut egui::Ui)) {
ui.horizontal(|ui| {
let (mark, _) = ui.allocate_exact_size(Vec2::new(4., 26.), Sense::hover());
ui.painter().rect_filled(mark, 1., ACCENT);
ui.label(
RichText::new(name)
.font(FontId::new(26., egui::FontFamily::Name("medium".into())))
.color(TEXT),
);
ui.label(RichText::new(subtitle).size(12.).color(MUTED));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), right);
});
}
/// Full-window backdrop gradient used behind every editor.
pub fn backdrop(ui: &egui::Ui) {
ui.painter()
.add(gradient(ui.max_rect(), Color32::from_rgb(20, 21, 24), BG));
}
/// Horizontal level bar in dBFS (−60..0) with a hairline frame.
pub fn meter(ui: &mut egui::Ui, label: &str, db: f32, color: Color32, width: f32) {
ui.horizontal(|ui| {
ui.add_sized(
Vec2::new(70., 16.),
egui::Label::new(RichText::new(label).size(11.).color(MUTED)),
);
let (rect, _) = ui.allocate_exact_size(Vec2::new(width, 10.), Sense::hover());
let p = ui.painter();
p.rect_filled(rect, 1., SCREEN);
let t = ((db + 60.) / 60.).clamp(0., 1.);
let mut fill = rect;
fill.set_width(rect.width() * t);
p.rect_filled(fill, 1., color);
p.rect_stroke(
rect,
1.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
ui.label(
RichText::new(if db <= -60. {
"−∞".to_string()
} else {
format!("{db:.1} dB")
})
.monospace()
.size(12.)
.color(TEXT),
);
});
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "crucible-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
common-dsp = { path = "../../common/dsp" }
crossbeam-queue = "0.3"
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+93
View File
@@ -0,0 +1,93 @@
//! Writes a take as ready-to-use files: the full loop, one file per slice and a wavetable.
use crate::settings::Settings;
use crate::take::{render, wavetable, Take, TABLE};
use common_dsp::{note_name, wav};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq)]
pub struct Exported {
pub folder: PathBuf,
pub files: usize,
/// Why no wavetable was written although one was requested.
pub wavetable_skipped: Option<&'static str>,
}
/// UTC date and time for folder names, from the Unix time in seconds.
pub fn stamp(unix: u64) -> String {
let days = (unix / 86400) as i64;
let secs = unix % 86400;
// Civil-from-days (H. Hinnant), proleptic Gregorian calendar.
let z = days + 719468;
let era = z.div_euclid(146097);
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!(
"{year:04}-{month:02}-{day:02}_{:02}{:02}{:02}",
secs / 3600,
secs / 60 % 60,
secs % 60
)
}
pub fn export(
take: &Take,
settings: &Settings,
selected: Option<usize>,
root: &Path,
unix: u64,
) -> std::io::Result<Exported> {
let folder = root.join(format!("{}_{:.0}bpm", stamp(unix), take.tempo()));
std::fs::create_dir_all(&folder)?;
let rate = take.rate.round() as u32;
let stereo = |samples: Vec<f32>, root: Option<u8>| wav::Wave {
channels: 2,
rate,
samples,
root,
frame: None,
};
wav::write(&folder.join("loop.wav"), &stereo(take.audio.clone(), None))?;
let mut files = 1;
for (i, s) in take.slices.iter().enumerate().filter(|(_, s)| s.enabled) {
let name = match s.root {
Some(n) => format!("s{:02}_{}.wav", i + 1, note_name(n)),
None => format!("s{:02}.wav", i + 1),
};
wav::write(
&folder.join(name),
&stereo(render(take, s, settings), s.root),
)?;
files += 1;
}
let mut wavetable_skipped = None;
if settings.wavetable {
let source = selected
.and_then(|i| take.slices.get(i))
.or_else(|| take.slices.iter().find(|s| s.enabled && s.root.is_some()));
match source.map(|s| wavetable(&take.mono(s.start, s.end), take.rate)) {
Some(Some(table)) => {
let w = wav::Wave {
channels: 1,
rate,
samples: table,
root: None,
frame: Some(TABLE as u32),
};
wav::write(&folder.join("wavetable.wav"), &w)?;
files += 1;
}
Some(None) => wavetable_skipped = Some("the slice has no clear pitch"),
None => wavetable_skipped = Some("no slice selected"),
}
}
Ok(Exported {
folder,
files,
wavetable_skipped,
})
}
+18
View File
@@ -0,0 +1,18 @@
//! Resampler: always records the input, captures the newest bars on request, slices them,
//! cleans each slice and writes ready-to-use files.
mod export;
mod player;
mod ring;
mod settings;
mod take;
pub use export::{export, stamp, Exported};
pub use player::{Clip, Player, Queues};
pub use ring::{Marker, Recorder, Ring, Transport, MARKERS};
pub use settings::{default_folder, Division, Mode, Settings};
pub use take::{
capture, render, root_key, transients, wavetable, Segment, Slice, Take, FALLBACK_TEMPO,
OVERVIEW, TABLE, TABLE_FRAMES,
};
/// Seconds of history: 16 bars at 60 BPM in 4/4, plus slack for the capture copy.
pub const HISTORY: f32 = 72.;
+73
View File
@@ -0,0 +1,73 @@
//! Audition of slices on the audio thread without allocating or freeing there.
use crossbeam_queue::ArrayQueue;
use std::sync::Arc;
pub type Clip = Arc<[f32]>;
/// Shared queues. The GUI sends clips and drains finished ones, so all frees happen there.
pub struct Queues {
incoming: ArrayQueue<Option<Clip>>,
finished: ArrayQueue<Clip>,
}
impl Default for Queues {
fn default() -> Self {
Self {
incoming: ArrayQueue::new(4),
finished: ArrayQueue::new(16),
}
}
}
impl Queues {
/// Plays an interleaved stereo clip, replacing the current one.
pub fn play(&self, clip: Clip) {
self.drain();
let _ = self.incoming.force_push(Some(clip));
}
pub fn stop(&self) {
let _ = self.incoming.force_push(None);
}
/// Frees finished clips. Call regularly from the GUI thread.
pub fn drain(&self) {
while self.finished.pop().is_some() {}
}
}
#[derive(Default)]
pub struct Player {
clip: Option<Clip>,
at: usize,
}
impl Player {
fn retire(&mut self, queues: &Queues) {
if let Some(old) = self.clip.take() {
// The GUI drains this queue every frame; a full queue only happens with no editor.
if let Err(old) = queues.finished.push(old) {
std::mem::forget(old);
}
}
}
/// Call once per block.
pub fn block(&mut self, queues: &Queues) {
while let Some(next) = queues.incoming.pop() {
self.retire(queues);
self.clip = next;
self.at = 0;
}
}
#[inline]
pub fn next(&mut self, queues: &Queues) -> [f32; 2] {
let Some(clip) = &self.clip else {
return [0.; 2];
};
if self.at + 1 >= clip.len() {
self.retire(queues);
return [0.; 2];
}
let out = [clip[self.at], clip[self.at + 1]];
self.at += 2;
out
}
pub fn playing(&self) -> bool {
self.clip.is_some()
}
}
+206
View File
@@ -0,0 +1,206 @@
//! Lock-free history of the input plus the host transport, shared by the audio and GUI threads.
//!
//! The audio thread writes samples with relaxed stores and publishes the frame count with a
//! release store. The GUI thread reads the count with acquire, copies, then checks the count
//! again: anything the writer may have overwritten during the copy is reported, never returned.
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
/// Transport markers kept for alignment; one is written at least every half second.
pub const MARKERS: usize = 512;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Transport {
pub playing: bool,
pub tempo: f64,
/// Song position in quarter notes at the first sample of the block.
pub beats: f64,
pub numerator: u32,
pub denominator: u32,
}
impl Transport {
/// Bar length in quarter notes.
pub fn bar(&self) -> f64 {
self.numerator.max(1) as f64 * 4. / self.denominator.max(1) as f64
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Marker {
/// Absolute recorded frame index.
pub frame: u64,
pub transport: Transport,
}
impl Marker {
/// Song position at `frame`, extrapolated at this marker's tempo.
pub fn beats_at(&self, frame: f64, rate: f64) -> f64 {
self.transport.beats + (frame - self.frame as f64) / rate * self.transport.tempo / 60.
}
pub fn frame_at(&self, beats: f64, rate: f64) -> f64 {
self.frame as f64 + (beats - self.transport.beats) * 60. / self.transport.tempo * rate
}
}
pub struct Ring {
rate: f32,
frames: usize,
data: Box<[AtomicU32]>,
written: AtomicU64,
markers: Box<[[AtomicU64; 4]]>,
marker_count: AtomicU64,
peak: AtomicU32,
}
impl Ring {
/// Stereo history of `seconds` at `rate`.
pub fn new(rate: f32, seconds: f32) -> Self {
let frames = (rate * seconds).ceil().max(1.) as usize;
Self {
rate,
frames,
data: (0..frames * 2).map(|_| AtomicU32::new(0)).collect(),
written: AtomicU64::new(0),
markers: (0..MARKERS)
.map(|_| std::array::from_fn(|_| AtomicU64::new(0)))
.collect(),
marker_count: AtomicU64::new(0),
peak: AtomicU32::new(0),
}
}
pub fn rate(&self) -> f32 {
self.rate
}
pub fn capacity(&self) -> usize {
self.frames
}
pub fn written(&self) -> u64 {
self.written.load(Ordering::Acquire)
}
/// Input peak since the last call, for the editor meter.
pub fn take_peak(&self) -> f32 {
f32::from_bits(self.peak.swap(0, Ordering::AcqRel))
}
/// Markers that are certainly intact, oldest first.
pub fn markers(&self) -> Vec<Marker> {
let count = self.marker_count.load(Ordering::Acquire);
// A margin keeps slots the writer may be refilling during this read out of the result.
let first = count.saturating_sub(MARKERS as u64 - 8);
let mut out: Vec<Marker> = (first..count)
.map(|i| {
let s = &self.markers[(i % MARKERS as u64) as usize];
let meta = s[3].load(Ordering::Relaxed);
Marker {
frame: s[0].load(Ordering::Relaxed),
transport: Transport {
beats: f64::from_bits(s[1].load(Ordering::Relaxed)),
tempo: f64::from_bits(s[2].load(Ordering::Relaxed)),
numerator: (meta & 0xffff) as u32,
denominator: ((meta >> 16) & 0xffff) as u32,
playing: meta >> 32 & 1 == 1,
},
}
})
.collect();
let after = self.marker_count.load(Ordering::Acquire);
let safe = after.saturating_sub(MARKERS as u64 - 8);
out.drain(..(safe.saturating_sub(first) as usize).min(out.len()));
out
}
/// Copies frames `[start, end)` as interleaved stereo. Fails if any were overwritten.
pub fn copy(&self, start: u64, end: u64) -> Result<Vec<f32>, &'static str> {
let written = self.written();
if end > written || start > end {
return Err("Capture range is not recorded yet.");
}
if written - start > self.frames as u64 {
return Err("Capture is longer than the recording buffer.");
}
let mut out = Vec::with_capacity(((end - start) * 2) as usize);
for f in start..end {
let at = (f % self.frames as u64) as usize * 2;
out.push(f32::from_bits(self.data[at].load(Ordering::Relaxed)));
out.push(f32::from_bits(self.data[at + 1].load(Ordering::Relaxed)));
}
if self.written() - start > self.frames as u64 {
return Err("Capture was overwritten while copying. Try again.");
}
Ok(out)
}
}
/// Audio-thread writer. Never allocates.
pub struct Recorder {
last: Option<Marker>,
frame: u64,
since_marker: u64,
peak: f32,
}
impl Default for Recorder {
fn default() -> Self {
Self::new()
}
}
impl Recorder {
pub fn new() -> Self {
Self {
last: None,
frame: 0,
since_marker: 0,
peak: 0.,
}
}
/// Call at the start of every block with the host transport.
pub fn block(&mut self, ring: &Ring, transport: Option<Transport>) {
let Some(t) =
transport.filter(|t| t.tempo.is_finite() && t.tempo > 1. && t.beats.is_finite())
else {
return;
};
let rate = ring.rate as f64;
let changed = match self.last {
None => true,
Some(m) => {
let expected = m.beats_at(self.frame as f64, rate);
m.transport.playing != t.playing
|| (m.transport.tempo - t.tempo).abs() > 1e-6
|| m.transport.numerator != t.numerator
|| m.transport.denominator != t.denominator
|| (t.playing && (expected - t.beats).abs() > 0.01)
}
};
if changed || self.since_marker as f64 >= rate * 0.5 {
let m = Marker {
frame: self.frame,
transport: t,
};
let count = ring.marker_count.load(Ordering::Relaxed);
let s = &ring.markers[(count % MARKERS as u64) as usize];
s[0].store(m.frame, Ordering::Relaxed);
s[1].store(t.beats.to_bits(), Ordering::Relaxed);
s[2].store(t.tempo.to_bits(), Ordering::Relaxed);
let meta = (t.numerator as u64 & 0xffff)
| (t.denominator as u64 & 0xffff) << 16
| (t.playing as u64) << 32;
s[3].store(meta, Ordering::Relaxed);
ring.marker_count.store(count + 1, Ordering::Release);
self.last = Some(m);
self.since_marker = 0;
}
}
#[inline]
pub fn push(&mut self, ring: &Ring, frame: [f32; 2]) {
let at = (self.frame % ring.frames as u64) as usize * 2;
ring.data[at].store(frame[0].to_bits(), Ordering::Relaxed);
ring.data[at + 1].store(frame[1].to_bits(), Ordering::Relaxed);
self.frame += 1;
self.since_marker += 1;
self.peak = self.peak.max(frame[0].abs()).max(frame[1].abs());
}
/// Makes the frames pushed so far visible to the GUI thread. Call at the end of a block.
pub fn publish(&mut self, ring: &Ring) {
ring.written.store(self.frame, Ordering::Release);
let old = f32::from_bits(ring.peak.load(Ordering::Relaxed));
if self.peak > old {
ring.peak.store(self.peak.to_bits(), Ordering::Relaxed);
}
self.peak = 0.;
}
}
+120
View File
@@ -0,0 +1,120 @@
//! Persisted editor choices. Takes are not part of plugin state; exported files are.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Mode {
/// Cut on the bar/beat grid.
#[default]
Grid,
/// Cut where a new hit starts.
Transient,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Division {
Quarter,
Eighth,
#[default]
Sixteenth,
EighthTriplet,
SixteenthTriplet,
}
impl Division {
pub const ALL: [Division; 5] = [
Division::Quarter,
Division::Eighth,
Division::Sixteenth,
Division::EighthTriplet,
Division::SixteenthTriplet,
];
/// Slices per quarter note.
pub fn per_quarter(self) -> u32 {
match self {
Division::Quarter => 1,
Division::Eighth => 2,
Division::Sixteenth => 4,
Division::EighthTriplet => 3,
Division::SixteenthTriplet => 6,
}
}
pub fn label(self) -> &'static str {
match self {
Division::Quarter => "1/4",
Division::Eighth => "1/8",
Division::Sixteenth => "1/16",
Division::EighthTriplet => "1/8T",
Division::SixteenthTriplet => "1/16T",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Settings {
pub bars: u32,
pub mode: Mode,
pub division: Division,
/// Transient sensitivity, 0–100 %.
pub sensitivity: f32,
/// Fade length at both slice edges in milliseconds.
pub declick: f32,
/// Peak-normalise each slice to −0.3 dBFS.
pub normalize: bool,
/// Remove leading silence below −60 dBFS.
pub trim: bool,
/// Also write a wavetable from the selected slice.
pub wavetable: bool,
/// Export folder; empty selects the default.
pub folder: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
bars: 4,
mode: Mode::Grid,
division: Division::Sixteenth,
sensitivity: 50.,
declick: 2.,
normalize: true,
trim: true,
wavetable: false,
folder: String::new(),
}
}
}
impl Settings {
pub const BARS: [u32; 5] = [1, 2, 4, 8, 16];
/// Clamps values from old or hand-edited state into their ranges.
pub fn sanitize(&mut self) {
if !Self::BARS.contains(&self.bars) {
self.bars = 4;
}
self.sensitivity = if self.sensitivity.is_finite() {
self.sensitivity.clamp(0., 100.)
} else {
50.
};
self.declick = if self.declick.is_finite() {
self.declick.clamp(0., 20.)
} else {
2.
};
}
pub fn folder(&self) -> PathBuf {
if !self.folder.trim().is_empty() {
return PathBuf::from(self.folder.trim());
}
default_folder()
}
}
pub fn default_folder() -> PathBuf {
if cfg!(windows) {
if let Some(home) = std::env::var_os("USERPROFILE") {
return PathBuf::from(home).join("Documents").join("Crucible");
}
}
match std::env::var_os("HOME") {
Some(home) => PathBuf::from(home).join("Crucible"),
None => PathBuf::from("Crucible"),
}
}
+478
View File
@@ -0,0 +1,478 @@
//! Captured audio with its tempo map, slice boundaries and per-slice cleanup.
use crate::ring::{Marker, Ring, Transport};
use crate::settings::{Division, Mode, Settings};
use common_dsp::{hz_to_midi, Yin};
/// Used when the host reports no tempo.
pub const FALLBACK_TEMPO: f64 = 140.;
/// Waveform overview resolution.
pub const OVERVIEW: usize = 2048;
/// One constant-tempo stretch of the take, relative to the take start.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Segment {
pub frame: f64,
pub quarters: f64,
pub tempo: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Slice {
pub start: usize,
pub end: usize,
pub enabled: bool,
pub root: Option<u8>,
}
#[derive(Clone, Debug)]
pub struct Take {
pub rate: f32,
/// Interleaved stereo.
pub audio: Vec<f32>,
pub bars: u32,
pub numerator: u32,
pub denominator: u32,
/// True when aligned to the host's bar lines; false when the transport was stopped.
pub synced: bool,
pub segments: Vec<Segment>,
/// Slice boundaries in frames, sorted, starting at 0 and ending at the take length.
pub cuts: Vec<usize>,
pub slices: Vec<Slice>,
/// Min/max per overview bucket of the mono sum.
pub overview: Vec<[f32; 2]>,
}
impl Take {
pub fn frames(&self) -> usize {
self.audio.len() / 2
}
pub fn tempo(&self) -> f64 {
self.segments.first().map_or(FALLBACK_TEMPO, |s| s.tempo)
}
pub fn bar_quarters(&self) -> f64 {
self.numerator.max(1) as f64 * 4. / self.denominator.max(1) as f64
}
pub fn quarters(&self) -> f64 {
self.bars as f64 * self.bar_quarters()
}
/// Frame at a position in quarter notes from the take start.
pub fn frame_at(&self, quarters: f64) -> f64 {
let s = self
.segments
.iter()
.rev()
.find(|s| s.quarters <= quarters + 1e-9)
.or(self.segments.first());
match s {
Some(s) => s.frame + (quarters - s.quarters) * 60. / s.tempo * self.rate as f64,
None => quarters * 60. / FALLBACK_TEMPO * self.rate as f64,
}
}
pub fn mono(&self, start: usize, end: usize) -> Vec<f32> {
self.audio[start * 2..end * 2]
.as_chunks::<2>()
.0
.iter()
.map(|f| 0.5 * (f[0] + f[1]))
.collect()
}
fn build_overview(&mut self) {
let n = self.frames();
self.overview = (0..OVERVIEW)
.map(|b| {
let (a, z) = (
b * n / OVERVIEW,
((b + 1) * n / OVERVIEW).max(b * n / OVERVIEW + 1),
);
let mut mm = [0f32, 0f32];
for f in a..z.min(n) {
let v = 0.5 * (self.audio[2 * f] + self.audio[2 * f + 1]);
mm[0] = mm[0].min(v);
mm[1] = mm[1].max(v);
}
mm
})
.collect();
}
pub fn from_audio(
rate: f32,
audio: Vec<f32>,
bars: u32,
transport: Transport,
segments: Vec<Segment>,
synced: bool,
) -> Self {
let mut t = Take {
rate,
audio,
bars,
numerator: transport.numerator.max(1),
denominator: transport.denominator.max(1),
synced,
segments,
cuts: Vec::new(),
slices: Vec::new(),
overview: Vec::new(),
};
t.build_overview();
let n = t.frames();
t.cuts = vec![0, n];
t.rebuild_slices();
t
}
/// Re-cuts the take from the settings, replacing manual edits.
pub fn slice(&mut self, settings: &Settings) {
let n = self.frames();
let mut cuts = match settings.mode {
Mode::Grid => grid(self, settings.division),
Mode::Transient => transients(&self.mono(0, n), self.rate, settings.sensitivity),
};
cuts.push(0);
cuts.push(n);
cuts.sort_unstable();
cuts.dedup();
cuts.retain(|c| *c <= n);
self.cuts = cuts;
self.rebuild_slices();
}
/// Adds a cut; returns false if it is too close to an existing one.
pub fn add_cut(&mut self, frame: usize) -> bool {
let min = (self.rate * 0.005) as usize;
if frame == 0 || frame >= self.frames() || self.cuts.iter().any(|c| c.abs_diff(frame) < min)
{
return false;
}
let at = self.cuts.partition_point(|c| *c < frame);
self.cuts.insert(at, frame);
self.rebuild_slices();
true
}
/// Removes the inner cut nearest to `frame`.
pub fn remove_cut(&mut self, frame: usize) -> bool {
let inner = 1..self.cuts.len().saturating_sub(1);
let Some(i) = inner.min_by_key(|i| self.cuts[*i].abs_diff(frame)) else {
return false;
};
self.cuts.remove(i);
self.rebuild_slices();
true
}
fn rebuild_slices(&mut self) {
let old = std::mem::take(&mut self.slices);
let mut yin = Yin::new((self.rate / 35.) as usize + 4);
self.slices = self
.cuts
.windows(2)
.map(|w| {
// Manual include/exclude choices survive a re-cut when the slice keeps its start.
let enabled = old
.iter()
.find(|s| s.start == w[0])
.is_none_or(|s| s.enabled);
let mono = self.mono(w[0], w[1]);
Slice {
start: w[0],
end: w[1],
enabled,
root: root_key(&mut yin, &mono, self.rate),
}
})
.collect();
}
pub fn slice_at(&self, frame: usize) -> Option<usize> {
self.slices
.iter()
.position(|s| frame >= s.start && frame < s.end)
}
}
fn grid(take: &Take, division: Division) -> Vec<usize> {
let per = division.per_quarter() as f64;
let steps = (take.quarters() * per).round() as usize;
(1..steps)
.map(|k| take.frame_at(k as f64 / per).round() as usize)
.collect()
}
/// Onsets where a window's high-frequency weighted energy jumps above the window before it.
/// Windows span 40 ms, longer than one cycle of the lowest bass note, so the edges inside a
/// distorted bass cycle never count as hits. Each onset is refined to its first loud sample.
pub fn transients(x: &[f32], rate: f32, sensitivity: f32) -> Vec<usize> {
let hop = ((rate / 375.) as usize).max(32);
let window = ((rate * 0.04) as usize / hop).max(1) * hop;
if x.len() < 2 * window + hop {
return Vec::new();
}
// Prefix sums of weighted energy, in f64 so long takes keep their precision.
let mut sums = Vec::with_capacity(x.len() + 1);
sums.push(0f64);
let mut prev = 0f32;
for v in x {
let d = v - prev;
prev = *v;
let e = (v * v + 4. * d * d) as f64;
sums.push(sums.last().unwrap() + e);
}
let level = |end: usize| {
let e = (sums[end] - sums[end - window]) / window as f64;
10. * (e + 1e-10).log10() as f32
};
let rise_db = 14. - 0.11 * sensitivity.clamp(0., 100.);
let floor = -55.;
let gap = (0.04 * rate) as usize;
let mut cuts: Vec<usize> = Vec::new();
let mut end = 2 * window;
while end <= x.len() {
let now = level(end);
if now > floor && now - level(end - window) >= rise_db {
let from = end - window;
let to = (end + hop).min(x.len());
let peak = x[from..to].iter().fold(0f32, |m, v| m.max(v.abs()));
let first = x[from..to]
.iter()
.position(|v| v.abs() >= 0.3 * peak)
.unwrap_or(0);
// Start slightly before the first loud sample so the attack stays whole.
let onset = (from + first).saturating_sub((rate * 0.0005) as usize);
if cuts.last().is_none_or(|c| onset >= c + gap) {
cuts.push(onset);
// Skip past this attack so it is not found again as the window slides on.
end = onset + window + gap;
}
}
end += hop;
}
cuts
}
/// Root note of a slice, if it is clearly pitched.
pub fn root_key(yin: &mut Yin, mono: &[f32], rate: f32) -> Option<u8> {
let window = (2 * yin.max_lag() + 2).min(mono.len());
if window < (rate / 1000.) as usize * 4 || mono.len() < window {
return None;
}
// Skip the attack: the body of a hit carries its pitch.
let mut notes: Vec<f32> = Vec::new();
let start = (mono.len() / 5).min(mono.len() - window);
let step = (window / 2).max(1);
let mut at = start;
while at + window <= mono.len() && notes.len() < 6 {
if let Some((hz, clarity)) = yin.estimate(&mono[at..at + window], rate, 35., 1000.) {
if clarity > 0.7 {
notes.push(hz_to_midi(hz));
}
}
at += step;
}
if notes.is_empty() {
return None;
}
notes.sort_by(f32::total_cmp);
let n = notes[notes.len() / 2].round();
(0. ..=127.).contains(&n).then_some(n as u8)
}
/// Builds a take from the newest `bars` complete bars, or from the newest audio when stopped.
pub fn capture(ring: &Ring, bars: u32) -> Result<Take, &'static str> {
let rate = ring.rate() as f64;
let written = ring.written();
let markers = ring.markers();
let last = markers.iter().rev().find(|m| m.frame <= written).copied();
let fallback = Transport {
playing: false,
tempo: FALLBACK_TEMPO,
beats: 0.,
numerator: 4,
denominator: 4,
};
let t = last.map_or(fallback, |m| m.transport);
let bar = t.bar();
let quarters = bars as f64 * bar;
let (start, end, segments, synced) = match last {
Some(m) if m.transport.playing => {
let now = m.beats_at(written as f64, rate);
let end_beats = (now / bar + 1e-9).floor() * bar;
let start_beats = end_beats - quarters;
let end = frame_for(&markers, end_beats, rate);
let start = frame_for(&markers, start_beats, rate);
let segments = segments(&markers, start_beats, end_beats, start, rate);
(start, end, segments, true)
}
_ => {
let length = quarters * 60. / t.tempo * rate;
let segments = vec![Segment {
frame: 0.,
quarters: 0.,
tempo: t.tempo,
}];
(written as f64 - length, written as f64, segments, false)
}
};
if start < 0. {
return Err("Not enough audio recorded yet. Play the section first.");
}
let (start, end) = (start.round() as u64, end.round() as u64);
if end - start > ring.capacity() as u64 {
return Err("Too long for the recording buffer. Capture fewer bars.");
}
let audio = ring.copy(start, end)?;
Ok(Take::from_audio(
ring.rate(),
audio,
bars,
t,
segments,
synced,
))
}
/// Frame of a song position, from the marker whose stretch contains it.
fn frame_for(markers: &[Marker], beats: f64, rate: f64) -> f64 {
let m = markers
.iter()
.rev()
.find(|m| m.transport.playing && m.transport.beats <= beats + 1e-9)
.or_else(|| markers.iter().find(|m| m.transport.playing));
m.map_or(0., |m| m.frame_at(beats, rate))
}
fn segments(markers: &[Marker], from: f64, to: f64, origin: f64, rate: f64) -> Vec<Segment> {
let mut out: Vec<Segment> = Vec::new();
for m in markers.iter().filter(|m| m.transport.playing) {
let q = m.transport.beats - from;
if m.transport.beats >= to {
break;
}
let tempo = m.transport.tempo;
if q <= 0. {
out.clear();
out.push(Segment {
frame: m.frame_at(from, rate) - origin,
quarters: 0.,
tempo,
});
} else if out.last().is_none_or(|s| (s.tempo - tempo).abs() > 1e-6) {
out.push(Segment {
frame: m.frame as f64 - origin,
quarters: q,
tempo,
});
}
}
if out.is_empty() {
out.push(Segment {
frame: 0.,
quarters: 0.,
tempo: FALLBACK_TEMPO,
});
}
out
}
/// A cleaned-up slice ready to save: trimmed, faded and optionally normalised.
pub fn render(take: &Take, slice: &Slice, settings: &Settings) -> Vec<f32> {
let mut start = slice.start;
let end = slice.end;
if settings.trim {
let threshold = 10f32.powf(-60. / 20.);
if let Some(i) = take.audio[start * 2..end * 2]
.as_chunks::<2>()
.0
.iter()
.position(|f| f[0].abs().max(f[1].abs()) > threshold)
{
// Keep a short pre-roll so the attack is not cut into.
start += i.saturating_sub((take.rate * 0.001) as usize);
}
}
let mut out = take.audio[start * 2..end * 2].to_vec();
let frames = out.len() / 2;
let fade = ((settings.declick * 0.001 * take.rate) as usize).min(frames / 2);
for i in 0..fade {
let g = i as f32 / fade as f32;
for c in 0..2 {
out[2 * i + c] *= g;
out[2 * (frames - 1 - i) + c] *= g;
}
}
if settings.normalize {
let peak = out.iter().fold(0f32, |m, v| m.max(v.abs()));
if peak > 1e-6 {
let g = 10f32.powf(-0.3 / 20.) / peak;
out.iter_mut().for_each(|v| *v *= g);
}
}
out
}
pub const TABLE: usize = 2048;
pub const TABLE_FRAMES: usize = 256;
/// Single-cycle frames from a pitched slice. Each frame starts on a rising zero crossing,
/// so neighbouring frames stay in phase and morph smoothly in a wavetable synth.
pub fn wavetable(mono: &[f32], rate: f32) -> Option<Vec<f32>> {
let mut yin = Yin::new((rate / 30.) as usize + 4);
let window = 2 * yin.max_lag() + 2;
let probe = &mono[(mono.len() / 5).min(mono.len().saturating_sub(window))..];
let (hz, _) = yin.estimate(&probe[..window.min(probe.len())], rate, 30., 2000.)?;
let period = rate as f64 / hz as f64;
let rising = |i: usize| mono[i - 1] < 0. && mono[i] >= 0.;
let crossing = |i: usize| (i - 1) as f64 + mono[i - 1] as f64 / (mono[i - 1] - mono[i]) as f64;
let mut starts = Vec::new();
let mut at = (1..mono.len()).find(|i| rising(*i))?;
starts.push(crossing(at));
loop {
let expect = starts.last().unwrap() + period;
let tolerance = period * 0.2;
let lo = (expect - tolerance).max(1.) as usize;
let hi = ((expect + tolerance) as usize).min(mono.len() - 1);
if lo >= hi {
break;
}
// The rising crossing nearest one period on; slight drift and detune are followed.
let Some(next) = (lo..=hi).filter(|i| rising(*i)).min_by(|a, b| {
(crossing(*a) - expect)
.abs()
.total_cmp(&(crossing(*b) - expect).abs())
}) else {
// No crossing: skip one period and look again.
at = (expect as usize).min(mono.len() - 1);
match (at..mono.len()).find(|i| rising(*i)) {
Some(i) => {
starts.push(crossing(i));
continue;
}
None => break,
}
};
starts.push(crossing(next));
}
let cycles: Vec<(f64, f64)> = starts
.windows(2)
.map(|w| (w[0], w[1]))
.filter(|(a, b)| (b - a - period).abs() < period * 0.25)
.collect();
if cycles.is_empty() {
return None;
}
let count = cycles.len().min(TABLE_FRAMES);
let mut table = Vec::with_capacity(count * TABLE);
for k in 0..count {
let (a, b) = cycles[k * cycles.len() / count];
for j in 0..TABLE {
let pos = a + (b - a) * j as f64 / TABLE as f64;
let i = pos.floor() as usize;
let frac = (pos - i as f64) as f32;
let x0 = mono[i.min(mono.len() - 1)];
let x1 = mono[(i + 1).min(mono.len() - 1)];
table.push(x0 + (x1 - x0) * frac);
}
}
let peak = table.iter().fold(0f32, |m, v| m.max(v.abs()));
if peak < 1e-6 {
return None;
}
let g = 10f32.powf(-0.3 / 20.) / peak;
table.iter_mut().for_each(|v| *v *= g);
Some(table)
}
+258
View File
@@ -0,0 +1,258 @@
use common_dsp::{wav, Saw};
use crucible_core::*;
const RATE: f32 = 48000.;
fn playing(tempo: f64, beats: f64) -> Transport {
Transport {
playing: true,
tempo,
beats,
numerator: 4,
denominator: 4,
}
}
/// Records `frames` of a ramp (sample value = frame index) in blocks, with transport.
fn record(ring: &Ring, rec: &mut Recorder, from: u64, frames: u64, tempo: f64, beats0: f64) {
let block = 480u64;
let mut f = 0;
while f < frames {
let beats = beats0 + f as f64 / RATE as f64 * tempo / 60.;
rec.block(ring, Some(playing(tempo, beats)));
for i in 0..block.min(frames - f) {
let v = (from + f + i) as f32;
rec.push(ring, [v, -v]);
}
rec.publish(ring);
f += block;
}
}
#[test]
fn ring_copy_is_exact_across_the_wrap_and_detects_overwrites() {
let ring = Ring::new(RATE, 1.);
let mut rec = Recorder::new();
record(&ring, &mut rec, 0, 70000, 120., 0.);
let got = ring.copy(60000, 70000).unwrap();
for (k, f) in got.chunks(2).enumerate() {
assert_eq!(f[0], (60000 + k) as f32);
assert_eq!(f[1], -((60000 + k) as f32));
}
assert!(ring.copy(10000, 20000).is_err());
assert!(ring.copy(69000, 71000).is_err());
}
#[test]
fn capture_ends_on_the_last_bar_line() {
let ring = Ring::new(RATE, HISTORY);
let mut rec = Recorder::new();
// 120 BPM: one beat = 24000 frames, one 4/4 bar = 96000 frames. Stop mid bar 4.
record(&ring, &mut rec, 0, 96000 * 3 + 30000, 120., 0.);
let take = capture(&ring, 2).unwrap();
assert!(take.synced);
assert_eq!(take.frames(), 2 * 96000);
assert_eq!(take.audio[0], 96000.);
assert_eq!(take.audio[take.audio.len() - 2], (3 * 96000 - 1) as f32);
assert!(capture(&ring, 4).is_err(), "only three full bars exist");
}
#[test]
fn capture_follows_tempo_changes() {
let ring = Ring::new(RATE, HISTORY);
let mut rec = Recorder::new();
// 4 beats at 120 BPM (96000 frames), then 60 BPM (48000 frames per beat).
record(&ring, &mut rec, 0, 96000, 120., 0.);
record(&ring, &mut rec, 96000, 5 * 48000 + 1000, 60., 4.);
let take = capture(&ring, 2).unwrap();
// Bars 1–2 end at beat 8 = 96000 + 4 × 48000.
assert_eq!(take.frames(), 96000 + 4 * 48000);
assert_eq!(take.audio[0], 0.);
assert_eq!(take.segments.len(), 2);
assert!((take.frame_at(4.) - 96000.).abs() < 1.);
assert!((take.frame_at(6.) - (96000. + 2. * 48000.)).abs() < 1.);
}
#[test]
fn stopped_transport_captures_the_newest_audio() {
let ring = Ring::new(RATE, HISTORY);
let mut rec = Recorder::new();
for _ in 0..500 {
rec.block(
&ring,
Some(Transport {
playing: false,
..playing(150., 0.)
}),
);
for _ in 0..480 {
rec.push(&ring, [0.1, 0.1]);
}
rec.publish(&ring);
}
let take = capture(&ring, 1).unwrap();
assert!(!take.synced);
// One bar at 150 BPM = 1.6 s.
assert_eq!(take.frames(), 76800);
}
fn take_of(mono: Vec<f32>, tempo: f64, bars: u32) -> Take {
let audio = mono.iter().flat_map(|v| [*v, *v]).collect();
Take::from_audio(
RATE,
audio,
bars,
playing(tempo, 0.),
vec![Segment {
frame: 0.,
quarters: 0.,
tempo,
}],
true,
)
}
#[test]
fn grid_slices_straight_and_triplet() {
let mut take = take_of(vec![0.; 96000], 120., 1);
let mut s = Settings {
division: Division::Sixteenth,
..Settings::default()
};
take.slice(&s);
assert_eq!(take.slices.len(), 16);
assert_eq!(take.cuts[1], 6000);
s.division = Division::EighthTriplet;
take.slice(&s);
assert_eq!(take.slices.len(), 12);
assert_eq!(take.cuts[1], 8000);
}
#[test]
fn transients_find_hits_within_two_milliseconds() {
let mut x = vec![0.; 96000];
let hits = [4800usize, 20000, 31111, 60000, 80000];
for &h in &hits {
for i in 0..2000 {
x[h + i] = (i as f32 * 0.3).sin() * (-(i as f32) / 400.).exp() * 0.8;
}
}
let found = transients(&x, RATE, 50.);
assert_eq!(found.len(), hits.len(), "{found:?}");
for (f, h) in found.iter().zip(hits) {
assert!(f.abs_diff(h) < 96, "{f} vs {h}");
}
let mut take = take_of(x, 120., 1);
take.slice(&Settings {
mode: Mode::Transient,
..Settings::default()
});
assert_eq!(take.slices.len(), hits.len() + 1);
}
#[test]
fn manual_cuts_add_and_remove() {
let mut take = take_of(vec![0.; 48000], 120., 1);
assert!(take.add_cut(12000));
assert!(!take.add_cut(12010), "too close");
assert_eq!(take.slices.len(), 2);
take.slices[1].enabled = false;
assert!(take.add_cut(30000));
assert!(!take.slices[1].enabled, "choice survives a re-cut");
assert!(take.remove_cut(29000));
assert_eq!(take.cuts, vec![0, 12000, 48000]);
}
#[test]
fn render_trims_fades_and_normalises() {
let mut x = vec![0.; 4800];
x[1000..]
.iter_mut()
.enumerate()
.for_each(|(i, v)| *v = 0.25 * (i as f32 * 0.05).sin().signum());
let take = take_of(x, 120., 1);
let out = render(&take, &take.slices[0], &Settings::default());
let frames = out.len() / 2;
// Leading silence gone except a 1 ms pre-roll.
assert_eq!(frames, 3800 + 48);
assert_eq!(out[0], 0.);
assert_eq!(out[out.len() - 1], 0.);
let peak = out.iter().fold(0f32, |m, v| m.max(v.abs()));
assert!((20. * peak.log10() + 0.3).abs() < 0.01);
}
#[test]
fn root_key_is_detected_per_slice() {
let mut saw = Saw::new(RATE, 43.65);
let take = take_of((0..48000).map(|_| saw.sample() * 0.5).collect(), 120., 1);
assert_eq!(take.slices[0].root, Some(29));
let silent = take_of(vec![0.; 48000], 120., 1);
assert_eq!(silent.slices[0].root, None);
}
#[test]
fn wavetable_frames_start_on_rising_zero_crossings_and_match() {
let mut saw = Saw::new(RATE, 110.);
let x: Vec<f32> = (0..192000).map(|_| saw.sample() * 0.5).collect();
let table = wavetable(&x, RATE).unwrap();
let frames = table.len() / TABLE;
assert_eq!(frames, TABLE_FRAMES);
for f in table.chunks(TABLE) {
assert!(f[0].abs() < 0.02 && f[1] > f[0], "{} {}", f[0], f[1]);
let a = &table[..TABLE];
let dot: f32 = a.iter().zip(f).map(|(p, q)| p * q).sum();
let na: f32 = a.iter().map(|v| v * v).sum();
let nb: f32 = f.iter().map(|v| v * v).sum();
assert!(dot / (na * nb).sqrt() > 0.99);
}
assert!(wavetable(&vec![0.; 48000], RATE).is_none());
}
#[test]
fn export_writes_loop_slices_and_wavetable() {
let dir = std::env::temp_dir().join(format!("crucible-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut saw = Saw::new(RATE, 55.);
let mut take = take_of((0..96000).map(|_| saw.sample() * 0.5).collect(), 120., 1);
let settings = Settings {
division: Division::Quarter,
wavetable: true,
..Settings::default()
};
take.slice(&settings);
take.slices[3].enabled = false;
let done = export(&take, &settings, Some(0), &dir, 1_700_000_000).unwrap();
assert_eq!(done.files, 1 + 3 + 1);
assert!(done.folder.ends_with("2023-11-14_221320_120bpm"));
let read = |name: &str| wav::decode(&std::fs::read(done.folder.join(name)).unwrap()).unwrap();
assert_eq!(read("loop.wav").samples.len(), 192000);
assert_eq!(read("s01_A1.wav").root, Some(33));
assert_eq!(read("wavetable.wav").frame, Some(2048));
assert!(!done.folder.join("s04_A1.wav").exists());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn audition_plays_and_returns_clips_for_freeing() {
let q = Queues::default();
let mut p = Player::default();
q.play(std::sync::Arc::from(vec![0.5f32, -0.5, 0.25, -0.25]));
p.block(&q);
assert_eq!(p.next(&q), [0.5, -0.5]);
assert_eq!(p.next(&q), [0.25, -0.25]);
assert_eq!(p.next(&q), [0., 0.]);
assert!(!p.playing());
q.drain();
}
#[test]
fn settings_survive_json_and_are_sanitised() {
let mut s: Settings =
serde_json::from_str(r#"{"bars":3,"declick":99,"sensitivity":-5}"#).unwrap();
s.sanitize();
assert_eq!((s.bars, s.declick, s.sensitivity), (4, 20., 0.));
let json = serde_json::to_string(&Settings::default()).unwrap();
assert_eq!(
serde_json::from_str::<Settings>(&json).unwrap(),
Settings::default()
);
}
+68
View File
@@ -0,0 +1,68 @@
use crucible_core::{Player, Queues, Recorder, Ring, Transport};
use std::sync::Arc;
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 recording_transport_and_audition_do_not_allocate_or_free() {
let ring = Ring::new(48000., 4.);
let queues = Queues::default();
let mut recorder = Recorder::new();
let mut player = Player::default();
let clips: Vec<Arc<[f32]>> = (0..4).map(|i| Arc::from(vec![0.1; 2000 + i])).collect();
CHECK.with(|v| v.set(true));
for block in 0..2000u32 {
if block % 300 == 0 {
// Sending only moves the Arc; freeing happens on drain, outside the check.
queues.play(clips[(block / 300) as usize % 4].clone());
}
recorder.block(
&ring,
Some(Transport {
playing: block % 700 < 600,
tempo: if block < 1000 { 140. } else { 150. },
beats: block as f64 * 0.1,
numerator: 4,
denominator: 4,
}),
);
player.block(&queues);
for i in 0..256 {
let a = player.next(&queues);
recorder.push(&ring, [i as f32 * 1e-3 + a[0], a[1]]);
}
recorder.publish(&ring);
}
CHECK.with(|v| v.set(false));
assert_eq!(OPERATIONS.with(Cell::get), 0);
queues.drain();
}
+2382
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "crucible"
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" }
crucible-core = { path = "../core" }
crucible-ui = { path = "../ui" }
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+200
View File
@@ -0,0 +1,200 @@
//! CLAP audio effect. Audio passes through unchanged; the input is recorded continuously.
use crucible_core::{Player, Queues, Recorder, Ring, Settings, Transport, HISTORY};
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui, EguiState};
use std::sync::{Arc, Mutex, RwLock};
#[derive(Params)]
pub struct CrucibleParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[persist = "crucible-settings"]
pub settings: RwLock<Settings>,
#[id = "bypass"]
pub bypass: BoolParam,
}
impl Default for CrucibleParams {
fn default() -> Self {
Self {
editor: EguiState::from_size(crucible_ui::WIDTH, crucible_ui::HEIGHT),
settings: RwLock::new(Settings::default()),
bypass: BoolParam::new("Bypass", false).make_bypass(),
}
}
}
struct Shared {
/// Replaced on activation; the editor clones the Arc briefly, never the audio thread.
ring: Mutex<Option<Arc<Ring>>>,
queues: Arc<Queues>,
}
pub struct Crucible {
params: Arc<CrucibleParams>,
shared: Arc<Shared>,
ring: Option<Arc<Ring>>,
recorder: Recorder,
player: Player,
}
impl Default for Crucible {
fn default() -> Self {
Self {
params: Arc::new(CrucibleParams::default()),
shared: Arc::new(Shared {
ring: Mutex::new(None),
queues: Arc::new(Queues::default()),
}),
ring: None,
recorder: Recorder::new(),
player: Player::default(),
}
}
}
impl Plugin for Crucible {
const NAME: &'static str = "Crucible";
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 SAMPLE_ACCURATE_AUTOMATION: bool = false;
type SysExMessage = ();
type BackgroundTask = ();
fn params(&self) -> Arc<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
let params = self.params.clone();
let shared = self.shared.clone();
create_egui_editor(
params.editor.clone(),
crucible_ui::State::default(),
|ctx, _| crucible_ui::style(ctx),
move |ctx, _setter, state| {
let link = crucible_ui::Link {
ring: shared.ring.lock().ok().and_then(|r| r.clone()),
queues: shared.queues.clone(),
};
let mut settings = params
.settings
.read()
.map(|s| s.clone())
.unwrap_or_default();
let before = settings.clone();
nih_plug_egui::resizable_window::ResizableWindow::new("crucible-editor")
.min_size(egui::vec2(
crucible_ui::WIDTH as f32 - 80.,
crucible_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
crucible_ui::show(ui, &mut settings, state, &link)
});
if settings != before {
if let Ok(mut s) = params.settings.write() {
*s = settings;
}
}
// Meters and the transport readout follow the audio.
ctx.request_repaint_after(std::time::Duration::from_millis(50));
},
)
}
fn initialize(
&mut self,
_: &AudioIOLayout,
config: &BufferConfig,
_: &mut impl InitContext<Self>,
) -> bool {
if let Ok(mut s) = self.params.settings.write() {
s.sanitize();
}
let ring = Arc::new(Ring::new(config.sample_rate, HISTORY));
self.ring = Some(ring.clone());
self.recorder = Recorder::new();
self.player = Player::default();
if let Ok(mut slot) = self.shared.ring.lock() {
*slot = Some(ring);
}
true
}
fn reset(&mut self) {
self.player = Player::default();
}
fn process(
&mut self,
buffer: &mut Buffer,
_: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(ring) = self.ring.as_deref() else {
return ProcessStatus::Normal;
};
let bypass = self.params.bypass.value();
let t = context.transport();
let transport = match (t.tempo, t.pos_beats()) {
(Some(tempo), Some(beats)) => Some(Transport {
playing: t.playing,
tempo,
beats,
numerator: t.time_sig_numerator.unwrap_or(4).clamp(1, 64) as u32,
denominator: t.time_sig_denominator.unwrap_or(4).clamp(1, 64) as u32,
}),
_ => None,
};
if !bypass {
self.recorder.block(ring, transport);
}
self.player.block(&self.shared.queues);
let channels = buffer.channels();
for mut sample in buffer.iter_samples() {
let left = *sample.get_mut(0).unwrap();
let right = if channels > 1 {
*sample.get_mut(1).unwrap()
} else {
left
};
if bypass {
continue;
}
self.recorder.push(ring, [left, right]);
// Auditioned slices are added to the output, never recorded.
let a = self.player.next(&self.shared.queues);
if self.player.playing() || a != [0.; 2] {
*sample.get_mut(0).unwrap() += if channels > 1 {
a[0]
} else {
0.5 * (a[0] + a[1])
};
if channels > 1 {
*sample.get_mut(1).unwrap() += a[1];
}
}
}
self.recorder.publish(ring);
ProcessStatus::Normal
}
}
impl ClapPlugin for Crucible {
const CLAP_ID: &'static str = "io.storyboard.resampler";
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;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Utility,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Crucible);
+205
View File
@@ -0,0 +1,205 @@
//! 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 = &crucible::clap_entry;
assert!(entry.init.unwrap()(c"crucible.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"Crucible");
assert_eq!(CStr::from_ptr((*descriptor).id), c"io.storyboard.resampler");
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;
assert!(envelope.fields.contains_key("crucible-settings"));
envelope
.params
.insert("bypass".into(), ParamValue::Bool(false));
let settings = crucible_core::Settings {
bars: 8,
declick: 5.,
wavetable: true,
folder: "D:/Samples/Crucible".into(),
..Default::default()
};
envelope.fields.insert(
"crucible-settings".into(),
serde_json::to_string(&settings).unwrap(),
);
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 restored_settings: crucible_core::Settings =
serde_json::from_str(&restored.fields["crucible-settings"]).unwrap();
assert_eq!(restored_settings, settings);
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
assert_eq!(delay, 0);
assert!(p.start_processing.unwrap()(plugin));
let mut position = 0;
while position < delay + 1024 {
let frames = [1, 127, 512, 31][position % 4].min(delay + 1024 - position);
let mut input_left = [0f32; 512];
let mut input_right = [0f32; 512];
for i in 0..frames {
let x = if position + i == 0 { 0.5 } else { 0. };
input_left[i] = x;
input_right[i] = -x;
}
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 {
// Audio passes through untouched.
let expected = if position + i == 0 { 0.5 } else { 0. };
assert!((left[i] - expected).abs() < 1e-6);
assert!((right[i] + expected).abs() < 1e-6);
}
position += frames;
}
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 = "crucible-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
crucible-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
common-dsp = { path = "../../common/dsp" }
+567
View File
@@ -0,0 +1,567 @@
//! 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,
};
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use std::sync::{Arc, Mutex};
pub use common_ui::style;
pub const WIDTH: u32 = 1040;
pub const HEIGHT: u32 = 680;
const MAX_TAKES: usize = 8;
const DEFAULT_HELP: &str =
"Play your bass, press Capture, then drag the exported files from FL's browser.";
/// Connection to the audio thread.
pub struct Link {
pub ring: Option<Arc<Ring>>,
pub queues: Arc<Queues>,
}
pub struct State {
pub takes: Vec<Take>,
pub take: Option<usize>,
pub slice: Option<usize>,
pub status: String,
pub help: String,
pub peak: f32,
pub transport: Option<Transport>,
pub folder: String,
counter: usize,
names: Vec<String>,
/// Filled by the export thread when it finishes.
job: Option<Arc<Mutex<Option<std::io::Result<Exported>>>>>,
last_export: Option<std::path::PathBuf>,
}
impl Default for State {
fn default() -> Self {
Self {
takes: Vec::new(),
take: None,
slice: None,
status: String::new(),
help: DEFAULT_HELP.into(),
peak: 0.,
transport: None,
folder: String::new(),
counter: 0,
names: Vec::new(),
job: None,
last_export: None,
}
}
}
impl State {
/// Adds a take (newest first) and slices it with the current settings.
pub fn add(&mut self, mut take: Take, settings: &Settings) {
take.slice(settings);
self.counter += 1;
self.names.insert(
0,
format!(
"Take {} · {} bar{} · {:.0} BPM",
self.counter,
take.bars,
if take.bars == 1 { "" } else { "s" },
take.tempo()
),
);
self.takes.insert(0, take);
self.takes.truncate(MAX_TAKES);
self.names.truncate(MAX_TAKES);
self.take = Some(0);
self.slice = None;
}
fn current(&mut self) -> Option<&mut Take> {
self.take.and_then(|i| self.takes.get_mut(i))
}
}
fn hint(r: &egui::Response, state: &mut State, text: &str) {
if r.hovered() {
state.help = text.into();
}
}
fn chip(ui: &mut egui::Ui, on: bool, label: &str) -> egui::Response {
ui.selectable_label(on, RichText::new(label).size(13.))
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn open_folder(path: &std::path::Path) {
let program = if cfg!(windows) {
"explorer"
} else {
"xdg-open"
};
let _ = std::process::Command::new(program).arg(path).spawn();
}
fn audition(link: &Link, take: &Take, index: usize, settings: &Settings) {
if let Some(slice) = take.slices.get(index) {
link.queues.play(Arc::from(render(take, slice, settings)));
}
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link: &Link) {
backdrop(ui);
link.queues.drain();
state.help = DEFAULT_HELP.into();
if let Some(ring) = &link.ring {
let p = ring.take_peak();
state.peak = p.max(state.peak * 0.9);
if let Some(m) = ring.markers().last() {
state.transport = Some(m.transport);
}
}
let finished = state
.job
.as_ref()
.and_then(|job| job.lock().ok().and_then(|mut slot| slot.take()));
if let Some(result) = finished {
state.job = None;
state.status = match result {
Ok(done) => {
let mut s = format!("Exported {} files to {}", done.files, done.folder.display());
if let Some(why) = done.wavetable_skipped {
s += &format!(" (no wavetable: {why})");
}
state.last_export = Some(done.folder);
s
}
Err(e) => format!("Export failed: {e}"),
};
}
ui.add_space(2.);
header(ui, "CRUCIBLE", "RESAMPLER", |ui| {
let db = 20. * state.peak.max(1e-6).log10();
ui.label(
RichText::new(if db < -90. {
"—".into()
} else {
format!("{db:.1} dB")
})
.monospace()
.size(12.)
.color(if db > -0.1 { ACCENT } else { MUTED }),
);
caption(ui, "INPUT");
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| capture_row(ui, settings, state, link));
});
ui.add_space(6.);
let body = 300.;
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(190.);
ui.set_min_height(body);
ui.vertical(|ui| takes(ui, state));
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
waveform(ui, settings, state, link, body);
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state, link));
});
ui.separator();
let line = if state.status.is_empty() || state.help != DEFAULT_HELP {
state.help.clone()
} else {
state.status.clone()
};
ui.label(RichText::new(line).color(MUTED));
}
fn capture_row(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link: &Link) {
let button = egui::Button::new(
RichText::new("CAPTURE")
.font(FontId::new(17., egui::FontFamily::Name("medium".into())))
.color(Color32::WHITE),
)
.fill(Color32::from_rgb(150, 0, 56))
.stroke(Stroke::new(1.0_f32, ACCENT))
.min_size(Vec2::new(130., 34.));
let r = ui.add_enabled(link.ring.is_some(), button);
hint(
&r,
state,
"Grab the newest complete bars. While stopped, grabs the newest audio instead.",
);
if r.clicked() {
if let Some(ring) = &link.ring {
match capture(ring, settings.bars) {
Ok(take) => {
let synced = take.synced;
state.add(take, settings);
state.status = if synced {
format!("Captured {} bars on the bar grid.", settings.bars)
} else {
"Transport stopped: captured the newest audio at the last tempo.".into()
};
}
Err(e) => state.status = e.into(),
}
}
}
ui.add_space(10.);
caption(ui, "BARS");
for bars in Settings::BARS {
if chip(ui, settings.bars == bars, &bars.to_string()).clicked() {
settings.bars = bars;
}
}
ui.add_space(10.);
ui.separator();
let text = match state.transport {
Some(t) => format!(
"{:.1} BPM · {}/{} · {}",
t.tempo,
t.numerator,
t.denominator,
if t.playing { "playing" } else { "stopped" }
),
None if link.ring.is_some() => "No host tempo yet · 140 BPM assumed".into(),
None => "Audio engine inactive".into(),
};
ui.label(RichText::new(text).monospace().size(13.).color(STEEL));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(ring) = &link.ring {
ui.label(
RichText::new(format!(
"{:.0} s history",
ring.capacity() as f32 / ring.rate()
))
.size(12.)
.color(MUTED),
);
}
});
}
fn takes(ui: &mut egui::Ui, state: &mut State) {
caption(ui, "TAKES");
if state.takes.is_empty() {
ui.label(RichText::new("Nothing captured yet.").color(DIM));
}
let mut pick = None;
for (i, name) in state.names.iter().enumerate() {
let r = ui.add_sized(
Vec2::new(ui.available_width(), 26.),
egui::SelectableLabel::new(state.take == Some(i), RichText::new(name).size(13.)),
);
if r.clicked() {
pick = Some(i);
}
}
if let Some(i) = pick {
state.take = Some(i);
state.slice = None;
}
if let Some(i) = state.take {
ui.add_space(8.);
if ui.button(RichText::new("REMOVE TAKE").size(12.)).clicked() {
state.takes.remove(i);
state.names.remove(i);
state.take = (!state.takes.is_empty()).then_some(0);
state.slice = None;
}
}
}
fn waveform(ui: &mut egui::Ui, settings: &Settings, state: &mut State, link: &Link, height: f32) {
let (rect, response) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::click());
let p = ui.painter_at(rect);
p.rect_filled(rect, 2., SCREEN);
let Some(ti) = state.take.filter(|i| *i < state.takes.len()) else {
p.text(
rect.center(),
egui::Align2::CENTER_CENTER,
"Play the part you want to resample, then press CAPTURE",
FontId::proportional(15.),
DIM,
);
return;
};
let take = &state.takes[ti];
let frames = take.frames().max(1) as f32;
let label_band = 18.;
let wave = Rect::from_min_max(rect.min + Vec2::new(0., label_band), rect.max);
let x_of = |frame: usize| wave.left() + wave.width() * frame as f32 / frames;
// Slices: tint, disabled dimming, selection.
for (i, s) in take.slices.iter().enumerate() {
let r = Rect::from_min_max(
Pos2::new(x_of(s.start), wave.top()),
Pos2::new(x_of(s.end), wave.bottom()),
);
let fill = if state.slice == Some(i) {
Color32::from_rgba_unmultiplied(255, 1, 96, 28)
} else if !s.enabled {
Color32::from_rgba_unmultiplied(0, 0, 0, 120)
} else {
Color32::TRANSPARENT
};
p.rect_filled(r, 0., fill);
if r.width() > 26. {
let text = s.root.map_or("·".into(), common_dsp::note_name);
p.text(
Pos2::new(r.center().x, rect.top() + 3.),
egui::Align2::CENTER_TOP,
text,
FontId::proportional(11.),
if s.enabled { STEEL } else { DIM },
);
}
}
// Bar and beat grid.
let quarters = take.quarters();
let bar = take.bar_quarters();
let mut q = 0.;
while q <= quarters + 1e-6 {
let x = x_of(take.frame_at(q).round() as usize);
let is_bar = ((q / bar).round() * bar - q).abs() < 1e-6;
p.line_segment(
[Pos2::new(x, wave.top()), Pos2::new(x, wave.bottom())],
Stroke::new(
1.0_f32,
if is_bar {
Color32::from_rgb(60, 64, 72)
} else {
Color32::from_rgb(28, 30, 34)
},
),
);
q += 1.;
}
// Overview waveform.
let mid = wave.center().y;
let half = wave.height() * 0.46;
let peak = take
.overview
.iter()
.fold(0.01f32, |m, v| m.max(v[0].abs()).max(v[1].abs()));
let columns = wave.width().max(1.) as usize;
for c in 0..columns {
let a = c * OVERVIEW / columns;
let z = ((c + 1) * OVERVIEW / columns).max(a + 1).min(OVERVIEW);
let (mut lo, mut hi) = (0f32, 0f32);
for v in &take.overview[a..z] {
lo = lo.min(v[0]);
hi = hi.max(v[1]);
}
let x = wave.left() + c as f32 + 0.5;
p.line_segment(
[
Pos2::new(x, mid - hi / peak * half),
Pos2::new(x, mid - lo / peak * half + 0.5),
],
Stroke::new(1.0_f32, STEEL),
);
}
// Cuts.
for c in &take.cuts[1..take.cuts.len().saturating_sub(1)] {
let x = x_of(*c);
p.line_segment(
[Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())],
Stroke::new(1.0_f32, ACCENT),
);
}
p.rect_stroke(
rect,
2.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
let frame_at =
|pos: Pos2| (((pos.x - wave.left()) / wave.width()).clamp(0., 1.) * frames) as usize;
if response.hovered() {
state.help =
"Click a slice to hear it. Double-click to add a cut, right-click to remove the nearest."
.into();
}
let pointer = response.interact_pointer_pos();
if response.double_clicked() {
if let Some(pos) = pointer {
let f = frame_at(pos);
if let Some(t) = state.current() {
if t.add_cut(f) {
state.slice = state.takes[ti].slice_at(f);
}
}
}
} else if response.clicked() {
if let Some(pos) = pointer {
let f = frame_at(pos);
state.slice = state.takes[ti].slice_at(f);
if let Some(i) = state.slice {
audition(link, &state.takes[ti], i, settings);
}
}
} else if response.secondary_clicked() {
if let Some(pos) = pointer {
let f = frame_at(pos);
if let Some(t) = state.current() {
t.remove_cut(f);
}
state.slice = None;
}
}
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link: &Link) {
let before = settings.clone();
ui.horizontal(|ui| {
caption(ui, "SLICE BY");
for (mode, label) in [(Mode::Grid, "GRID"), (Mode::Transient, "TRANSIENTS")] {
if chip(ui, settings.mode == mode, label).clicked() {
settings.mode = mode;
}
}
ui.add_space(8.);
match settings.mode {
Mode::Grid => {
for d in Division::ALL {
if chip(ui, settings.division == d, d.label()).clicked() {
settings.division = d;
}
}
}
Mode::Transient => {
caption(ui, "SENSITIVITY");
let r = ui.add(
egui::Slider::new(&mut settings.sensitivity, 0. ..=100.)
.suffix(" %")
.clamping(egui::SliderClamping::Always),
);
hint(&r, state, "Higher finds quieter hits.");
}
}
ui.separator();
caption(ui, "DECLICK");
let r = ui.add(
egui::Slider::new(&mut settings.declick, 0. ..=20.)
.suffix(" ms")
.clamping(egui::SliderClamping::Always),
);
hint(
&r,
state,
"Fade at both ends of every slice, so cuts never click.",
);
});
ui.horizontal(|ui| {
let r = chip(ui, settings.trim, "TRIM SILENCE");
if r.clicked() {
settings.trim = !settings.trim;
}
hint(&r, state, "Remove silence before the first sound of a slice.");
let r = chip(ui, settings.normalize, "NORMALIZE");
if r.clicked() {
settings.normalize = !settings.normalize;
}
hint(&r, state, "Peak every slice at −0.3 dBFS.");
let r = chip(ui, settings.wavetable, "WAVETABLE");
if r.clicked() {
settings.wavetable = !settings.wavetable;
}
hint(
&r,
state,
"Also write wavetable.wav from the selected slice: 2048-sample frames for Serum or Vital.",
);
ui.separator();
let take = state.take;
let slice = state.slice;
match (take.and_then(|t| state.takes.get_mut(t)), slice) {
(Some(t), Some(i)) if i < t.slices.len() => {
let s = &mut t.slices[i];
let seconds = (s.end - s.start) as f32 / t.rate;
ui.label(
RichText::new(format!(
"SLICE {} · {} · {:.2} s",
i + 1,
s.root.map_or("no pitch".into(), common_dsp::note_name),
seconds
))
.size(13.)
.color(TEXT),
);
ui.checkbox(&mut s.enabled, "Export");
if ui.small_button("▶").clicked() {
audition(link, t, i, settings);
}
}
_ => {
ui.label(RichText::new("No slice selected").size(13.).color(DIM));
}
}
});
let recut = settings.mode != before.mode
|| settings.division != before.division
|| (settings.mode == Mode::Transient && settings.sensitivity != before.sensitivity);
if recut {
if let Some(t) = state.current() {
t.slice(settings);
}
state.slice = None;
}
ui.add_space(4.);
ui.separator();
ui.horizontal(|ui| {
caption(ui, "FOLDER");
if state.folder.is_empty() {
state.folder = settings.folder().display().to_string();
}
let r = ui.add(egui::TextEdit::singleline(&mut state.folder).desired_width(420.));
if r.changed() {
settings.folder = state.folder.clone();
}
hint(
&r,
state,
"Add this folder to FL's browser once; exports then appear there for dragging.",
);
let can = state.take.is_some() && state.job.is_none();
let export_button =
egui::Button::new(RichText::new("EXPORT").size(14.).color(Color32::WHITE))
.fill(Color32::from_rgb(150, 0, 56))
.stroke(Stroke::new(1.0_f32, ACCENT));
if ui.add_enabled(can, export_button).clicked() {
if let Some(take) = state.take.and_then(|i| state.takes.get(i)).cloned() {
let slot = Arc::new(Mutex::new(None));
let done = slot.clone();
let settings = settings.clone();
let selected = state.slice;
std::thread::spawn(move || {
let folder = settings.folder();
let result = export(&take, &settings, selected, &folder, unix_now());
if let Ok(mut slot) = done.lock() {
*slot = Some(result);
}
});
state.job = Some(slot);
state.status = "Exporting…".into();
}
}
if ui.button(RichText::new("OPEN FOLDER").size(13.)).clicked() {
let path = state
.last_export
.clone()
.unwrap_or_else(|| settings.folder());
let _ = std::fs::create_dir_all(&path);
open_folder(&path);
}
});
}
+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.1.0"
edition = "2021"
license = "ISC"
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
lathe-core = { path = "../core" }
lathe-ui = { path = "../ui" }
crossbeam-queue = "0.3"
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+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.storyboard.harmonic-eq";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("EQ the harmonics of the played note: a cut on harmonic 3 stays on harmonic 3");
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Equalizer,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Lathe);
+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.storyboard.harmonic-eq"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
clap_version: CLAP_VERSION,
host_data: ptr::null_mut(),
name: c"Storyboard test host".as_ptr(),
vendor: c"Tests".as_ptr(),
url: c"".as_ptr(),
version: c"1.0".as_ptr(),
get_extension: Some(extension),
request_restart: Some(request),
request_process: Some(request),
request_callback: Some(request),
};
let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id);
assert!(!plugin.is_null());
let p = &*plugin;
assert!(p.init.unwrap()(plugin));
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
as *const clap_plugin_audio_ports;
assert!(!ports.is_null());
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
as *const clap_plugin_latency;
assert!(!latency.is_null());
let state =
p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state;
assert!(!state.is_null());
let state = &*state;
let save = || {
let mut bytes = Vec::new();
let stream = clap_ostream {
ctx: &mut bytes as *mut Vec<u8> as *mut c_void,
write: Some(write),
};
assert!(state.save.unwrap()(plugin, &stream));
bytes
};
let mut envelope: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
use nih_plug::wrapper::state::ParamValue;
envelope.params.insert("tilt".into(), ParamValue::F32(-3.));
envelope
.params
.insert("harmonics".into(), ParamValue::I32(16));
let mut gains = [0f32; 32];
gains[2] = -12.;
gains[4] = 6.;
envelope
.fields
.insert("lathe-gains".into(), serde_json::to_string(&gains).unwrap());
envelope
.params
.insert("snap".into(), ParamValue::Bool(false));
envelope
.params
.insert("source".into(), ParamValue::String("midi".into()));
let encoded = serde_json::to_vec(&envelope).unwrap();
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
framed.extend_from_slice(&encoded);
let mut bytes = Cursor::new(framed);
let stream = clap_istream {
ctx: &mut bytes as *mut Cursor<Vec<u8>> as *mut c_void,
read: Some(read),
};
assert!(state.load.unwrap()(plugin, &stream));
let restored: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
assert_eq!(
serde_json::to_value(&restored.params).unwrap(),
serde_json::to_value(&envelope.params).unwrap()
);
let saved: [f32; 32] = serde_json::from_str(&restored.fields["lathe-gains"]).unwrap();
assert_eq!(saved, gains);
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
// About 67 ms lookahead: two periods of the lowest detected note (30 Hz).
assert!((delay as f64 / rate - 0.067).abs() < 0.01, "{delay}");
assert!(p.start_processing.unwrap()(plugin));
let total = delay + 9600;
let mut position = 0;
let (mut energy_in, mut energy_out) = (0f64, 0f64);
while position < total {
let frames = [1, 127, 512, 31][position % 4].min(total - position);
// A 1 kHz tone sits above the split and passes through unchanged.
let tone = |n: usize| {
0.25 * (std::f64::consts::TAU * 1000. * n as f64 / rate).sin() as f32
};
let mut input_left = [0f32; 512];
let mut input_right = [0f32; 512];
for i in 0..frames {
input_left[i] = tone(position + i);
input_right[i] = -tone(position + i);
}
let mut left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
let incoming = clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let mut outgoing_audio = clap_audio_buffer {
data32: out_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let input = clap_input_events {
ctx: ptr::null_mut(),
size: Some(count),
get: Some(event),
};
let outgoing = clap_output_events {
ctx: ptr::null_mut(),
try_push: Some(push),
};
let process = clap_process {
steady_time: position as i64,
frames_count: frames as u32,
transport: ptr::null(),
audio_inputs: &incoming,
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 1,
audio_outputs_count: 1,
in_events: &input,
out_events: &outgoing,
};
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
for i in 0..frames {
let n = position + i;
if n < delay {
assert!(left[i].abs() < 1e-6, "output before the lookahead");
} else if n > delay + 4800 {
energy_in += (tone(n - delay) as f64).powi(2);
energy_out += (left[i] as f64).powi(2) + (right[i] as f64).powi(2);
}
}
position += frames;
}
let ratio_db = 10. * (energy_out / (2. * energy_in)).log10();
assert!(ratio_db.abs() < 0.2, "{ratio_db}");
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}
p.destroy.unwrap()(plugin);
entry.deinit.unwrap()();
}
}
+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.");
});
}
+8
View File
@@ -306,6 +306,13 @@ dependencies = [
"memchr",
]
[[package]]
name = "common-ui"
version = "0.1.0"
dependencies = [
"egui",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -1435,6 +1442,7 @@ dependencies = [
name = "patina-ui"
version = "0.2.0"
dependencies = [
"common-ui",
"egui",
"patina-core",
]
+1 -1
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");
+1
View File
@@ -7,3 +7,4 @@ license = "ISC"
[dependencies]
egui = "0.31.1"
patina-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
+2 -262
View File
@@ -1,87 +1,10 @@
//! Shared native editor, also exercised by the headless renderer.
pub use common_ui::style;
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use patina_core::{focus_offset, frequency, AnalyzerFrame, Focus, Settings, GRID};
use std::time::{Duration, Instant};
// Dark anodized hardware: graphite surfaces, one signal colour, steel data traces.
const BG: Color32 = Color32::from_rgb(14, 15, 17);
const PANEL: Color32 = Color32::from_rgb(25, 27, 31);
const PANEL_TOP: Color32 = Color32::from_rgb(34, 36, 41);
const EDGE: Color32 = Color32::from_rgb(44, 47, 53);
const SCREEN: Color32 = Color32::from_rgb(9, 10, 11);
const TEXT: Color32 = Color32::from_rgb(232, 233, 236);
const MUTED: Color32 = Color32::from_rgb(140, 146, 156);
const DIM: Color32 = Color32::from_rgb(70, 74, 82);
/// Signal colour: cuts, active states, value arcs.
const ACCENT: Color32 = Color32::from_rgb(255, 1, 96);
/// Measured input and neutral data.
const STEEL: Color32 = Color32::from_rgb(184, 192, 204);
/// Focus / protect curve, distinct from cuts without competing with them.
const FOCUS: Color32 = Color32::from_rgb(126, 167, 216);
pub fn style(ctx: &egui::Context) {
let mut fonts = egui::FontDefinitions::default();
fonts.font_data.insert(
"regular".into(),
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Regular.ttf")).into(),
);
fonts.font_data.insert(
"medium".into(),
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Medium.ttf")).into(),
);
fonts
.families
.get_mut(&egui::FontFamily::Proportional)
.unwrap()
.insert(0, "regular".into());
fonts.families.insert(
egui::FontFamily::Name("medium".into()),
vec![
"medium".into(),
"Ubuntu-Light".into(),
"NotoEmoji-Regular".into(),
"emoji-icon-font".into(),
],
);
ctx.set_fonts(fonts);
let mut s = (*ctx.style()).clone();
let v = &mut s.visuals;
*v = egui::Visuals::dark();
v.panel_fill = BG;
v.window_fill = PANEL;
v.extreme_bg_color = SCREEN;
v.faint_bg_color = PANEL;
v.override_text_color = Some(TEXT);
v.selection.bg_fill = Color32::from_rgb(110, 6, 46);
v.selection.stroke = Stroke::new(1.0_f32, ACCENT);
v.hyperlink_color = ACCENT;
v.window_stroke = Stroke::new(1.0_f32, EDGE);
for (w, fill, stroke) in [
(&mut v.widgets.noninteractive, PANEL, EDGE),
(&mut v.widgets.inactive, Color32::from_rgb(35, 38, 43), EDGE),
(
&mut v.widgets.hovered,
Color32::from_rgb(45, 48, 54),
Color32::from_rgb(78, 82, 90),
),
(&mut v.widgets.active, Color32::from_rgb(58, 18, 34), ACCENT),
(&mut v.widgets.open, Color32::from_rgb(35, 38, 43), EDGE),
] {
w.bg_fill = fill;
w.weak_bg_fill = fill;
w.bg_stroke = Stroke::new(1.0_f32, stroke);
w.corner_radius = 3.into();
}
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, TEXT);
v.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, TEXT);
v.slider_trailing_fill = true;
s.spacing.item_spacing = Vec2::new(10., 8.);
s.spacing.button_padding = Vec2::new(12., 6.);
s.text_styles
.insert(egui::TextStyle::Body, FontId::proportional(16.));
s.text_styles
.insert(egui::TextStyle::Button, FontId::proportional(16.));
ctx.set_style(s);
}
pub struct State {
pub delta: bool,
pub selected: Option<usize>,
@@ -139,29 +62,6 @@ impl State {
fn db(x: f32) -> f32 {
20. * x.max(1e-6).log10()
}
/// Disc with a vertical light-to-dark gradient, like brushed metal lit from above.
fn metal_disc(p: &egui::Painter, center: Pos2, radius: f32, top: Color32, bottom: Color32) {
let mut mesh = egui::Mesh::default();
let mix = |t: f32| {
let l = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8;
Color32::from_rgb(
l(top.r(), bottom.r()),
l(top.g(), bottom.g()),
l(top.b(), bottom.b()),
)
};
mesh.colored_vertex(center, mix(0.5));
let n = 48;
for i in 0..=n {
let a = std::f32::consts::TAU * i as f32 / n as f32;
let point = center + Vec2::angled(a) * radius;
mesh.colored_vertex(point, mix((point.y - (center.y - radius)) / (2. * radius)));
if i > 0 {
mesh.add_triangle(0, i, i + 1);
}
}
p.add(egui::Shape::mesh(mesh));
}
/// Shows a help line in the status bar while the knob is hovered or focused.
fn hint(response: egui::Response, state: &mut State, text: &str) {
if response.hovered() || response.has_focus() {
@@ -212,166 +112,6 @@ fn control(
}
});
}
#[allow(clippy::too_many_arguments)]
fn rotary(
ui: &mut egui::Ui,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
size: f32,
color: Color32,
) -> egui::Response {
let low = *range.start();
let high = *range.end();
let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click_and_drag());
if response.drag_started() {
response.request_focus();
}
if response.dragged() {
let delta = ui.input(|i| i.pointer.delta().y);
let fine = if ui.input(|i| i.modifiers.shift) {
0.1
} else {
1.
};
*value = (*value - delta * (high - low) / 180. * fine).clamp(low, high);
}
if response.double_clicked() {
*value = default;
}
if response.has_focus() {
let step = if ui.input(|i| i.modifiers.shift) {
0.1
} else {
1.
};
if ui.input(|i| i.key_pressed(egui::Key::ArrowUp) || i.key_pressed(egui::Key::ArrowRight)) {
*value = (*value + step).min(high);
}
if ui.input(|i| i.key_pressed(egui::Key::ArrowDown) || i.key_pressed(egui::Key::ArrowLeft))
{
*value = (*value - step).max(low);
}
}
let center = rect.center();
let radius = size * 0.46;
let p = ui.painter();
let start = std::f32::consts::PI * 0.75;
let span = std::f32::consts::PI * 1.5;
let normalized = ((*value - low) / (high - low)).clamp(0., 1.);
// Scale track and value arc around the knob.
for (fraction, stroke) in [
(1., Stroke::new(2.5_f32, DIM)),
(normalized, Stroke::new(2.5_f32, color)),
] {
let points: Vec<_> = (0..=64)
.map(|i| {
let a = start + span * fraction * i as f32 / 64.;
center + Vec2::angled(a) * (radius - 1.5)
})
.collect();
p.add(egui::Shape::line(points, stroke));
}
// Machined body: drop shadow, brushed gradient skirt, flat cap.
let body = radius - 7.;
p.circle_filled(
center + Vec2::new(0., 2.),
body + 1.,
Color32::from_black_alpha(140),
);
metal_disc(
p,
center,
body,
Color32::from_rgb(78, 82, 90),
Color32::from_rgb(28, 30, 34),
);
p.circle_stroke(
center,
body,
Stroke::new(1.0_f32, Color32::from_rgb(10, 11, 12)),
);
let cap = body * 0.74;
metal_disc(
p,
center,
cap,
Color32::from_rgb(44, 47, 52),
Color32::from_rgb(33, 35, 40),
);
p.circle_stroke(
center,
cap,
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 22)),
);
let angle = start + span * normalized;
p.line_segment(
[
center + Vec2::angled(angle) * (cap * 0.35),
center + Vec2::angled(angle) * (cap - 2.),
],
Stroke::new(2.0_f32, TEXT),
);
if response.has_focus() {
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, color));
}
ui.label(
RichText::new(name.to_uppercase())
.size(if size > 100. { 14. } else { 12. })
.color(MUTED),
);
ui.add(
egui::DragValue::new(value)
.range(range)
.speed(0.1)
.suffix(unit),
);
response
}
/// Vertical gradient rectangle, used for panel faces.
fn gradient(rect: Rect, top: Color32, bottom: Color32) -> egui::Shape {
let mut mesh = egui::Mesh::default();
for (pos, color) in [
(rect.left_top(), top),
(rect.right_top(), top),
(rect.right_bottom(), bottom),
(rect.left_bottom(), bottom),
] {
mesh.colored_vertex(pos, color);
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
egui::Shape::mesh(mesh)
}
/// Raised hardware panel: graphite face, hairline edge, faint top highlight.
fn panel<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let face = ui.painter().add(egui::Shape::Noop);
let inner = egui::Frame::new()
.inner_margin(14.)
.stroke(Stroke::new(1.0_f32, EDGE))
.show(ui, add);
let r = inner.response.rect;
ui.painter().set(face, gradient(r, PANEL_TOP, PANEL));
ui.painter().line_segment(
[
r.left_top() + Vec2::new(1., 1.),
r.right_top() + Vec2::new(-1., 1.),
],
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 16)),
);
inner.inner
}
fn caption(ui: &mut egui::Ui, text: &str) {
ui.label(RichText::new(text).size(11.).color(MUTED));
}
/// Small labelled hardware-style readout.
fn readout(ui: &mut egui::Ui, label: &str, value: String, color: Color32) {
ui.label(RichText::new(label).size(11.).color(MUTED));
ui.label(RichText::new(value).monospace().size(13.).color(color));
ui.add_space(10.);
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.spacing_mut().slider_width = ((ui.available_width() - 100.) / 4. - 85.).max(95.);
let before = *settings;
+1 -1
View File
@@ -66,7 +66,7 @@ impl Default for Rivet {
}
impl Plugin for Rivet {
const NAME: &'static str = "Rivet";
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");
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "temper-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
common-dsp = { path = "../../common/dsp" }
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+405
View File
@@ -0,0 +1,405 @@
//! Note-tracked distortion: the filters around the shaper follow the played note and the
//! input level is normalised per note, so every note distorts with the same character.
//!
//! The audio path is delayed by the tracker's lookahead, so filters and drive are set for a
//! note before its attack reaches the shaper. The shaper runs at 4× the sample rate.
mod settings;
use common_dsp::meter::{HarmonicMeter, HARMONICS};
use common_dsp::oversample::Oversampler;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{NoteEvent, PitchFollower, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, gain_to_db, hz_to_midi};
pub use settings::{Curve, Settings, Source};
pub const MIN_HZ: f32 = TrackerConfig::BASS.min_hz;
pub const MAX_HZ: f32 = TrackerConfig::BASS.max_hz;
/// Harmonics shown by the editor.
pub const SHOWN: usize = 16;
/// RMS level that Normalize brings every note to before Drive.
const TARGET_DB: f32 = -12.;
const RETUNE: usize = 16;
const DRY: usize = 64;
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
pub hz: f32,
pub note: Option<u8>,
/// Harmonic levels in dBFS of the delayed input and of the output (index 0 = fundamental).
pub input: [f32; SHOWN],
pub output: [f32; SHOWN],
/// Peak level reaching the shaper, after normalisation and drive, in dB.
pub shaper_db: f32,
/// Gain applied by Normalize and by Auto gain, in dB.
pub normalize_db: f32,
pub auto_db: f32,
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
hz: 0.,
note: None,
input: [-120.; SHOWN],
output: [-120.; SHOWN],
shaper_db: -120.,
normalize_db: 0.,
auto_db: 0.,
latency: 0,
sample_rate: 48000.,
}
}
}
/// Per-note input level, measured on the undelayed input so it is known at the attack.
#[derive(Clone, Copy, Debug, Default)]
struct Level {
/// RMS of the undelayed input.
ahead: f32,
/// Loudest `ahead` since the note started, slowly released.
note: f32,
}
impl Level {
fn event(&mut self, e: NoteEvent) {
if let NoteEvent::Start { glide: false, .. } = e {
// `ahead` already looks one lookahead into the new note.
self.note = self.ahead;
}
}
}
#[derive(Clone, Copy, Debug)]
struct Filters {
floor: SvfCoef,
push: SvfCoef,
bright: SvfCoef,
}
struct Channel {
floor: Svf,
push: Svf,
bright: Svf,
over: Oversampler,
dc_x: f32,
dc_y: f32,
dry: [f32; DRY],
}
impl Channel {
fn new() -> Self {
Self {
floor: Svf::default(),
push: Svf::default(),
bright: Svf::default(),
over: Oversampler::new(),
dc_x: 0.,
dc_y: 0.,
dry: [0.; DRY],
}
}
fn reset(&mut self) {
self.floor.reset();
self.push.reset();
self.bright.reset();
self.over.reset();
self.dc_x = 0.;
self.dc_y = 0.;
self.dry = [0.; DRY];
}
}
struct Smoothed {
value: f32,
coef: f32,
}
impl Smoothed {
fn new(value: f32, coef: f32) -> Self {
Self { value, coef }
}
#[inline]
fn next(&mut self, target: f32) -> f32 {
let d = target - self.value;
self.value = if d.abs() < 1e-6 {
target
} else {
self.value + d * self.coef
};
self.value
}
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
latency: usize,
delay: Vec<[f32; 2]>,
delay_at: usize,
dry_at: usize,
tracker: Tracker,
pitch: PitchFollower,
hz_coef: f32,
countdown: usize,
filters: Filters,
channel: [Channel; 2],
level: Level,
rms_coef: f32,
release: f32,
norm_db: Smoothed,
drive: Smoothed,
bias: Smoothed,
mix: Smoothed,
output: Smoothed,
bypass: Smoothed,
auto_db: Smoothed,
power_in: f32,
power_out: f32,
power_coef: f32,
dc_r: f32,
shaper_peak: f32,
meter_in: HarmonicMeter,
meter_out: HarmonicMeter,
/// Smoothed controls start at the next settings instead of gliding from defaults.
fresh: bool,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(8000.0..=768000.).contains(&rate) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let lookahead = tracker.latency();
let smooth = coefficient(rate, 10.);
let settings = Settings::default();
let mut e = Self {
rate,
channels,
settings,
latency: lookahead + Oversampler::latency(),
delay: vec![[0.; 2]; lookahead + 1],
delay_at: 0,
dry_at: 0,
tracker,
pitch: PitchFollower::default(),
hz_coef: coefficient(rate, 5.),
countdown: 0,
filters: Filters {
floor: SvfCoef::IDENTITY,
push: SvfCoef::IDENTITY,
bright: SvfCoef::IDENTITY,
},
channel: [Channel::new(), Channel::new()],
level: Level::default(),
rms_coef: coefficient(rate, 10.),
release: 1. - coefficient(rate, 1500.),
norm_db: Smoothed::new(0., coefficient(rate, 5.)),
drive: Smoothed::new(db_to_gain(settings.drive), smooth),
bias: Smoothed::new(0., smooth),
mix: Smoothed::new(1., smooth),
output: Smoothed::new(1., smooth),
bypass: Smoothed::new(0., smooth),
auto_db: Smoothed::new(0., coefficient(rate, 50.)),
power_in: 0.,
power_out: 0.,
power_coef: coefficient(rate, 300.),
dc_r: 1. - std::f32::consts::TAU * 10. / rate,
shaper_peak: 0.,
meter_in: HarmonicMeter::new(rate, 85.),
meter_out: HarmonicMeter::new(rate, 85.),
fresh: true,
};
e.set_settings(settings)?;
e.fresh = true;
Ok(e)
}
/// Lookahead plus the oversampling filters.
pub fn latency_samples(&self) -> u32 {
self.latency as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.settings
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
self.tracker.set(s.source == Source::Midi, s.gate, true);
if std::mem::take(&mut self.fresh) {
self.drive.value = db_to_gain(s.drive);
self.bias.value = s.bias * 0.008;
self.mix.value = s.mix * 0.01;
self.output.value = db_to_gain(s.output);
self.bypass.value = f32::from(s.bypass);
}
self.settings = s;
Ok(())
}
pub fn reset(&mut self) {
self.delay.fill([0.; 2]);
self.tracker.reset();
for c in &mut self.channel {
c.reset();
}
self.level = Level::default();
self.power_in = 0.;
self.power_out = 0.;
self.shaper_peak = 0.;
self.meter_in.reset();
self.meter_out.reset();
self.countdown = 0;
self.fresh = true;
}
pub fn note_on(&mut self, note: u8) {
self.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.tracker.note_off(note);
}
fn retune(&mut self, hz: f32) {
let s = &self.settings;
let limit = 0.45 * self.rate;
let tuned = |mult: f32| hz > 0. && mult > 0. && hz * mult < limit;
self.filters.floor = if tuned(s.floor) {
SvfCoef::highpass(self.rate, hz * s.floor, std::f32::consts::FRAC_1_SQRT_2)
} else {
SvfCoef::IDENTITY
};
let k = s.push_harmonic as f32;
self.filters.push = if tuned(k) && s.push_gain != 0. {
SvfCoef::bell(self.rate, hz * k, k / 0.6, s.push_gain)
} else {
SvfCoef::IDENTITY
};
self.filters.bright = if tuned(s.bright) {
SvfCoef::lowpass(self.rate, hz * s.bright, std::f32::consts::FRAC_1_SQRT_2)
} else {
SvfCoef::IDENTITY
};
}
pub fn process_sample(&mut self, x: [f32; 2]) -> [f32; 2] {
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
x[0]
};
// Level of the input one lookahead ahead of the audio that is about to be shaped.
let a = &mut self.level;
a.ahead += (mono * mono - a.ahead) * self.rms_coef;
a.note = (a.note * self.release).max(a.ahead);
let (pitch, level) = (&mut self.pitch, &mut self.level);
self.tracker.process(mono, |e| {
pitch.event(false, e);
level.event(e);
});
let len = self.delay.len();
self.delay[self.delay_at] = x;
self.delay_at = (self.delay_at + 1) % len;
let y = self.delay[self.delay_at];
let (hz, jump) = self.pitch.step(self.hz_coef);
if jump || self.countdown == 0 {
self.retune(hz);
self.countdown = RETUNE;
}
self.countdown -= 1;
let s = self.settings;
let rms_db = 10. * self.level.note.max(1e-12).log10();
// Below −60 dBFS there is nothing to normalise; hold the last gain.
let norm_target = if rms_db > -60. {
(s.normalize * 0.01 * (TARGET_DB - rms_db)).clamp(-24., 36.)
} else {
self.norm_db.value
};
let norm = db_to_gain(self.norm_db.next(norm_target));
let drive = self.drive.next(db_to_gain(s.drive));
let bias = self.bias.next(s.bias * 0.008);
let mix = self.mix.next(s.mix * 0.01);
let output = self.output.next(db_to_gain(s.output));
let bypass = self.bypass.next(f32::from(s.bypass));
let curve = s.curve;
let offset = curve.shape(bias);
let f = self.filters;
let dry_at = self.dry_at;
let back = (dry_at + DRY - Oversampler::latency()) % DRY;
let mut wet = [0.; 2];
let mut dry = [0.; 2];
let mut peak = self.shaper_peak;
for c in 0..self.channels {
let ch = &mut self.channel[c];
ch.dry[dry_at] = y[c];
dry[c] = ch.dry[back];
let v = ch.floor.process(&f.floor, y[c]);
let v = ch.push.process(&f.push, v) * norm * drive;
let up = ch.over.up(v);
let shaped = up.map(|u| {
peak = peak.max(u.abs());
curve.shape(u + bias) - offset
});
let v = ch.over.down(shaped);
let v = ch.bright.process(&f.bright, v);
// DC blocker: bias and asymmetric curves leave an offset.
let d = v - ch.dc_x + self.dc_r * ch.dc_y;
ch.dc_x = v;
ch.dc_y = d;
wet[c] = d;
}
self.shaper_peak = peak;
self.dry_at = (dry_at + 1) % DRY;
let (dry_mono, wet_mono) = if self.channels == 2 {
(0.5 * (dry[0] + dry[1]), 0.5 * (wet[0] + wet[1]))
} else {
(dry[0], wet[0])
};
self.power_in += (dry_mono * dry_mono - self.power_in) * self.power_coef;
self.power_out += (wet_mono * wet_mono - self.power_out) * self.power_coef;
let auto_target = if !s.auto_gain {
0.
} else if self.power_in > 1e-7 && self.power_out > 1e-9 {
(10. * (self.power_in / self.power_out).log10()).clamp(-36., 24.)
} else {
self.auto_db.value
};
let auto = db_to_gain(self.auto_db.next(auto_target));
let mut out = [0.; 2];
for c in 0..self.channels {
let v = (mix * wet[c] * auto + (1. - mix) * dry[c]) * output;
out[c] = v * (1. - bypass) + dry[c] * bypass;
}
let tone = if self.tracker.sounding() { hz } else { 0. };
let o = if self.channels == 2 {
0.5 * (out[0] + out[1])
} else {
out[0]
};
self.meter_in.push(dry_mono, tone);
self.meter_out.push(o, tone);
out
}
pub fn take_analyzer(&mut self) -> AnalyzerFrame {
let hz = self.pitch.hz();
let mut f = AnalyzerFrame {
hz,
latency: self.latency as u32,
sample_rate: self.rate,
shaper_db: gain_to_db(self.shaper_peak),
normalize_db: self.norm_db.value,
auto_db: self.auto_db.value,
..AnalyzerFrame::default()
};
self.shaper_peak = 0.;
f.input
.copy_from_slice(&self.meter_in.levels()[..SHOWN.min(HARMONICS)]);
f.output
.copy_from_slice(&self.meter_out.levels()[..SHOWN.min(HARMONICS)]);
if self.tracker.sounding() && hz > 0. {
f.note = Some(hz_to_midi(hz).round().clamp(0., 127.) as u8);
}
f
}
}
+138
View File
@@ -0,0 +1,138 @@
//! Host-independent controls. Levels in dB, harmonic positions as multiples of the fundamental.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Source {
/// Pitch and note starts are detected from the incoming bass.
#[default]
Audio,
/// Notes come from MIDI note input.
Midi,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Curve {
/// tanh: smooth, odd harmonics, rounded top.
#[default]
Soft,
/// Clipping with a narrow knee: dense, square-like.
Hard,
/// Sine wavefolder: drive folds the wave back, adding moving upper harmonics.
Fold,
/// Asymmetric: the negative half clips earlier, adding even harmonics.
Tube,
}
impl Curve {
pub const ALL: [Curve; 4] = [Curve::Soft, Curve::Hard, Curve::Fold, Curve::Tube];
pub fn name(self) -> &'static str {
match self {
Curve::Soft => "Soft",
Curve::Hard => "Hard",
Curve::Fold => "Fold",
Curve::Tube => "Tube",
}
}
/// Transfer function; maps 0 to 0.
#[inline]
pub fn shape(self, u: f32) -> f32 {
match self {
Curve::Soft => u.tanh(),
Curve::Hard => {
let u = u.clamp(-8., 8.);
u / (1. + u.abs().powi(8)).powf(0.125)
}
// Scaled so full drive gives about five folds; more is mostly aliasing noise.
Curve::Fold => (std::f32::consts::FRAC_PI_2 * 0.4 * u.clamp(-64., 64.)).sin(),
Curve::Tube => {
if u >= 0. {
u.tanh()
} else {
0.6 * (u / 0.6).tanh()
}
}
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// Gain into the shaper after level normalisation.
pub drive: f32,
pub curve: Curve,
/// Asymmetry in percent: shifts the curve's operating point, adding even harmonics.
pub bias: f32,
/// How far the input level is normalised per note before the shaper, in percent. At 100 %
/// a quiet patch and a loud patch are driven the same.
pub normalize: f32,
/// Highpass in front of the shaper at this multiple of the fundamental; 0 is off.
pub floor: f32,
/// Harmonic that `push_gain` boosts or cuts before the shaper.
pub push_harmonic: u32,
pub push_gain: f32,
/// Lowpass after the shaper at this harmonic number; 0 is off.
pub bright: f32,
/// Matches output loudness to the input, so Drive changes character, not level.
pub auto_gain: bool,
/// Detection threshold in dBFS for note starts (Audio source).
pub gate: f32,
pub mix: f32,
pub output: f32,
pub bypass: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
drive: 12.,
curve: Curve::Soft,
bias: 0.,
normalize: 100.,
floor: 0.,
push_harmonic: 3,
push_gain: 0.,
bright: 0.,
auto_gain: true,
gate: -42.,
mix: 100.,
output: 0.,
bypass: false,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
let finite = [
self.drive,
self.bias,
self.normalize,
self.floor,
self.push_gain,
self.bright,
self.gate,
self.mix,
self.output,
]
.iter()
.all(|v| v.is_finite());
if !finite {
return Err("non-finite setting");
}
let ok = (0.0..=36.).contains(&self.drive)
&& (0.0..=100.).contains(&self.bias)
&& (0.0..=100.).contains(&self.normalize)
&& (self.floor == 0. || (0.25..=2.).contains(&self.floor))
&& (1..=16).contains(&self.push_harmonic)
&& (-12.0..=12.).contains(&self.push_gain)
&& (self.bright == 0. || (4.0..=64.).contains(&self.bright))
&& (-60.0..=-20.).contains(&self.gate)
&& (0.0..=100.).contains(&self.mix)
&& (-12.0..=12.).contains(&self.output);
if ok {
Ok(())
} else {
Err("setting out of range")
}
}
}
+244
View File
@@ -0,0 +1,244 @@
use common_dsp::meter::HarmonicMeter;
use common_dsp::Saw;
use std::f32::consts::TAU;
use temper_core::{Curve, Engine, Settings, Source};
const RATE: f32 = 48000.;
fn engine(s: Settings) -> (Engine, usize) {
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(s).unwrap();
let l = e.latency_samples() as usize;
(e, l)
}
fn saw(hz: f32, seconds: f32, amp: f32) -> Vec<f32> {
let mut s = Saw::new(RATE, hz);
(0..(seconds * RATE) as usize)
.map(|_| s.sample() * amp)
.collect()
}
fn render(e: &mut Engine, x: &[f32]) -> Vec<f32> {
x.iter().map(|&v| e.process_sample([v, v])[0]).collect()
}
fn harmonics(x: &[f32], hz: f32) -> [f32; 32] {
let mut m = HarmonicMeter::new(RATE, 85.);
for &v in x {
m.push(v, hz);
}
*m.levels()
}
/// Harmonics 1–n relative to the fundamental.
fn profile(x: &[f32], hz: f32, n: usize) -> Vec<f32> {
let h = harmonics(x, hz);
(0..n).map(|k| h[k] - h[0]).collect()
}
fn rms_db(x: &[f32]) -> f32 {
10. * (x.iter().map(|v| v * v).sum::<f32>() / x.len() as f32).log10()
}
#[test]
fn quiet_and_loud_patches_distort_the_same() {
let s = Settings {
drive: 18.,
auto_gain: false,
..Settings::default()
};
let mut profiles = vec![];
for amp in [0.5, 0.06] {
let (mut e, _) = engine(s);
let y = render(&mut e, &saw(55., 1.2, amp));
profiles.push(profile(&y[y.len() / 2..], 55., 8));
}
for k in 1..8 {
let d = profiles[0][k] - profiles[1][k];
assert!(d.abs() < 1., "harmonic {}: {profiles:?}", k + 1);
}
// Without normalisation the quiet patch is barely distorted.
let s = Settings { normalize: 0., ..s };
let (mut e, _) = engine(s);
let y = render(&mut e, &saw(55., 1.2, 0.06));
let quiet = profile(&y[y.len() / 2..], 55., 8);
assert!(
(quiet[2] - profiles[0][2]).abs() > 2.,
"{quiet:?} vs {profiles:?}"
);
}
#[test]
fn bright_follows_the_note() {
let s = Settings {
drive: 24.,
curve: Curve::Hard,
bright: 8.,
..Settings::default()
};
let mut profiles = vec![];
for hz in [43.65, 87.31] {
let (mut e, _) = engine(s);
let y = render(&mut e, &saw(hz, 1.2, 0.4));
profiles.push(profile(&y[y.len() / 2..], hz, 14));
}
for k in 1..14 {
let d = profiles[0][k] - profiles[1][k];
assert!(d.abs() < 1.5, "harmonic {}: {profiles:?}", k + 1);
}
// Above the cutoff the harmonics fall away on both notes.
assert!(profiles[0][12] < profiles[0][4] - 10.);
}
/// Energy away from the harmonics of `hz`, relative to the harmonics, in dB.
fn alias_ratio(curve: Curve, drive: f32, hz: f32) -> f32 {
let s = Settings {
drive,
curve,
auto_gain: false,
..Settings::default()
};
let (mut e, _) = engine(s);
let x: Vec<f32> = (0..36000)
.map(|i| 0.3 * (TAU * hz * i as f32 / RATE).sin())
.collect();
let y = render(&mut e, &x);
let tail = &y[12000..];
let n = tail.len();
let window: Vec<f32> = (0..n)
.map(|i| 0.5 - 0.5 * (TAU * i as f32 / n as f32).cos())
.collect();
let (mut harmonic, mut other) = (0f64, 0f64);
for f in (20..23000).step_by(10) {
// Goertzel power at f.
let c = 2. * (TAU as f64 * f as f64 / RATE as f64).cos();
let (mut s1, mut s2) = (0f64, 0f64);
for (v, w) in tail.iter().zip(&window) {
let s0 = (v * w) as f64 + c * s1 - s2;
s2 = s1;
s1 = s0;
}
let p = s1 * s1 + s2 * s2 - c * s1 * s2;
let near = (f as f32 / hz - (f as f32 / hz).round()).abs() * hz < 30.;
if near {
harmonic += p;
} else {
other += p;
}
}
(10. * (other / harmonic).log10()) as f32
}
#[test]
fn oversampling_keeps_aliasing_down_in_the_bass_range() {
// 2 kHz stands for the upper harmonics of a growl, 440 Hz for a high bass fundamental.
for curve in Curve::ALL {
let r = alias_ratio(curve, 24., 2000.);
assert!(r < -60., "{} 2 kHz: {r}", curve.name());
let r = alias_ratio(curve, 36., 440.);
assert!(r < -55., "{} 440 Hz: {r}", curve.name());
}
}
#[test]
#[ignore]
fn print_alias_ratios() {
for curve in Curve::ALL {
for hz in [440., 2000., 5000.] {
for drive in [12., 24., 36.] {
println!(
"{} {hz} {drive}: {:.1}",
curve.name(),
alias_ratio(curve, drive, hz)
);
}
}
}
}
#[test]
fn auto_gain_matches_input_loudness() {
for drive in [6., 24., 36.] {
let s = Settings {
drive,
..Settings::default()
};
let (mut e, l) = engine(s);
let x = saw(55., 2., 0.3);
let y = render(&mut e, &x);
let d = rms_db(&y[y.len() / 2..]) - rms_db(&x[x.len() / 2 - l..x.len() - l]);
assert!(d.abs() < 1., "{drive}: {d}");
}
}
#[test]
fn zero_mix_is_the_delayed_dry_signal_and_latency_is_exact() {
let s = Settings {
mix: 0.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x = saw(55., 1., 0.5);
let y = render(&mut e, &x);
for i in RATE as usize / 2..x.len() {
assert_eq!(y[i], x[i - l], "{i}");
}
// With the shaper nearly linear the wet path lines up with the dry one.
let s = Settings {
drive: 0.,
normalize: 0.,
curve: Curve::Soft,
auto_gain: false,
..Settings::default()
};
let (mut e, l) = engine(s);
let mut x = vec![0f32; 9600];
x[100] = 0.01;
let y = render(&mut e, &x);
let at = y
.iter()
.enumerate()
.max_by(|a, b| a.1.abs().total_cmp(&b.1.abs()))
.unwrap()
.0;
assert_eq!(at, 100 + l);
}
#[test]
fn midi_source_tunes_filters_at_the_delayed_note_time() {
let s = Settings {
source: Source::Midi,
bright: 4.,
drive: 18.,
curve: Curve::Hard,
..Settings::default()
};
let (mut e, _) = engine(s);
e.note_on(29);
let y = render(&mut e, &saw(43.65, 1., 0.4));
let p = profile(&y[y.len() / 2..], 43.65, 12);
assert!(p[10] < p[2] - 12., "{p:?}");
let f = e.take_analyzer();
assert_eq!(f.note, Some(29));
}
#[test]
fn settings_are_validated_and_serialise() {
let s = Settings::default();
let json = serde_json::to_string(&s).unwrap();
assert_eq!(serde_json::from_str::<Settings>(&json).unwrap(), s);
for bad in [
Settings { floor: 0.1, ..s },
Settings { bright: 2., ..s },
Settings {
push_harmonic: 0,
..s
},
Settings {
drive: f32::NAN,
..s
},
] {
assert!(bad.validate().is_err(), "{bad:?}");
}
for c in Curve::ALL {
assert_eq!(c.shape(0.), 0.);
assert!(c.shape(100.).abs() <= 1.0001);
}
}
+71
View File
@@ -0,0 +1,71 @@
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
};
use temper_core::{Curve, Engine, Settings, Source};
struct CheckedAllocator;
thread_local! {
static CHECK:Cell<bool>=const {Cell::new(false)};
static OPERATIONS:Cell<usize>=const {Cell::new(0)};
}
fn record() {
if CHECK.try_with(Cell::get).unwrap_or(false) {
let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1));
}
}
unsafe impl GlobalAlloc for CheckedAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
record();
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
record();
unsafe { System.dealloc(pointer, layout) }
}
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
record();
unsafe { System.realloc(pointer, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CheckedAllocator = CheckedAllocator;
#[test]
fn processing_notes_settings_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings::default();
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..96000 {
if i % 4800 == 0 {
settings.curve = Curve::ALL[(i / 4800) % 4];
settings.bright = if i % 9600 == 0 { 8. } else { 0. };
settings.floor = if i % 9600 == 0 { 0. } else { 0.5 };
settings.push_gain = ((i / 4800) % 5) as f32 - 2.;
settings.source = if i > 48000 {
Source::Midi
} else {
Source::Audio
};
engine.set_settings(settings).unwrap();
engine.note_on(30 + (i / 4800 % 12) as u8);
}
if i % 4800 == 2400 {
engine.note_off(30 + (i / 4800 % 12) as u8);
}
let t = i as f32 / 48000.;
let x = if (i / 2400) % 2 == 0 {
(std::f32::consts::TAU * 55. * t).sin() * 0.5
} else {
0.
};
std::hint::black_box(engine.process_sample([x, x]));
if i % 1600 == 0 {
std::hint::black_box(engine.take_analyzer());
}
}
engine.reset();
CHECK.with(|v| v.set(false));
assert_eq!(OPERATIONS.with(Cell::get), 0);
}
+2381
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "temper"
version = "0.1.0"
edition = "2021"
license = "ISC"
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
temper-core = { path = "../core" }
temper-ui = { path = "../ui" }
crossbeam-queue = "0.3"
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+183
View File
@@ -0,0 +1,183 @@
//! CLAP audio effect. All DSP state is owned by the processing thread.
mod params;
use crossbeam_queue::ArrayQueue;
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::TemperParams;
use std::sync::Arc;
use temper_core::{AnalyzerFrame, Engine};
struct EditorData {
ui: temper_ui::State,
gestures: [bool; 9],
}
pub struct Temper {
params: Arc<TemperParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
engine: Option<Engine>,
publish_count: usize,
publish_interval: usize,
}
impl Default for Temper {
fn default() -> Self {
Self {
params: Arc::new(TemperParams::default()),
analyzer: Arc::new(ArrayQueue::new(2)),
engine: None,
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for Temper {
const NAME: &'static str = "Temper";
const VENDOR: &'static str = "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: temper_ui::State::default(),
gestures: [false; 9],
},
|ctx, _| temper_ui::style(ctx),
move |ctx, setter, data| {
while let Some(frame) = analyzer.pop() {
data.ui.accept_frame(frame);
}
let before = params.settings();
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("temper-editor")
.min_size(egui::vec2(
temper_ui::WIDTH as f32 - 80.,
temper_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
temper_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
// Meters follow the audio.
ctx.request_repaint_after(std::time::Duration::from_millis(40));
},
)
}
fn initialize(
&mut self,
layout: &AudioIOLayout,
config: &BufferConfig,
context: &mut impl InitContext<Self>,
) -> bool {
let Ok(mut engine) = Engine::new(
config.sample_rate,
layout.main_input_channels.map_or(0, |v| v.get() as usize),
) else {
return false;
};
if engine.set_settings(self.params.settings()).is_err() {
return false;
}
context.set_latency_samples(engine.latency_samples());
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
true
}
fn reset(&mut self) {
if let Some(e) = self.engine.as_mut() {
e.reset();
}
self.publish_count = 0;
}
fn process(
&mut self,
buffer: &mut Buffer,
_: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let _ = engine.set_settings(self.params.settings());
let mut next = context.next_event();
for (index, mut sample) in buffer.iter_samples().enumerate() {
while let Some(event) = next {
if event.timing() as usize > index {
break;
}
match event {
NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => {
engine.note_on(note)
}
NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => {
engine.note_off(note)
}
NoteEvent::Choke { note, .. } => engine.note_off(note),
_ => {}
}
next = context.next_event();
}
let x = [
*sample.get_mut(0).unwrap(),
if engine.channels() == 2 {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let out = engine.process_sample(x);
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
self.analyzer.force_push(engine.take_analyzer());
self.publish_count = 0;
}
}
// The lookahead delay, oversampling filters and tails must drain after input stops.
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for Temper {
const CLAP_ID: &'static str = "io.storyboard.tracked-distortion";
const CLAP_DESCRIPTION: Option<&'static str> = Some(
"Distortion whose drive and tone filters follow the note, so every note distorts the same",
);
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Distortion,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Temper);
+207
View File
@@ -0,0 +1,207 @@
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::Arc;
use temper_core::{Curve, Settings, Source};
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SourceParam {
#[id = "audio"]
Audio,
#[id = "midi"]
#[name = "MIDI"]
Midi,
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum CurveParam {
#[id = "soft"]
Soft,
#[id = "hard"]
Hard,
#[id = "fold"]
Fold,
#[id = "tube"]
Tube,
}
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
}
const FLOATS: usize = 9;
#[derive(Params)]
pub struct TemperParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[id = "curve"]
pub curve: EnumParam<CurveParam>,
#[id = "drive"]
pub drive: FloatParam,
#[id = "bias"]
pub bias: FloatParam,
#[id = "normalize"]
pub normalize: FloatParam,
#[id = "floor"]
pub floor: FloatParam,
#[id = "push"]
pub push_gain: FloatParam,
#[id = "bright"]
pub bright: FloatParam,
#[id = "gate"]
pub gate: FloatParam,
#[id = "mix"]
pub mix: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "push-at"]
pub push_harmonic: IntParam,
#[id = "auto-gain"]
pub auto_gain: BoolParam,
#[id = "bypass"]
pub bypass: BoolParam,
}
impl Default for TemperParams {
fn default() -> Self {
let d = Settings::default();
Self {
editor: EguiState::from_size(temper_ui::WIDTH, temper_ui::HEIGHT),
source: EnumParam::new("Pitch source", SourceParam::Audio),
curve: EnumParam::new("Curve", CurveParam::Soft),
drive: linear("Drive", d.drive, 0., 36., " dB"),
bias: linear("Bias", d.bias, 0., 100., " %"),
normalize: linear("Normalize", d.normalize, 0., 100., " %"),
floor: linear("Floor", d.floor, 0., 2., " ×f0"),
push_gain: linear("Push", d.push_gain, -12., 12., " dB"),
bright: linear("Bright", d.bright, 0., 64., " ×f0"),
gate: linear("Gate", d.gate, -60., -20., " dB"),
mix: linear("Mix", d.mix, 0., 100., " %"),
output: linear("Output", d.output, -12., 12., " dB"),
push_harmonic: IntParam::new(
"Push at",
d.push_harmonic as i32,
IntRange::Linear { min: 1, max: 16 },
),
auto_gain: BoolParam::new("Auto gain", d.auto_gain),
bypass: BoolParam::new("Bypass", false).make_bypass(),
}
}
}
fn curve_to(c: CurveParam) -> Curve {
match c {
CurveParam::Soft => Curve::Soft,
CurveParam::Hard => Curve::Hard,
CurveParam::Fold => Curve::Fold,
CurveParam::Tube => Curve::Tube,
}
}
fn curve_from(c: Curve) -> CurveParam {
match c {
Curve::Soft => CurveParam::Soft,
Curve::Hard => CurveParam::Hard,
Curve::Fold => CurveParam::Fold,
Curve::Tube => CurveParam::Tube,
}
}
impl TemperParams {
pub fn settings(&self) -> Settings {
// Floor and Bright are continuous for automation; below their ranges they are off.
let floor = self.floor.value();
let bright = self.bright.value();
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
curve: curve_to(self.curve.value()),
drive: self.drive.value(),
bias: self.bias.value(),
normalize: self.normalize.value(),
floor: if floor < 0.25 { 0. } else { floor },
push_harmonic: self.push_harmonic.value() as u32,
push_gain: self.push_gain.value(),
bright: if bright < 4. { 0. } else { bright },
auto_gain: self.auto_gain.value(),
gate: self.gate.value(),
mix: self.mix.value(),
output: self.output.value(),
bypass: self.bypass.value(),
}
}
fn floats(&self) -> [&FloatParam; FLOATS] {
[
&self.drive,
&self.bias,
&self.normalize,
&self.floor,
&self.push_gain,
&self.bright,
&self.gate,
&self.mix,
&self.output,
]
}
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
/// while the pointer is down; switches are set in a single gesture.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; FLOATS],
) {
let values = [
(old.drive, new.drive),
(old.bias, new.bias),
(old.normalize, new.normalize),
(old.floor, new.floor),
(old.push_gain, new.push_gain),
(old.bright, new.bright),
(old.gate, new.gate),
(old.mix, new.mix),
(old.output, new.output),
];
for (i, (p, (a, b))) in self.floats().into_iter().zip(values).enumerate() {
if a != b {
if !open[i] {
open[i] = true;
setter.begin_set_parameter(p);
}
setter.set_parameter(p, b);
}
if !dragging && open[i] {
setter.end_set_parameter(p);
open[i] = false;
}
}
if new.push_harmonic != old.push_harmonic {
setter.begin_set_parameter(&self.push_harmonic);
setter.set_parameter(&self.push_harmonic, new.push_harmonic as i32);
setter.end_set_parameter(&self.push_harmonic);
}
for (p, a, b) in [
(&self.auto_gain, old.auto_gain, new.auto_gain),
(&self.bypass, old.bypass, new.bypass),
] {
if a != b {
setter.begin_set_parameter(p);
setter.set_parameter(p, b);
setter.end_set_parameter(p);
}
}
if new.curve != old.curve {
setter.begin_set_parameter(&self.curve);
setter.set_parameter(&self.curve, curve_from(new.curve));
setter.end_set_parameter(&self.curve);
}
if new.source != old.source {
let v = match new.source {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
setter.begin_set_parameter(&self.source);
setter.set_parameter(&self.source, v);
setter.end_set_parameter(&self.source);
}
}
}
+211
View File
@@ -0,0 +1,211 @@
//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW.
use clap_sys::{
audio_buffer::*,
events::*,
ext::{audio_ports::*, latency::*, state::*},
factory::plugin_factory::*,
host::*,
process::*,
stream::*,
version::*,
};
use std::{
ffi::{c_char, c_void, CStr},
io::{Cursor, Read},
ptr,
};
unsafe extern "C" fn extension(_: *const clap_host, _: *const c_char) -> *const c_void {
ptr::null()
}
unsafe extern "C" fn request(_: *const clap_host) {}
unsafe extern "C" fn count(_: *const clap_input_events) -> u32 {
0
}
unsafe extern "C" fn event(_: *const clap_input_events, _: u32) -> *const clap_event_header {
ptr::null()
}
unsafe extern "C" fn push(_: *const clap_output_events, _: *const clap_event_header) -> bool {
true
}
unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 {
// SAFETY: The test owns the Vec for the entire synchronous save call.
let bytes = unsafe { &mut *((*stream).ctx as *mut Vec<u8>) };
bytes.extend_from_slice(unsafe {
std::slice::from_raw_parts(buffer as *const u8, size as usize)
});
size as i64
}
unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
// SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage.
let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor<Vec<u8>>) };
cursor
.read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) })
.unwrap() as i64
}
#[test]
fn effect_ports_state_latency_and_audio_lifecycle() {
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
unsafe {
let entry = &temper::clap_entry;
assert!(entry.init.unwrap()(c"temper.clap".as_ptr()));
let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr())
as *const clap_plugin_factory;
assert!(!factory.is_null());
let factory = &*factory;
assert_eq!(factory.get_plugin_count.unwrap()(factory), 1);
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(CStr::from_ptr((*descriptor).name), c"Temper");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.storyboard.tracked-distortion"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
clap_version: CLAP_VERSION,
host_data: ptr::null_mut(),
name: c"Storyboard test host".as_ptr(),
vendor: c"Tests".as_ptr(),
url: c"".as_ptr(),
version: c"1.0".as_ptr(),
get_extension: Some(extension),
request_restart: Some(request),
request_process: Some(request),
request_callback: Some(request),
};
let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id);
assert!(!plugin.is_null());
let p = &*plugin;
assert!(p.init.unwrap()(plugin));
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
as *const clap_plugin_audio_ports;
assert!(!ports.is_null());
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
as *const clap_plugin_latency;
assert!(!latency.is_null());
let state =
p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state;
assert!(!state.is_null());
let state = &*state;
let save = || {
let mut bytes = Vec::new();
let stream = clap_ostream {
ctx: &mut bytes as *mut Vec<u8> as *mut c_void,
write: Some(write),
};
assert!(state.save.unwrap()(plugin, &stream));
bytes
};
let mut envelope: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
use nih_plug::wrapper::state::ParamValue;
envelope.params.insert("drive".into(), ParamValue::F32(24.));
envelope
.params
.insert("curve".into(), ParamValue::String("fold".into()));
envelope.params.insert("push-at".into(), ParamValue::I32(5));
envelope
.params
.insert("source".into(), ParamValue::String("midi".into()));
// Mix 0: the output must be the dry signal, delayed by the reported latency.
envelope.params.insert("mix".into(), ParamValue::F32(0.));
let encoded = serde_json::to_vec(&envelope).unwrap();
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
framed.extend_from_slice(&encoded);
let mut bytes = Cursor::new(framed);
let stream = clap_istream {
ctx: &mut bytes as *mut Cursor<Vec<u8>> as *mut c_void,
read: Some(read),
};
assert!(state.load.unwrap()(plugin, &stream));
let restored: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
assert_eq!(
serde_json::to_value(&restored.params).unwrap(),
serde_json::to_value(&envelope.params).unwrap()
);
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
// About 67 ms lookahead plus the oversampling filters.
assert!((delay as f64 / rate - 0.068).abs() < 0.01, "{delay}");
assert!(p.start_processing.unwrap()(plugin));
let total = delay + 9600;
let mut position = 0;
let (mut energy_in, mut energy_out) = (0f64, 0f64);
while position < total {
let frames = [1, 127, 512, 31][position % 4].min(total - position);
// A 1 kHz tone sits above the split and passes through unchanged.
let tone = |n: usize| {
0.25 * (std::f64::consts::TAU * 1000. * n as f64 / rate).sin() as f32
};
let mut input_left = [0f32; 512];
let mut input_right = [0f32; 512];
for i in 0..frames {
input_left[i] = tone(position + i);
input_right[i] = -tone(position + i);
}
let mut left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
let incoming = clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let mut outgoing_audio = clap_audio_buffer {
data32: out_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let input = clap_input_events {
ctx: ptr::null_mut(),
size: Some(count),
get: Some(event),
};
let outgoing = clap_output_events {
ctx: ptr::null_mut(),
try_push: Some(push),
};
let process = clap_process {
steady_time: position as i64,
frames_count: frames as u32,
transport: ptr::null(),
audio_inputs: &incoming,
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 1,
audio_outputs_count: 1,
in_events: &input,
out_events: &outgoing,
};
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
for i in 0..frames {
let n = position + i;
if n < delay {
assert!(left[i].abs() < 1e-6, "output before the lookahead");
} else if n > delay + 4800 {
energy_in += (tone(n - delay) as f64).powi(2);
energy_out += (left[i] as f64).powi(2) + (right[i] as f64).powi(2);
}
}
position += frames;
}
let ratio_db = 10. * (energy_out / (2. * energy_in)).log10();
assert!(ratio_db.abs() < 0.2, "{ratio_db}");
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}
p.destroy.unwrap()(plugin);
entry.deinit.unwrap()();
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "temper-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
temper-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
common-dsp = { path = "../../common/dsp" }
+413
View File
@@ -0,0 +1,413 @@
//! Temper editor, shared by the CLAP wrapper and the headless renderer.
use common_dsp::db_to_gain;
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use temper_core::{AnalyzerFrame, Curve, Settings, Source, SHOWN};
pub use common_ui::style;
pub const WIDTH: u32 = 1040;
pub const HEIGHT: u32 = 650;
pub struct State {
pub analyzer: AnalyzerFrame,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
gesture_active: false,
help: DEFAULT_HELP.into(),
}
}
}
const DEFAULT_HELP: &str =
"Every note is driven the same: the input level is normalised per note and the tone filters follow the pitch.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
let old = self.analyzer;
self.analyzer = frame;
for k in 0..SHOWN {
self.analyzer.input[k] = frame.input[k].max(old.input[k] - 3.);
self.analyzer.output[k] = frame.output[k].max(old.output[k] - 3.);
}
self.analyzer.shaper_db = frame.shaper_db.max(old.shaper_db - 1.5);
}
}
fn hint(r: &egui::Response, state: &mut State, text: &str) {
if r.hovered() || r.has_focus() {
state.help = text.into();
}
}
#[allow(clippy::too_many_arguments)]
fn knob(
ui: &mut egui::Ui,
state: &mut State,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
size: f32,
help: &str,
) {
ui.vertical_centered(|ui| {
let r = rotary(ui, name, value, range, default, unit, size, ACCENT);
hint(&r, state, help);
});
}
const GRID: Color32 = Color32::from_rgb(24, 26, 30);
/// Input peak at which a normalised note reaches the shaper before Drive.
const NOMINAL: f32 = 0.35;
fn transfer(ui: &mut egui::Ui, settings: &Settings, size: f32) {
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, 2., SCREEN);
let c = rect.center();
let half = rect.width() * 0.45;
for t in [-1., -0.5, 0.5, 1.] {
p.line_segment(
[
Pos2::new(c.x + t * half, rect.top()),
Pos2::new(c.x + t * half, rect.bottom()),
],
Stroke::new(1.0_f32, GRID),
);
p.line_segment(
[
Pos2::new(rect.left(), c.y - t * half),
Pos2::new(rect.right(), c.y - t * half),
],
Stroke::new(1.0_f32, GRID),
);
}
p.line_segment(
[Pos2::new(rect.left(), c.y), Pos2::new(rect.right(), c.y)],
Stroke::new(1.0_f32, EDGE),
);
p.line_segment(
[Pos2::new(c.x, rect.top()), Pos2::new(c.x, rect.bottom())],
Stroke::new(1.0_f32, EDGE),
);
p.line_segment(
[
Pos2::new(c.x - half, c.y + half),
Pos2::new(c.x + half, c.y - half),
],
Stroke::new(1.0_f32, DIM),
);
// Input axis: a normalised note's peak at ±1; output: the shaper's ±1.
let gain = db_to_gain(settings.drive) * NOMINAL;
let bias = settings.bias * 0.008;
let offset = settings.curve.shape(bias);
let points: Vec<Pos2> = (0..=160)
.map(|i| {
let x = -1.1 + 2.2 * i as f32 / 160.;
let y = settings.curve.shape(x * gain + bias) - offset;
Pos2::new(c.x + x * half, c.y - y.clamp(-1.1, 1.1) * half)
})
.collect();
p.add(egui::Shape::line(points, Stroke::new(2.0_f32, ACCENT)));
p.text(
rect.left_top() + Vec2::new(8., 6.),
egui::Align2::LEFT_TOP,
"TRANSFER",
FontId::proportional(11.),
MUTED,
);
p.rect_stroke(
rect,
2.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
}
fn spectrum(ui: &mut egui::Ui, frame: &AnalyzerFrame, settings: &Settings, height: f32) {
let size = Vec2::new(ui.available_width(), height);
let (outer, response) = ui.allocate_exact_size(size, Sense::hover());
let p = ui.painter_at(outer);
p.rect_filled(outer, 2., SCREEN);
let area = Rect::from_min_max(
outer.left_top() + Vec2::new(34., 24.),
outer.right_bottom() - Vec2::new(10., 24.),
);
let floor = -72.;
let y = |db: f32| area.bottom() - area.height() * ((db - floor) / -floor).clamp(0., 1.);
for db in [0., -12., -24., -36., -48., -60.] {
let yy = y(db);
p.line_segment(
[Pos2::new(area.left(), yy), Pos2::new(area.right(), yy)],
Stroke::new(1.0_f32, GRID),
);
p.text(
Pos2::new(outer.left() + 6., yy),
egui::Align2::LEFT_CENTER,
format!("{db:.0}"),
FontId::proportional(10.),
DIM,
);
}
let col = area.width() / SHOWN as f32;
for k in 0..SHOWN {
let x0 = area.left() + col * k as f32;
for (i, (db, color)) in [(frame.input[k], STEEL), (frame.output[k], ACCENT)]
.into_iter()
.enumerate()
{
if db <= floor {
continue;
}
let w = col * 0.32;
let x = x0 + col * 0.16 + i as f32 * (w + col * 0.04);
p.rect_filled(
Rect::from_min_max(Pos2::new(x, y(db)), Pos2::new(x + w, area.bottom())),
0.,
color,
);
}
let marked = (k + 1) as u32 == settings.push_harmonic && settings.push_gain != 0.;
p.text(
Pos2::new(x0 + col * 0.5, area.bottom() + 4.),
egui::Align2::CENTER_TOP,
format!("{}", k + 1),
FontId::proportional(10.),
if marked { ACCENT } else { MUTED },
);
}
// Tracked filter positions, in harmonic numbers.
let mark = |h: f32, label: &str| {
if h > 0. && h <= SHOWN as f32 + 0.5 {
let x = area.left() + col * (h - 0.5);
p.line_segment(
[Pos2::new(x, area.top()), Pos2::new(x, area.bottom())],
Stroke::new(1.0_f32, FOCUS),
);
p.text(
Pos2::new(x + 4., area.top()),
egui::Align2::LEFT_TOP,
label,
FontId::proportional(10.),
FOCUS,
);
}
};
mark(settings.floor, "FLOOR");
mark(settings.bright, "BRIGHT");
p.text(
outer.left_top() + Vec2::new(34., 6.),
egui::Align2::LEFT_TOP,
"HARMONICS",
FontId::proportional(11.),
MUTED,
);
p.text(
outer.right_top() + Vec2::new(-10., 6.),
egui::Align2::RIGHT_TOP,
"IN · OUT",
FontId::proportional(11.),
MUTED,
);
p.rect_stroke(
outer,
2.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
let _ = response;
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
backdrop(ui);
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
ui.add_space(2.);
header(ui, "TEMPER", "TRACKED DISTORTION", |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.");
if r.clicked() {
settings.bypass = !settings.bypass;
}
let r = ui
.selectable_label(settings.auto_gain, RichText::new("AUTO GAIN").size(13.))
.on_hover_text(
"Match output loudness to the input, so Drive changes character, not level.",
);
if r.clicked() {
settings.auto_gain = !settings.auto_gain;
}
ui.separator();
for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] {
let r = ui.selectable_label(settings.source == source, RichText::new(label).size(13.));
if r.clicked() {
settings.source = source;
}
}
caption(ui, "PITCH FROM");
});
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(210.);
ui.set_min_height(TOP);
ui.vertical(|ui| note_panel(ui, settings, &frame));
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_min_height(TOP);
ui.vertical(|ui| {
transfer(ui, settings, 236.);
ui.add_space(4.);
ui.horizontal(|ui| {
for curve in Curve::ALL {
let r = ui.selectable_label(
settings.curve == curve,
RichText::new(curve.name().to_uppercase()).size(12.),
);
if r.clicked() {
settings.curve = curve;
}
hint(&r, state, match curve {
Curve::Soft => "Soft: tanh. Smooth, odd harmonics, rounded top.",
Curve::Hard => "Hard: clipping with a narrow knee. Dense, square-like.",
Curve::Fold => "Fold: the wave folds back on itself. Drive moves the upper harmonics.",
Curve::Tube => "Tube: the negative half clips earlier. Adds even harmonics.",
});
}
});
});
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
spectrum(ui, &frame, settings, TOP);
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
const TOP: f32 = 290.;
fn note_panel(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame) {
caption(ui, "NOTE");
let (name, hz) = match frame.note {
Some(n) if frame.hz > 0. => (common_dsp::note_name(n), format!("{:.1} Hz", frame.hz)),
_ if frame.hz > 0. => ("—".into(), format!("holding {:.1} Hz", frame.hz)),
_ => ("—".into(), "no note yet".into()),
};
ui.label(
RichText::new(name)
.font(FontId::new(54., egui::FontFamily::Name("medium".into())))
.color(if frame.note.is_some() { TEXT } else { DIM }),
);
ui.label(RichText::new(hz).monospace().size(14.).color(MUTED));
ui.add_space(6.);
ui.label(
RichText::new(format!(
"{:.0} ms lookahead · {}",
frame.latency as f32 / frame.sample_rate * 1000.,
match settings.source {
Source::Audio => "detecting pitch",
Source::Midi => "following MIDI",
}
))
.size(12.)
.color(MUTED),
);
ui.add_space(14.);
caption(ui, "INTO SHAPER");
let (rect, _) = ui.allocate_exact_size(Vec2::new(180., 10.), Sense::hover());
let p = ui.painter();
p.rect_filled(rect, 1., SCREEN);
// −24..+24 dB around the curve's knee at 0 dB.
let t = ((frame.shaper_db + 24.) / 48.).clamp(0., 1.);
let mut fill = rect;
fill.set_width(rect.width() * t);
p.rect_filled(fill, 1., ACCENT);
let knee = rect.left() + rect.width() * 0.5;
p.line_segment(
[
Pos2::new(knee, rect.top() - 2.),
Pos2::new(knee, rect.bottom() + 2.),
],
Stroke::new(1.0_f32, TEXT),
);
p.rect_stroke(
rect,
1.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
ui.label(
RichText::new(if frame.shaper_db < -60. {
"—".to_string()
} else {
format!("{:+.1} dB over the knee", frame.shaper_db)
})
.monospace()
.size(12.)
.color(TEXT),
);
ui.add_space(8.);
readout(
ui,
"NORMALIZE GAIN",
format!("{:+.1} dB", frame.normalize_db),
STEEL,
);
readout(
ui,
"AUTO GAIN",
if settings.auto_gain {
format!("{:+.1} dB", frame.auto_db)
} else {
"off".into()
},
STEEL,
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let d = Settings::default();
ui.columns(10, |c| {
knob(&mut c[0], state, "Drive", &mut settings.drive, 0. ..=36., d.drive, " dB", 80.,
"Gain into the curve. With Normalize at 100 % every note starts from the same level.");
knob(&mut c[1], state, "Bias", &mut settings.bias, 0. ..=100., d.bias, " %", 80.,
"Shift the curve's operating point: asymmetric clipping, even harmonics.");
knob(&mut c[2], state, "Push", &mut settings.push_gain, -12. ..=12., d.push_gain, " dB", 80.,
"Boost or cut one harmonic before the curve, so the distortion bites there on every note.");
knob(&mut c[3], state, "Mix", &mut settings.mix, 0. ..=100., d.mix, " %", 80.,
"Blend with the delayed dry signal.");
let mut push = settings.push_harmonic as f32;
knob(&mut c[4], state, "Push at", &mut push, 1. ..=16., d.push_harmonic as f32, "", 54.,
"Harmonic number that Push boosts or cuts.");
settings.push_harmonic = push.round().clamp(1., 16.) as u32;
knob(&mut c[5], state, "Floor", &mut settings.floor, 0. ..=2., d.floor, " ×f0", 54.,
"Highpass before the curve at this multiple of the note. 0 is off. Keeps sub mud out of the distortion.");
if settings.floor < 0.25 {
settings.floor = 0.;
}
knob(&mut c[6], state, "Bright", &mut settings.bright, 0. ..=64., d.bright, " ×f0", 54.,
"Lowpass after the curve at this harmonic. 0 is off. The top end stops at the same harmonic on every note.");
if settings.bright < 4. {
settings.bright = 0.;
}
knob(&mut c[7], state, "Normalize", &mut settings.normalize, 0. ..=100., d.normalize, " %", 54.,
"How far each note's level is evened out before Drive. 100 %: quiet and loud patches distort the same.");
knob(&mut c[8], state, "Gate", &mut settings.gate, -60. ..=-20., d.gate, " dB", 54.,
"Input level that counts as a note. Raise it if tails retrigger.");
knob(&mut c[9], state, "Output", &mut settings.output, -12. ..=12., d.output, " dB", 54.,
"Output trim.");
});
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "weld-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
crossbeam-queue = "0.3"
rustfft = "6.4"
serde = { workspace = true }
[dev-dependencies]
rustfft = "6.4"
serde_json = { workspace = true }
+429
View File
@@ -0,0 +1,429 @@
//! The measuring half of Weld. Runs on the worker thread; deterministic, so tests drive
//! it directly.
//!
//! A Welch estimate of the cross-spectrum S = F_A·conj(F_B) and both auto-spectra runs
//! over Hann-windowed frames with 50 % overlap. Both layers share one complex FFT
//! (A in the real part, B in the imaginary part).
use crate::chain::Fit;
use crate::fit::{search, Point, Problem};
use crate::report::{band_of, curve_frequency, Report, Status, BANDS};
use crate::settings::{Mode, Settings};
use rustfft::{num_complex::Complex32, Fft, FftPlanner};
use std::sync::Arc;
/// Bins below this coherence do not steer the fit.
const MIN_COHERENCE: f32 = 0.3;
/// Below this mean coherence the layers count as unrelated.
const LOOSE_COHERENCE: f32 = 0.3;
/// Frames needed before the first fit.
const MIN_FRAMES: u32 = 8;
/// A new fit replaces the current one only when it gains this much (Follow mode).
const FOLLOW_GAIN_DB: f32 = 0.3;
const LEARN_GAIN_DB: f32 = 0.1;
/// Lag search range and the smallest lag reported as "late".
const LAG_RANGE_S: f32 = 0.005;
const LATE_S: f32 = 0.0001;
const LATE_STRENGTH: f32 = 0.3;
/// Report and fit intervals in seconds of analysed audio.
const REPORT_S: f32 = 0.25;
const FIT_S: f32 = 0.5;
/// −90 dBFS: anything quieter on the sidechain counts as no signal.
const SILENCE: f32 = 3.2e-5;
/// What a poll produced.
#[derive(Debug, Clone, Copy)]
pub struct Update {
/// Set when a new alignment was accepted.
pub fit: Option<Fit>,
pub report: Report,
}
pub struct Analyzer {
rate: f32,
n: usize,
hop: usize,
window: Vec<f32>,
ring_a: Vec<f32>,
ring_b: Vec<f32>,
write: usize,
filled: usize,
since_frame: usize,
fft: Arc<dyn Fft<f32>>,
ifft: Arc<dyn Fft<f32>>,
buf: Vec<Complex32>,
scratch: Vec<Complex32>,
sab: Vec<Complex32>,
saa: Vec<f32>,
sbb: Vec<f32>,
frames: u32,
settings: Settings,
current: Fit,
since_report: usize,
since_fit: usize,
finish_learn: bool,
side_level: f32,
gated: bool,
}
impl Analyzer {
pub fn new(rate: f32, fit: Fit) -> Self {
// About 11.7 Hz per bin at any rate.
let n = ((4096. * rate / 48000.) as usize)
.next_power_of_two()
.clamp(1024, 65536);
let mut planner = FftPlanner::new();
let fft = planner.plan_fft_forward(n);
let ifft = planner.plan_fft_inverse(n);
let scratch_len = fft
.get_inplace_scratch_len()
.max(ifft.get_inplace_scratch_len());
let window = (0..n)
.map(|i| 0.5 - 0.5 * (std::f32::consts::TAU * i as f32 / n as f32).cos())
.collect();
let bins = n / 2 + 1;
Self {
rate,
n,
hop: n / 2,
window,
ring_a: vec![0.; n],
ring_b: vec![0.; n],
write: 0,
filled: 0,
since_frame: 0,
fft,
ifft,
buf: vec![Complex32::default(); n],
scratch: vec![Complex32::default(); scratch_len],
sab: vec![Complex32::default(); bins],
saa: vec![0.; bins],
sbb: vec![0.; bins],
frames: 0,
settings: Settings::default(),
current: fit.sanitized(rate),
since_report: 0,
since_fit: 0,
finish_learn: false,
side_level: 0.,
gated: false,
}
}
pub fn fft_size(&self) -> usize {
self.n
}
pub fn current(&self) -> &Fit {
&self.current
}
pub fn frames(&self) -> u32 {
self.frames
}
pub fn set_settings(&mut self, settings: Settings) {
let old = self.settings.mode;
if settings.mode == Mode::Learn && old != Mode::Learn {
self.clear();
}
if old == Mode::Learn && settings.mode != Mode::Learn {
self.finish_learn = true;
}
self.settings = settings;
}
/// Forgets the measurement.
pub fn clear(&mut self) {
self.sab.fill(Complex32::default());
self.saa.fill(0.);
self.sbb.fill(0.);
self.frames = 0;
}
/// Feeds mono samples of the main layer `a` and the sidechain layer `b`.
pub fn push(&mut self, a: &[f32], b: &[f32]) {
for (&x, &y) in a.iter().zip(b) {
self.ring_a[self.write] = x;
self.ring_b[self.write] = y;
self.write = (self.write + 1) % self.n;
self.filled = (self.filled + 1).min(self.n);
self.since_frame += 1;
if self.filled == self.n && self.since_frame >= self.hop {
self.since_frame = 0;
self.frame();
}
}
self.since_report += a.len().min(b.len());
self.since_fit += a.len().min(b.len());
}
fn frame(&mut self) {
let n = self.n;
let (mut ea, mut eb) = (0f32, 0f32);
for i in 0..n {
let j = (self.write + i) % n;
let (a, b) = (self.ring_a[j], self.ring_b[j]);
ea += a * a;
eb += b * b;
self.buf[i] = Complex32::new(a * self.window[i], b * self.window[i]);
}
let (ra, rb) = ((ea / n as f32).sqrt(), (eb / n as f32).sqrt());
self.side_level = rb;
let gate = 10f32.powf(self.settings.gate / 20.);
self.gated = ra < gate || rb < gate;
if self.gated {
return;
}
self.fft
.process_with_scratch(&mut self.buf, &mut self.scratch);
self.frames = self.frames.saturating_add(1);
let count = 1. / self.frames as f32;
let alpha = match self.settings.mode {
Mode::Learn => count,
_ => {
let follow = 1. - (-(self.hop as f32) / (self.settings.response * self.rate)).exp();
count.max(follow)
}
};
for k in 0..=n / 2 {
let x = self.buf[k];
let xc = self.buf[(n - k) % n].conj();
let fa = (x + xc) * 0.5;
let fb = (x - xc) * Complex32::new(0., -0.5);
let old = self.sab[k];
self.sab[k] = old + (fa * fb.conj() - old) * alpha;
self.saa[k] += (fa.norm_sqr() - self.saa[k]) * alpha;
self.sbb[k] += (fb.norm_sqr() - self.sbb[k]) * alpha;
}
}
fn frequency(&self, k: usize) -> f32 {
k as f32 * self.rate / self.n as f32
}
fn coherence(&self, k: usize) -> f32 {
let d = self.saa[k] * self.sbb[k];
if d > 0. {
(self.sab[k].norm_sqr() / d).min(1.)
} else {
0.
}
}
fn range(&self) -> (f32, f32) {
let high = self.settings.high.min(0.45 * self.rate);
(self.settings.low.min(high * 0.5), high)
}
/// Fit points between Low and High: one per bin at the bottom, merged to 1/32 octave
/// higher up.
fn points(&self) -> Vec<Point> {
let (low, high) = self.range();
let mut points: Vec<Point> = Vec::new();
let mut last = usize::MAX;
for k in 1..=self.n / 2 {
let f = self.frequency(k);
if f < low || f > high {
continue;
}
let slot = (32. * (f / low).log2()) as usize;
if slot != last {
points.push(Point::new(f, self.rate));
last = slot;
}
let p = points.last_mut().expect("pushed above");
if self.coherence(k) >= MIN_COHERENCE {
p.fit += self.sab[k];
}
p.all += self.sab[k];
p.power += self.saa[k] + self.sbb[k];
}
points
}
/// Produces an update once enough audio has been analysed since the last one.
pub fn poll(&mut self) -> Option<Update> {
let report_due = self.since_report as f32 >= REPORT_S * self.rate;
if !report_due && !self.finish_learn {
return None;
}
self.since_report = 0;
let fit_due = self.since_fit as f32 >= FIT_S * self.rate;
let mode = self.settings.mode;
let threshold = if self.finish_learn {
Some(LEARN_GAIN_DB)
} else if fit_due && mode == Mode::Follow {
Some(FOLLOW_GAIN_DB)
} else if fit_due && mode == Mode::Learn {
Some(LEARN_GAIN_DB)
} else {
None
};
if fit_due {
self.since_fit = 0;
}
self.finish_learn = false;
Some(self.analyse(threshold))
}
/// Measures, fits when `threshold` is given (minimum predicted gain in dB over the
/// current fit), and reports.
pub fn analyse(&mut self, threshold: Option<f32>) -> Update {
let points = self.points();
let problem = Problem::new(points, self.rate);
let (weight, coherent) = problem.points.iter().fold((0., 0.), |(w, c), p| {
let x = p.all.norm();
(w + p.w * x, c + p.w * x * coherence_of(p))
});
let mean_coherence = if weight > 0. { coherent / weight } else { 0. };
let (lag_ms, lag_strength) = self.lag();
let late = lag_ms > LATE_S * 1000. && lag_strength > LATE_STRENGTH;
let status = if self.side_level < SILENCE && self.frames == 0 {
Status::NoSidechain
} else if self.frames < MIN_FRAMES {
if self.gated {
Status::Quiet
} else {
Status::Waiting
}
} else if mean_coherence < LOOSE_COHERENCE || problem.is_empty() {
Status::Loose
} else if late {
Status::Late
} else {
Status::Aligned
};
let mut changed = None;
if let Some(threshold) = threshold {
if matches!(status, Status::Aligned | Status::Late) {
let candidate = search(&problem, self.settings.sections, &self.current);
let gain = problem.mean_gain(
&problem.bands(&self.current),
&problem.bands(&candidate.fit),
);
if gain.is_finite() && gain >= threshold && candidate.fit != self.current {
self.current = candidate.fit;
changed = Some(candidate.fit);
}
}
}
let mut report = self.report(status, &problem);
report.lag_ms = lag_ms;
report.lag_strength = lag_strength;
report.changed = changed.is_some();
Update {
fit: changed,
report,
}
}
fn report(&self, status: Status, problem: &Problem) -> Report {
let fit = self.current;
let mut report = Report {
status,
fit,
frames: self.frames,
low: self.range().0,
high: self.range().1,
..Report::default()
};
// Display bands over all bins.
// Sums per band: before, after, ideal, coherence·power, power.
let mut sums = [[0f32; 5]; BANDS];
for k in 1..=self.n / 2 {
let f = self.frequency(k);
if !(20. ..=0.45 * self.rate).contains(&f) {
continue;
}
let (hr, hi) = fit.response(f, self.rate);
let s = self.sab[k];
let p = self.saa[k] + self.sbb[k];
let b = &mut sums[band_of(f)];
b[0] += p + 2. * s.re;
b[1] += p + 2. * (s * Complex32::new(hr, hi)).re;
b[2] += p + 2. * s.norm();
b[3] += self.coherence(k) * p;
b[4] += p;
}
for (band, [before, after, ideal, coherent, power]) in report.bands.iter_mut().zip(sums) {
if ideal > 1e-12 && power > 0. {
band.before = db_ratio(before, ideal);
band.after = db_ratio(after, ideal);
band.coherence = (coherent / power).clamp(0., 1.);
band.valid = true;
}
}
// Phase curve: bins within ±1/24 octave of each point.
for (i, point) in report.curve.iter_mut().enumerate() {
let f = curve_frequency(i);
if f > 0.45 * self.rate {
continue;
}
let lo = ((f * 2f32.powf(-1. / 24.)) * self.n as f32 / self.rate).floor() as usize;
let hi = ((f * 2f32.powf(1. / 24.)) * self.n as f32 / self.rate).ceil() as usize;
let centre = (f * self.n as f32 / self.rate).round() as usize;
let (lo, hi) = (
lo.max(1).min(centre.max(1)),
hi.min(self.n / 2).max(centre.max(1)),
);
let (mut s, mut pa, mut pb) = (Complex32::default(), 0f32, 0f32);
for k in lo..=hi {
s += self.sab[k];
pa += self.saa[k];
pb += self.sbb[k];
}
if pa * pb <= 0. {
continue;
}
let (hr, hi) = fit.response(f, self.rate);
let h = Complex32::new(hr, hi);
point.before = s.arg().to_degrees();
point.after = (s * h).arg().to_degrees();
point.chain = h.arg().to_degrees();
point.coherence = (s.norm_sqr() / (pa * pb)).min(1.);
point.valid = true;
}
// Headline numbers over the fit range, every band weighted the same.
if !problem.is_empty() {
let before = problem.before_bands();
report.cost_db = problem.mean_gain(&before, &problem.ideal_bands());
report.recovered_db = problem.mean_gain(&before, &problem.bands(&fit));
}
report
}
/// Time offset from the phase transform of the cross-spectrum (GCC-PHAT) between
/// Low and High. Returns (ms, strength); positive means this layer is late.
fn lag(&mut self) -> (f32, f32) {
let n = self.n;
let (low, high) = self.range();
self.buf.fill(Complex32::default());
let mut used = 0usize;
for k in 1..n / 2 {
let f = self.frequency(k);
if f < low || f > high || self.coherence(k) < MIN_COHERENCE {
continue;
}
let s = self.sab[k];
let m = s.norm();
if m <= 0. {
continue;
}
let u = s / m;
self.buf[k] = u;
self.buf[n - k] = u.conj();
used += 1;
}
if used < 4 {
return (0., 0.);
}
self.ifft
.process_with_scratch(&mut self.buf, &mut self.scratch);
let range = (LAG_RANGE_S * self.rate) as isize;
let (mut best, mut at) = (0f32, 0isize);
for tau in -range..=range {
let v = self.buf[tau.rem_euclid(n as isize) as usize].re.abs();
if v > best {
best = v;
at = tau;
}
}
(at as f32 / self.rate * 1000., best / (2 * used) as f32)
}
}
fn coherence_of(p: &Point) -> f32 {
if p.all.norm() > 0. {
p.fit.norm() / p.all.norm()
} else {
0.
}
}
fn db_ratio(a: f32, b: f32) -> f32 {
(10. * (a.max(1e-20) / b.max(1e-20)).log10()).clamp(-40., 40.)
}
+163
View File
@@ -0,0 +1,163 @@
//! Polarity plus a cascade of second-order allpass sections. Magnitude is flat by
//! construction; only the phase of the main layer changes.
use serde::{Deserialize, Serialize};
pub const MAX_SECTIONS: usize = 4;
pub const MIN_FC: f32 = 30.;
pub const MIN_Q: f32 = 0.2;
pub const MAX_Q: f32 = 4.;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Section {
/// Centre frequency in Hz, where the section's phase passes −180°.
pub fc: f32,
pub q: f32,
}
impl Default for Section {
fn default() -> Self {
Self { fc: 1000., q: 0.7 }
}
}
/// The result of a fit: what Weld applies to the main layer.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
pub struct Fit {
/// Invert the main layer.
pub flip: bool,
/// Number of active sections, 0..=MAX_SECTIONS.
pub count: usize,
pub sections: [Section; MAX_SECTIONS],
}
impl Fit {
/// The identity: no polarity change, no sections.
pub const NONE: Fit = Fit {
flip: false,
count: 0,
sections: [Section { fc: 1000., q: 0.7 }; MAX_SECTIONS],
};
pub fn active(&self) -> &[Section] {
&self.sections[..self.count.min(MAX_SECTIONS)]
}
/// Clamps every value into the range the filter supports; repairs restored state.
pub fn sanitized(mut self, rate: f32) -> Self {
self.count = self.count.min(MAX_SECTIONS);
let top = max_fc(rate);
for s in &mut self.sections {
s.fc = if s.fc.is_finite() {
s.fc.clamp(MIN_FC, top)
} else {
1000f32.min(top)
};
s.q = if s.q.is_finite() {
s.q.clamp(MIN_Q, MAX_Q)
} else {
0.7
};
}
self
}
/// Complex response at `f` Hz as (re, im).
pub fn response(&self, f: f32, rate: f32) -> (f32, f32) {
let w = std::f32::consts::TAU * f / rate;
let (mut re, mut im) = (if self.flip { -1. } else { 1. }, 0.);
for s in self.active() {
let c = Coefs::allpass(s.fc, s.q, rate);
let (hr, hi) = c.response(w.cos(), w.sin(), (2. * w).cos(), (2. * w).sin());
(re, im) = (re * hr - im * hi, re * hi + im * hr);
}
(re, im)
}
}
pub fn max_fc(rate: f32) -> f32 {
0.45 * rate
}
/// Normalised allpass coefficients: H(z) = (a2 + a1 z⁻¹ + z⁻²) / (1 + a1 z⁻¹ + a2 z⁻²).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Coefs {
pub a1: f32,
pub a2: f32,
}
impl Coefs {
/// RBJ cookbook allpass.
pub fn allpass(fc: f32, q: f32, rate: f32) -> Self {
let fc = fc.clamp(MIN_FC, max_fc(rate));
let q = q.clamp(MIN_Q, MAX_Q);
let w = std::f32::consts::TAU * fc / rate;
let alpha = w.sin() / (2. * q);
let a0 = 1. + alpha;
Self {
a1: -2. * w.cos() / a0,
a2: (1. - alpha) / a0,
}
}
/// Response at z⁻¹ = e^{−jω}, given cos/sin of ω and 2ω.
/// For an allpass the numerator is z⁻²·conj(den), so H = z⁻²·conj(den)/den.
#[inline]
pub fn response(&self, c1: f32, s1: f32, c2: f32, s2: f32) -> (f32, f32) {
let x = 1. + self.a1 * c1 + self.a2 * c2;
let y = -(self.a1 * s1 + self.a2 * s2);
let m = x * x + y * y;
// conj(den)/den = conj(den)² / |den|²
let (qr, qi) = ((x * x - y * y) / m, -2. * x * y / m);
// times z⁻² = c2 − j s2
(qr * c2 + qi * s2, qi * c2 - qr * s2)
}
}
/// Transposed direct form II state, in f64 so sections near 30 Hz stay quiet.
#[derive(Debug, Clone, Copy, Default)]
struct State {
s1: f64,
s2: f64,
}
/// A running chain for up to two channels. Allocation-free.
#[derive(Debug, Clone)]
pub struct Chain {
fit: Fit,
sign: f32,
coefs: [(f64, f64); MAX_SECTIONS],
state: [[State; MAX_SECTIONS]; 2],
}
impl Chain {
pub fn new(fit: Fit, rate: f32) -> Self {
let mut chain = Self {
fit: Fit::NONE,
sign: 1.,
coefs: [(0., 0.); MAX_SECTIONS],
state: [[State::default(); MAX_SECTIONS]; 2],
};
chain.set(fit, rate);
chain
}
pub fn fit(&self) -> &Fit {
&self.fit
}
/// Replaces the coefficients and clears the state.
pub fn set(&mut self, fit: Fit, rate: f32) {
self.fit = fit.sanitized(rate);
self.sign = if self.fit.flip { -1. } else { 1. };
for (c, s) in self.coefs.iter_mut().zip(self.fit.active()) {
let k = Coefs::allpass(s.fc, s.q, rate);
*c = (k.a1 as f64, k.a2 as f64);
}
self.reset();
}
pub fn reset(&mut self) {
self.state = [[State::default(); MAX_SECTIONS]; 2];
}
#[inline]
pub fn process(&mut self, channel: usize, x: f32) -> f32 {
let mut y = (self.sign * x) as f64;
for (k, &(a1, a2)) in self.coefs[..self.fit.count].iter().enumerate() {
let st = &mut self.state[channel][k];
let out = a2 * y + st.s1;
st.s1 = a1 * y - a1 * out + st.s2;
st.s2 = y - a2 * out;
y = out;
}
y as f32
}
}
+372
View File
@@ -0,0 +1,372 @@
//! Fits polarity plus allpass sections to a measured cross-spectrum.
//!
//! With S = F_A·conj(F_B), the power of the sum after rotating A by H is
//! |A|² + |B|² + 2·Re(S·H). The fit maximises Σ w(f)·Re(S·H) with w = 1/f, so every
//! octave counts the same. Flipping polarity negates the sum, so the best polarity is
//! the sign of the result and only the sections need searching.
use crate::chain::{max_fc, Coefs, Fit, Section, MAX_Q, MAX_SECTIONS, MIN_FC, MIN_Q};
use crate::report::{band_of, BANDS, DISPLAY_HIGH, DISPLAY_LOW};
/// Bands for the loss penalty. Narrower bands make the penalty brittle next to the
/// notches of a comb, which a delay between the layers always has.
const FINE: usize = 10;
fn fine_of(f: f32) -> usize {
let t = (f / DISPLAY_LOW).ln() / (DISPLAY_HIGH / DISPLAY_LOW).ln();
((t * FINE as f32).max(0.) as usize).min(FINE - 1)
}
use rustfft::num_complex::Complex32;
/// One fit point: bins merged over at most 1/32 octave.
#[derive(Debug, Clone, Copy)]
pub(crate) struct Point {
pub c1: f32,
pub s1: f32,
pub c2: f32,
pub s2: f32,
/// 1/f weight.
pub w: f32,
/// Cross-spectrum of the coherent bins only; drives the objective.
pub fit: Complex32,
/// Cross-spectrum and summed auto-spectra of all bins; drive the predicted levels.
pub all: Complex32,
pub power: f32,
pub band: usize,
pub fine: usize,
}
impl Point {
pub fn new(f: f32, rate: f32) -> Self {
let w = std::f32::consts::TAU * f / rate;
Self {
c1: w.cos(),
s1: w.sin(),
c2: (2. * w).cos(),
s2: (2. * w).sin(),
w: 1. / f,
fit: Complex32::default(),
all: Complex32::default(),
power: 0.,
band: band_of(f),
fine: fine_of(f),
}
}
}
pub(crate) struct Problem {
pub points: Vec<Point>,
pub rate: f32,
/// Σ w·|S| over the coherent bins: the objective of a perfect per-bin alignment.
norm: f32,
/// Per-band level of the unprocessed sum.
before: [f32; BANDS],
/// The same in third-octave bands, for the loss penalty.
before_fine: [f32; FINE],
}
/// Score of a perfectly aligned sum is 1. Losing more than 1 dB in any band against the
/// unprocessed sum costs extra, so a fit cannot win the mids by giving away the top.
const LOSS_FREE_DB: f32 = 1.0;
const LOSS_WEIGHT: f32 = 0.05;
/// The smallest model that reaches this share of the best model's improvement wins.
const MODEL_SHARE: f32 = 0.95;
/// Improvements below this score never justify another section.
const MODEL_FLOOR: f32 = 0.001;
#[derive(Debug, Clone, Copy)]
pub struct Candidate {
pub fit: Fit,
pub score: f32,
}
impl Problem {
pub fn new(points: Vec<Point>, rate: f32) -> Self {
let norm = points.iter().map(|p| p.w * p.fit.norm()).sum::<f32>();
let mut before = [0f32; BANDS];
let mut before_fine = [0f32; FINE];
for p in &points {
before[p.band] += p.power + 2. * p.all.re;
before_fine[p.fine] += p.power + 2. * p.all.re;
}
Self {
points,
rate,
norm,
before,
before_fine,
}
}
pub fn is_empty(&self) -> bool {
self.norm <= 0.
}
fn coefs(&self, x: &[f32], out: &mut [Coefs; MAX_SECTIONS]) -> (usize, f32) {
let k = x.len() / 2;
let mut outside = 0.;
let (lo_f, hi_f) = (MIN_FC.ln(), max_fc(self.rate).ln());
let (lo_q, hi_q) = (MIN_Q.ln(), MAX_Q.ln());
for i in 0..k {
let (f, q) = (x[2 * i], x[2 * i + 1]);
outside +=
(lo_f - f).max(0.) + (f - hi_f).max(0.) + (lo_q - q).max(0.) + (q - hi_q).max(0.);
out[i] = Coefs::allpass(
f.clamp(lo_f, hi_f).exp(),
q.clamp(lo_q, hi_q).exp(),
self.rate,
);
}
(k, outside)
}
#[inline]
fn response(p: &Point, coefs: &[Coefs]) -> Complex32 {
let mut h = Complex32::new(1., 0.);
for c in coefs {
let (re, im) = c.response(p.c1, p.s1, p.c2, p.s2);
h *= Complex32::new(re, im);
}
h
}
/// Score and polarity for sections given as [ln fc, ln q, …].
fn score(&self, x: &[f32]) -> (f32, bool) {
let mut coefs = [Coefs { a1: 0., a2: 0. }; MAX_SECTIONS];
let (k, outside) = self.coefs(x, &mut coefs);
let coefs = &coefs[..k];
let mut j = 0f32;
let mut cross = [0f32; FINE];
let mut power = [0f32; FINE];
for p in &self.points {
let h = Self::response(p, coefs);
j += p.w * (p.fit * h).re;
cross[p.fine] += (p.all * h).re;
power[p.fine] += p.power;
}
let flip = j < 0.;
let sign = if flip { -1. } else { 1. };
let mut penalty = outside * outside;
for b in 0..FINE {
let before = self.before_fine[b];
if before <= 0. {
continue;
}
let after = (power[b] + 2. * sign * cross[b]).max(before * 1e-6);
let loss = 10. * (before / after).log10();
if loss > LOSS_FREE_DB {
penalty += LOSS_WEIGHT * (loss - LOSS_FREE_DB).powi(2);
}
}
(j.abs() / self.norm - penalty, flip)
}
fn candidate(&self, x: &[f32]) -> Candidate {
let (score, flip) = self.score(x);
let mut fit = Fit {
flip,
count: x.len() / 2,
..Fit::NONE
};
for (i, s) in fit.sections[..fit.count].iter_mut().enumerate() {
*s = Section {
fc: x[2 * i].exp().clamp(MIN_FC, max_fc(self.rate)),
q: x[2 * i + 1].exp().clamp(MIN_Q, MAX_Q),
};
}
Candidate { fit, score }
}
/// Level of the sum per display band with `fit` applied (sign included).
pub fn bands(&self, fit: &Fit) -> [f32; BANDS] {
let mut coefs = [Coefs { a1: 0., a2: 0. }; MAX_SECTIONS];
for (c, s) in coefs.iter_mut().zip(fit.active()) {
*c = Coefs::allpass(s.fc, s.q, self.rate);
}
let coefs = &coefs[..fit.active().len()];
let sign = if fit.flip { -1. } else { 1. };
let mut out = [0f32; BANDS];
for p in &self.points {
out[p.band] += p.power + 2. * sign * (p.all * Self::response(p, coefs)).re;
}
out
}
/// Level per display band of a perfectly aligned sum.
pub fn ideal_bands(&self) -> [f32; BANDS] {
let mut out = [0f32; BANDS];
for p in &self.points {
out[p.band] += p.power + 2. * p.all.norm();
}
out
}
/// Mean change in dB over the bands that carry signal, from `from` to `to`. Every
/// band counts the same, so a hollow octave in the mids weighs as much as the sub.
pub fn mean_gain(&self, from: &[f32; BANDS], to: &[f32; BANDS]) -> f32 {
let total: f32 = self.before.iter().sum();
let (mut sum, mut count) = (0f32, 0);
for b in 0..BANDS {
if self.before[b] > total * 1e-4 && from[b] > 0. {
sum += 10. * (to[b].max(from[b] * 1e-6) / from[b]).log10();
count += 1;
}
}
if count > 0 {
sum / count as f32
} else {
0.
}
}
pub fn before_bands(&self) -> [f32; BANDS] {
self.before
}
/// The frequency where the current sections leave the most cross-energy unaligned,
/// smoothed over one third of an octave.
fn worst_frequency(&self, x: &[f32]) -> f32 {
let mut coefs = [Coefs { a1: 0., a2: 0. }; MAX_SECTIONS];
let (k, _) = self.coefs(x, &mut coefs);
let (_, flip) = self.score(x);
let sign = if flip { -1. } else { 1. };
let mut buckets = [0f32; 48];
let mut first = None;
for p in &self.points {
let h = Self::response(p, &coefs[..k]);
let residual = p.w * (p.fit.norm() - sign * (p.fit * h).re);
let f = 1. / p.w;
let low = *first.get_or_insert(f);
let i = ((3. * (f / low).log2()) as usize).min(47);
buckets[i] += residual;
}
let low = first.unwrap_or(100.);
let best = (0..48)
.max_by(|&a, &b| buckets[a].total_cmp(&buckets[b]))
.unwrap_or(0);
low * 2f32.powf((best as f32 + 0.5) / 3.)
}
}
/// Searches 0..=`max_sections` sections, warm-started from `warm`. Deterministic.
pub(crate) fn search(problem: &Problem, max_sections: usize, warm: &Fit) -> Candidate {
let max_sections = max_sections.min(MAX_SECTIONS);
let mut best: Vec<Candidate> = Vec::with_capacity(max_sections + 1);
best.push(problem.candidate(&[]));
let mut previous: Vec<f32> = Vec::new();
for k in 1..=max_sections {
let mut seeds: Vec<Vec<f32>> = Vec::new();
let worst = problem.worst_frequency(&previous);
for f in [worst, worst * 0.4, worst * 2.5] {
let mut x = previous.clone();
x.extend([f.ln(), 0.7f32.ln()]);
seeds.push(x);
}
if k == 1 {
// Low-Q sections high up act like a short delay below their centre.
for (f, q) in [
(120f32, 0.7f32),
(400., 0.7),
(1500., 0.7),
(3000., 0.5),
(6000., 0.5),
] {
seeds.push(vec![f.ln(), q.ln()]);
}
}
if warm.count == k {
seeds.push(
warm.active()
.iter()
.flat_map(|s| [s.fc.ln(), s.q.ln()])
.collect(),
);
}
let mut top: Option<(Vec<f32>, f32)> = None;
for seed in seeds {
let (x, value) = nelder_mead(|x| -problem.score(x).0, &seed, 0.35, 120 * k * 2);
if top.as_ref().is_none_or(|(_, v)| value < *v) {
top = Some((x, value));
}
}
let (x, _) = top.expect("at least one seed");
let candidate = problem.candidate(&x);
let top = best.iter().map(|c| c.score).fold(f32::MIN, f32::max);
let gained = candidate.score - top;
let slack = MODEL_FLOOR.max((1. - MODEL_SHARE) * (top - best[0].score));
best.push(candidate);
previous = x;
// A section that adds next to nothing is not worth it; more of them rarely are.
if k >= 2 && gained < slack {
break;
}
}
let top = best.iter().map(|c| c.score).fold(f32::MIN, f32::max);
let slack = MODEL_FLOOR.max((1. - MODEL_SHARE) * (top - best[0].score));
*best
.iter()
.find(|c| c.score >= top - slack)
.expect("the best model qualifies")
}
/// Downhill simplex. Returns the best point and its value.
fn nelder_mead(
mut f: impl FnMut(&[f32]) -> f32,
x0: &[f32],
step: f32,
max_evals: usize,
) -> (Vec<f32>, f32) {
let n = x0.len();
let mut simplex: Vec<(Vec<f32>, f32)> = Vec::with_capacity(n + 1);
simplex.push((x0.to_vec(), f(x0)));
for i in 0..n {
let mut x = x0.to_vec();
x[i] += step;
let v = f(&x);
simplex.push((x, v));
}
let mut evals = n + 1;
let point = |a: &[f32], b: &[f32], t: f32| -> Vec<f32> {
a.iter().zip(b).map(|(a, b)| a + t * (b - a)).collect()
};
while evals < max_evals {
simplex.sort_by(|a, b| a.1.total_cmp(&b.1));
let spread = simplex[n].1 - simplex[0].1;
let size = simplex[1..]
.iter()
.flat_map(|(x, _)| x.iter().zip(&simplex[0].0).map(|(a, b)| (a - b).abs()))
.fold(0f32, f32::max);
if spread < 1e-7 && size < 1e-3 {
break;
}
let mut centroid = vec![0f32; n];
for (x, _) in &simplex[..n] {
for (c, v) in centroid.iter_mut().zip(x) {
*c += v / n as f32;
}
}
let worst = simplex[n].clone();
let reflected = point(&centroid, &worst.0, -1.);
let fr = f(&reflected);
evals += 1;
if fr < simplex[0].1 {
let expanded = point(&centroid, &worst.0, -2.);
let fe = f(&expanded);
evals += 1;
simplex[n] = if fe < fr {
(expanded, fe)
} else {
(reflected, fr)
};
} else if fr < simplex[n - 1].1 {
simplex[n] = (reflected, fr);
} else {
let contracted = if fr < worst.1 {
point(&centroid, &reflected, 0.5)
} else {
point(&centroid, &worst.0, 0.5)
};
let fc = f(&contracted);
evals += 1;
if fc < worst.1.min(fr) {
simplex[n] = (contracted, fc);
} else {
let best = simplex[0].0.clone();
for (x, v) in &mut simplex[1..] {
*x = point(&best, x, 0.5);
*v = f(x);
}
evals += n;
}
}
}
simplex.sort_by(|a, b| a.1.total_cmp(&b.1));
simplex.swap_remove(0)
}
+328
View File
@@ -0,0 +1,328 @@
//! Weld engine: aligns the phase of the main layer to a sidechain layer so the two sum
//! instead of cancelling. The audio thread runs polarity plus an allpass chain and hands
//! mono blocks to a worker thread, which measures the cross-spectrum and fits the chain.
//! Allocation-free on the audio thread after `new`; zero latency.
mod analyzer;
mod chain;
mod fit;
mod report;
mod settings;
mod worker;
pub use analyzer::{Analyzer, Update};
pub use chain::{Chain, Fit, Section, MAX_SECTIONS};
pub use report::{
band_edge, curve_frequency, Band, CurvePoint, Report, Status, BANDS, CURVE, DISPLAY_HIGH,
DISPLAY_LOW,
};
pub use settings::{Listen, Mode, Settings};
pub use worker::Worker;
use crossbeam_queue::ArrayQueue;
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
/// Samples per block handed to the worker.
pub const BLOCK: usize = 256;
/// Blocks the queue holds: about 340 ms at 48 kHz.
const QUEUE_BLOCKS: usize = 64;
/// A new fit runs silently this long before it is faded in, so its filters settle.
const WARM_S: f32 = 0.04;
/// Crossfade time from the old fit to the new one.
const FADE_S: f32 = 0.04;
/// Mono blocks of both layers, before Weld's processing.
#[derive(Clone, Copy)]
pub struct Block {
pub a: [f32; BLOCK],
pub b: [f32; BLOCK],
}
/// Lock-free queues between one engine and its worker.
pub struct Link {
pub(crate) blocks: ArrayQueue<Block>,
pub(crate) fits: ArrayQueue<Fit>,
pub(crate) settings: ArrayQueue<Settings>,
dropped: AtomicUsize,
}
impl Link {
fn new() -> Self {
Self {
blocks: ArrayQueue::new(QUEUE_BLOCKS),
fits: ArrayQueue::new(4),
settings: ArrayQueue::new(1),
dropped: AtomicUsize::new(0),
}
}
/// Blocks dropped because the worker fell behind.
pub fn dropped(&self) -> usize {
self.dropped.load(Ordering::Relaxed)
}
/// Takes the next block, as the worker does. For tests and offline tools.
pub fn pop_block(&self) -> Option<Block> {
self.blocks.pop()
}
/// Takes the latest settings, as the worker does. For tests and offline tools.
pub fn pop_settings(&self) -> Option<Settings> {
self.settings.pop()
}
/// Hands a fit to the engine, as the worker does. For tests and offline tools.
pub fn push_fit(&self, fit: Fit) {
self.fits.force_push(fit);
}
}
/// Levels for the editor, published a few times per second by the audio thread.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AnalyzerFrame {
/// RMS of the main input in dBFS since the last frame.
pub main_db: f32,
/// RMS of the sidechain input in dBFS since the last frame.
pub side_db: f32,
/// Peak of the sidechain input in dBFS since the last frame.
pub side_peak_db: f32,
/// True once any sidechain sample above −90 dBFS arrived since activation.
pub side_seen: bool,
/// Blocks the worker could not take in time, since activation.
pub dropped: usize,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
main_db: -120.,
side_db: -120.,
side_peak_db: -120.,
side_seen: false,
dropped: 0,
sample_rate: 48000.,
}
}
}
fn db(power_or_amp: f32, power: bool) -> f32 {
let v = power_or_amp.max(1e-12);
if power {
10. * v.log10()
} else {
20. * v.log10()
}
.max(-120.)
}
#[derive(Default)]
struct Levels {
main: f64,
side: f64,
peak: f32,
count: usize,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Phase {
Steady,
/// The next chain runs unheard for this many more samples.
Warm(usize),
/// The next chain fades in over this many more samples.
Fade(usize),
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
sent: Option<Settings>,
gain: f32,
target_gain: f32,
gain_coef: f32,
fresh: bool,
levels: Levels,
side_seen: bool,
current: Chain,
next: Chain,
phase: Phase,
warm: usize,
fade: usize,
link: Arc<Link>,
block: Block,
fill: 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");
}
Ok(Self {
rate,
channels,
settings: Settings::default(),
sent: None,
gain: 1.,
target_gain: 1.,
gain_coef: 1. - (-1. / (0.02 * rate)).exp(),
fresh: true,
levels: Levels::default(),
side_seen: false,
current: Chain::new(Fit::NONE, rate),
next: Chain::new(Fit::NONE, rate),
phase: Phase::Steady,
warm: ((WARM_S * rate) as usize).max(1),
fade: ((FADE_S * rate) as usize).max(1),
link: Arc::new(Link::new()),
block: Block {
a: [0.; BLOCK],
b: [0.; BLOCK],
},
fill: 0,
})
}
pub const fn latency_samples(&self) -> u32 {
0
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn sample_rate(&self) -> f32 {
self.rate
}
pub fn settings(&self) -> &Settings {
&self.settings
}
/// The queues for this engine's worker.
pub fn link(&self) -> Arc<Link> {
self.link.clone()
}
/// The alignment heard now (the target one during a crossfade).
pub fn fit(&self) -> Fit {
match self.phase {
Phase::Steady => *self.current.fit(),
_ => *self.next.fit(),
}
}
/// Applies `fit` at once, without a crossfade. For restoring saved state.
pub fn set_fit(&mut self, fit: Fit) {
self.current.set(fit, self.rate);
self.phase = Phase::Steady;
}
pub fn set_settings(&mut self, settings: Settings) -> Result<(), &'static str> {
settings.validate()?;
self.settings = settings;
self.target_gain = 10f32.powf(settings.output / 20.);
if self.fresh {
self.gain = self.target_gain;
self.fresh = false;
}
if self.sent != Some(settings) {
self.link.settings.force_push(settings);
self.sent = Some(settings);
}
Ok(())
}
pub fn reset(&mut self) {
self.gain = self.target_gain;
self.levels = Levels::default();
self.current.reset();
if self.phase != Phase::Steady {
std::mem::swap(&mut self.current, &mut self.next);
self.current.reset();
self.phase = Phase::Steady;
}
self.fill = 0;
}
/// One frame of main input `x` and sidechain input `side`. Mono uses index 0 only.
#[inline]
pub fn process_sample(&mut self, x: [f32; 2], side: [f32; 2]) -> [f32; 2] {
let n = self.channels;
let (mut m, mut s) = (0f32, 0f32);
for c in 0..n {
m += x[c];
s += side[c];
self.levels.peak = self.levels.peak.max(side[c].abs());
}
m /= n as f32;
s /= n as f32;
self.levels.main += (m * m) as f64;
self.levels.side += (s * s) as f64;
self.levels.count += 1;
if s.abs() > 3e-5 {
self.side_seen = true;
}
self.feed(m, s);
if self.phase == Phase::Steady {
if let Some(fit) = self.link.fits.pop() {
self.next.set(fit, self.rate);
self.phase = Phase::Warm(self.warm);
}
}
let mut aligned = [0f32; 2];
let mix = match self.phase {
Phase::Steady => None,
Phase::Warm(_) => Some(0.),
Phase::Fade(left) => Some(1. - left as f32 / self.fade as f32),
};
for c in 0..n {
let a = self.current.process(c, x[c]);
aligned[c] = match mix {
None => a,
Some(t) => {
let b = self.next.process(c, x[c]);
a + t * (b - a)
}
};
}
self.phase = match self.phase {
Phase::Steady => Phase::Steady,
Phase::Warm(1) => Phase::Fade(self.fade),
Phase::Warm(left) => Phase::Warm(left - 1),
Phase::Fade(1) => {
std::mem::swap(&mut self.current, &mut self.next);
Phase::Steady
}
Phase::Fade(left) => Phase::Fade(left - 1),
};
self.gain += (self.target_gain - self.gain) * self.gain_coef;
if self.settings.bypass {
return x;
}
let mut out = [0.; 2];
for c in 0..n {
out[c] = self.gain
* match self.settings.listen {
Listen::Layer => aligned[c],
Listen::Sum => aligned[c] + side[c],
Listen::SumDry => x[c] + side[c],
};
}
out
}
#[inline]
fn feed(&mut self, a: f32, b: f32) {
self.block.a[self.fill] = a;
self.block.b[self.fill] = b;
self.fill += 1;
if self.fill == BLOCK {
self.fill = 0;
if self.link.blocks.push(self.block).is_err() {
self.link.dropped.fetch_add(1, Ordering::Relaxed);
}
}
}
/// Levels since the previous call.
pub fn take_analyzer(&mut self) -> AnalyzerFrame {
let n = self.levels.count.max(1) as f64;
let frame = AnalyzerFrame {
main_db: db((self.levels.main / n) as f32, true),
side_db: db((self.levels.side / n) as f32, true),
side_peak_db: db(self.levels.peak, false),
side_seen: self.side_seen,
dropped: self.link.dropped(),
sample_rate: self.rate,
};
self.levels = Levels::default();
frame
}
}
+108
View File
@@ -0,0 +1,108 @@
//! What the analysis tells the editor. Plain `Copy` data, so it crosses threads through
//! a lock-free queue without allocating.
use crate::chain::Fit;
/// Display bands, equally wide on a log axis from `DISPLAY_LOW` to `DISPLAY_HIGH`.
pub const BANDS: usize = 10;
/// Points of the phase curve.
pub const CURVE: usize = 96;
pub const DISPLAY_LOW: f32 = 30.;
pub const DISPLAY_HIGH: f32 = 12000.;
/// Index of the display band that contains `f` (clamped to the outer bands).
pub fn band_of(f: f32) -> usize {
let t = (f / DISPLAY_LOW).ln() / (DISPLAY_HIGH / DISPLAY_LOW).ln();
((t * BANDS as f32).max(0.) as usize).min(BANDS - 1)
}
/// Lower edge of display band `i`; `band_edge(BANDS)` is the top edge.
pub fn band_edge(i: usize) -> f32 {
DISPLAY_LOW * (DISPLAY_HIGH / DISPLAY_LOW).powf(i as f32 / BANDS as f32)
}
/// Frequency of curve point `i`.
pub fn curve_frequency(i: usize) -> f32 {
DISPLAY_LOW * (DISPLAY_HIGH / DISPLAY_LOW).powf(i as f32 / (CURVE - 1) as f32)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Status {
/// Not enough measured audio yet.
#[default]
Waiting,
/// The sidechain carries no signal.
NoSidechain,
/// Both layers play, but one of them is under the gate.
Quiet,
/// The layers are not related closely enough to align (free-running oscillators,
/// different notes, noise).
Loose,
/// This layer is later than the sidechain layer. An allpass cannot move it earlier.
Late,
/// Measuring and aligning.
Aligned,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Band {
/// Level of the unprocessed sum relative to a perfectly aligned sum, in dB (≤ 0).
pub before: f32,
/// Level of the sum with Weld's alignment, relative to a perfect one, in dB (≤ 0).
pub after: f32,
/// Power-weighted coherence of the two layers, 0..1.
pub coherence: f32,
/// False when the band carries no measured signal.
pub valid: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct CurvePoint {
/// Phase of the main layer relative to the sidechain layer, degrees, ±180.
pub before: f32,
/// The same with Weld's alignment applied.
pub after: f32,
/// Phase of Weld's chain, degrees, ±180.
pub chain: f32,
pub coherence: f32,
pub valid: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Report {
pub status: Status,
/// The alignment currently applied (or about to be).
pub fit: Fit,
/// True when this report comes with a newly accepted fit.
pub changed: bool,
pub bands: [Band; BANDS],
pub curve: [CurvePoint; CURVE],
/// What the cancellation costs between Low and High: aligned sum over unprocessed sum.
pub cost_db: f32,
/// What Weld wins back between Low and High: aligned-by-Weld sum over unprocessed sum.
pub recovered_db: f32,
/// Time offset of this layer against the sidechain layer, ms. Positive: this layer
/// is late.
pub lag_ms: f32,
/// How clear the time offset is, 0..1.
pub lag_strength: f32,
/// Measured frames in the current average.
pub frames: u32,
pub low: f32,
pub high: f32,
}
impl Default for Report {
fn default() -> Self {
Self {
status: Status::Waiting,
fit: Fit::NONE,
changed: false,
bands: [Band::default(); BANDS],
curve: [CurvePoint::default(); CURVE],
cost_db: 0.,
recovered_db: 0.,
lag_ms: 0.,
lag_strength: 0.,
frames: 0,
low: 60.,
high: 6000.,
}
}
}
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more