feat(braze): add Braze tempo-locked beating
Check and Windows preview / check (anvil) (push) Successful in 2m15s
Check and Windows preview / check (braze) (push) Successful in 2m26s
Check and Windows preview / check (crucible) (push) Successful in 1m52s
Check and Windows preview / check (knurl) (push) Successful in 1m42s
Check and Windows preview / check (lathe) (push) Successful in 1m44s
Check and Windows preview / check (patina) (push) Successful in 2m38s
Check and Windows preview / check (planish) (push) Successful in 1m29s
Check and Windows preview / check (rivet) (push) Successful in 1m39s
Check and Windows preview / check (temper) (push) Successful in 1m37s
Check and Windows preview / check (weld) (push) Canceled after 2s
Check and Windows preview / previews (push) Canceled after 0s
Check and Windows preview / windows (push) Canceled after 0s

Adds a detuned copy of the bass whose beating stays on the tempo grid on
every note. The chosen harmonic k beats once per Rate note value, and
harmonic n beats n/k times as fast, so every harmonic is on the grid.

- The copy is read from a delay line whose delay falls one period of the
  tracked note per beat cycle (r = 1 + B/(k f0)) and jumps back exactly
  one period, crossfaded over 6 ms. On a periodic note the jump has no
  seam: the output matches the ideal sum within 0.003 on harmonics 1-4.
- The delay follows the host's beat position, shifted by the lookahead,
  so beat peaks land on the grid; free-running when stopped.
- Low cut: LR4 high-pass on the copy, the bass split by the same
  crossover, so the sub does not beat and keeps its level.
- Depth, Width, Phase, Gate, Output, Snap, Bypass. Pitch from audio
  (about 67 ms lookahead) or MIDI.
- Editor: one bar of the beat per harmonic with the grid and a playhead.
- Catalog, workflows, editor-check, docs, signal chain and site copy.
This commit is contained in:
nvrl committed 2026-11-06 14:02:58 +01:00
1 parent e7f3346077
commit 330ec54150
28 files changed
+4565 -18

No files matched your search

+3 -1
View File
@@ -22,7 +22,7 @@ jobs:
strategy:
fail-fast: false
matrix:
plugin: [rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish]
plugin: [rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish, braze]
steps:
- uses: https://github.com/actions/checkout@v4
with:
@@ -63,6 +63,7 @@ jobs:
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/weld.png --plugin weld
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/knurl.png --plugin knurl
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/planish.png --plugin planish
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/braze.png --plugin braze
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
name: editor-previews
@@ -95,6 +96,7 @@ jobs:
python3 scripts/build-plugin.py --plugin weld --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin knurl --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin planish --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin braze --target x86_64-pc-windows-gnu
python3 scripts/package-windows.py
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
+1 -1
View File
@@ -1,7 +1,7 @@
name: Independent plugin release
on:
push:
tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*", "lathe-v*", "temper-v*", "weld-v*", "knurl-v*", "planish-v*"]
tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*", "lathe-v*", "temper-v*", "weld-v*", "knurl-v*", "planish-v*", "braze-v*"]
permissions:
contents: read
Generated
+19
View File
@@ -61,6 +61,25 @@ version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "braze-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
"serde_json",
]
[[package]]
name = "braze-ui"
version = "0.1.0"
dependencies = [
"braze-core",
"common-dsp",
"common-ui",
"egui",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/lathe/core", "plugins/lathe/ui", "plugins/temper/core", "plugins/temper/ui", "plugins/weld/core", "plugins/weld/ui", "plugins/knurl/core", "plugins/knurl/ui", "plugins/planish/core", "plugins/planish/ui", "plugins/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"]
members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/lathe/core", "plugins/lathe/ui", "plugins/temper/core", "plugins/temper/ui", "plugins/weld/core", "plugins/weld/ui", "plugins/knurl/core", "plugins/knurl/ui", "plugins/planish/core", "plugins/planish/ui", "plugins/braze/core", "plugins/braze/ui", "plugins/crucible/core", "plugins/crucible/ui", "plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"]
exclude = ["plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin", "plugins/anvil/plugin", "plugins/crucible/plugin", "plugins/lathe/plugin", "plugins/temper/plugin", "plugins/weld/plugin", "plugins/knurl/plugin", "plugins/planish/plugin"]
[workspace.package]
+6 -4
View File
@@ -29,9 +29,10 @@ note. Each plugin is a separate product with its own version, guide and release.
| **[Weld](docs/weld/README.md)** | Phase-aligns one bass layer to another layer through the sidechain, so the layers sum in the mids instead of cancelling | One layer's track, other layer as sidechain | none | [Guide](docs/weld/README.md) · [Changes](docs/weld/CHANGELOG.md) |
| **[Knurl](docs/knurl/README.md)** | Gives every cycle of the note its own level, timing and grit: in-tune subharmonic growl, rattle and fry on every note | Bass | ≈ 71 ms | [Guide](docs/knurl/README.md) · [Changes](docs/knurl/CHANGELOG.md) |
| **[Planish](docs/planish/README.md)** | Upward and downward compression (OTT) in bands set by harmonic number: band 2 holds harmonics 2–4 on every note | Bass | ≈ 67 ms | [Guide](docs/planish/README.md) · [Changes](docs/planish/CHANGELOG.md) |
| **[Braze](docs/braze/README.md)** | Adds a detuned copy whose beating stays on the tempo grid on every note: reese movement in time | Bass | ≈ 67 ms | [Guide](docs/braze/README.md) · [Changes](docs/braze/CHANGELOG.md) |
Plugins with latency report it to the host, so FL Studio compensates the delay.
Anvil, Lathe, Temper, Knurl and Planish detect pitch from the audio. They can also take the
Anvil, Lathe, Temper, Knurl, Planish and Braze detect pitch from the audio. They can also take the
pitch from MIDI. The order of the plugins matters: see [Signal chain](docs/chain.md).
The names come from metalwork: a **rivet** joins pieces; **patina** changes a
@@ -40,7 +41,7 @@ is struck on; a **crucible** melts material down to be cast again; a **lathe**
turns a piece so each cut lands the same all the way round; **tempering** sets
how hard and how tough a metal ends up; a **weld** fuses two pieces into one;
**knurling** presses a ridged grip pattern into a surface; **planishing**
hammers a surface smooth and even.
hammers a surface smooth and even; **brazing** joins two metals with a filler.
The suite is the **foundry**: the workshop where the bass gets cast, shaped and
finished. Hosts list the plugins under the vendor name *Bass Foundry*.
@@ -65,7 +66,7 @@ finished. Hosts list the plugins under the vendor name *Bass Foundry*.
</tr>
<tr>
<td><a href="docs/planish/README.md"><img src="docs/planish/assets/editor.png" alt="Planish editor"></a><br><b>Planish</b>: harmonic OTT</td>
<td></td>
<td><a href="docs/braze/README.md"><img src="docs/braze/assets/editor.png" alt="Braze editor"></a><br><b>Braze</b>: tempo-locked beating</td>
</tr>
</table>
@@ -125,7 +126,7 @@ cargo run --manifest-path tools/editor-check/Cargo.toml -- target/editor/lathe.p
```
Plugin names for the scripts are `rivet`, `patina`, `anvil`, `crucible`,
`lathe`, `temper`, `weld`, `knurl` and `planish`. Builds go to `dist/<target>/<Name>.clap`. No
`lathe`, `temper`, `weld`, `knurl`, `planish` and `braze`. Builds go to `dist/<target>/<Name>.clap`. No
script installs anything into a DAW. [Development and testing](docs/development.md)
has the full command list.
@@ -164,6 +165,7 @@ plugins/
weld/ core · ui · plugin
knurl/ core · ui · plugin
planish/ core · ui · plugin
braze/ core · ui · plugin
common/
ui/ Shared look: palette, embedded fonts, knobs, panels, meters
dsp/ Shared filters, pitch tracking, oversampling, meters, WAV files
+18
View File
@@ -0,0 +1,18 @@
# Braze changelog
## Unreleased (0.1.0)
- Introduce Braze, a detuned copy of the bass whose beating stays on the tempo
grid on every note.
- The chosen harmonic (1–8) beats once per Rate note value (1/1 to 1/32, and
triplets); harmonic n beats n/harmonic times as fast, so every harmonic beats
on the grid. The beat peaks land on the host's beat position.
- The detune follows the tracked note. The copy jumps by exactly one period of
the note, so the jumps are seamless on a steady note.
- Low cut keeps the copy out of the sub, so the sub neither beats nor changes
level.
- Depth, Width (right channel's beat against the left), Phase, Gate, Output,
Snap to semitone, Bypass.
- Detect pitch and note starts from the audio with about 67 ms of latency, or
follow MIDI notes sent to the effect.
- Editor: one bar of the beat per harmonic with the grid and a playhead.
+77
View File
@@ -0,0 +1,77 @@
# Braze
Braze makes a reese's movement play in time. Current version **0.1.0**
(unreleased).
Two layers detuned against each other beat: harmonic k of a note at f0 swells
and fades k·f0·(r − 1) times per second, where r is the detune ratio. With a
fixed detune that rate changes with every note, so the movement of a reese
never lines up with the track. Braze adds a detuned copy of the bass and sets
its detune on every note so that the beating follows the tempo.
![Braze](assets/editor.png)
## Install on Windows
Copy `Braze.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio 2024.1
or newer, open Options → Manage plugins, enable Verify plugins, and Find installed
plugins. Load Braze in the mixer slot of the bass. Keep the font license with
redistributed packages.
## Work with it
1. Put Braze on a single bass layer: a plain saw, or a reese whose own detune
you have turned down.
2. Pick **Rate**: the note value one beat lasts. 1/8 at 140 BPM is 4.67 beats
per second.
3. Pick the **harmonic** that should beat at Rate. Harmonic n beats
n/harmonic times as fast, so all of them beat on the grid: with harmonic 4 at
1/8, harmonic 2 beats in quarters, harmonic 8 in sixteenths, harmonic 3 in
quarter triplets.
4. Set **Depth**. At 100 % the beating goes down to full nulls; 50–70 % keeps
the note present.
5. Use **Phase** to move where the beat peaks. At 0° all harmonics peak
together on every beat of the chosen harmonic's cycle.
The display shows one bar: a row per harmonic with the level it gets from the
beating, the grid at Rate, and the playhead.
Other controls:
- **Low cut** removes the copy below a frequency, so the sub does not beat.
The sub keeps its level. Harmonics under the cut show *cut* in the display.
Under 20 Hz the cut is off. If the chosen harmonic is under the cut on a
note, it does not beat: the note panel says so; pick a higher harmonic.
- **Width** moves the right channel's beat against the left, up to half a
cycle. At 100 % one side peaks while the other nulls. Check the mono sum.
- **Gate** is the input level that counts as a note.
- **Snap to semitone** rounds the detected pitch. Leave it off on detuned or
bent notes: the copy jumps by one period of the pitch in use.
### How the detune is made
The copy is the bass read from a delay line. Its delay falls by one period of
the note per beat cycle of the chosen harmonic, which tunes the copy up by
exactly r = 1 + B/(k·f0) (B: beats per second, k: the chosen harmonic). When it
has fallen a whole period, it jumps back by one period: on a steady note both
read positions hold the same waveform, so the jump is crossfaded over 6 ms
without a seam. At a beat peak the copy lines up with the bass sample for
sample.
The low detune that low notes need is large: harmonic 1 at 1/8 and 140 BPM on
F1 (43.7 Hz) is r = 1.107, about 1.8 semitones. Higher harmonics need less:
harmonic 4 asks for about 45 cents on F1.
## Timing and limits
Detection uses about 67 ms of lookahead, reported to the host for delay
compensation. Pitch range is 30–500 Hz, one voice. The beat position comes from
the host while it plays, shifted by the lookahead so the beat stays on the
timeline. When the host is stopped, the beat runs on freely at the last tempo.
Noise, chords and the attack of a note are not periodic, so the copy's jumps
are heard there as a short blur. Before the first note, the copy assumes 55 Hz.
Braze changes the waveform in time, so pitch detection after it can be less
sure: put tracked plugins before Braze, or use MIDI. See
[Signal chain](../chain.md).
Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

+10 -4
View File
@@ -1,13 +1,13 @@
# Signal chain
The order of the Bass Foundry plugins in a chain matters. Anvil, Lathe, Temper,
Knurl and Planish each detect the pitch from the audio they receive. If a plugin earlier
Knurl, Planish and Braze each detect the pitch from the audio they receive. If a plugin earlier
in the chain changes the audio, the pitch detection after it can go wrong.
## Recommended order
```text
bass track: synth → Lathe → Temper → Planish → Knurl → Patina → Weld
bass track: synth → Lathe → Temper → Planish → Braze → Knurl → Patina → Weld
bass bus: Anvil → (bus compression, clipping) → Crucible
```
@@ -16,6 +16,12 @@ bass bus: Anvil → (bus compression, clipping) → Crucible
- **Planish after the distortion, before Knurl.** OTT usually comes after
distortion. Planish's band edges follow the tracked note, so it also needs a
clear pitch: after Knurl, use MIDI.
- **Braze after Planish.** Its copy beats against the bass, so a pitch
detector after it is less sure: Knurl after Braze works best in MIDI mode.
Weld after Braze sees the beating as a looser match between the layers; if
it reads Loose, move Braze to the bus after Anvil, with Low cut at or above
Anvil's Split. There it reads the pitch from Anvil's clean sub, and the sub
stays out of the beating.
- **Knurl last among the tracked plugins.** See below.
- **Patina after the tracked plugins.** It does not detect pitch, so the order
does not change its result.
@@ -45,8 +51,8 @@ and route the bass's MIDI notes to it.
## Latency
Each tracked plugin adds its own lookahead: Anvil 80 ms, Lathe, Temper and
Planish about 67 ms, Knurl about 71 ms, Patina about 45 ms. A full chain is
Each tracked plugin adds its own lookahead: Anvil 80 ms, Lathe, Temper,
Planish and Braze about 67 ms, Knurl about 71 ms, Patina about 45 ms. A full chain is
about 400 ms. FL
Studio compensates this for playback and rendering, but playing a MIDI keyboard
through the full chain is not practical. Write with a shorter chain and add the
+3 -2
View File
@@ -25,7 +25,7 @@ Patina has `render` for offline WAV evaluation. The shared crates are:
- `common-ui`: palette, embedded fonts, header, panels, knobs, meters.
- `common-dsp`: filters, YIN pitch tracking, the note tracker used by Anvil,
Lathe, Temper, Knurl and Planish, 4× oversampling, meters and WAV files.
Lathe, Temper, Knurl, Planish and Braze, 4× oversampling, meters and WAV files.
The wrappers stay separate workspaces, each with its own lockfile, so the
NIH-plug Git dependency stays out of the root workspace. Host identities and
@@ -45,7 +45,7 @@ python3 -m unittest discover -s scripts/tests -v
```
Products: `rivet`, `patina`, `anvil`, `crucible`, `lathe`, `temper`, `weld`,
`knurl`, `planish`.
`knurl`, `planish`, `braze`.
`check-plugin.py` runs formatting, tests and clippy for one product's packages
and its wrapper, without compiling the other products. Run it for every product
@@ -145,6 +145,7 @@ identify plugins by CLAP ID, so the vendor name does not affect saved projects.
| Weld | (new) | `io.narl.bass-foundry.weld` |
| Knurl | (new) | `io.narl.bass-foundry.knurl` |
| Planish | (new) | `io.narl.bass-foundry.planish` |
| Braze | (new) | `io.narl.bass-foundry.braze` |
CLAP IDs use the form `io.narl.bass-foundry.<product>`. Builds tagged before
this scheme used `io.storyboard.*` IDs; hosts treat the new IDs as different
+14 -1
View File
@@ -263,7 +263,8 @@ one period of the note, so heavy settings do not ripple at f0. See [Planish](pla
- In parallel, from the research findings, in this order: Weld delay
spread (done, unreleased); the fatness lane, as the first Assay piece
(prototype `scripts/fatness.py`); Anvil Ghost harmonics and Phone listen
(done, unreleased); Braze; Anvil Kick lock with offset audition.
(done, unreleased); Braze (done, unreleased); Anvil Kick lock with offset
audition.
4. Forge, when enough modules exist.
5. Assay, one plugin for the display-only ideas.
6. Texture plugins (Anneal, Swarf, Etch, Bellows) and the note-keyed sequencer.
@@ -296,6 +297,18 @@ beats at the same rate. Controls: rate (synced), target harmonic, depth,
spread across harmonics, mode. It reuses the tracker, and Weld should not
"fix" this beating: put Weld before Braze.
Built (unreleased 0.1.0, see [Braze](braze/README.md)). The detune is a delay
line whose delay falls one period per beat cycle and jumps back one period,
which is seamless on a periodic note, and the delay follows the host's beat
position, so the peaks land on the grid. Harmonic n then beats at n/k · B: every
harmonic is on the grid, so the planned spread control was not needed. A Low
cut (LR4 on the copy, the bass split by the same crossover) keeps the sub out.
Open: the frequency-shift mode. Mixed with the dry bass, a plain single-sideband
shift gives every harmonic the same envelope at the same moment, which is
tremolo; it needs a dispersive allpass on the copy to stagger the harmonics
before it sounds like movement. Also open: a listening check on a real reese,
and how Weld reads a layer after Braze.
### Arc — self-FM growl (research spike)
An arc welds with a sustained spark. Arc runs loopback FM (Smyth, DAFx 2026)
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "braze-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
common-dsp = { path = "../../common/dsp" }
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+356
View File
@@ -0,0 +1,356 @@
//! Tempo-locked beating: adds a detuned copy of the bass whose beating stays in time with
//! the track on every note.
//!
//! Two layers detuned by a ratio r beat at k·f0·(r − 1) on harmonic k, so a fixed detune
//! beats at a different rate on every note. Braze sets the detune per note from the tracked
//! fundamental: harmonic `harmonic` beats once per `rate` note value, and harmonic n beats
//! n/harmonic times as fast, so every harmonic beats on the tempo grid.
//!
//! The copy is the bass read from a delay line whose delay falls by one period of the note
//! per beat cycle of the chosen harmonic. That shifts the copy up by exactly the wanted
//! ratio. When the delay has fallen by a whole period it jumps back by one period: for a
//! periodic note both read positions hold the same waveform, so the jump is crossfaded over
//! a few milliseconds without a seam. The delay is set from the host's beat position, so
//! the beat's peaks land on the grid, not just at the right rate. At a beat peak the copy
//! lines up with the bass exactly.
//!
//! Low cut removes the copy below a frequency with a 4th-order Linkwitz–Riley high-pass,
//! so the sub does not beat. The bass is split by the same crossover; its two bands are in
//! phase with the copy's high-pass at every frequency, so the beat peaks stay on the grid,
//! and the loudness correction for the copy applies above the cut only, so the sub keeps
//! its level.
mod settings;
use common_dsp::svf::{Svf, SvfCoef};
use common_dsp::tracker::{PitchFollower, Tracker, TrackerConfig};
use common_dsp::{coefficient, db_to_gain, hz_to_midi};
pub use settings::{Rate, Settings, Source, HARMONIC_MAX, LOW_CUT_MAX, LOW_CUT_OFF};
pub const MIN_HZ: f32 = TrackerConfig::BASS.min_hz;
pub const MAX_HZ: f32 = TrackerConfig::BASS.max_hz;
/// Fundamental the copy assumes before the first note.
pub const FALLBACK_HZ: f32 = 55.;
/// Tempo when the host reports none.
pub const DEFAULT_TEMPO: f64 = 120.;
/// Crossfade between the old and the new read position.
const FADE_MS: f32 = 6.;
/// A beat position that moves further than this (in cycles of the chosen harmonic) from
/// one sample to the next counts as a jump: a locate, a loop, or a changed control.
const JUMP: f64 = 1e-3;
/// Pitch smoothing for bends and slides.
const PITCH_MS: f32 = 5.;
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
/// Fundamental the copy is tuned to, 0 before the first note.
pub hz: f32,
/// Sounding note, rounded; None between notes.
pub note: Option<u8>,
/// Beat position at the output, in quarter notes.
pub beats: f64,
pub tempo: f64,
/// True when the beat position comes from the host's transport.
pub synced: bool,
/// Detune of the copy as a ratio, 1 before the first note.
pub ratio: f32,
pub latency: u32,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
hz: 0.,
note: None,
beats: 0.,
tempo: DEFAULT_TEMPO,
synced: false,
ratio: 1.,
latency: 0,
sample_rate: 48000.,
}
}
}
/// Beat cycles of the chosen harmonic at `beats` quarter notes, for one channel.
pub fn cycles(s: &Settings, beats: f64, channel: usize) -> f64 {
beats / s.rate.quarters() + s.phase as f64 / 360. + channel as f64 * s.width as f64 * 0.005
}
/// Level of harmonic `n` in the sum, against the bass alone, at `cycles` of the chosen
/// harmonic, with the copy at `depth` (0–1). Includes the output's loudness correction.
pub fn envelope(s: &Settings, n: u32, cycles: f64, depth: f32) -> f32 {
let theta = std::f64::consts::TAU * n as f64 / s.harmonic as f64 * cycles;
let (re, im) = (1. + depth as f64 * theta.cos(), depth as f64 * theta.sin());
((re * re + im * im).sqrt() / (1. + depth as f64 * depth as f64).sqrt()) as f32
}
#[derive(Clone, Copy, Debug, Default)]
struct Tap {
/// Delay in samples.
delay: f32,
/// Delay change per sample, so a fading tap keeps its course.
slope: f32,
}
#[derive(Clone, Copy, Debug, Default)]
struct Channel {
active: Tap,
old: Tap,
/// Weight of the old tap, falling to 0.
fade: f32,
/// Whole cycles the active tap's delay is counted from.
whole: f64,
/// Beat cycle position (`cycles / harmonic + 0.5`) of the previous sample.
last: f64,
started: bool,
copy_high: [Svf; 2],
high: [Svf; 2],
low: [Svf; 2],
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
latency: usize,
audio: Vec<[f32; 2]>,
audio_at: usize,
tracker: Tracker,
pitch: PitchFollower,
pitch_coef: f32,
hz: f32,
beats: f64,
tempo: f64,
synced: bool,
state: [Channel; 2],
/// High-pass and low-pass of the low cut's crossover; None when off.
low_cut: Option<(SvfCoef, SvfCoef)>,
fade_step: f32,
smooth: f32,
depth: f32,
output: f32,
bypass: f32,
fresh: bool,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(8000.0..=768000.).contains(&rate) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
let tracker = Tracker::new(rate, TrackerConfig::BASS);
let latency = tracker.latency();
// The copy reads up to half a period (plus a fade) on either side of the dry path.
let reach = (rate / MIN_HZ).ceil() as usize + 8;
let mut e = Self {
rate,
channels,
settings: Settings::default(),
latency,
audio: vec![[0.; 2]; latency + reach],
audio_at: 0,
tracker,
pitch: PitchFollower::default(),
pitch_coef: coefficient(rate, PITCH_MS),
hz: 0.,
beats: 0.,
tempo: DEFAULT_TEMPO,
synced: false,
state: [Channel::default(); 2],
low_cut: None,
fade_step: 1. / (rate * FADE_MS * 0.001),
smooth: coefficient(rate, 10.),
depth: 0.,
output: 1.,
bypass: 0.,
fresh: true,
};
e.set_settings(Settings::default())?;
e.fresh = true;
Ok(e)
}
pub fn latency_samples(&self) -> u32 {
self.latency as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.settings
}
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
s.validate()?;
self.tracker.set(s.source == Source::Midi, s.gate, true);
if s.low_cut != self.settings.low_cut || self.fresh {
let q = std::f32::consts::FRAC_1_SQRT_2;
let hz = s.low_cut.min(0.45 * self.rate);
self.low_cut = (s.low_cut >= LOW_CUT_OFF).then(|| {
(
SvfCoef::highpass(self.rate, hz, q),
SvfCoef::lowpass(self.rate, hz, q),
)
});
}
self.settings = s;
if std::mem::take(&mut self.fresh) {
self.depth = s.depth * 0.01;
self.output = db_to_gain(s.output);
self.bypass = f32::from(s.bypass);
}
Ok(())
}
/// Tempo and, while the host plays, its beat position in quarter notes at the first
/// input sample of the next block. Without a position the beat runs on freely.
pub fn set_transport(&mut self, tempo: Option<f64>, position: Option<f64>) {
self.tempo = tempo
.filter(|t| t.is_finite() && *t > 0.)
.unwrap_or(DEFAULT_TEMPO)
.clamp(20., 999.);
match position.filter(|p| p.is_finite()) {
Some(p) => {
// The output lags the input by the lookahead, and the host compensates it:
// what leaves now belongs `latency` samples earlier on the timeline.
self.beats = p - self.latency as f64 / self.rate as f64 * self.tempo / 60.;
self.synced = true;
}
None => self.synced = false,
}
}
pub fn reset(&mut self) {
self.audio.fill([0.; 2]);
self.tracker.reset();
self.pitch = PitchFollower::default();
self.hz = 0.;
self.state = [Channel::default(); 2];
self.fresh = true;
}
pub fn note_on(&mut self, note: u8) {
self.tracker.note_on(note);
}
pub fn note_off(&mut self, note: u8) {
self.tracker.note_off(note);
}
/// The audio `back` samples (fractional) before the newest one, cubic Hermite.
#[inline]
fn read(&self, back: f32, c: usize) -> f32 {
let len = self.audio.len();
let back = back.clamp(1., (len - 3) as f32);
let whole = back.floor();
let t = back - whole;
let base = whole as usize;
let at = |back: usize| self.audio[(self.audio_at + len - back) % len][c];
// y0 is one sample newer than y1; y2 and y3 are older.
let (y0, y1, y2, y3) = (at(base - 1), at(base), at(base + 1), at(base + 2));
let c1 = 0.5 * (y2 - y0);
let c2 = y0 - 2.5 * y1 + 2. * y2 - 0.5 * y3;
let c3 = 0.5 * (y3 - y0) + 1.5 * (y1 - y2);
((c3 * t + c2) * t + c1) * t + y1
}
// Channels index the input, the state and the output at once.
#[allow(clippy::needless_range_loop)]
pub fn process_sample(&mut self, x: [f32; 2]) -> [f32; 2] {
let mono = if self.channels == 2 {
0.5 * (x[0] + x[1])
} else {
x[0]
};
let (pitch, snap) = (&mut self.pitch, self.settings.snap);
self.tracker.process(mono, |e| pitch.event(snap, e));
let (hz, jump) = self.pitch.step(self.pitch_coef);
self.hz = hz;
let len = self.audio.len();
self.audio_at = (self.audio_at + 1) % len;
self.audio[self.audio_at] = x;
let s = self.settings;
approach(&mut self.depth, s.depth * 0.01, self.smooth);
approach(&mut self.output, db_to_gain(s.output), self.smooth);
approach(&mut self.bypass, f32::from(s.bypass), self.smooth);
let period = self.rate / if hz > 0. { hz } else { FALLBACK_HZ };
let step = self.tempo / 60. / self.rate as f64 / s.rate.quarters() / s.harmonic as f64;
let centre = self.latency as f32;
let mut out = [0.; 2];
for c in 0..self.channels {
// Position in beat cycles of the chosen harmonic, shifted so a cycle starts at
// the delay's highest point and the beat peaks (delay = centre) at cycle 0.
let u = cycles(&s, self.beats, c) / s.harmonic as f64 + 0.5;
let ch = self.state[c];
let moved = (u - ch.last - step).abs() > JUMP;
let mut ch = ch;
if !ch.started || jump || moved || u.floor() != ch.whole {
if ch.started {
ch.old = ch.active;
ch.fade = 1.;
}
ch.whole = u.floor();
ch.started = true;
}
let delay = centre + period * (0.5 - (u - ch.whole) as f32);
ch.active.slope = if ch.fade == 1. {
0.
} else {
delay - ch.active.delay
};
ch.active.delay = delay;
ch.last = u;
let mut copy = self.read(ch.active.delay, c);
if ch.fade > 0. {
ch.old.delay += ch.old.slope;
let w = ch.fade * ch.fade * (3. - 2. * ch.fade);
copy += (self.read(ch.old.delay, c) - copy) * w;
ch.fade = (ch.fade - self.fade_step).max(0.);
}
let delayed = self.audio[(self.audio_at + len - self.latency) % len][c];
let g = self.depth;
let norm = 1. / (1. + g * g).sqrt();
let wet = match &self.low_cut {
Some((high, low)) => {
let lr4 = |f: &mut [Svf; 2], c: &SvfCoef, x: f32| {
let first = f[0].process(c, x);
f[1].process(c, first)
};
let copy = lr4(&mut ch.copy_high, high, copy);
let above = lr4(&mut ch.high, high, delayed);
let below = lr4(&mut ch.low, low, delayed);
below + (above + g * copy) * norm
}
None => (delayed + g * copy) * norm,
};
self.state[c] = ch;
out[c] = wet * self.output * (1. - self.bypass) + delayed * self.bypass;
}
self.beats += self.tempo / 60. / self.rate as f64;
out
}
pub fn take_analyzer(&self) -> AnalyzerFrame {
let s = self.settings;
let hz = self.hz;
let mut f = AnalyzerFrame {
hz,
beats: self.beats,
tempo: self.tempo,
synced: self.synced,
latency: self.latency as u32,
sample_rate: self.rate,
..AnalyzerFrame::default()
};
if hz > 0. {
let beat_hz = self.tempo / 60. / s.rate.quarters();
f.ratio = 1. + (beat_hz / (s.harmonic as f64 * hz as f64)) as f32;
}
if self.tracker.sounding() && hz > 0. {
f.note = Some(hz_to_midi(hz).round().clamp(0., 127.) as u8);
}
f
}
}
/// One-pole step that lands exactly on the target, so switched states end bit-exact.
fn approach(value: &mut f32, target: f32, coef: f32) {
*value += (target - *value) * coef;
if (target - *value).abs() < 1e-6 {
*value = target;
}
}
+146
View File
@@ -0,0 +1,146 @@
//! Host-independent controls. Levels in dB, amounts in percent, angles in degrees.
use serde::{Deserialize, Serialize};
/// Highest harmonic the beat rate can be set on.
pub const HARMONIC_MAX: u32 = 8;
pub const LOW_CUT_MAX: f32 = 400.;
/// A low cut below this is off: no filter on either path.
pub const LOW_CUT_OFF: f32 = 20.;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Source {
/// Pitch and note starts are detected from the incoming bass.
#[default]
Audio,
/// Notes come from MIDI note input.
Midi,
}
/// One beat cycle lasts this note value.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Rate {
Whole,
Half,
Quarter,
#[default]
Eighth,
Sixteenth,
ThirtySecond,
QuarterTriplet,
EighthTriplet,
SixteenthTriplet,
}
impl Rate {
pub const ALL: [Rate; 9] = [
Rate::Whole,
Rate::Half,
Rate::Quarter,
Rate::Eighth,
Rate::Sixteenth,
Rate::ThirtySecond,
Rate::QuarterTriplet,
Rate::EighthTriplet,
Rate::SixteenthTriplet,
];
/// Length of one beat cycle in quarter notes.
pub fn quarters(self) -> f64 {
match self {
Rate::Whole => 4.,
Rate::Half => 2.,
Rate::Quarter => 1.,
Rate::Eighth => 0.5,
Rate::Sixteenth => 0.25,
Rate::ThirtySecond => 0.125,
Rate::QuarterTriplet => 2. / 3.,
Rate::EighthTriplet => 1. / 3.,
Rate::SixteenthTriplet => 1. / 6.,
}
}
pub fn label(self) -> &'static str {
match self {
Rate::Whole => "1/1",
Rate::Half => "1/2",
Rate::Quarter => "1/4",
Rate::Eighth => "1/8",
Rate::Sixteenth => "1/16",
Rate::ThirtySecond => "1/32",
Rate::QuarterTriplet => "1/4T",
Rate::EighthTriplet => "1/8T",
Rate::SixteenthTriplet => "1/16T",
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub source: Source,
/// How long one beat cycle of `harmonic` lasts.
pub rate: Rate,
/// The harmonic that beats exactly at `rate`. Harmonic n beats n/harmonic times as fast.
pub harmonic: u32,
/// Level of the detuned copy against the bass. 100 % gives full nulls.
pub depth: f32,
/// Below this frequency the copy is removed, so the sub stays steady and mono-safe.
/// Under `LOW_CUT_OFF` Hz the filter is off.
pub low_cut: f32,
/// Moves the right channel's beat against the left, up to half a cycle.
pub width: f32,
/// Where in the cycle the beat peaks, in degrees. 0 puts the peak on the beat.
pub phase: f32,
/// Detection threshold in dBFS for note starts (Audio source).
pub gate: f32,
/// Round detected pitch to the nearest semitone. Off by default: the copy jumps by one
/// period of the pitch in use, and a period that does not fit the note is heard.
pub snap: bool,
pub output: f32,
pub bypass: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
source: Source::Audio,
rate: Rate::Eighth,
// Harmonic 4 sits above the low cut on every bass note, and on F1 it asks for
// about 45 cents of detune, a usual reese spread.
harmonic: 4,
depth: 70.,
low_cut: 100.,
width: 0.,
phase: 0.,
gate: -48.,
snap: false,
output: 0.,
bypass: false,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
let finite = [
self.depth,
self.low_cut,
self.width,
self.phase,
self.gate,
self.output,
]
.iter()
.all(|v| v.is_finite());
if !finite {
return Err("non-finite setting");
}
let ok = (1..=HARMONIC_MAX).contains(&self.harmonic)
&& (0.0..=100.).contains(&self.depth)
&& (0.0..=LOW_CUT_MAX).contains(&self.low_cut)
&& (0.0..=100.).contains(&self.width)
&& (0.0..=360.).contains(&self.phase)
&& (-60.0..=-20.).contains(&self.gate)
&& (-12.0..=12.).contains(&self.output);
if ok {
Ok(())
} else {
Err("setting out of range")
}
}
}
+278
View File
@@ -0,0 +1,278 @@
// Sample indices address several signals at once.
#![allow(clippy::needless_range_loop)]
use braze_core::{cycles, envelope, Engine, Rate, Settings};
use common_dsp::Saw;
use std::f32::consts::TAU;
const RATE: f32 = 48000.;
const TEMPO: f64 = 120.;
fn engine(s: Settings) -> (Engine, usize) {
let mut e = Engine::new(RATE, 2).unwrap();
e.set_settings(s).unwrap();
e.set_transport(Some(TEMPO), Some(0.));
let l = e.latency_samples() as usize;
(e, l)
}
fn seconds(t: f32) -> usize {
(t * RATE) as usize
}
fn render(e: &mut Engine, x: &[f32]) -> Vec<[f32; 2]> {
x.iter().map(|&s| e.process_sample([s, s])).collect()
}
/// A saw at `hz` from 0.05 s on, with a 1 ms fade-in.
fn saw(hz: f32, total: f32) -> Vec<f32> {
let mut osc = Saw::new(RATE, hz);
(0..seconds(total))
.map(|i| {
let start = seconds(0.05);
if i < start {
return 0.;
}
0.5 * osc.sample() * ((i - start) as f32 / seconds(0.001) as f32).min(1.)
})
.collect()
}
/// Amplitude of harmonic `n` of `hz` over one period centred on sample `at`.
fn harmonic(x: &[f32], at: usize, hz: f32, n: u32) -> f32 {
let half = (RATE / hz / 2.).round() as usize;
let w = TAU * hz * n as f32 / RATE;
let (mut re, mut im) = (0f32, 0f32);
for i in at - half..at + half {
re += x[i] * (w * i as f32).cos();
im -= x[i] * (w * i as f32).sin();
}
2. * (re * re + im * im).sqrt() / (2 * half) as f32
}
/// Beat position (quarter notes) of output sample `i`.
fn beats(i: usize, latency: usize) -> f64 {
(i as f64 - latency as f64) / RATE as f64 * TEMPO / 60.
}
/// Harmonics 1–4 of A1 at falling levels, a periodic note the tracker reads exactly.
fn partials(t: f64) -> f64 {
(1..=4)
.map(|n| 0.4 / n as f64 * (std::f64::consts::TAU * 55. * n as f64 * t).sin())
.sum()
}
#[test]
fn every_harmonic_beats_on_the_tempo_grid() {
for (harmonic, rate) in [(1, Rate::Eighth), (2, Rate::Sixteenth), (3, Rate::Quarter)] {
let s = Settings {
harmonic,
rate,
depth: 100.,
snap: true,
low_cut: 0.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x: Vec<f32> = (0..seconds(1.5))
.map(|i| partials(i as f64 / RATE as f64) as f32)
.collect();
let y = render(&mut e, &x);
let mut worst = 0f32;
for i in seconds(0.5)..seconds(1.4) {
// Cycles of the chosen harmonic, from the grid alone: peaks on every multiple
// of `rate`. The copy lags the bass by (0.5 − frac(u)) periods.
let u = beats(i, l) / rate.quarters() / harmonic as f64 + 0.5;
let t = (i - l) as f64 / RATE as f64;
let lag = (0.5 - u.fract()) / 55.;
let ideal = (partials(t) + partials(t - lag)) / 2f64.sqrt();
worst = worst.max((y[i][0] - ideal as f32).abs());
}
assert!(worst < 3e-3, "harmonic {harmonic}, {rate:?}: {worst}");
}
}
#[test]
fn envelope_peaks_on_the_grid_and_harmonic_n_beats_n_over_k_as_fast() {
let s = Settings {
harmonic: 2,
..Settings::default()
};
// At cycle 0 every harmonic peaks: twice the bass, less the loudness correction.
for n in 1..=8 {
assert!((envelope(&s, n, 0., 1.) - 2f32.sqrt()).abs() < 1e-5);
}
// Harmonic 2 nulls half a cycle later; harmonic 1, beating half as fast, a cycle later.
assert!(envelope(&s, 2, 0.5, 1.) < 1e-5);
assert!(envelope(&s, 1, 1., 1.) < 1e-5);
assert!(envelope(&s, 4, 0.25, 1.) < 1e-5);
}
#[test]
fn the_copy_is_seamless_across_its_jumps() {
// A pure sine at exactly A1: the ideal copy is a smooth sine whose phase follows the beat.
let s = Settings {
harmonic: 1,
rate: Rate::Sixteenth,
depth: 100.,
snap: true,
low_cut: 0.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x: Vec<f32> = (0..seconds(1.5))
.map(|i| 0.5 * (TAU * 55. * i as f32 / RATE).sin())
.collect();
let y = render(&mut e, &x);
let mut worst = 0f32;
for i in seconds(0.5)..seconds(1.4) {
let u = cycles(&s, beats(i, l), 0) + 0.5;
let dry = x[i - l];
let phase = TAU as f64 * (55. * (i - l) as f64 / RATE as f64 + u.fract());
let copy = -0.5 * phase.sin() as f32;
let ideal = (dry + copy) / 2f32.sqrt();
worst = worst.max((y[i][0] - ideal).abs());
}
assert!(worst < 2e-3, "{worst}");
}
#[test]
fn depth_zero_is_the_delayed_input() {
let (mut e, l) = engine(Settings {
depth: 0.,
low_cut: 0.,
..Settings::default()
});
let x = saw(55., 0.6);
let y = render(&mut e, &x);
for i in l..x.len() {
assert_eq!(y[i][0], x[i - l]);
}
}
#[test]
fn width_moves_the_right_channel_half_a_cycle() {
let s = Settings {
harmonic: 1,
width: 100.,
depth: 100.,
rate: Rate::Quarter,
low_cut: 0.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x = saw(55., 1.5);
let y = render(&mut e, &x);
let left: Vec<f32> = y.iter().map(|v| v[0]).collect();
let right: Vec<f32> = y.iter().map(|v| v[1]).collect();
// On the beat the left fundamental peaks and the right one nulls; half a beat later
// they swap.
for (beat, loud, quiet) in [
(1., &left, &right),
(1.5, &right, &left),
(2., &left, &right),
] {
let at = l + (beat / (TEMPO / 60.) * RATE as f64) as usize;
let (a, b) = (harmonic(loud, at, 55., 1), harmonic(quiet, at, 55., 1));
assert!(20. * (a / b).log10() > 20., "beat {beat}: {a} vs {b}");
}
}
#[test]
fn note_changes_and_transport_jumps_do_not_click() {
let s = Settings {
depth: 100.,
..Settings::default()
};
let (mut e, _) = engine(s);
// Phase-continuous sine from A1 to D2, no gap.
let mut phase = 0f32;
let x: Vec<f32> = (0..seconds(1.2))
.map(|i| {
let hz = if i < seconds(0.6) { 55. } else { 73.42 };
phase = (phase + hz / RATE).fract();
0.5 * (TAU * phase).sin()
})
.collect();
let mut y = Vec::new();
for (i, &v) in x.iter().enumerate() {
if i == seconds(0.9) {
// A locate to another bar.
e.set_transport(Some(TEMPO), Some(37.3));
}
y.push(e.process_sample([v, v])[0]);
}
// Two sines of peak 0.5 at up to 79 Hz move at most this much per sample (after the
// loudness correction), plus some room for the fades.
let bound = 2. * 0.5 * TAU * 79. / RATE / 2f32.sqrt() * 1.5;
for i in seconds(0.2)..y.len() - 1 {
assert!(
(y[i + 1] - y[i]).abs() < bound,
"{i}: {}",
(y[i + 1] - y[i]).abs()
);
}
}
#[test]
fn low_cut_keeps_the_sub_steady_and_the_beat_on_the_grid() {
let s = Settings {
harmonic: 1,
depth: 100.,
rate: Rate::Half,
low_cut: 150.,
..Settings::default()
};
let (mut e, l) = engine(s);
let x = saw(55., 2.4);
let y: Vec<f32> = render(&mut e, &x).iter().map(|v| v[0]).collect();
// One cycle (a half note, 1 s at 120 BPM): the fundamental, under the cut, does not beat.
let sub: Vec<f32> = (0..50)
.map(|k| harmonic(&y, seconds(1.) + k * seconds(0.02), 55., 1))
.collect();
let (lo, hi) = sub
.iter()
.fold((f32::MAX, 0f32), |(a, b), &v| (a.min(v), b.max(v)));
// Without the cut it would null fully; what remains is leakage of the beating
// harmonics into the one-period window.
assert!(20. * (hi / lo).log10() < 1.5, "{lo} .. {hi}");
// And it keeps its level: the loudness correction for the copy applies above the cut.
let dry = harmonic(&x, seconds(1.) - l, 55., 1);
let mean = sub.iter().sum::<f32>() / sub.len() as f32;
assert!((20. * (mean / dry).log10()).abs() < 0.5, "{mean} vs {dry}");
// Harmonic 6 (330 Hz) beats 6 times per cycle: it peaks on the grid, the high-pass's
// phase is matched on the bass, and nulls 1/12 cycle later.
for beat in [2., 4.] {
let peak = l + (beat / (TEMPO / 60.) * RATE as f64) as usize;
let null = peak + (RATE / 12.) as usize;
let (a, b) = (harmonic(&y, peak, 55., 6), harmonic(&y, null, 55., 6));
assert!(20. * (a / b).log10() > 15., "beat {beat}: {a} vs {b}");
}
}
#[test]
fn the_ratio_follows_the_note() {
let (mut e, _) = engine(Settings {
harmonic: 1,
..Settings::default()
});
for hz in [55., 110.] {
let x = saw(hz, 0.5);
render(&mut e, &x);
let f = e.take_analyzer();
// An eighth at 120 BPM beats 4 times per second: r = 1 + 4 / f0.
assert!((f.ratio - (1. + 4. / hz)).abs() < 2e-3, "{hz}: {}", f.ratio);
}
}
#[test]
fn settings_are_validated_and_serialise() {
let mut e = Engine::new(RATE, 1).unwrap();
assert!(e
.set_settings(Settings {
harmonic: 0,
..Settings::default()
})
.is_err());
let json = serde_json::to_string(&Settings::default()).unwrap();
assert_eq!(
serde_json::from_str::<Settings>(&json).unwrap(),
Settings::default()
);
assert!(Engine::new(1000., 2).is_err());
assert!(Engine::new(48000., 3).is_err());
}
+79
View File
@@ -0,0 +1,79 @@
use braze_core::{Engine, Rate, Settings, Source};
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
};
struct CheckedAllocator;
thread_local! {
static CHECK:Cell<bool>=const {Cell::new(false)};
static OPERATIONS:Cell<usize>=const {Cell::new(0)};
}
fn record() {
if CHECK.try_with(Cell::get).unwrap_or(false) {
let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1));
}
}
unsafe impl GlobalAlloc for CheckedAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
record();
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
record();
unsafe { System.dealloc(pointer, layout) }
}
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
record();
unsafe { System.realloc(pointer, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CheckedAllocator = CheckedAllocator;
#[test]
fn processing_notes_settings_transport_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings {
width: 50.,
..Settings::default()
};
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..96000 {
if i % 512 == 0 {
let playing = (i / 24000) % 2 == 0;
engine.set_transport(
Some(140.),
playing.then_some(i as f64 / 48000. * 140. / 60.),
);
}
if i % 4800 == 0 {
settings.rate = Rate::ALL[(i / 4800) % Rate::ALL.len()];
settings.harmonic = 1 + (i / 4800 % 4) as u32;
settings.source = if i > 48000 {
Source::Midi
} else {
Source::Audio
};
engine.set_settings(settings).unwrap();
engine.note_on(30 + (i / 4800 % 12) as u8);
}
if i % 4800 == 2400 {
engine.note_off(30 + (i / 4800 % 12) as u8);
}
let t = i as f32 / 48000.;
let x = if (i / 2400) % 2 == 0 {
(std::f32::consts::TAU * 55. * t).sin() * 0.5
} else {
0.
};
std::hint::black_box(engine.process_sample([x, x]));
if i % 1600 == 0 {
std::hint::black_box(engine.take_analyzer());
}
}
engine.reset();
CHECK.with(|v| v.set(false));
assert_eq!(OPERATIONS.with(Cell::get), 0);
}
+2381
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "braze"
version = "0.1.0"
edition = "2021"
license = "ISC"
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
braze-core = { path = "../core" }
braze-ui = { path = "../ui" }
crossbeam-queue = "0.3"
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+193
View File
@@ -0,0 +1,193 @@
//! CLAP audio effect. All DSP state is owned by the processing thread.
mod params;
use braze_core::{AnalyzerFrame, Engine};
use crossbeam_queue::ArrayQueue;
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::{BrazeParams, FLOATS};
use std::sync::Arc;
struct EditorData {
ui: braze_ui::State,
gestures: [bool; FLOATS],
}
pub struct Braze {
params: Arc<BrazeParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
engine: Option<Engine>,
publish_count: usize,
publish_interval: usize,
}
impl Default for Braze {
fn default() -> Self {
Self {
params: Arc::new(BrazeParams::default()),
analyzer: Arc::new(ArrayQueue::new(2)),
engine: None,
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for Braze {
const NAME: &'static str = "Braze";
const VENDOR: &'static str = "Bass Foundry";
const URL: &'static str = "";
const EMAIL: &'static str = "";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
AudioIOLayout {
main_input_channels: NonZeroU32::new(2),
main_output_channels: NonZeroU32::new(2),
..AudioIOLayout::const_default()
},
AudioIOLayout {
main_input_channels: NonZeroU32::new(1),
main_output_channels: NonZeroU32::new(1),
..AudioIOLayout::const_default()
},
];
const MIDI_INPUT: MidiConfig = MidiConfig::Basic;
const SAMPLE_ACCURATE_AUTOMATION: bool = false;
type SysExMessage = ();
type BackgroundTask = ();
fn params(&self) -> Arc<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
let params = self.params.clone();
let analyzer = self.analyzer.clone();
create_egui_editor(
params.editor.clone(),
EditorData {
ui: braze_ui::State::default(),
gestures: [false; FLOATS],
},
|ctx, _| braze_ui::style(ctx),
move |ctx, setter, data| {
while let Some(frame) = analyzer.pop() {
data.ui.accept_frame(frame);
}
let before = params.settings();
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("braze-editor")
.min_size(egui::vec2(
braze_ui::WIDTH as f32 - 80.,
braze_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
braze_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
// The playhead follows the transport.
ctx.request_repaint_after(std::time::Duration::from_millis(40));
},
)
}
fn initialize(
&mut self,
layout: &AudioIOLayout,
config: &BufferConfig,
context: &mut impl InitContext<Self>,
) -> bool {
let Ok(mut engine) = Engine::new(
config.sample_rate,
layout.main_input_channels.map_or(0, |v| v.get() as usize),
) else {
return false;
};
let settings = self.params.settings();
if engine.set_settings(settings).is_err() {
return false;
}
context.set_latency_samples(engine.latency_samples());
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
true
}
fn reset(&mut self) {
if let Some(e) = self.engine.as_mut() {
e.reset();
}
self.publish_count = 0;
}
fn process(
&mut self,
buffer: &mut Buffer,
_: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let settings = self.params.settings();
let _ = engine.set_settings(settings);
let transport = context.transport();
engine.set_transport(
transport.tempo,
if transport.playing {
transport.pos_beats()
} else {
None
},
);
let mut next = context.next_event();
for (index, mut sample) in buffer.iter_samples().enumerate() {
while let Some(event) = next {
if event.timing() as usize > index {
break;
}
match event {
NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => {
engine.note_on(note)
}
NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => {
engine.note_off(note)
}
NoteEvent::Choke { note, .. } => engine.note_off(note),
_ => {}
}
next = context.next_event();
}
let x = [
*sample.get_mut(0).unwrap(),
if engine.channels() == 2 {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let out = engine.process_sample(x);
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
self.analyzer.force_push(engine.take_analyzer());
self.publish_count = 0;
}
}
// The lookahead delay must drain after input stops.
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for Braze {
const CLAP_ID: &'static str = "io.narl.bass-foundry.braze";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Adds a detuned copy whose beating stays on the tempo grid on every note");
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Chorus,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Braze);
+219
View File
@@ -0,0 +1,219 @@
use braze_core::{Rate, Settings, Source, HARMONIC_MAX, LOW_CUT_MAX};
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::Arc;
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SourceParam {
#[id = "audio"]
Audio,
#[id = "midi"]
#[name = "MIDI"]
Midi,
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum RateParam {
#[id = "1-1"]
#[name = "1/1"]
Whole,
#[id = "1-2"]
#[name = "1/2"]
Half,
#[id = "1-4"]
#[name = "1/4"]
Quarter,
#[id = "1-8"]
#[name = "1/8"]
Eighth,
#[id = "1-16"]
#[name = "1/16"]
Sixteenth,
#[id = "1-32"]
#[name = "1/32"]
ThirtySecond,
#[id = "1-4t"]
#[name = "1/4T"]
QuarterTriplet,
#[id = "1-8t"]
#[name = "1/8T"]
EighthTriplet,
#[id = "1-16t"]
#[name = "1/16T"]
SixteenthTriplet,
}
const RATES: [(RateParam, Rate); 9] = [
(RateParam::Whole, Rate::Whole),
(RateParam::Half, Rate::Half),
(RateParam::Quarter, Rate::Quarter),
(RateParam::Eighth, Rate::Eighth),
(RateParam::Sixteenth, Rate::Sixteenth),
(RateParam::ThirtySecond, Rate::ThirtySecond),
(RateParam::QuarterTriplet, Rate::QuarterTriplet),
(RateParam::EighthTriplet, Rate::EighthTriplet),
(RateParam::SixteenthTriplet, Rate::SixteenthTriplet),
];
fn to_param(rate: Rate) -> RateParam {
RATES
.iter()
.find(|(_, r)| *r == rate)
.map_or(RateParam::Eighth, |(p, _)| *p)
}
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
}
/// Continuous controls, in the order of the gesture slots.
pub const FLOATS: usize = 6;
#[derive(Params)]
pub struct BrazeParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[id = "rate"]
pub rate: EnumParam<RateParam>,
#[id = "harmonic"]
pub harmonic: IntParam,
#[id = "depth"]
pub depth: FloatParam,
#[id = "low_cut"]
pub low_cut: FloatParam,
#[id = "width"]
pub width: FloatParam,
#[id = "phase"]
pub phase: FloatParam,
#[id = "gate"]
pub gate: FloatParam,
#[id = "snap"]
pub snap: BoolParam,
#[id = "output"]
pub output: FloatParam,
#[id = "bypass"]
pub bypass: BoolParam,
}
impl Default for BrazeParams {
fn default() -> Self {
let d = Settings::default();
Self {
editor: EguiState::from_size(braze_ui::WIDTH, braze_ui::HEIGHT),
source: EnumParam::new("Pitch source", SourceParam::Audio),
rate: EnumParam::new("Rate", to_param(d.rate)),
harmonic: IntParam::new(
"Harmonic",
d.harmonic as i32,
IntRange::Linear {
min: 1,
max: HARMONIC_MAX as i32,
},
),
depth: linear("Depth", d.depth, 0., 100., " %"),
low_cut: FloatParam::new(
"Low cut",
d.low_cut,
FloatRange::Skewed {
min: 0.,
max: LOW_CUT_MAX,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(" Hz"),
width: linear("Width", d.width, 0., 100., " %"),
phase: linear("Phase", d.phase, 0., 360., "°"),
gate: linear("Gate", d.gate, -60., -20., " dB"),
snap: BoolParam::new("Snap to semitone", d.snap),
output: linear("Output", d.output, -12., 12., " dB"),
bypass: BoolParam::new("Bypass", false).make_bypass(),
}
}
}
impl BrazeParams {
pub fn settings(&self) -> Settings {
let rate = self.rate.value();
Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
rate: RATES
.iter()
.find(|(p, _)| *p == rate)
.map_or(Rate::Eighth, |(_, r)| *r),
harmonic: self.harmonic.value() as u32,
depth: self.depth.value(),
low_cut: self.low_cut.value(),
width: self.width.value(),
phase: self.phase.value(),
gate: self.gate.value(),
snap: self.snap.value(),
output: self.output.value(),
bypass: self.bypass.value(),
}
}
fn floats(&self) -> [&FloatParam; FLOATS] {
[
&self.depth,
&self.low_cut,
&self.width,
&self.phase,
&self.gate,
&self.output,
]
}
fn values(s: &Settings) -> [f32; FLOATS] {
[s.depth, s.low_cut, s.width, s.phase, s.gate, s.output]
}
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
/// while the pointer is down; switches are set in a single gesture.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; FLOATS],
) {
let pairs = Self::values(old).into_iter().zip(Self::values(new));
for (i, (p, (a, b))) in self.floats().into_iter().zip(pairs).enumerate() {
if a != b {
if !open[i] {
open[i] = true;
setter.begin_set_parameter(p);
}
setter.set_parameter(p, b);
}
if !dragging && open[i] {
setter.end_set_parameter(p);
open[i] = false;
}
}
if new.harmonic != old.harmonic {
setter.begin_set_parameter(&self.harmonic);
setter.set_parameter(&self.harmonic, new.harmonic as i32);
setter.end_set_parameter(&self.harmonic);
}
if new.rate != old.rate {
setter.begin_set_parameter(&self.rate);
setter.set_parameter(&self.rate, to_param(new.rate));
setter.end_set_parameter(&self.rate);
}
for (p, a, b) in [
(&self.snap, old.snap, new.snap),
(&self.bypass, old.bypass, new.bypass),
] {
if a != b {
setter.begin_set_parameter(p);
setter.set_parameter(p, b);
setter.end_set_parameter(p);
}
}
if new.source != old.source {
let v = match new.source {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
setter.begin_set_parameter(&self.source);
setter.set_parameter(&self.source, v);
setter.end_set_parameter(&self.source);
}
}
}
+216
View File
@@ -0,0 +1,216 @@
//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW.
use clap_sys::{
audio_buffer::*,
events::*,
ext::{audio_ports::*, latency::*, state::*},
factory::plugin_factory::*,
host::*,
process::*,
stream::*,
version::*,
};
use std::{
ffi::{c_char, c_void, CStr},
io::{Cursor, Read},
ptr,
};
unsafe extern "C" fn extension(_: *const clap_host, _: *const c_char) -> *const c_void {
ptr::null()
}
unsafe extern "C" fn request(_: *const clap_host) {}
unsafe extern "C" fn count(_: *const clap_input_events) -> u32 {
0
}
unsafe extern "C" fn event(_: *const clap_input_events, _: u32) -> *const clap_event_header {
ptr::null()
}
unsafe extern "C" fn push(_: *const clap_output_events, _: *const clap_event_header) -> bool {
true
}
unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 {
// SAFETY: The test owns the Vec for the entire synchronous save call.
let bytes = unsafe { &mut *((*stream).ctx as *mut Vec<u8>) };
bytes.extend_from_slice(unsafe {
std::slice::from_raw_parts(buffer as *const u8, size as usize)
});
size as i64
}
unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
// SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage.
let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor<Vec<u8>>) };
cursor
.read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) })
.unwrap() as i64
}
#[test]
fn effect_ports_state_latency_and_audio_lifecycle() {
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
unsafe {
let entry = &braze::clap_entry;
assert!(entry.init.unwrap()(c"braze.clap".as_ptr()));
let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr())
as *const clap_plugin_factory;
assert!(!factory.is_null());
let factory = &*factory;
assert_eq!(factory.get_plugin_count.unwrap()(factory), 1);
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(CStr::from_ptr((*descriptor).name), c"Braze");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.narl.bass-foundry.braze"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
clap_version: CLAP_VERSION,
host_data: ptr::null_mut(),
name: c"Storyboard test host".as_ptr(),
vendor: c"Tests".as_ptr(),
url: c"".as_ptr(),
version: c"1.0".as_ptr(),
get_extension: Some(extension),
request_restart: Some(request),
request_process: Some(request),
request_callback: Some(request),
};
let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id);
assert!(!plugin.is_null());
let p = &*plugin;
assert!(p.init.unwrap()(plugin));
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
as *const clap_plugin_audio_ports;
assert!(!ports.is_null());
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
as *const clap_plugin_latency;
assert!(!latency.is_null());
let state =
p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state;
assert!(!state.is_null());
let state = &*state;
let save = || {
let mut bytes = Vec::new();
let stream = clap_ostream {
ctx: &mut bytes as *mut Vec<u8> as *mut c_void,
write: Some(write),
};
assert!(state.save.unwrap()(plugin, &stream));
bytes
};
let mut envelope: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
use nih_plug::wrapper::state::ParamValue;
// Depth 0 passes the input through the lookahead delay unchanged.
envelope.params.insert("depth".into(), ParamValue::F32(0.));
envelope.params.insert("width".into(), ParamValue::F32(40.));
envelope
.params
.insert("harmonic".into(), ParamValue::I32(3));
envelope
.params
.insert("rate".into(), ParamValue::String("1-16t".into()));
envelope
.params
.insert("snap".into(), ParamValue::Bool(true));
envelope
.params
.insert("source".into(), ParamValue::String("midi".into()));
let encoded = serde_json::to_vec(&envelope).unwrap();
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
framed.extend_from_slice(&encoded);
let mut bytes = Cursor::new(framed);
let stream = clap_istream {
ctx: &mut bytes as *mut Cursor<Vec<u8>> as *mut c_void,
read: Some(read),
};
assert!(state.load.unwrap()(plugin, &stream));
let restored: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
assert_eq!(
serde_json::to_value(&restored.params).unwrap(),
serde_json::to_value(&envelope.params).unwrap()
);
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
// About 67 ms lookahead: two periods of the lowest detected note (30 Hz).
assert!((delay as f64 / rate - 0.067).abs() < 0.01, "{delay}");
assert!(p.start_processing.unwrap()(plugin));
let total = delay + 9600;
let mut position = 0;
let (mut energy_in, mut energy_out) = (0f64, 0f64);
while position < total {
let frames = [1, 127, 512, 31][position % 4].min(total - position);
// A 1 kHz tone at depth 0.
let tone = |n: usize| {
0.25 * (std::f64::consts::TAU * 1000. * n as f64 / rate).sin() as f32
};
let mut input_left = [0f32; 512];
let mut input_right = [0f32; 512];
for i in 0..frames {
input_left[i] = tone(position + i);
input_right[i] = -tone(position + i);
}
let mut left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
let incoming = clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let mut outgoing_audio = clap_audio_buffer {
data32: out_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let input = clap_input_events {
ctx: ptr::null_mut(),
size: Some(count),
get: Some(event),
};
let outgoing = clap_output_events {
ctx: ptr::null_mut(),
try_push: Some(push),
};
let process = clap_process {
steady_time: position as i64,
frames_count: frames as u32,
transport: ptr::null(),
audio_inputs: &incoming,
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 1,
audio_outputs_count: 1,
in_events: &input,
out_events: &outgoing,
};
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
for i in 0..frames {
let n = position + i;
if n < delay {
assert!(left[i].abs() < 1e-6, "output before the lookahead");
} else if n > delay + 4800 {
energy_in += (tone(n - delay) as f64).powi(2);
energy_out += (left[i] as f64).powi(2) + (right[i] as f64).powi(2);
}
}
position += frames;
}
let ratio_db = 10. * (energy_out / (2. * energy_in)).log10();
assert!(ratio_db.abs() < 0.2, "{ratio_db}");
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}
p.destroy.unwrap()(plugin);
entry.deinit.unwrap()();
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "braze-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
braze-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
common-dsp = { path = "../../common/dsp" }
+410
View File
@@ -0,0 +1,410 @@
//! Braze editor, shared by the CLAP wrapper and the headless renderer.
use braze_core::{
cycles, envelope, AnalyzerFrame, Rate, Settings, Source, HARMONIC_MAX, LOW_CUT_MAX, LOW_CUT_OFF,
};
use common_ui::*;
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
pub use common_ui::style;
pub const WIDTH: u32 = 980;
pub const HEIGHT: u32 = 716;
pub struct State {
pub analyzer: AnalyzerFrame,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
gesture_active: false,
help: DEFAULT_HELP.into(),
}
}
}
const DEFAULT_HELP: &str =
"Put Braze on a reese or a layered bass. Pick the note value the beating should follow.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
self.analyzer = frame;
}
}
fn hint(r: &egui::Response, state: &mut State, text: &str) {
if r.hovered() || r.has_focus() {
state.help = text.into();
}
}
#[allow(clippy::too_many_arguments)]
fn knob(
ui: &mut egui::Ui,
state: &mut State,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
size: f32,
help: &str,
) {
ui.vertical_centered(|ui| {
let r = rotary(ui, name, value, range, default, unit, size, ACCENT);
hint(&r, state, help);
});
}
/// Note value of one beat of harmonic `n`, as a label: "1/8", "1/16T", "1/8D", or "1/40".
pub fn beat_label(s: &Settings, n: u32) -> String {
let quarters = s.rate.quarters() * s.harmonic as f64 / n as f64;
let per_whole = 4. / quarters;
for den in [1u32, 2, 4, 8, 16, 32, 64, 128] {
let d = den as f64;
if (per_whole - d).abs() < 1e-6 {
return format!("1/{den}");
}
if (per_whole - d * 1.5).abs() < 1e-6 {
return format!("1/{den}T");
}
if (per_whole - d / 1.5).abs() < 1e-6 {
return format!("1/{den}D");
}
}
if (per_whole - per_whole.round()).abs() < 1e-6 {
format!("1/{}", per_whole.round())
} else {
format!("{:.2} beats", quarters)
}
}
/// One bar of the beat of harmonics 1–8, with the beat grid and a playhead.
fn beats(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame, height: f32) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, 2., SCREEN);
let label_w = 86.;
let area = Rect::from_min_max(
rect.left_top() + Vec2::new(label_w, 26.),
rect.right_bottom() - Vec2::new(12., 10.),
);
p.text(
rect.left_top() + Vec2::new(10., 8.),
egui::Align2::LEFT_TOP,
"BEAT PER HARMONIC · ONE BAR",
FontId::proportional(11.),
MUTED,
);
p.text(
rect.right_top() + Vec2::new(-12., 8.),
egui::Align2::RIGHT_TOP,
if frame.synced {
format!("{:.1} BPM · host transport", frame.tempo)
} else {
format!("{:.1} BPM · free running", frame.tempo)
},
FontId::proportional(11.),
MUTED,
);
// Grid: every beat of the chosen rate, quarters brighter.
let step = settings.rate.quarters();
let mut q = 0f64;
while q < 4. - 1e-9 {
let x = area.left() + area.width() * (q / 4.) as f32;
let on_quarter = (q - q.round()).abs() < 1e-6;
let color = if on_quarter {
Color32::from_rgb(58, 62, 70)
} else {
Color32::from_rgb(32, 35, 40)
};
p.line_segment(
[Pos2::new(x, area.top()), Pos2::new(x, area.bottom())],
Stroke::new(1.0_f32, color),
);
q += step;
}
let rows = HARMONIC_MAX as usize;
let row_h = area.height() / rows as f32;
let depth = settings.depth * 0.01;
let points = 240;
let bar_start = (frame.beats / 4.).floor() * 4.;
for row in 0..rows {
let n = row as u32 + 1;
let top = area.top() + row_h * row as f32;
let chosen = n == settings.harmonic;
// Under the low cut the copy is removed: that harmonic keeps the bass's level.
let cut = settings.low_cut >= LOW_CUT_OFF
&& frame.hz > 0.
&& n as f32 * frame.hz < settings.low_cut;
let color = match (cut, chosen) {
(true, _) => DIM,
(false, true) => ACCENT,
(false, false) => STEEL,
};
p.text(
Pos2::new(rect.left() + 10., top + row_h * 0.5),
egui::Align2::LEFT_CENTER,
format!("H{n}"),
FontId::proportional(11.),
if chosen { ACCENT } else { MUTED },
);
p.text(
Pos2::new(rect.left() + 36., top + row_h * 0.5),
egui::Align2::LEFT_CENTER,
if cut {
"cut".to_string()
} else {
beat_label(settings, n)
},
FontId::monospace(11.),
if chosen { TEXT } else { DIM },
);
let line: Vec<Pos2> = (0..=points)
.map(|i| {
let t = i as f64 / points as f64;
let level = if cut {
1.
} else {
envelope(settings, n, cycles(settings, bar_start + 4. * t, 0), depth)
};
// 0 at the bottom of the row, twice the bass (√2 after correction) at the top.
let y = top + row_h * (0.92 - 0.8 * level / 2f32.sqrt());
Pos2::new(area.left() + area.width() * t as f32, y)
})
.collect();
p.add(egui::Shape::line(
line,
Stroke::new(if chosen { 2.0_f32 } else { 1.2_f32 }, color),
));
}
let head = area.left() + area.width() * ((frame.beats - bar_start) / 4.) as f32;
p.line_segment(
[Pos2::new(head, area.top()), Pos2::new(head, area.bottom())],
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 90)),
);
p.rect_stroke(
rect,
2.,
Stroke::new(1.0_f32, EDGE),
egui::StrokeKind::Inside,
);
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
backdrop(ui);
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
ui.add_space(2.);
header(ui, "BRAZE", "TEMPO-LOCKED BEATING", |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Crossfade to the delayed dry signal.");
if r.clicked() {
settings.bypass = !settings.bypass;
}
ui.separator();
for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] {
let r = ui.selectable_label(settings.source == source, RichText::new(label).size(13.));
if r.clicked() {
settings.source = source;
}
}
caption(ui, "PITCH FROM");
});
ui.add_space(6.);
let frame = state.analyzer;
ui.horizontal_top(|ui| {
panel(ui, |ui| {
ui.set_width(240.);
ui.set_min_height(TOP);
ui.vertical(|ui| note_panel(ui, settings, &frame));
});
ui.add_space(4.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
beats(ui, settings, &frame, TOP);
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.vertical(|ui| controls(ui, settings, state));
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}
const TOP: f32 = 300.;
fn note_panel(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame) {
caption(ui, "NOTE");
let (name, hz) = match frame.note {
Some(n) if frame.hz > 0. => (common_dsp::note_name(n), format!("{:.1} Hz", frame.hz)),
_ => ("—".into(), "no note".into()),
};
ui.label(
RichText::new(name)
.font(FontId::new(54., egui::FontFamily::Name("medium".into())))
.color(if frame.note.is_some() { TEXT } else { DIM }),
);
ui.label(RichText::new(hz).monospace().size(14.).color(MUTED));
ui.add_space(14.);
let beat_hz = frame.tempo / 60. / settings.rate.quarters();
readout(
ui,
&format!("HARMONIC {} BEATS", settings.harmonic),
format!("{:.2} Hz, every {}", beat_hz, settings.rate.label()),
ACCENT,
);
if settings.low_cut >= LOW_CUT_OFF
&& frame.hz > 0.
&& settings.harmonic as f32 * frame.hz < settings.low_cut
{
ui.label(
RichText::new("Under Low cut on this note: it does not beat. Pick a higher harmonic.")
.size(12.)
.color(ACCENT),
);
ui.add_space(6.);
}
let cents = 1200. * frame.ratio.max(1e-6).log2();
readout(
ui,
"COPY DETUNE ON THIS NOTE",
if frame.hz > 0. {
format!("+{cents:.1} cents")
} else {
"waiting for a note".into()
},
TEXT,
);
ui.label(
RichText::new(format!(
"{:.0} ms lookahead · {}",
frame.latency as f32 / frame.sample_rate * 1000.,
match settings.source {
Source::Audio => "detecting pitch",
Source::Midi => "following MIDI notes",
}
))
.size(12.)
.color(MUTED),
);
}
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let d = Settings::default();
ui.horizontal(|ui| {
caption(ui, "RATE");
ui.add_space(6.);
for rate in Rate::ALL {
let r =
ui.selectable_label(settings.rate == rate, RichText::new(rate.label()).size(13.));
if r.clicked() {
settings.rate = rate;
}
hint(
&r,
state,
"One beat cycle of the chosen harmonic lasts this note value, on every note.",
);
}
ui.add_space(18.);
caption(ui, "ON HARMONIC");
ui.add_space(6.);
for n in 1..=HARMONIC_MAX {
let r = ui.selectable_label(
settings.harmonic == n,
RichText::new(format!("{n}")).size(13.),
);
if r.clicked() {
settings.harmonic = n;
}
hint(
&r,
state,
"This harmonic beats at Rate. Harmonic n beats n/this times as fast. \
Pick the harmonic you hear the movement on.",
);
}
});
ui.add_space(8.);
ui.separator();
ui.columns(6, |c| {
knob(
&mut c[0],
state,
"Depth",
&mut settings.depth,
0. ..=100.,
d.depth,
" %",
72.,
"Level of the detuned copy. 100 % beats down to full nulls; 0 % is the dry bass.",
);
knob(
&mut c[1],
state,
"Low cut",
&mut settings.low_cut,
0. ..=LOW_CUT_MAX,
d.low_cut,
" Hz",
72.,
"No copy below this frequency: the sub does not beat and keeps its level. \
Under 20 Hz the cut is off.",
);
knob(
&mut c[2],
state,
"Width",
&mut settings.width,
0. ..=100.,
d.width,
" %",
72.,
"Moves the right channel's beat against the left. 100 %: one side peaks while \
the other nulls. Check the mono sum.",
);
knob(
&mut c[3],
state,
"Phase",
&mut settings.phase,
0. ..=360.,
d.phase,
"°",
72.,
"Where the beat peaks. 0° puts the peak of the chosen harmonic on the beat.",
);
knob(
&mut c[4],
state,
"Gate",
&mut settings.gate,
-60. ..=-20.,
d.gate,
" dB",
72.,
"Input level that counts as a note. Raise it if tails retrigger.",
);
knob(
&mut c[5],
state,
"Output",
&mut settings.output,
-12. ..=12.,
d.output,
" dB",
72.,
"Output trim.",
);
});
ui.add_space(4.);
let r = ui.selectable_label(settings.snap, RichText::new("SNAP TO SEMITONE").size(13.));
if r.clicked() {
settings.snap = !settings.snap;
}
hint(
&r,
state,
"Round the detected pitch. Off keeps the copy's jumps on the real period, which is \
seamless on detuned patches.",
);
}
+17
View File
@@ -148,5 +148,22 @@
"planish-core",
"planish-ui"
]
},
"braze": {
"name": "Braze",
"description": "Adds a detuned copy whose beating stays on the tempo grid on every note",
"manifest": "plugins/braze/plugin/Cargo.toml",
"library": "braze",
"documentation": "docs/braze",
"clap_id": "io.narl.bass-foundry.braze",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"common-dsp",
"braze-core",
"braze-ui"
]
}
}
+20
View File
@@ -66,6 +66,24 @@ version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "braze-core"
version = "0.1.0"
dependencies = [
"common-dsp",
"serde",
]
[[package]]
name = "braze-ui"
version = "0.1.0"
dependencies = [
"braze-core",
"common-dsp",
"common-ui",
"egui",
]
[[package]]
name = "bytemuck"
version = "1.25.2"
@@ -153,6 +171,8 @@ version = "0.1.0"
dependencies = [
"anvil-core",
"anvil-ui",
"braze-core",
"braze-ui",
"common-dsp",
"crucible-core",
"crucible-ui",
+2
View File
@@ -27,5 +27,7 @@ knurl-core = { path = "../../plugins/knurl/core" }
knurl-ui = { path = "../../plugins/knurl/ui" }
planish-core = { path = "../../plugins/planish/core" }
planish-ui = { path = "../../plugins/planish/ui" }
braze-core = { path = "../../plugins/braze/core" }
braze-ui = { path = "../../plugins/braze/ui" }
weld-core = { path = "../../plugins/weld/core" }
weld-ui = { path = "../../plugins/weld/ui" }
+33 -4
View File
@@ -12,11 +12,13 @@ fn main() {
.unwrap_or("rivet");
if ![
"rivet", "patina", "anvil", "crucible", "lathe", "temper", "weld", "knurl", "planish",
"braze",
]
.contains(&plugin)
{
eprintln!(
"Use --plugin rivet, patina, anvil, crucible, lathe, temper, weld, knurl or planish"
"Use --plugin rivet, patina, anvil, crucible, lathe, temper, weld, knurl, planish \
or braze"
);
std::process::exit(2);
}
@@ -29,7 +31,10 @@ fn main() {
let weld = plugin == "weld";
let knurl = plugin == "knurl";
let planish = plugin == "planish";
let (width, height) = if planish {
let braze = plugin == "braze";
let (width, height) = if braze {
(braze_ui::WIDTH, braze_ui::HEIGHT)
} else if planish {
(planish_ui::WIDTH, planish_ui::HEIGHT)
} else if knurl {
(knurl_ui::WIDTH, knurl_ui::HEIGHT)
@@ -191,6 +196,26 @@ fn main() {
}
planish_state.accept_frame(engine.take_analyzer());
}
let mut braze_settings = braze_core::Settings {
width: 30.,
..Default::default()
};
let mut braze_state = braze_ui::State::default();
if braze {
// A reese-like F1 saw at 140 BPM, 2.3 beats into the bar.
let mut engine = braze_core::Engine::new(48000., 2).unwrap();
engine.set_settings(braze_settings).unwrap();
engine.set_transport(Some(140.), Some(0.));
let mut saw = common_dsp::Saw::new(48000., 43.65);
for i in 0..47300 {
let x = (saw.sample() * 1.6).tanh() * 0.4;
engine.process_sample([x, x]);
if i % 1600 == 0 {
braze_state.accept_frame(engine.take_analyzer());
}
}
braze_state.accept_frame(engine.take_analyzer());
}
let mut weld_settings = weld_core::Settings::default();
let mut weld_state = weld_ui::State::default();
if weld {
@@ -320,7 +345,9 @@ fn main() {
let session_motif = session.motifs[0].id;
shared.replace(session).unwrap();
let ctx = egui::Context::default();
if planish {
if braze {
braze_ui::style(&ctx);
} else if planish {
planish_ui::style(&ctx);
} else if knurl {
knurl_ui::style(&ctx);
@@ -359,7 +386,9 @@ fn main() {
},
|ui| {
egui::CentralPanel::default().show(ui, |ui| {
if planish {
if braze {
braze_ui::show(ui, &mut braze_settings, &mut braze_state);
} else if planish {
planish_ui::show(ui, &mut planish_settings, &mut planish_state);
} else if knurl {
knurl_ui::show(ui, &mut knurl_settings, &mut knurl_state);
+15
View File
@@ -184,6 +184,21 @@ const COPY: Record<string, Copy> = {
latency: '≈ 67 ms',
tracksNotes: true,
},
braze: {
role: 'Tempo-locked beating',
metal: 'Brazing joins two metals with a filler.',
problem:
'Detuned layers beat at a rate set by the detune and the note: the same reese pulses faster on C2 than on F1, and its movement never lines up with the track.',
points: [
'A detuned copy whose detune is set on every note from the tracked pitch.',
'The chosen harmonic beats once per note value, 1/1 to 1/32 or triplets; every other harmonic beats on the grid too.',
'Beat peaks follow the host’s beat position.',
'Low cut keeps the sub out of the beating, at its own level.',
],
loadOn: 'Bass, after Planish',
latency: '≈ 67 ms',
tracksNotes: true,
},
};
export interface Plugin extends Copy {